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
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-03 15:40 +0000
1from collections.abc import Mapping, MutableSequence, Sequence
3from pydantic import BaseModel, Field
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 }
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 }
82 @staticmethod
83 def _inch_to_twip(inch: float) -> int:
84 """Convert inches to twips."""
85 return round(inch * 1440)
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 ]
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
109class TextContent(BaseModel):
110 """Represents RTF text with formatting."""
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")
132 def _get_paragraph_formatting(self) -> str:
133 """Get RTF paragraph formatting codes."""
134 rtf = []
136 # Hyphenation
137 if self.hyphenation:
138 rtf.append("\\hyphpar")
139 else:
140 rtf.append("\\hyphpar0")
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")
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)}")
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])
161 return "".join(rtf)
163 def _get_text_formatting(self) -> str:
164 """Get RTF text formatting codes."""
165 rtf = []
167 # Size (RTF uses half-points)
168 rtf.append(f"\\fs{self.size * 2}")
170 # Font
171 rtf.append(f"{ \\f{int(self.font - 1)}")
173 # Color
174 if self.color:
175 rtf.append(f"\\cf{Utils._get_color_index(self.color)}")
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}")
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 )
200 return "".join(rtf)
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 }
216 for char, rtf in rtf_chars.items():
217 text = self.text.replace(char, rtf)
219 return text
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"
228 if method == "plain":
229 return f"{self._get_text_formatting()} {self._convert_special_chars()}} "
231 if method == "paragraph_format":
232 return f"{ \\pard{self._get_paragraph_formatting()}{self.text}\\par} "
234 if method == "cell_format":
235 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell"
237 raise ValueError(f"Invalid method: {method}")
240class Border(BaseModel):
241 """Represents a single border's style, color, and width."""
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")
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 }
269 if self.style not in border_codes:
270 raise ValueError(f"Invalid border type: {self.style}")
272 rtf = f"{border_codes[self.style]}\\brdrw{self.width}"
274 # Add color if specified
275 if self.color is not None:
276 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}"
278 return rtf
281class Cell(BaseModel):
282 """Represents a cell in an RTF table."""
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")
294 def _as_rtf(self) -> str:
295 """Format a single table cell in RTF."""
296 # Cell Border
297 rtf = []
299 if self.border_left is not None:
300 rtf.append("\\clbrdrl" + self.border_left._as_rtf())
302 if self.border_top is not None:
303 rtf.append("\\clbrdrt" + self.border_top._as_rtf())
305 if self.border_right is not None:
306 rtf.append("\\clbrdrr" + self.border_right._as_rtf())
308 if self.border_bottom is not None:
309 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf())
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])
319 # Cell width
320 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}")
322 return "".join(rtf)
325class Row(BaseModel):
326 """Represents a row in an RTF table."""
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")
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 )
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