Coverage for src / rtflite / row.py: 93%

163 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-08 04:50 +0000

1from collections.abc import Mapping, MutableSequence, Sequence 

2 

3from pydantic import BaseModel, Field 

4 

5from .core.constants import RTFConstants, RTFMeasurements 

6from .fonts_mapping import FontMapping 

7 

8# Import constants from centralized location for backwards compatibility 

9FORMAT_CODES = RTFConstants.FORMAT_CODES 

10 

11TEXT_JUSTIFICATION_CODES = RTFConstants.TEXT_JUSTIFICATION_CODES 

12 

13ROW_JUSTIFICATION_CODES = RTFConstants.ROW_JUSTIFICATION_CODES 

14 

15BORDER_CODES = RTFConstants.BORDER_CODES 

16 

17VERTICAL_ALIGNMENT_CODES = RTFConstants.VERTICAL_ALIGNMENT_CODES 

18 

19 

20class Utils: 

21 @staticmethod 

22 def _font_type() -> Mapping: 

23 """Define font types""" 

24 return FontMapping.get_font_table() 

25 

26 @staticmethod 

27 def _inch_to_twip(inch: float) -> int: 

28 """Convert inches to twips.""" 

29 return RTFMeasurements.inch_to_twip(inch) 

30 

31 @staticmethod 

32 def _col_widths(rel_widths: Sequence[float], col_width: float) -> list[float]: 

33 """Convert relative widths to absolute widths. 

34 Returns mutable list since we are building it. 

35 """ 

36 total_width = sum(rel_widths) 

37 cumulative_sum = 0.0 

38 return [ 

39 cumulative_sum := cumulative_sum + (width * col_width / total_width) 

40 for width in rel_widths 

41 ] 

42 

43 @staticmethod 

44 def _get_color_index(color: str, used_colors=None) -> int: 

45 """Get the index of a color in the color table.""" 

46 if not color or color == "black": 

47 return 0 # Default/black color 

48 

49 from .services.color_service import ColorValidationError, color_service 

50 

51 try: 

52 # If no explicit used_colors provided, the color service 

53 # will uses document context 

54 return color_service.get_rtf_color_index(color, used_colors) 

55 except ColorValidationError: 

56 # Invalid color name - return default 

57 return 0 

58 

59 

60class TextContent(BaseModel): 

61 """Represents RTF text with formatting.""" 

62 

63 text: str = Field(..., description="The text content") 

64 font: int = Field(default=1, description="Font index") 

65 size: int = Field(default=9, description="Font size") 

66 format: str | None = Field( 

67 default=None, 

68 description=( 

69 "Text formatting codes: b=bold, i=italic, u=underline, " 

70 "s=strikethrough, ^=superscript, _=subscript" 

71 ), 

72 ) 

73 color: str | None = Field(default=None, description="Text color") 

74 background_color: str | None = Field(default=None, description="Background color") 

75 justification: str = Field( 

76 default="l", description="Text justification (l, c, r, d, j)" 

77 ) 

78 indent_first: int = Field(default=0, description="First line indent") 

79 indent_left: int = Field(default=0, description="Left indent") 

80 indent_right: int = Field(default=0, description="Right indent") 

81 space: int = Field(default=1, description="Line spacing") 

82 space_before: int = Field( 

83 default=RTFConstants.DEFAULT_SPACE_BEFORE, description="Space before paragraph" 

84 ) 

85 space_after: int = Field( 

86 default=RTFConstants.DEFAULT_SPACE_AFTER, description="Space after paragraph" 

87 ) 

88 convert: bool = Field( 

89 default=True, description="Enable LaTeX to Unicode conversion" 

90 ) 

91 hyphenation: bool = Field(default=True, description="Enable hyphenation") 

92 

93 def _get_paragraph_formatting(self) -> str: 

94 """Get RTF paragraph formatting codes.""" 

95 rtf = [] 

96 

97 # Hyphenation 

98 if self.hyphenation: 

99 rtf.append("\\hyphpar") 

100 else: 

101 rtf.append("\\hyphpar0") 

102 

103 # Spacing 

104 rtf.append(f"\\sb{self.space_before}") 

105 rtf.append(f"\\sa{self.space_after}") 

106 if self.space != 1: 

107 rtf.append( 

108 f"\\sl{int(self.space * RTFConstants.LINE_SPACING_FACTOR)}\\slmult1" 

109 ) 

110 

111 # Indentation 

112 indent_first = self.indent_first / RTFConstants.TWIPS_PER_INCH 

113 indent_left = self.indent_left / RTFConstants.TWIPS_PER_INCH 

114 indent_right = self.indent_right / RTFConstants.TWIPS_PER_INCH 

115 rtf.append(f"\\fi{Utils._inch_to_twip(indent_first)}") 

116 rtf.append(f"\\li{Utils._inch_to_twip(indent_left)}") 

117 rtf.append(f"\\ri{Utils._inch_to_twip(indent_right)}") 

118 

119 # Justification 

120 if self.justification not in TEXT_JUSTIFICATION_CODES: 

121 allowed = ", ".join(TEXT_JUSTIFICATION_CODES.keys()) 

122 raise ValueError( 

123 "Text: Invalid justification " 

124 f"'{self.justification}'. Must be one of: {allowed}" 

125 ) 

126 rtf.append(TEXT_JUSTIFICATION_CODES[self.justification]) 

127 

128 return "".join(rtf) 

129 

130 def _get_text_formatting(self) -> str: 

131 """Get RTF text formatting codes.""" 

132 rtf = [] 

133 

134 # Size (RTF uses half-points) 

135 rtf.append(f"\\fs{self.size * 2}") 

136 

137 # Font 

138 rtf.append(f"{{\\f{int(self.font - 1)}") 

139 

140 # Color 

141 if self.color: 

142 rtf.append(f"\\cf{Utils._get_color_index(self.color)}") 

143 

144 # Background color 

145 if self.background_color: 

146 bp_color = Utils._get_color_index(self.background_color) 

147 rtf.append(f"\\chshdng0\\chcbpat{bp_color}\\cb{bp_color}") 

148 

149 # Format (bold, italic, etc) 

150 if self.format: 

151 for fmt in sorted(list(set(self.format))): 

152 if fmt in FORMAT_CODES: 

153 rtf.append(FORMAT_CODES[fmt]) 

154 else: 

155 allowed = ", ".join(FORMAT_CODES.keys()) 

156 raise ValueError( 

157 "Text: Invalid format character " 

158 f"'{fmt}' in '{self.format}'. Must be one of: {allowed}" 

159 ) 

160 

161 return "".join(rtf) 

162 

163 def _convert_special_chars(self) -> str: 

164 """Convert special characters to RTF codes.""" 

165 text = self.text 

166 

167 # Basic RTF character conversion (matching r2rtf char_rtf mapping) 

168 # Only apply character conversions if text conversion is enabled 

169 if self.convert: 

170 rtf_chars = RTFConstants.RTF_CHAR_MAPPING 

171 for char, rtf in rtf_chars.items(): 

172 text = text.replace(char, rtf) 

173 

174 # Apply LaTeX to Unicode conversion if enabled 

175 from .services.text_conversion_service import TextConversionService 

176 

177 service = TextConversionService() 

178 converted_text = service.convert_text_content(text, self.convert) 

179 

180 if converted_text is None: 

181 return "" 

182 

183 text = str(converted_text) 

184 

185 converted_text = "" 

186 for char in text: 

187 unicode_int = ord(char) 

188 if unicode_int <= 255 and unicode_int != 177: 

189 converted_text += char 

190 else: 

191 rtf_value = unicode_int - (0 if unicode_int < 32768 else 65536) 

192 converted_text += f"\\uc1\\u{rtf_value}*" 

193 

194 text = converted_text 

195 

196 return text 

197 

198 def _as_rtf(self, method: str) -> str: 

199 """Format source as RTF.""" 

200 formatted_text = self._convert_special_chars() 

201 if method == "paragraph": 

202 return ( 

203 "{\\pard" 

204 f"{self._get_paragraph_formatting()}" 

205 f"{self._get_text_formatting()} " 

206 f"{formatted_text}}}\\par}}" 

207 ) 

208 if method == "cell": 

209 return ( 

210 "\\pard" 

211 f"{self._get_paragraph_formatting()}" 

212 f"{self._get_text_formatting()} " 

213 f"{formatted_text}}}\\cell" 

214 ) 

215 

216 if method == "plain": 

217 return f"{self._get_text_formatting()} {formatted_text}}}" 

218 

219 if method == "paragraph_format": 

220 return f"{{\\pard{self._get_paragraph_formatting()}{self.text}\\par}}" 

221 

222 if method == "cell_format": 

223 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell" 

224 

225 raise ValueError(f"Invalid method: {method}") 

226 

227 

228class Border(BaseModel): 

229 """Represents a single border's style, color, and width.""" 

230 

231 style: str = Field( 

232 default="single", description="Border style (single, double, dashed, etc)" 

233 ) 

234 width: int = Field( 

235 default=RTFConstants.DEFAULT_BORDER_WIDTH, description="Border width in twips" 

236 ) 

237 color: str | None = Field(default=None, description="Border color") 

238 

239 def _as_rtf(self) -> str: 

240 """Get RTF border style codes.""" 

241 if self.style not in BORDER_CODES: 

242 raise ValueError(f"Invalid border type: {self.style}") 

243 

244 rtf = f"{BORDER_CODES[self.style]}\\brdrw{self.width}" 

245 

246 # Add color if specified 

247 if self.color is not None: 

248 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}" 

249 

250 return rtf 

251 

252 

253class Cell(BaseModel): 

254 """Represents a cell in an RTF table.""" 

255 

256 text: TextContent 

257 width: float = Field(..., description="Cell width") 

258 vertical_justification: str | None = Field( 

259 default="bottom", description="Vertical alignment" 

260 ) 

261 border_top: Border | None = Field(default=Border(), description="Top border") 

262 border_right: Border | None = Field(default=Border(), description="Right border") 

263 border_bottom: Border | None = Field(default=Border(), description="Bottom border") 

264 border_left: Border | None = Field(default=Border(), description="Left border") 

265 

266 def _as_rtf(self) -> str: 

267 """Format a single table cell in RTF.""" 

268 # Cell Border 

269 rtf = [] 

270 

271 if self.border_left is not None: 

272 rtf.append("\\clbrdrl" + self.border_left._as_rtf()) 

273 

274 if self.border_top is not None: 

275 rtf.append("\\clbrdrt" + self.border_top._as_rtf()) 

276 

277 if self.border_right is not None: 

278 rtf.append("\\clbrdrr" + self.border_right._as_rtf()) 

279 

280 if self.border_bottom is not None: 

281 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf()) 

282 

283 # Cell vertical alignment 

284 if self.vertical_justification is not None: 

285 rtf.append(VERTICAL_ALIGNMENT_CODES[self.vertical_justification]) 

286 

287 # Cell width 

288 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}") 

289 

290 return "".join(rtf) 

291 

292 

293class Row(BaseModel): 

294 """Represents a row in an RTF table.""" 

295 

296 row_cells: Sequence[Cell] = Field(..., description="List of cells in the row") 

297 justification: str = Field(default="c", description="Row justification (l, c, r)") 

298 height: float = Field(default=0.15, description="Row height") 

299 

300 def _as_rtf(self) -> MutableSequence[str]: 

301 """Format a row of cells in RTF. 

302 Returns mutable list since we are building it.""" 

303 # Justification 

304 if self.justification not in ROW_JUSTIFICATION_CODES: 

305 allowed = ", ".join(ROW_JUSTIFICATION_CODES.keys()) 

306 raise ValueError( 

307 "Row: Invalid justification " 

308 f"'{self.justification}'. Must be one of: {allowed}" 

309 ) 

310 

311 row_height = int(Utils._inch_to_twip(self.height) / 2) 

312 justification_code = ROW_JUSTIFICATION_CODES[self.justification] 

313 rtf = [(f"\\trowd\\trgaph{row_height}\\trleft0{justification_code}")] 

314 rtf.extend(cell._as_rtf() for cell in self.row_cells) 

315 rtf.extend(cell.text._as_rtf(method="cell") for cell in self.row_cells) 

316 rtf.append("\\intbl\\row\\pard") 

317 return rtf