Coverage for src / rtflite / row.py: 93%
163 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 04:50 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 04:50 +0000
1from collections.abc import Mapping, MutableSequence, Sequence
3from pydantic import BaseModel, Field
5from .core.constants import RTFConstants, RTFMeasurements
6from .fonts_mapping import FontMapping
8# Import constants from centralized location for backwards compatibility
9FORMAT_CODES = RTFConstants.FORMAT_CODES
11TEXT_JUSTIFICATION_CODES = RTFConstants.TEXT_JUSTIFICATION_CODES
13ROW_JUSTIFICATION_CODES = RTFConstants.ROW_JUSTIFICATION_CODES
15BORDER_CODES = RTFConstants.BORDER_CODES
17VERTICAL_ALIGNMENT_CODES = RTFConstants.VERTICAL_ALIGNMENT_CODES
20class Utils:
21 @staticmethod
22 def _font_type() -> Mapping:
23 """Define font types"""
24 return FontMapping.get_font_table()
26 @staticmethod
27 def _inch_to_twip(inch: float) -> int:
28 """Convert inches to twips."""
29 return RTFMeasurements.inch_to_twip(inch)
31 @staticmethod
32 def _col_widths(rel_widths: Sequence[float], col_width: float) -> list[float]:
33 """Convert relative widths to absolute widths.
34 Returns mutable list since we are building it.
35 """
36 total_width = sum(rel_widths)
37 cumulative_sum = 0.0
38 return [
39 cumulative_sum := cumulative_sum + (width * col_width / total_width)
40 for width in rel_widths
41 ]
43 @staticmethod
44 def _get_color_index(color: str, used_colors=None) -> int:
45 """Get the index of a color in the color table."""
46 if not color or color == "black":
47 return 0 # Default/black color
49 from .services.color_service import ColorValidationError, color_service
51 try:
52 # If no explicit used_colors provided, the color service
53 # will uses document context
54 return color_service.get_rtf_color_index(color, used_colors)
55 except ColorValidationError:
56 # Invalid color name - return default
57 return 0
60class TextContent(BaseModel):
61 """Represents RTF text with formatting."""
63 text: str = Field(..., description="The text content")
64 font: int = Field(default=1, description="Font index")
65 size: int = Field(default=9, description="Font size")
66 format: str | None = Field(
67 default=None,
68 description=(
69 "Text formatting codes: b=bold, i=italic, u=underline, "
70 "s=strikethrough, ^=superscript, _=subscript"
71 ),
72 )
73 color: str | None = Field(default=None, description="Text color")
74 background_color: str | None = Field(default=None, description="Background color")
75 justification: str = Field(
76 default="l", description="Text justification (l, c, r, d, j)"
77 )
78 indent_first: int = Field(default=0, description="First line indent")
79 indent_left: int = Field(default=0, description="Left indent")
80 indent_right: int = Field(default=0, description="Right indent")
81 space: int = Field(default=1, description="Line spacing")
82 space_before: int = Field(
83 default=RTFConstants.DEFAULT_SPACE_BEFORE, description="Space before paragraph"
84 )
85 space_after: int = Field(
86 default=RTFConstants.DEFAULT_SPACE_AFTER, description="Space after paragraph"
87 )
88 convert: bool = Field(
89 default=True, description="Enable LaTeX to Unicode conversion"
90 )
91 hyphenation: bool = Field(default=True, description="Enable hyphenation")
93 def _get_paragraph_formatting(self) -> str:
94 """Get RTF paragraph formatting codes."""
95 rtf = []
97 # Hyphenation
98 if self.hyphenation:
99 rtf.append("\\hyphpar")
100 else:
101 rtf.append("\\hyphpar0")
103 # Spacing
104 rtf.append(f"\\sb{self.space_before}")
105 rtf.append(f"\\sa{self.space_after}")
106 if self.space != 1:
107 rtf.append(
108 f"\\sl{int(self.space * RTFConstants.LINE_SPACING_FACTOR)}\\slmult1"
109 )
111 # Indentation
112 indent_first = self.indent_first / RTFConstants.TWIPS_PER_INCH
113 indent_left = self.indent_left / RTFConstants.TWIPS_PER_INCH
114 indent_right = self.indent_right / RTFConstants.TWIPS_PER_INCH
115 rtf.append(f"\\fi{Utils._inch_to_twip(indent_first)}")
116 rtf.append(f"\\li{Utils._inch_to_twip(indent_left)}")
117 rtf.append(f"\\ri{Utils._inch_to_twip(indent_right)}")
119 # Justification
120 if self.justification not in TEXT_JUSTIFICATION_CODES:
121 allowed = ", ".join(TEXT_JUSTIFICATION_CODES.keys())
122 raise ValueError(
123 "Text: Invalid justification "
124 f"'{self.justification}'. Must be one of: {allowed}"
125 )
126 rtf.append(TEXT_JUSTIFICATION_CODES[self.justification])
128 return "".join(rtf)
130 def _get_text_formatting(self) -> str:
131 """Get RTF text formatting codes."""
132 rtf = []
134 # Size (RTF uses half-points)
135 rtf.append(f"\\fs{self.size * 2}")
137 # Font
138 rtf.append(f"{{\\f{int(self.font - 1)}")
140 # Color
141 if self.color:
142 rtf.append(f"\\cf{Utils._get_color_index(self.color)}")
144 # Background color
145 if self.background_color:
146 bp_color = Utils._get_color_index(self.background_color)
147 rtf.append(f"\\chshdng0\\chcbpat{bp_color}\\cb{bp_color}")
149 # Format (bold, italic, etc)
150 if self.format:
151 for fmt in sorted(list(set(self.format))):
152 if fmt in FORMAT_CODES:
153 rtf.append(FORMAT_CODES[fmt])
154 else:
155 allowed = ", ".join(FORMAT_CODES.keys())
156 raise ValueError(
157 "Text: Invalid format character "
158 f"'{fmt}' in '{self.format}'. Must be one of: {allowed}"
159 )
161 return "".join(rtf)
163 def _convert_special_chars(self) -> str:
164 """Convert special characters to RTF codes."""
165 text = self.text
167 # Basic RTF character conversion (matching r2rtf char_rtf mapping)
168 # Only apply character conversions if text conversion is enabled
169 if self.convert:
170 rtf_chars = RTFConstants.RTF_CHAR_MAPPING
171 for char, rtf in rtf_chars.items():
172 text = text.replace(char, rtf)
174 # Apply LaTeX to Unicode conversion if enabled
175 from .services.text_conversion_service import TextConversionService
177 service = TextConversionService()
178 converted_text = service.convert_text_content(text, self.convert)
180 if converted_text is None:
181 return ""
183 text = str(converted_text)
185 converted_text = ""
186 for char in text:
187 unicode_int = ord(char)
188 if unicode_int <= 255 and unicode_int != 177:
189 converted_text += char
190 else:
191 rtf_value = unicode_int - (0 if unicode_int < 32768 else 65536)
192 converted_text += f"\\uc1\\u{rtf_value}*"
194 text = converted_text
196 return text
198 def _as_rtf(self, method: str) -> str:
199 """Format source as RTF."""
200 formatted_text = self._convert_special_chars()
201 if method == "paragraph":
202 return (
203 "{\\pard"
204 f"{self._get_paragraph_formatting()}"
205 f"{self._get_text_formatting()} "
206 f"{formatted_text}}}\\par}}"
207 )
208 if method == "cell":
209 return (
210 "\\pard"
211 f"{self._get_paragraph_formatting()}"
212 f"{self._get_text_formatting()} "
213 f"{formatted_text}}}\\cell"
214 )
216 if method == "plain":
217 return f"{self._get_text_formatting()} {formatted_text}}}"
219 if method == "paragraph_format":
220 return f"{{\\pard{self._get_paragraph_formatting()}{self.text}\\par}}"
222 if method == "cell_format":
223 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell"
225 raise ValueError(f"Invalid method: {method}")
228class Border(BaseModel):
229 """Represents a single border's style, color, and width."""
231 style: str = Field(
232 default="single", description="Border style (single, double, dashed, etc)"
233 )
234 width: int = Field(
235 default=RTFConstants.DEFAULT_BORDER_WIDTH, description="Border width in twips"
236 )
237 color: str | None = Field(default=None, description="Border color")
239 def _as_rtf(self) -> str:
240 """Get RTF border style codes."""
241 if self.style not in BORDER_CODES:
242 raise ValueError(f"Invalid border type: {self.style}")
244 rtf = f"{BORDER_CODES[self.style]}\\brdrw{self.width}"
246 # Add color if specified
247 if self.color is not None:
248 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}"
250 return rtf
253class Cell(BaseModel):
254 """Represents a cell in an RTF table."""
256 text: TextContent
257 width: float = Field(..., description="Cell width")
258 vertical_justification: str | None = Field(
259 default="bottom", description="Vertical alignment"
260 )
261 border_top: Border | None = Field(default=Border(), description="Top border")
262 border_right: Border | None = Field(default=Border(), description="Right border")
263 border_bottom: Border | None = Field(default=Border(), description="Bottom border")
264 border_left: Border | None = Field(default=Border(), description="Left border")
266 def _as_rtf(self) -> str:
267 """Format a single table cell in RTF."""
268 # Cell Border
269 rtf = []
271 if self.border_left is not None:
272 rtf.append("\\clbrdrl" + self.border_left._as_rtf())
274 if self.border_top is not None:
275 rtf.append("\\clbrdrt" + self.border_top._as_rtf())
277 if self.border_right is not None:
278 rtf.append("\\clbrdrr" + self.border_right._as_rtf())
280 if self.border_bottom is not None:
281 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf())
283 # Cell vertical alignment
284 if self.vertical_justification is not None:
285 rtf.append(VERTICAL_ALIGNMENT_CODES[self.vertical_justification])
287 # Cell width
288 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}")
290 return "".join(rtf)
293class Row(BaseModel):
294 """Represents a row in an RTF table."""
296 row_cells: Sequence[Cell] = Field(..., description="List of cells in the row")
297 justification: str = Field(default="c", description="Row justification (l, c, r)")
298 height: float = Field(default=0.15, description="Row height")
300 def _as_rtf(self) -> MutableSequence[str]:
301 """Format a row of cells in RTF.
302 Returns mutable list since we are building it."""
303 # Justification
304 if self.justification not in ROW_JUSTIFICATION_CODES:
305 allowed = ", ".join(ROW_JUSTIFICATION_CODES.keys())
306 raise ValueError(
307 "Row: Invalid justification "
308 f"'{self.justification}'. Must be one of: {allowed}"
309 )
311 row_height = int(Utils._inch_to_twip(self.height) / 2)
312 justification_code = ROW_JUSTIFICATION_CODES[self.justification]
313 rtf = [(f"\\trowd\\trgaph{row_height}\\trleft0{justification_code}")]
314 rtf.extend(cell._as_rtf() for cell in self.row_cells)
315 rtf.extend(cell.text._as_rtf(method="cell") for cell in self.row_cells)
316 rtf.append("\\intbl\\row\\pard")
317 return rtf