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

1"""RTF Figure encoding service. 

2 

3This module provides services for encoding images into RTF format. 

4""" 

5 

6from collections.abc import Sequence 

7 

8from ..figure import rtf_read_figure 

9from ..input import RTFFigure 

10 

11 

12class RTFFigureService: 

13 """Service for encoding figures/images in RTF format.""" 

14 

15 @staticmethod 

16 def encode_figure(rtf_figure: RTFFigure | None) -> str: 

17 """Encode figure component to RTF. 

18 

19 Args: 

20 rtf_figure: RTFFigure object containing image file paths and settings 

21 

22 Returns: 

23 RTF string containing encoded figures 

24 """ 

25 if rtf_figure is None or rtf_figure.figures is None: 

26 return "" 

27 

28 # Read figure data and formats from file paths 

29 figure_data_list, figure_formats = rtf_read_figure(rtf_figure.figures) 

30 

31 rtf_output = [] 

32 

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) 

39 

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) 

45 

46 # Add page break between figures (each figure on separate page) 

47 if i < len(figure_data_list) - 1: 

48 rtf_output.append("\\page ") 

49 

50 # Final paragraph after all figures 

51 rtf_output.append("\\par ") 

52 

53 return "".join(rtf_output) 

54 

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 

61 

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 = [] 

72 

73 # Add alignment 

74 alignment_map = {"center": "\\qc ", "right": "\\qr ", "left": "\\ql "} 

75 rtf_parts.append(alignment_map.get(alignment, "\\ql ")) 

76 

77 # Start picture group 

78 rtf_parts.append("{\\pict") 

79 

80 # Add format specifier 

81 format_map = {"png": "\\pngblip", "jpeg": "\\jpegblip", "emf": "\\emfblip"} 

82 rtf_parts.append(format_map.get(figure_format, "\\pngblip")) 

83 

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) 

92 

93 # Convert display dimensions to twips 

94 width_twips = int(width * 1440) 

95 height_twips = int(height * 1440) 

96 

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 ) 

106 

107 # Add hex data 

108 rtf_parts.append(" ") 

109 rtf_parts.append(RTFFigureService._binary_to_hex(figure_data)) 

110 rtf_parts.append("}") 

111 

112 return "".join(rtf_parts) 

113 

114 @staticmethod 

115 def _binary_to_hex(data: bytes) -> str: 

116 """Convert binary data to hexadecimal string for RTF. 

117 

118 Args: 

119 data: Binary image data 

120 

121 Returns: 

122 Hexadecimal string representation 

123 """ 

124 # Convert each byte to 2-digit hex 

125 hex_string = data.hex() 

126 

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]) 

133 

134 return "\n".join(lines) 

135 

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. 

141 

142 Args: 

143 data: Image binary data 

144 format: Image format ('png', 'jpeg', 'emf') 

145 

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 

156 

157 return None, None 

158 

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 

164 

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 

169 

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 

175 

176 import struct 

177 

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