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

1from collections.abc import Mapping, MutableSequence, Sequence 

2 

3from pydantic import BaseModel, Field 

4 

5from .core.constants import RTFConstants, RTFMeasurements 

6from .fonts_mapping import FontMapping 

7from .text_convert import text_convert 

8 

9# Import constants from centralized location for backwards compatibility 

10FORMAT_CODES = RTFConstants.FORMAT_CODES 

11 

12TEXT_JUSTIFICATION_CODES = RTFConstants.TEXT_JUSTIFICATION_CODES 

13 

14ROW_JUSTIFICATION_CODES = RTFConstants.ROW_JUSTIFICATION_CODES 

15 

16BORDER_CODES = RTFConstants.BORDER_CODES 

17 

18VERTICAL_ALIGNMENT_CODES = RTFConstants.VERTICAL_ALIGNMENT_CODES 

19 

20 

21class Utils: 

22 @staticmethod 

23 def _font_type() -> Mapping: 

24 """Define font types""" 

25 return FontMapping.get_font_table() 

26 

27 @staticmethod 

28 def _inch_to_twip(inch: float) -> int: 

29 """Convert inches to twips.""" 

30 return RTFMeasurements.inch_to_twip(inch) 

31 

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 ] 

41 

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 

47 

48 from .services.color_service import ColorValidationError, color_service 

49 

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 

56 

57 

58class TextContent(BaseModel): 

59 """Represents RTF text with formatting.""" 

60 

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") 

87 

88 def _get_paragraph_formatting(self) -> str: 

89 """Get RTF paragraph formatting codes.""" 

90 rtf = [] 

91 

92 # Hyphenation 

93 if self.hyphenation: 

94 rtf.append("\\hyphpar") 

95 else: 

96 rtf.append("\\hyphpar0") 

97 

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 ) 

105 

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 ) 

116 

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]) 

123 

124 return "".join(rtf) 

125 

126 def _get_text_formatting(self) -> str: 

127 """Get RTF text formatting codes.""" 

128 rtf = [] 

129 

130 # Size (RTF uses half-points) 

131 rtf.append(f"\\fs{self.size * 2}") 

132 

133 # Font 

134 rtf.append(f"{{\\f{int(self.font - 1)}") 

135 

136 # Color 

137 if self.color: 

138 rtf.append(f"\\cf{Utils._get_color_index(self.color)}") 

139 

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}") 

144 

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 ) 

154 

155 return "".join(rtf) 

156 

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) 

161 

162 if text is None: 

163 return "" 

164 

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}*" 

173 

174 text = converted_text 

175 

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) 

182 

183 return text 

184 

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" 

191 

192 if method == "plain": 

193 return f"{self._get_text_formatting()} {self._convert_special_chars()}}}" 

194 

195 if method == "paragraph_format": 

196 return f"{{\\pard{self._get_paragraph_formatting()}{self.text}\\par}}" 

197 

198 if method == "cell_format": 

199 return f"\\pard{self._get_paragraph_formatting()}{self.text}\\cell" 

200 

201 raise ValueError(f"Invalid method: {method}") 

202 

203 

204class Border(BaseModel): 

205 """Represents a single border's style, color, and width.""" 

206 

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") 

214 

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}") 

219 

220 rtf = f"{BORDER_CODES[self.style]}\\brdrw{self.width}" 

221 

222 # Add color if specified 

223 if self.color is not None: 

224 rtf = rtf + f"\\brdrcf{Utils._get_color_index(self.color)}" 

225 

226 return rtf 

227 

228 

229class Cell(BaseModel): 

230 """Represents a cell in an RTF table.""" 

231 

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") 

241 

242 def _as_rtf(self) -> str: 

243 """Format a single table cell in RTF.""" 

244 # Cell Border 

245 rtf = [] 

246 

247 if self.border_left is not None: 

248 rtf.append("\\clbrdrl" + self.border_left._as_rtf()) 

249 

250 if self.border_top is not None: 

251 rtf.append("\\clbrdrt" + self.border_top._as_rtf()) 

252 

253 if self.border_right is not None: 

254 rtf.append("\\clbrdrr" + self.border_right._as_rtf()) 

255 

256 if self.border_bottom is not None: 

257 rtf.append("\\clbrdrb" + self.border_bottom._as_rtf()) 

258 

259 # Cell vertical alignment 

260 if self.vertical_justification is not None: 

261 rtf.append(VERTICAL_ALIGNMENT_CODES[self.vertical_justification]) 

262 

263 # Cell width 

264 rtf.append(f"\\cellx{Utils._inch_to_twip(self.width)}") 

265 

266 return "".join(rtf) 

267 

268 

269class Row(BaseModel): 

270 """Represents a row in an RTF table.""" 

271 

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") 

275 

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 ) 

283 

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