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

1"""RTF Figure encoding service. 

2 

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

4""" 

5 

6from ..figure import rtf_read_figure 

7from ..input import RTFFigure 

8 

9 

10class RTFFigureService: 

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

12 

13 @staticmethod 

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

15 """Encode figure component to RTF. 

16 

17 Args: 

18 rtf_figure: RTFFigure object containing image file paths and settings 

19 

20 Returns: 

21 RTF string containing encoded figures 

22 """ 

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

24 return "" 

25 

26 # Read figure data and formats from file paths 

27 figure_data_list, figure_formats = rtf_read_figure(rtf_figure.figures) 

28 

29 rtf_output = [] 

30 

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) 

37 

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) 

43 

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

45 if i < len(figure_data_list) - 1: 

46 rtf_output.append("\\page ") 

47 

48 # Final paragraph after all figures 

49 rtf_output.append("\\par ") 

50 

51 return "".join(rtf_output) 

52 

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 

59 

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

70 

71 # Add alignment 

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

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

74 

75 # Start picture group 

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

77 

78 # Add format specifier 

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

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

81 

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) 

90 

91 # Convert display dimensions to twips 

92 width_twips = int(width * 1440) 

93 height_twips = int(height * 1440) 

94 

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 ) 

104 

105 # Add hex data 

106 rtf_parts.append(" ") 

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

108 rtf_parts.append("}") 

109 

110 return "".join(rtf_parts) 

111 

112 @staticmethod 

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

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

115 

116 Args: 

117 data: Binary image data 

118 

119 Returns: 

120 Hexadecimal string representation 

121 """ 

122 # Convert each byte to 2-digit hex 

123 hex_string = data.hex() 

124 

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

131 

132 return "\n".join(lines) 

133 

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. 

139 

140 Args: 

141 data: Image binary data 

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

143 

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 

154 

155 return None, None 

156 

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 

162 

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 

167 

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 

173 

174 import struct 

175 

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