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