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
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 05:03 +0000
1from collections.abc import Mapping, MutableSequence, Sequence
3from pydantic import BaseModel, Field
5FORMAT_CODES = {
6 "": "",
7 "b": "\\b",
8 "i": "\\i",
9 "u": "\\ul",
10 "s": "\\strike",
11 "^": "\\super",
12 "_": "\\sub",
13}
15TEXT_JUSTIFICATION_CODES = {
16 "": "",
17 "l": "\\ql",
18 "c": "\\qc",
19 "r": "\\qr",
20 "d": "\\qd",
21 "j": "\\qj",
22}
24ROW_JUSTIFICATION_CODES = {"": "", "l": "\\trql", "c": "\\trqc", "r": "\\trqr"}
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}
44VERTICAL_ALIGNMENT_CODES = {
45 "top": "\\clvertalt",
46 "center": "\\clvertalc",
47 "bottom": "\\clvertalb",
48 "": "",
49}
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 }
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 }
140 @staticmethod
141 def _inch_to_twip(inch: float) -> int:
142 """Convert inches to twips."""
143 return round(inch * 1440)
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 ]
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
167class TextContent(BaseModel):
168 """Represents RTF text with formatting."""
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")
190 def _get_paragraph_formatting(self) -> str:
191 """Get RTF paragraph formatting codes."""
192 rtf = []
194 # Hyphenation
195 if self.hyphenation:
196 rtf.append("\\hyphpar")
197 else:
198 rtf.append("\\hyphpar0")
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")
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)}")
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])
218 return "".join(rtf)
220 def _get_text_formatting(self) -> str:
221 """Get RTF text formatting codes."""
222 rtf = []
224 # Size (RTF uses half-points)
225 rtf.append(f"\\fs{self.size * 2}")
227 # Font
228 rtf.append(f"{ \\f{int(self.font - 1)}")
230 # Color
231 if self.color:
232 rtf.append(f"\\cf{Utils._get_color_index(self.color)}")
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}")
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 )
249 return "".join(rtf)
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 }
265 for char, rtf in rtf_chars.items():
266 text = self.text.replace(char, rtf)
268 return text
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"
277 if method == "plain":
278 return f"{self._get_text_formatting()} {self._convert_special_chars()}} "
280 if method == "paragraph_format":
281 return f"{ \\pard{self._get_paragraph_formatting()}{self.text}\\par} "
283 if method == "cell_format":
284 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell"
286 raise ValueError(f"Invalid method: {method}")
289class Border(BaseModel):
290 """Represents a single border's style, color, and width."""
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")
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}")
303 rtf = f"{BORDER_CODES[self.style]}\\brdrw{self.width}"
305 # Add color if specified
306 if self.color is not None:
307 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}"
309 return rtf
312class Cell(BaseModel):
313 """Represents a cell in an RTF table."""
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")
325 def _as_rtf(self) -> str:
326 """Format a single table cell in RTF."""
327 # Cell Border
328 rtf = []
330 if self.border_left is not None:
331 rtf.append("\\clbrdrl" + self.border_left._as_rtf())
333 if self.border_top is not None:
334 rtf.append("\\clbrdrt" + self.border_top._as_rtf())
336 if self.border_right is not None:
337 rtf.append("\\clbrdrr" + self.border_right._as_rtf())
339 if self.border_bottom is not None:
340 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf())
342 # Cell vertical alignment
343 if self.vertical_justification is not None:
344 rtf.append(VERTICAL_ALIGNMENT_CODES[self.vertical_justification])
346 # Cell width
347 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}")
349 return "".join(rtf)
352class Row(BaseModel):
353 """Represents a row in an RTF table."""
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")
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 )
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