Coverage for src/rtflite/services/figure_service.py: 80%
87 statements
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-25 22:35 +0000
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-25 22:35 +0000
1"""RTF Figure encoding service.
3This module provides services for encoding images into RTF format.
4"""
6from collections.abc import Sequence
8from ..figure import rtf_read_figure
9from ..input import RTFFigure
12class RTFFigureService:
13 """Service for encoding figures/images in RTF format."""
15 @staticmethod
16 def encode_figure(rtf_figure: RTFFigure | None) -> str:
17 """Encode figure component to RTF.
19 Args:
20 rtf_figure: RTFFigure object containing image file paths and settings
22 Returns:
23 RTF string containing encoded figures
24 """
25 if rtf_figure is None or rtf_figure.figures is None:
26 return ""
28 # Read figure data and formats from file paths
29 figure_data_list, figure_formats = rtf_read_figure(rtf_figure.figures)
31 rtf_output = []
33 for i, (figure_data, figure_format) in enumerate(
34 zip(figure_data_list, figure_formats)
35 ):
36 # Get dimensions for this figure
37 width = RTFFigureService._get_dimension(rtf_figure.fig_width, i)
38 height = RTFFigureService._get_dimension(rtf_figure.fig_height, i)
40 # Encode single figure
41 figure_rtf = RTFFigureService._encode_single_figure(
42 figure_data, figure_format, width, height, rtf_figure.fig_align
43 )
44 rtf_output.append(figure_rtf)
46 # Add page break between figures (each figure on separate page)
47 if i < len(figure_data_list) - 1:
48 rtf_output.append("\\page ")
50 # Final paragraph after all figures
51 rtf_output.append("\\par ")
53 return "".join(rtf_output)
55 @staticmethod
56 def _get_dimension(dimension: float | Sequence[float], index: int) -> float:
57 """Get dimension for specific figure index."""
58 if not isinstance(dimension, (int, float)):
59 return dimension[index] if index < len(dimension) else dimension[-1]
60 return dimension
62 @staticmethod
63 def _encode_single_figure(
64 figure_data: bytes,
65 figure_format: str,
66 width: float,
67 height: float,
68 alignment: str,
69 ) -> str:
70 """Encode a single figure to RTF format."""
71 rtf_parts = []
73 # Add alignment
74 alignment_map = {"center": "\\qc ", "right": "\\qr ", "left": "\\ql "}
75 rtf_parts.append(alignment_map.get(alignment, "\\ql "))
77 # Start picture group
78 rtf_parts.append("{\\pict")
80 # Add format specifier
81 format_map = {"png": "\\pngblip", "jpeg": "\\jpegblip", "emf": "\\emfblip"}
82 rtf_parts.append(format_map.get(figure_format, "\\pngblip"))
84 # Get dimensions
85 pic_width, pic_height = RTFFigureService._get_image_dimensions(
86 figure_data, figure_format
87 )
88 if pic_width is None:
89 # Fallback: use 96 DPI assumption
90 pic_width = int(width * 96)
91 pic_height = int(height * 96)
93 # Convert display dimensions to twips
94 width_twips = int(width * 1440)
95 height_twips = int(height * 1440)
97 # Add dimensions
98 rtf_parts.extend(
99 [
100 f"\\picw{pic_width}",
101 f"\\pich{pic_height}",
102 f"\\picwgoal{width_twips}",
103 f"\\pichgoal{height_twips}",
104 ]
105 )
107 # Add hex data
108 rtf_parts.append(" ")
109 rtf_parts.append(RTFFigureService._binary_to_hex(figure_data))
110 rtf_parts.append("}")
112 return "".join(rtf_parts)
114 @staticmethod
115 def _binary_to_hex(data: bytes) -> str:
116 """Convert binary data to hexadecimal string for RTF.
118 Args:
119 data: Binary image data
121 Returns:
122 Hexadecimal string representation
123 """
124 # Convert each byte to 2-digit hex
125 hex_string = data.hex()
127 # RTF typically expects line breaks every 80 characters or so
128 # This helps with readability but is not strictly required
129 line_length = 80
130 lines = []
131 for i in range(0, len(hex_string), line_length):
132 lines.append(hex_string[i : i + line_length])
134 return "\n".join(lines)
136 @staticmethod
137 def _get_image_dimensions(
138 data: bytes, format: str
139 ) -> tuple[int | None, int | None]:
140 """Extract actual pixel dimensions from image data.
142 Args:
143 data: Image binary data
144 format: Image format ('png', 'jpeg', 'emf')
146 Returns:
147 Tuple of (width, height) in pixels, or (None, None) if unable to extract
148 """
149 try:
150 if format == "png":
151 return RTFFigureService._get_png_dimensions(data)
152 elif format == "jpeg":
153 return RTFFigureService._get_jpeg_dimensions(data)
154 except Exception:
155 pass
157 return None, None
159 @staticmethod
160 def _get_png_dimensions(data: bytes) -> tuple[int | None, int | None]:
161 """Extract dimensions from PNG data."""
162 if len(data) > 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
163 import struct
165 width = struct.unpack(">I", data[16:20])[0]
166 height = struct.unpack(">I", data[20:24])[0]
167 return width, height
168 return None, None
170 @staticmethod
171 def _get_jpeg_dimensions(data: bytes) -> tuple[int | None, int | None]:
172 """Extract dimensions from JPEG data."""
173 if len(data) < 10 or data[:2] != b"\xff\xd8":
174 return None, None
176 import struct
178 i = 2
179 while i < len(data) - 9:
180 if data[i] == 0xFF:
181 marker = data[i + 1]
182 # SOF markers contain dimension info
183 sof_markers = {
184 0xC0,
185 0xC1,
186 0xC2,
187 0xC3,
188 0xC5,
189 0xC6,
190 0xC7,
191 0xC9,
192 0xCA,
193 0xCB,
194 0xCD,
195 0xCE,
196 0xCF,
197 }
198 if marker in sof_markers:
199 height = struct.unpack(">H", data[i + 5 : i + 7])[0]
200 width = struct.unpack(">H", data[i + 7 : i + 9])[0]
201 return width, height
202 # Skip to next marker
203 length = struct.unpack(">H", data[i + 2 : i + 4])[0]
204 i += 2 + length
205 else:
206 i += 1
207 return None, None