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

137 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-07 05:03 +0000

1from collections.abc import Mapping, MutableSequence, Sequence 

2 

3from pydantic import BaseModel, Field 

4 

5FORMAT_CODES = { 

6 "": "", 

7 "b": "\\b", 

8 "i": "\\i", 

9 "u": "\\ul", 

10 "s": "\\strike", 

11 "^": "\\super", 

12 "_": "\\sub", 

13} 

14 

15TEXT_JUSTIFICATION_CODES = { 

16 "": "", 

17 "l": "\\ql", 

18 "c": "\\qc", 

19 "r": "\\qr", 

20 "d": "\\qd", 

21 "j": "\\qj", 

22} 

23 

24ROW_JUSTIFICATION_CODES = {"": "", "l": "\\trql", "c": "\\trqc", "r": "\\trqr"} 

25 

26BORDER_CODES = { 

27 "single": "\\brdrs", 

28 "double": "\\brdrdb", 

29 "thick": "\\brdrth", 

30 "dotted": "\\brdrdot", 

31 "dashed": "\\brdrdash", 

32 "dash-dotted": "\\brdrdashd", 

33 "dash-dot-dotted": "\\brdrdashdd", 

34 "triple": "\\brdrtriple", 

35 "wavy": "\\brdrwavy", 

36 "double-wavy": "\\brdrwavydb", 

37 "striped": "\\brdrengrave", 

38 "embossed": "\\brdremboss", 

39 "engraved": "\\brdrengrave", 

40 "frame": "\\brdrframe", 

41 "": "", # No border 

42} 

43 

44VERTICAL_ALIGNMENT_CODES = { 

45 "top": "\\clvertalt", 

46 "center": "\\clvertalc", 

47 "bottom": "\\clvertalb", 

48 "": "", 

49} 

50 

51 

52class Utils: 

53 @staticmethod 

54 def _color_table() -> Mapping: 

55 """Define color table.""" 

56 return { 

57 "color": [ 

58 "black", 

59 "red", 

60 "green", 

61 "blue", 

62 "white", 

63 "lightgray", 

64 "darkgray", 

65 "yellow", 

66 "magenta", 

67 "cyan", 

68 ], 

69 "type": list(range(1, 11)), 

70 "rtf_code": [ 

71 "\\red0\\green0\\blue0;", # black 

72 "\\red255\\green0\\blue0;", # red 

73 "\\red0\\green255\\blue0;", # green 

74 "\\red0\\green0\\blue255;", # blue 

75 "\\red255\\green255\\blue255;", # white 

76 "\\red211\\green211\\blue211;", # lightgray 

77 "\\red169\\green169\\blue169;", # darkgray 

78 "\\red255\\green255\\blue0;", # yellow 

79 "\\red255\\green0\\blue255;", # magenta 

80 "\\red0\\green255\\blue255;", # cyan 

81 ], 

82 } 

83 

84 @staticmethod 

85 def _font_type() -> Mapping: 

86 """Define font types""" 

87 return { 

88 "type": list(range(1, 11)), 

89 "name": [ 

90 "Times New Roman", 

91 "Times New Roman Greek", 

92 "Arial Greek", 

93 "Arial", 

94 "Helvetica", 

95 "Calibri", 

96 "Georgia", 

97 "Cambria", 

98 "Courier New", 

99 "Symbol", 

100 ], 

101 "style": [ 

102 "\\froman", 

103 "\\froman", 

104 "\\fswiss", 

105 "\\fswiss", 

106 "\\fswiss", 

107 "\\fswiss", 

108 "\\froman", 

109 "\\ffroman", 

110 "\\fmodern", 

111 "\\ftech", 

112 ], 

113 "rtf_code": [f"\\f{i}" for i in range(10)], 

114 "family": [ 

115 "Times", 

116 "Times", 

117 "ArialMT", 

118 "ArialMT", 

119 "Helvetica", 

120 "Calibri", 

121 "Georgia", 

122 "Cambria", 

123 "Courier", 

124 "Times", 

125 ], 

126 "charset": [ 

127 "\\fcharset1", 

128 "\\fcharset161", 

129 "\\fcharset161", 

130 "\\fcharset0", 

131 "\\fcharset1", 

132 "\\fcharset1", 

133 "\\fcharset1", 

134 "\\fcharset1", 

135 "\\fcharset0", 

136 "\\fcharset2", 

137 ], 

138 } 

139 

140 @staticmethod 

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

142 """Convert inches to twips.""" 

143 return round(inch * 1440) 

144 

145 @staticmethod 

146 def _col_widths( 

147 rel_widths: Sequence[float], col_width: float 

148 ) -> MutableSequence[float]: 

149 """Convert relative widths to absolute widths. Returns mutable list since we're building it.""" 

150 total_width = sum(rel_widths) 

151 cumulative_sum = 0 

152 return [ 

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

154 for width in rel_widths 

155 ] 

156 

157 @staticmethod 

158 def _get_color_index(color: str) -> int: 

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

160 colors = Utils._color_table() 

161 try: 

162 return colors["color"].index(color) + 1 

163 except ValueError: 

164 return 0 # Default to black 

165 

166 

167class TextContent(BaseModel): 

168 """Represents RTF text with formatting.""" 

169 

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

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

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

173 format: str | None = Field( 

174 default=None, 

175 description="Text formatting codes: b=bold, i=italic, u=underline, s=strikethrough, ^=superscript, _=subscript", 

176 ) 

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

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

179 justification: str = Field( 

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

181 ) 

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

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

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

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

186 space_before: int = Field(default=15, description="Space before paragraph") 

187 space_after: int = Field(default=15, description="Space after paragraph") 

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

189 

190 def _get_paragraph_formatting(self) -> str: 

191 """Get RTF paragraph formatting codes.""" 

192 rtf = [] 

193 

194 # Hyphenation 

195 if self.hyphenation: 

196 rtf.append("\\hyphpar") 

197 else: 

198 rtf.append("\\hyphpar0") 

199 

200 # Spacing 

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

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

203 if self.space != 1: 

204 rtf.append(f"\\sl{int(self.space * 240)}\\slmult1") 

205 

206 # Indentation 

207 rtf.append(f"\\fi{Utils._inch_to_twip(self.indent_first / 1440)}") 

208 rtf.append(f"\\li{Utils._inch_to_twip(self.indent_left / 1440)}") 

209 rtf.append(f"\\ri{Utils._inch_to_twip(self.indent_right / 1440)}") 

210 

211 # Justification 

212 if self.justification not in TEXT_JUSTIFICATION_CODES: 

213 raise ValueError( 

214 f"Text: Invalid justification '{self.justification}'. Must be one of: {', '.join(TEXT_JUSTIFICATION_CODES.keys())}" 

215 ) 

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

217 

218 return "".join(rtf) 

219 

220 def _get_text_formatting(self) -> str: 

221 """Get RTF text formatting codes.""" 

222 rtf = [] 

223 

224 # Size (RTF uses half-points) 

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

226 

227 # Font 

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

229 

230 # Color 

231 if self.color: 

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

233 

234 # Background color 

235 if self.background_color: 

236 bp_color = Utils._get_color_index(self.background_color) 

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

238 

239 # Format (bold, italic, etc) 

240 if self.format: 

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

242 if fmt in FORMAT_CODES: 

243 rtf.append(FORMAT_CODES[fmt]) 

244 else: 

245 raise ValueError( 

246 f"Text: Invalid format character '{fmt}' in '{self.format}'. Must be one of: {', '.join(FORMAT_CODES.keys())}" 

247 ) 

248 

249 return "".join(rtf) 

250 

251 def _convert_special_chars(self) -> str: 

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

253 # Basic RTF character conversion 

254 rtf_chars = { 

255 "\\": "\\\\", 

256 "{": "\\{", 

257 "}": "\\}", 

258 "\n": "\\line ", 

259 "^": "\\super ", 

260 "_": "\\sub ", 

261 "≥": "\\geq ", 

262 "≤": "\\leq ", 

263 } 

264 

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

266 text = self.text.replace(char, rtf) 

267 

268 return text 

269 

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

271 """Format source as RTF.""" 

272 if method == "paragraph": 

273 return f"{ \\pard{self._get_paragraph_formatting()}{self._get_text_formatting()} {self._convert_special_chars()}} \\par} " 

274 if method == "cell": 

275 return f"\\pard{self._get_paragraph_formatting()}{self._get_text_formatting()} {self._convert_special_chars()}} \\cell" 

276 

277 if method == "plain": 

278 return f"{self._get_text_formatting()} {self._convert_special_chars()}} " 

279 

280 if method == "paragraph_format": 

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

282 

283 if method == "cell_format": 

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

285 

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

287 

288 

289class Border(BaseModel): 

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

291 

292 style: str = Field( 

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

294 ) 

295 width: int = Field(default=15, description="Border width in twips") 

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

297 

298 def _as_rtf(self) -> str: 

299 """Get RTF border style codes.""" 

300 if self.style not in BORDER_CODES: 

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

302 

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

304 

305 # Add color if specified 

306 if self.color is not None: 

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

308 

309 return rtf 

310 

311 

312class Cell(BaseModel): 

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

314 

315 text: TextContent 

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

317 vertical_justification: str | None = Field( 

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

319 ) 

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

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

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

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

324 

325 def _as_rtf(self) -> str: 

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

327 # Cell Border 

328 rtf = [] 

329 

330 if self.border_left is not None: 

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

332 

333 if self.border_top is not None: 

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

335 

336 if self.border_right is not None: 

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

338 

339 if self.border_bottom is not None: 

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

341 

342 # Cell vertical alignment 

343 if self.vertical_justification is not None: 

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

345 

346 # Cell width 

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

348 

349 return "".join(rtf) 

350 

351 

352class Row(BaseModel): 

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

354 

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

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

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

358 

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

360 """Format a row of cells in RTF. Returns mutable list since we're building it.""" 

361 # Justification 

362 if self.justification not in ROW_JUSTIFICATION_CODES: 

363 raise ValueError( 

364 f"Row: Invalid justification '{self.justification}'. Must be one of: {', '.join(ROW_JUSTIFICATION_CODES.keys())}" 

365 ) 

366 

367 rtf = [ 

368 f"\\trowd\\trgaph{int(Utils._inch_to_twip(self.height) / 2)}\\trleft0{ROW_JUSTIFICATION_CODES[self.justification]}" 

369 ] 

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

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

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

373 return rtf