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

136 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-02-03 15:40 +0000

1from collections.abc import Mapping, MutableSequence, Sequence 

2 

3from pydantic import BaseModel, Field 

4 

5 

6class Utils: 

7 @staticmethod 

8 def _color_table() -> Mapping: 

9 """Define color table.""" 

10 return { 

11 "color": [ 

12 "black", 

13 "red", 

14 "green", 

15 "blue", 

16 "white", 

17 "lightgray", 

18 "darkgray", 

19 "yellow", 

20 "magenta", 

21 "cyan", 

22 ], 

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

24 "rtf_code": [ 

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

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

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

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

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

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

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

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

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

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

35 ], 

36 } 

37 

38 @staticmethod 

39 def _font_type() -> Mapping: 

40 """Define font types""" 

41 return { 

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

43 "name": [ 

44 "Times New Roman", 

45 "Times New Roman Greek", 

46 "Arial Greek", 

47 "Arial", 

48 "Helvetica", 

49 "Calibri", 

50 "Georgia", 

51 "Cambria", 

52 "Courier New", 

53 "Symbol", 

54 ], 

55 "style": [ 

56 "\\froman", 

57 "\\froman", 

58 "\\fswiss", 

59 "\\fswiss", 

60 "\\fswiss", 

61 "\\fswiss", 

62 "\\froman", 

63 "\\ffroman", 

64 "\\fmodern", 

65 "\\ftech", 

66 ], 

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

68 "family": [ 

69 "Times", 

70 "Times", 

71 "ArialMT", 

72 "ArialMT", 

73 "Helvetica", 

74 "Calibri", 

75 "Georgia", 

76 "Cambria", 

77 "Courier", 

78 "Times", 

79 ], 

80 } 

81 

82 @staticmethod 

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

84 """Convert inches to twips.""" 

85 return round(inch * 1440) 

86 

87 @staticmethod 

88 def _col_widths( 

89 rel_widths: Sequence[float], col_width: float 

90 ) -> MutableSequence[float]: 

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

92 total_width = sum(rel_widths) 

93 cumulative_sum = 0 

94 return [ 

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

96 for width in rel_widths 

97 ] 

98 

99 @staticmethod 

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

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

102 colors = Utils._color_table() 

103 try: 

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

105 except ValueError: 

106 return 0 # Default to black 

107 

108 

109class TextContent(BaseModel): 

110 """Represents RTF text with formatting.""" 

111 

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

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

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

115 format: str | None = Field( 

116 default=None, 

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

118 ) 

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

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

121 justification: str = Field( 

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

123 ) 

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

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

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

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

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

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

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

131 

132 def _get_paragraph_formatting(self) -> str: 

133 """Get RTF paragraph formatting codes.""" 

134 rtf = [] 

135 

136 # Hyphenation 

137 if self.hyphenation: 

138 rtf.append("\\hyphpar") 

139 else: 

140 rtf.append("\\hyphpar0") 

141 

142 # Spacing 

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

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

145 if self.space != 1: 

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

147 

148 # Indentation 

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

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

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

152 

153 # Justification 

154 just_codes = {"l": "\\ql", "c": "\\qc", "r": "\\qr", "d": "\\qd", "j": "\\qj"} 

155 if self.justification not in just_codes: 

156 raise ValueError( 

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

158 ) 

159 rtf.append(just_codes[self.justification]) 

160 

161 return "".join(rtf) 

162 

163 def _get_text_formatting(self) -> str: 

164 """Get RTF text formatting codes.""" 

165 rtf = [] 

166 

167 # Size (RTF uses half-points) 

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

169 

170 # Font 

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

172 

173 # Color 

174 if self.color: 

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

176 

177 # Background color 

178 if self.background_color: 

179 bp_color = Utils._get_color_index(self.background_color) 

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

181 

182 # Format (bold, italic, etc) 

183 if self.format: 

184 format_codes = { 

185 "b": "\\b", 

186 "i": "\\i", 

187 "u": "\\ul", 

188 "s": "\\strike", 

189 "^": "\\super", 

190 "_": "\\sub", 

191 } 

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

193 if fmt in format_codes: 

194 rtf.append(format_codes[fmt]) 

195 else: 

196 raise ValueError( 

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

198 ) 

199 

200 return "".join(rtf) 

201 

202 def _convert_special_chars(self) -> str: 

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

204 # Basic RTF character conversion 

205 rtf_chars = { 

206 "\\": "\\\\", 

207 "{": "\\{", 

208 "}": "\\}", 

209 "\n": "\\line ", 

210 "^": "\\super ", 

211 "_": "\\sub ", 

212 "≥": "\\geq ", 

213 "≤": "\\leq ", 

214 } 

215 

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

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

218 

219 return text 

220 

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

222 """Format source as RTF.""" 

223 if method == "paragraph": 

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

225 if method == "cell": 

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

227 

228 if method == "plain": 

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

230 

231 if method == "paragraph_format": 

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

233 

234 if method == "cell_format": 

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

236 

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

238 

239 

240class Border(BaseModel): 

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

242 

243 style: str = Field( 

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

245 ) 

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

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

248 

249 def _as_rtf(self) -> str: 

250 """Get RTF border style codes.""" 

251 border_codes = { 

252 "single": "\\brdrs", 

253 "double": "\\brdrdb", 

254 "thick": "\\brdrth", 

255 "dotted": "\\brdrdot", 

256 "dashed": "\\brdrdash", 

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

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

259 "triple": "\\brdrtriple", 

260 "wavy": "\\brdrwavy", 

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

262 "striped": "\\brdrengrave", 

263 "embossed": "\\brdremboss", 

264 "engraved": "\\brdrengrave", 

265 "frame": "\\brdrframe", 

266 "": "", # No border 

267 } 

268 

269 if self.style not in border_codes: 

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

271 

272 rtf = f"{border_codes[self.style]}\\brdrw{self.width}" 

273 

274 # Add color if specified 

275 if self.color is not None: 

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

277 

278 return rtf 

279 

280 

281class Cell(BaseModel): 

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

283 

284 text: TextContent 

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

286 vertical_justification: str = Field( 

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

288 ) 

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

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

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

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

293 

294 def _as_rtf(self) -> str: 

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

296 # Cell Border 

297 rtf = [] 

298 

299 if self.border_left is not None: 

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

301 

302 if self.border_top is not None: 

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

304 

305 if self.border_right is not None: 

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

307 

308 if self.border_bottom is not None: 

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

310 

311 # Cell vertical alignment 

312 valign_codes = { 

313 "top": "\\clvertalt", 

314 "center": "\\clvertalc", 

315 "bottom": "\\clvertalb", 

316 } 

317 rtf.append(valign_codes[self.vertical_justification]) 

318 

319 # Cell width 

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

321 

322 return "".join(rtf) 

323 

324 

325class Row(BaseModel): 

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

327 

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

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

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

331 

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

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

334 # Justification 

335 just_codes = {"l": "\\trql", "c": "\\trqc", "r": "\\trqr"} 

336 if self.justification not in just_codes: 

337 raise ValueError( 

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

339 ) 

340 

341 rtf = [ 

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

343 ] 

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

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

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

347 return rtf