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
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-28 05:09 +0000
1from collections.abc import Mapping, MutableSequence, Sequence
3from pydantic import BaseModel, Field
5from .core.constants import RTFConstants, RTFMeasurements
6from .fonts_mapping import FontMapping
7from .text_convert import text_convert
9# Import constants from centralized location for backwards compatibility
10FORMAT_CODES = RTFConstants.FORMAT_CODES
12TEXT_JUSTIFICATION_CODES = RTFConstants.TEXT_JUSTIFICATION_CODES
14ROW_JUSTIFICATION_CODES = RTFConstants.ROW_JUSTIFICATION_CODES
16BORDER_CODES = RTFConstants.BORDER_CODES
18VERTICAL_ALIGNMENT_CODES = RTFConstants.VERTICAL_ALIGNMENT_CODES
21class Utils:
22 @staticmethod
23 def _font_type() -> Mapping:
24 """Define font types"""
25 return FontMapping.get_font_table()
27 @staticmethod
28 def _inch_to_twip(inch: float) -> int:
29 """Convert inches to twips."""
30 return RTFMeasurements.inch_to_twip(inch)
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 ]
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
50 from .services.color_service import ColorValidationError, color_service
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
61class TextContent(BaseModel):
62 """Represents RTF text with formatting."""
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")
94 def _get_paragraph_formatting(self) -> str:
95 """Get RTF paragraph formatting codes."""
96 rtf = []
98 # Hyphenation
99 if self.hyphenation:
100 rtf.append("\\hyphpar")
101 else:
102 rtf.append("\\hyphpar0")
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 )
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)}")
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])
129 return "".join(rtf)
131 def _get_text_formatting(self) -> str:
132 """Get RTF text formatting codes."""
133 rtf = []
135 # Size (RTF uses half-points)
136 rtf.append(f"\\fs{self.size * 2}")
138 # Font
139 rtf.append(f"{{\\f{int(self.font - 1)}")
141 # Color
142 if self.color:
143 rtf.append(f"\\cf{Utils._get_color_index(self.color)}")
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}")
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 )
162 return "".join(rtf)
164 def _convert_special_chars(self) -> str:
165 """Convert special characters to RTF codes."""
166 text = self.text
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)
175 # Apply LaTeX to Unicode conversion if enabled
176 converted_text = text_convert(text, self.convert)
178 if converted_text is None:
179 return ""
181 text = converted_text
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}*"
192 text = converted_text
194 return text
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 )
214 if method == "plain":
215 return f"{self._get_text_formatting()} {formatted_text}}}"
217 if method == "paragraph_format":
218 return f"{{\\pard{self._get_paragraph_formatting()}{self.text}\\par}}"
220 if method == "cell_format":
221 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell"
223 raise ValueError(f"Invalid method: {method}")
226class Border(BaseModel):
227 """Represents a single border's style, color, and width."""
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")
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}")
242 rtf = f"{BORDER_CODES[self.style]}\\brdrw{self.width}"
244 # Add color if specified
245 if self.color is not None:
246 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}"
248 return rtf
251class Cell(BaseModel):
252 """Represents a cell in an RTF table."""
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")
264 def _as_rtf(self) -> str:
265 """Format a single table cell in RTF."""
266 # Cell Border
267 rtf = []
269 if self.border_left is not None:
270 rtf.append("\\clbrdrl" + self.border_left._as_rtf())
272 if self.border_top is not None:
273 rtf.append("\\clbrdrt" + self.border_top._as_rtf())
275 if self.border_right is not None:
276 rtf.append("\\clbrdrr" + self.border_right._as_rtf())
278 if self.border_bottom is not None:
279 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf())
281 # Cell vertical alignment
282 if self.vertical_justification is not None:
283 rtf.append(VERTICAL_ALIGNMENT_CODES[self.vertical_justification])
285 # Cell width
286 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}")
288 return "".join(rtf)
291class Row(BaseModel):
292 """Represents a row in an RTF table."""
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")
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 )
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