Coverage for src/rtflite/row.py: 93%
152 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-17 01:22 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-17 01:22 +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. Returns mutable list since we're building it."""
35 total_width = sum(rel_widths)
36 cumulative_sum = 0.0
37 return [
38 cumulative_sum := cumulative_sum + (width * col_width / total_width)
39 for width in rel_widths
40 ]
42 @staticmethod
43 def _get_color_index(color: str, used_colors=None) -> int:
44 """Get the index of a color in the color table."""
45 if not color or color == "black":
46 return 0 # Default/black color
48 from .services.color_service import ColorValidationError, color_service
50 try:
51 # If no explicit used_colors provided, the color service will use document context
52 return color_service.get_rtf_color_index(color, used_colors)
53 except ColorValidationError:
54 # Invalid color name - return default
55 return 0
58class TextContent(BaseModel):
59 """Represents RTF text with formatting."""
61 text: str = Field(..., description="The text content")
62 font: int = Field(default=1, description="Font index")
63 size: int = Field(default=9, description="Font size")
64 format: str | None = Field(
65 default=None,
66 description="Text formatting codes: b=bold, i=italic, u=underline, s=strikethrough, ^=superscript, _=subscript",
67 )
68 color: str | None = Field(default=None, description="Text color")
69 background_color: str | None = Field(default=None, description="Background color")
70 justification: str = Field(
71 default="l", description="Text justification (l, c, r, d, j)"
72 )
73 indent_first: int = Field(default=0, description="First line indent")
74 indent_left: int = Field(default=0, description="Left indent")
75 indent_right: int = Field(default=0, description="Right indent")
76 space: int = Field(default=1, description="Line spacing")
77 space_before: int = Field(
78 default=RTFConstants.DEFAULT_SPACE_BEFORE, description="Space before paragraph"
79 )
80 space_after: int = Field(
81 default=RTFConstants.DEFAULT_SPACE_AFTER, description="Space after paragraph"
82 )
83 convert: bool = Field(
84 default=True, description="Enable LaTeX to Unicode conversion"
85 )
86 hyphenation: bool = Field(default=True, description="Enable hyphenation")
88 def _get_paragraph_formatting(self) -> str:
89 """Get RTF paragraph formatting codes."""
90 rtf = []
92 # Hyphenation
93 if self.hyphenation:
94 rtf.append("\\hyphpar")
95 else:
96 rtf.append("\\hyphpar0")
98 # Spacing
99 rtf.append(f"\\sb{self.space_before}")
100 rtf.append(f"\\sa{self.space_after}")
101 if self.space != 1:
102 rtf.append(
103 f"\\sl{int(self.space * RTFConstants.LINE_SPACING_FACTOR)}\\slmult1"
104 )
106 # Indentation
107 rtf.append(
108 f"\\fi{Utils._inch_to_twip(self.indent_first / RTFConstants.TWIPS_PER_INCH)}"
109 )
110 rtf.append(
111 f"\\li{Utils._inch_to_twip(self.indent_left / RTFConstants.TWIPS_PER_INCH)}"
112 )
113 rtf.append(
114 f"\\ri{Utils._inch_to_twip(self.indent_right / RTFConstants.TWIPS_PER_INCH)}"
115 )
117 # Justification
118 if self.justification not in TEXT_JUSTIFICATION_CODES:
119 raise ValueError(
120 f"Text: Invalid justification '{self.justification}'. Must be one of: {', '.join(TEXT_JUSTIFICATION_CODES.keys())}"
121 )
122 rtf.append(TEXT_JUSTIFICATION_CODES[self.justification])
124 return "".join(rtf)
126 def _get_text_formatting(self) -> str:
127 """Get RTF text formatting codes."""
128 rtf = []
130 # Size (RTF uses half-points)
131 rtf.append(f"\\fs{self.size * 2}")
133 # Font
134 rtf.append(f"{{\\f{int(self.font - 1)}")
136 # Color
137 if self.color:
138 rtf.append(f"\\cf{Utils._get_color_index(self.color)}")
140 # Background color
141 if self.background_color:
142 bp_color = Utils._get_color_index(self.background_color)
143 rtf.append(f"\\chshdng0\\chcbpat{bp_color}\\cb{bp_color}")
145 # Format (bold, italic, etc)
146 if self.format:
147 for fmt in sorted(list(set(self.format))):
148 if fmt in FORMAT_CODES:
149 rtf.append(FORMAT_CODES[fmt])
150 else:
151 raise ValueError(
152 f"Text: Invalid format character '{fmt}' in '{self.format}'. Must be one of: {', '.join(FORMAT_CODES.keys())}"
153 )
155 return "".join(rtf)
157 def _convert_special_chars(self) -> str:
158 """Convert special characters to RTF codes."""
159 # First apply LaTeX to Unicode conversion if enabled
160 text = text_convert(self.text, self.convert)
162 if text is None:
163 return ""
165 converted_text = ""
166 for char in text:
167 unicode_int = ord(char)
168 if unicode_int <= 255 and unicode_int != 177:
169 converted_text += char
170 else:
171 rtf_value = unicode_int - (0 if unicode_int < 32768 else 65536)
172 converted_text += f"\\uc1\\u{rtf_value}*"
174 text = converted_text
176 # Basic RTF character conversion (matching r2rtf char_rtf mapping)
177 # Only apply character conversions if text conversion is enabled
178 if self.convert:
179 rtf_chars = RTFConstants.RTF_CHAR_MAPPING
180 for char, rtf in rtf_chars.items():
181 text = text.replace(char, rtf)
183 return text
185 def _as_rtf(self, method: str) -> str:
186 """Format source as RTF."""
187 if method == "paragraph":
188 return f"{{\\pard{self._get_paragraph_formatting()}{self._get_text_formatting()} {self._convert_special_chars()}}}\\par}}"
189 if method == "cell":
190 return f"\\pard{self._get_paragraph_formatting()}{self._get_text_formatting()} {self._convert_special_chars()}}}\\cell"
192 if method == "plain":
193 return f"{self._get_text_formatting()} {self._convert_special_chars()}}}"
195 if method == "paragraph_format":
196 return f"{{\\pard{self._get_paragraph_formatting()}{self.text}\\par}}"
198 if method == "cell_format":
199 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell"
201 raise ValueError(f"Invalid method: {method}")
204class Border(BaseModel):
205 """Represents a single border's style, color, and width."""
207 style: str = Field(
208 default="single", description="Border style (single, double, dashed, etc)"
209 )
210 width: int = Field(
211 default=RTFConstants.DEFAULT_BORDER_WIDTH, description="Border width in twips"
212 )
213 color: str | None = Field(default=None, description="Border color")
215 def _as_rtf(self) -> str:
216 """Get RTF border style codes."""
217 if self.style not in BORDER_CODES:
218 raise ValueError(f"Invalid border type: {self.style}")
220 rtf = f"{BORDER_CODES[self.style]}\\brdrw{self.width}"
222 # Add color if specified
223 if self.color is not None:
224 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}"
226 return rtf
229class Cell(BaseModel):
230 """Represents a cell in an RTF table."""
232 text: TextContent
233 width: float = Field(..., description="Cell width")
234 vertical_justification: str | None = Field(
235 default="bottom", description="Vertical alignment"
236 )
237 border_top: Border | None = Field(default=Border(), description="Top border")
238 border_right: Border | None = Field(default=Border(), description="Right border")
239 border_bottom: Border | None = Field(default=Border(), description="Bottom border")
240 border_left: Border | None = Field(default=Border(), description="Left border")
242 def _as_rtf(self) -> str:
243 """Format a single table cell in RTF."""
244 # Cell Border
245 rtf = []
247 if self.border_left is not None:
248 rtf.append("\\clbrdrl" + self.border_left._as_rtf())
250 if self.border_top is not None:
251 rtf.append("\\clbrdrt" + self.border_top._as_rtf())
253 if self.border_right is not None:
254 rtf.append("\\clbrdrr" + self.border_right._as_rtf())
256 if self.border_bottom is not None:
257 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf())
259 # Cell vertical alignment
260 if self.vertical_justification is not None:
261 rtf.append(VERTICAL_ALIGNMENT_CODES[self.vertical_justification])
263 # Cell width
264 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}")
266 return "".join(rtf)
269class Row(BaseModel):
270 """Represents a row in an RTF table."""
272 row_cells: Sequence[Cell] = Field(..., description="List of cells in the row")
273 justification: str = Field(default="c", description="Row justification (l, c, r)")
274 height: float = Field(default=0.15, description="Row height")
276 def _as_rtf(self) -> MutableSequence[str]:
277 """Format a row of cells in RTF. Returns mutable list since we're building it."""
278 # Justification
279 if self.justification not in ROW_JUSTIFICATION_CODES:
280 raise ValueError(
281 f"Row: Invalid justification '{self.justification}'. Must be one of: {', '.join(ROW_JUSTIFICATION_CODES.keys())}"
282 )
284 rtf = [
285 f"\\trowd\\trgaph{int(Utils._inch_to_twip(self.height) / 2)}\\trleft0{ROW_JUSTIFICATION_CODES[self.justification]}"
286 ]
287 rtf.extend(cell._as_rtf() for cell in self.row_cells)
288 rtf.extend(cell.text._as_rtf(method="cell") for cell in self.row_cells)
289 rtf.append("\\intbl\\row\\pard")
290 return rtf