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

162 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-28 05:09 +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 

7from .text_convert import text_convert 

8 

9# Import constants from centralized location for backwards compatibility 

10FORMAT_CODES = RTFConstants.FORMAT_CODES 

11 

12TEXT_JUSTIFICATION_CODES = RTFConstants.TEXT_JUSTIFICATION_CODES 

13 

14ROW_JUSTIFICATION_CODES = RTFConstants.ROW_JUSTIFICATION_CODES 

15 

16BORDER_CODES = RTFConstants.BORDER_CODES 

17 

18VERTICAL_ALIGNMENT_CODES = RTFConstants.VERTICAL_ALIGNMENT_CODES 

19 

20 

21class Utils: 

22 @staticmethod 

23 def _font_type() -> Mapping: 

24 """Define font types""" 

25 return FontMapping.get_font_table() 

26 

27 @staticmethod 

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

29 """Convert inches to twips.""" 

30 return RTFMeasurements.inch_to_twip(inch) 

31 

32 @staticmethod 

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

34 """Convert relative widths to absolute widths. 

35 Returns mutable list since we are building it. 

36 """ 

37 total_width = sum(rel_widths) 

38 cumulative_sum = 0.0 

39 return [ 

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

41 for width in rel_widths 

42 ] 

43 

44 @staticmethod 

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

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

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

48 return 0 # Default/black color 

49 

50 from .services.color_service import ColorValidationError, color_service 

51 

52 try: 

53 # If no explicit used_colors provided, the color service 

54 # will uses document context 

55 return color_service.get_rtf_color_index(color, used_colors) 

56 except ColorValidationError: 

57 # Invalid color name - return default 

58 return 0 

59 

60 

61class TextContent(BaseModel): 

62 """Represents RTF text with formatting.""" 

63 

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

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

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

67 format: str | None = Field( 

68 default=None, 

69 description=( 

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

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

72 ), 

73 ) 

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

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

76 justification: str = Field( 

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

78 ) 

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

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

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

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

83 space_before: int = Field( 

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

85 ) 

86 space_after: int = Field( 

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

88 ) 

89 convert: bool = Field( 

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

91 ) 

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

93 

94 def _get_paragraph_formatting(self) -> str: 

95 """Get RTF paragraph formatting codes.""" 

96 rtf = [] 

97 

98 # Hyphenation 

99 if self.hyphenation: 

100 rtf.append("\\hyphpar") 

101 else: 

102 rtf.append("\\hyphpar0") 

103 

104 # Spacing 

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

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

107 if self.space != 1: 

108 rtf.append( 

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

110 ) 

111 

112 # Indentation 

113 indent_first = self.indent_first / RTFConstants.TWIPS_PER_INCH 

114 indent_left = self.indent_left / RTFConstants.TWIPS_PER_INCH 

115 indent_right = self.indent_right / RTFConstants.TWIPS_PER_INCH 

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

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

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

119 

120 # Justification 

121 if self.justification not in TEXT_JUSTIFICATION_CODES: 

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

123 raise ValueError( 

124 "Text: Invalid justification " 

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

126 ) 

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

128 

129 return "".join(rtf) 

130 

131 def _get_text_formatting(self) -> str: 

132 """Get RTF text formatting codes.""" 

133 rtf = [] 

134 

135 # Size (RTF uses half-points) 

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

137 

138 # Font 

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

140 

141 # Color 

142 if self.color: 

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

144 

145 # Background color 

146 if self.background_color: 

147 bp_color = Utils._get_color_index(self.background_color) 

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

149 

150 # Format (bold, italic, etc) 

151 if self.format: 

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

153 if fmt in FORMAT_CODES: 

154 rtf.append(FORMAT_CODES[fmt]) 

155 else: 

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

157 raise ValueError( 

158 "Text: Invalid format character " 

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

160 ) 

161 

162 return "".join(rtf) 

163 

164 def _convert_special_chars(self) -> str: 

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

166 text = self.text 

167 

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

169 # Only apply character conversions if text conversion is enabled 

170 if self.convert: 

171 rtf_chars = RTFConstants.RTF_CHAR_MAPPING 

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

173 text = text.replace(char, rtf) 

174 

175 # Apply LaTeX to Unicode conversion if enabled 

176 converted_text = text_convert(text, self.convert) 

177 

178 if converted_text is None: 

179 return "" 

180 

181 text = converted_text 

182 

183 converted_text = "" 

184 for char in text: 

185 unicode_int = ord(char) 

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

187 converted_text += char 

188 else: 

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

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

191 

192 text = converted_text 

193 

194 return text 

195 

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

197 """Format source as RTF.""" 

198 formatted_text = self._convert_special_chars() 

199 if method == "paragraph": 

200 return ( 

201 "{\\pard" 

202 f"{self._get_paragraph_formatting()}" 

203 f"{self._get_text_formatting()} " 

204 f"{formatted_text}}}\\par}}" 

205 ) 

206 if method == "cell": 

207 return ( 

208 "\\pard" 

209 f"{self._get_paragraph_formatting()}" 

210 f"{self._get_text_formatting()} " 

211 f"{formatted_text}}}\\cell" 

212 ) 

213 

214 if method == "plain": 

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

216 

217 if method == "paragraph_format": 

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

219 

220 if method == "cell_format": 

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

222 

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

224 

225 

226class Border(BaseModel): 

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

228 

229 style: str = Field( 

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

231 ) 

232 width: int = Field( 

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

234 ) 

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

236 

237 def _as_rtf(self) -> str: 

238 """Get RTF border style codes.""" 

239 if self.style not in BORDER_CODES: 

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

241 

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

243 

244 # Add color if specified 

245 if self.color is not None: 

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

247 

248 return rtf 

249 

250 

251class Cell(BaseModel): 

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

253 

254 text: TextContent 

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

256 vertical_justification: str | None = Field( 

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

258 ) 

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

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

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

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

263 

264 def _as_rtf(self) -> str: 

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

266 # Cell Border 

267 rtf = [] 

268 

269 if self.border_left is not None: 

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

271 

272 if self.border_top is not None: 

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

274 

275 if self.border_right is not None: 

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

277 

278 if self.border_bottom is not None: 

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

280 

281 # Cell vertical alignment 

282 if self.vertical_justification is not None: 

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

284 

285 # Cell width 

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

287 

288 return "".join(rtf) 

289 

290 

291class Row(BaseModel): 

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

293 

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

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

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

297 

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

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

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

301 # Justification 

302 if self.justification not in ROW_JUSTIFICATION_CODES: 

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

304 raise ValueError( 

305 "Row: Invalid justification " 

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

307 ) 

308 

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

310 justification_code = ROW_JUSTIFICATION_CODES[self.justification] 

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

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

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

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

315 return rtf