Coverage for src/rtflite/input.py: 57%

237 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-07 05:03 +0000

1from collections.abc import Sequence 

2 

3from pydantic import BaseModel, ConfigDict, Field, field_validator 

4 

5from rtflite.attributes import TextAttributes, TableAttributes, BroadcastValue 

6from rtflite.row import BORDER_CODES 

7 

8 

9class RTFPage(BaseModel): 

10 """RTF page configuration""" 

11 

12 orientation: str | None = Field( 

13 default="portrait", description="Page orientation ('portrait' or 'landscape')" 

14 ) 

15 

16 @field_validator("orientation") 

17 def validate_orientation(cls, v): 

18 if v not in ["portrait", "landscape"]: 

19 raise ValueError( 

20 f"Invalid orientation. Must be 'portrait' or 'landscape'. Given: {v}" 

21 ) 

22 return v 

23 

24 width: float | None = Field(default=None, description="Page width in inches") 

25 height: float | None = Field(default=None, description="Page height in inches") 

26 margin: Sequence[float] | None = Field( 

27 default=None, 

28 description="Page margins [left, right, top, bottom, header, footer] in inches", 

29 ) 

30 

31 @field_validator("margin") 

32 def validate_margin(cls, v): 

33 if v is not None and len(v) != 6: 

34 raise ValueError("Margin must be a sequence of 6 values.") 

35 return v 

36 

37 nrow: int | None = Field(default=None, description="Number of rows per page") 

38 

39 border_first: str | None = Field( 

40 default="double", description="First row border style" 

41 ) 

42 border_last: str | None = Field( 

43 default="double", description="Last row border style" 

44 ) 

45 col_width: float | None = Field( 

46 default=None, description="Total width of table columns in inches" 

47 ) 

48 use_color: bool | None = Field( 

49 default=False, description="Whether to use color in the document" 

50 ) 

51 

52 @field_validator("border_first", "border_last") 

53 def validate_border(cls, v): 

54 if v not in BORDER_CODES: 

55 raise ValueError( 

56 f"{cls.__field_name__.capitalize()} with invalid border style: {v}" 

57 ) 

58 return v 

59 

60 @field_validator("width", "height", "nrow", "col_width") 

61 def validate_width_height(cls, v): 

62 if v is not None and v <= 0: 

63 raise ValueError( 

64 f"{cls.__field_name__.capitalize()} must be greater than 0." 

65 ) 

66 return v 

67 

68 def __init__(self, **data): 

69 super().__init__(**data) 

70 self._set_default() 

71 

72 def _set_default(self): 

73 if self.orientation == "portrait": 

74 self.width = self.width or 8.5 

75 self.height = self.height or 11 

76 self.margin = self.margin or [1.25, 1, 1.75, 1.25, 1.75, 1.00625] 

77 self.col_width = self.col_width or self.width - 2.25 

78 self.nrow = self.nrow or 40 

79 

80 if self.orientation == "landscape": 

81 self.width = self.width or 11 

82 self.height = self.height or 8.5 

83 self.margin = self.margin or [1.0, 1.0, 2, 1.25, 1.25, 1.25] 

84 self.col_width = self.col_width or self.width - 2.5 

85 self.nrow = self.nrow or 24 

86 

87 if len(self.margin) != 6: 

88 raise ValueError("Margin length must be 6.") 

89 

90 return self 

91 

92 

93class RTFPageHeader(TextAttributes): 

94 text: Sequence[str] | None = Field( 

95 default="Page \\pagenumber of \\pagefield", 

96 description="Page header text content", 

97 ) 

98 

99 @field_validator("text", mode="before") 

100 def convert_text(cls, v): 

101 if v is not None: 

102 if isinstance(v, str): 

103 return [v] 

104 return v 

105 

106 text_indent_reference: str | None = Field( 

107 default="page", 

108 description="Reference point for indentation ('page' or 'table')", 

109 ) 

110 

111 def __init__(self, **data): 

112 defaults = { 

113 "text_font": [1], 

114 "text_font_size": [9], 

115 "text_justification": ["r"], 

116 "text_indent_first": [0], 

117 "text_indent_left": [0], 

118 "text_indent_right": [0], 

119 "text_space": [1.0], 

120 "text_space_before": [15.0], 

121 "text_space_after": [15.0], 

122 "text_hyphenation": [True], 

123 "text_convert": [True], 

124 } 

125 

126 # Update defaults with any provided values 

127 defaults.update(data) 

128 super().__init__(**defaults) 

129 self._set_default() 

130 

131 def _set_default(self): 

132 for attr, value in self.__dict__.items(): 

133 if isinstance(value, (str, int, float, bool)): 

134 setattr(self, attr, [value]) 

135 if isinstance(value, list): 

136 setattr(self, attr, tuple(value)) 

137 return self 

138 

139 

140class RTFPageFooter(TextAttributes): 

141 text: Sequence[str] | None = Field( 

142 default=None, description="Page footer text content" 

143 ) 

144 text_indent_reference: str | None = Field( 

145 default="page", 

146 description="Reference point for indentation ('page' or 'table')", 

147 ) 

148 

149 @field_validator("text", mode="before") 

150 def convert_text(cls, v): 

151 if v is not None: 

152 if isinstance(v, str): 

153 return [v] 

154 return v 

155 

156 def __init__(self, **data): 

157 defaults = { 

158 "text_font": [1], 

159 "text_font_size": [9], 

160 "text_justification": ["c"], 

161 "text_indent_first": [0], 

162 "text_indent_left": [0], 

163 "text_indent_right": [0], 

164 "text_space": [1.0], 

165 "text_space_before": [15.0], 

166 "text_space_after": [15.0], 

167 "text_hyphenation": [True], 

168 "text_convert": [True], 

169 } 

170 

171 # Update defaults with any provided values 

172 defaults.update(data) 

173 super().__init__(**defaults) 

174 self._set_default() 

175 

176 def _set_default(self): 

177 for attr, value in self.__dict__.items(): 

178 if isinstance(value, (str, int, float, bool)): 

179 setattr(self, attr, [value]) 

180 if isinstance(value, list): 

181 setattr(self, attr, tuple(value)) 

182 return self 

183 

184 

185class RTFSubline(TextAttributes): 

186 text: Sequence[str] | None = Field( 

187 default=None, description="Page footer text content" 

188 ) 

189 text_indent_reference: str | None = Field( 

190 default="table", 

191 description="Reference point for indentation ('page' or 'table')", 

192 ) 

193 

194 @field_validator("text", mode="before") 

195 def convert_text(cls, v): 

196 if v is not None: 

197 if isinstance(v, str): 

198 return [v] 

199 return v 

200 

201 def __init__(self, **data): 

202 defaults = { 

203 "text_font": [1], 

204 "text_font_size": [9], 

205 "text_justification": ["l"], 

206 "text_indent_first": [0], 

207 "text_indent_left": [0], 

208 "text_indent_right": [0], 

209 "text_space": [1.0], 

210 "text_space_before": [15.0], 

211 "text_space_after": [15.0], 

212 "text_hyphenation": [True], 

213 "text_convert": [True], 

214 } 

215 

216 # Update defaults with any provided values 

217 defaults.update(data) 

218 super().__init__(**defaults) 

219 self._set_default() 

220 

221 def _set_default(self): 

222 for attr, value in self.__dict__.items(): 

223 if isinstance(value, (str, int, float, bool)): 

224 setattr(self, attr, [value]) 

225 if isinstance(value, list): 

226 setattr(self, attr, tuple(value)) 

227 return self 

228 

229 

230class RTFFootnote(TableAttributes): 

231 """Class for RTF footnote settings""" 

232 

233 model_config = ConfigDict(arbitrary_types_allowed=True) 

234 

235 text: Sequence[str] | None = Field(default=None, description="Footnote table") 

236 

237 @field_validator("text", mode="before") 

238 def convert_text(cls, v): 

239 if v is not None: 

240 if isinstance(v, str): 

241 return [v] 

242 return v 

243 

244 def __init__(self, **data): 

245 defaults = { 

246 "col_rel_width": [1], 

247 "border_left": ["single"], 

248 "border_right": ["single"], 

249 "border_top": ["single"], 

250 "border_bottom": [""], 

251 "border_width": [15], 

252 "cell_height": [0.15], 

253 "cell_justification": ["c"], 

254 "cell_vertical_justification": ["top"], 

255 "text_font": [1], 

256 "text_format": [""], 

257 "text_font_size": [9], 

258 "text_justification": ["l"], 

259 "text_indent_first": [0], 

260 "text_indent_left": [0], 

261 "text_indent_right": [0], 

262 "text_space": [1], 

263 "text_space_before": [15], 

264 "text_space_after": [15], 

265 "text_hyphenation": [False], 

266 "text_convert": [True], 

267 } 

268 

269 # Update defaults with any provided values 

270 defaults.update(data) 

271 super().__init__(**defaults) 

272 self._set_default() 

273 # Convert text to DataFrame during initialization 

274 if self.text is not None: 

275 if isinstance(self.text, Sequence): 

276 self.text = "\\line ".join(self.text) 

277 

278 def _set_default(self): 

279 for attr, value in self.__dict__.items(): 

280 if isinstance(value, (str, int, float, bool)): 

281 setattr(self, attr, [value]) 

282 

283 return self 

284 

285 

286class RTFSource(TableAttributes): 

287 """Class for RTF data source settings""" 

288 

289 model_config = ConfigDict(arbitrary_types_allowed=True) 

290 

291 text: Sequence[str] | None = Field(default=None, description="Data source table") 

292 

293 @field_validator("text", mode="before") 

294 def convert_text(cls, v): 

295 if v is not None: 

296 if isinstance(v, str): 

297 return [v] 

298 return v 

299 

300 def __init__(self, **data): 

301 defaults = { 

302 "col_rel_width": [1], 

303 "border_left": [""], 

304 "border_right": [""], 

305 "border_top": [""], 

306 "border_bottom": [""], 

307 "border_width": [15], 

308 "cell_height": [0.15], 

309 "cell_justification": ["c"], 

310 "cell_vertical_justification": ["top"], 

311 "text_font": [1], 

312 "text_format": [""], 

313 "text_font_size": [9], 

314 "text_justification": ["c"], 

315 "text_indent_first": [0], 

316 "text_indent_left": [0], 

317 "text_indent_right": [0], 

318 "text_space": [1], 

319 "text_space_before": [15], 

320 "text_space_after": [15], 

321 "text_hyphenation": [False], 

322 "text_convert": [True], 

323 } 

324 

325 # Update defaults with any provided values 

326 defaults.update(data) 

327 super().__init__(**defaults) 

328 self._set_default() 

329 

330 # Convert text to DataFrame during initialization 

331 if self.text is not None: 

332 if isinstance(self.text, Sequence): 

333 self.text = "\\line ".join(self.text) 

334 

335 def _set_default(self): 

336 for attr, value in self.__dict__.items(): 

337 if isinstance(value, (str, int, float, bool)): 

338 setattr(self, attr, [value]) 

339 

340 return self 

341 

342 

343class RTFTitle(TextAttributes): 

344 text: Sequence[str] | None = Field(default=None, description="Title text content") 

345 text_indent_reference: str | None = Field( 

346 default="table", 

347 description="Reference point for indentation ('page' or 'table')", 

348 ) 

349 

350 @field_validator("text", mode="before") 

351 def convert_text(cls, v): 

352 if v is not None: 

353 if isinstance(v, str): 

354 return [v] 

355 return v 

356 

357 def __init__(self, **data): 

358 defaults = { 

359 "text_font": [1], 

360 "text_font_size": [12], 

361 "text_justification": ["c"], 

362 "text_indent_first": [0], 

363 "text_indent_left": [0], 

364 "text_indent_right": [0], 

365 "text_space": [1.0], 

366 "text_space_before": [180.0], 

367 "text_space_after": [180.0], 

368 "text_hyphenation": [True], 

369 "text_convert": [True], 

370 } 

371 

372 # Update defaults with any provided values 

373 defaults.update(data) 

374 super().__init__(**defaults) 

375 self._set_default() 

376 

377 def _set_default(self): 

378 for attr, value in self.__dict__.items(): 

379 if isinstance(value, (str, int, float, bool)): 

380 setattr(self, attr, [value]) 

381 if isinstance(value, list): 

382 setattr(self, attr, tuple(value)) 

383 return self 

384 

385 

386class RTFColumnHeader(TableAttributes): 

387 """Class for RTF column header settings""" 

388 

389 model_config = ConfigDict(arbitrary_types_allowed=True) 

390 

391 text: Sequence[str] | None = Field(default=None, description="Column header table") 

392 

393 @field_validator("text", mode="before") 

394 def convert_text(cls, v): 

395 if v is not None: 

396 if isinstance(v, str): 

397 return [v] 

398 return v 

399 

400 def __init__(self, **data): 

401 defaults = { 

402 "border_left": ["single"], 

403 "border_right": ["single"], 

404 "border_top": ["single"], 

405 "border_bottom": [""], 

406 "border_width": [15], 

407 "cell_height": [0.15], 

408 "cell_justification": ["c"], 

409 "cell_vertical_justification": ["bottom"], 

410 "text_font": [1], 

411 "text_format": [""], 

412 "text_font_size": [9], 

413 "text_justification": ["c"], 

414 "text_indent_first": [0], 

415 "text_indent_left": [0], 

416 "text_indent_right": [0], 

417 "text_space": [1], 

418 "text_space_before": [15], 

419 "text_space_after": [15], 

420 "text_hyphenation": [False], 

421 "text_convert": [True], 

422 } 

423 

424 # Update defaults with any provided values 

425 defaults.update(data) 

426 super().__init__(**defaults) 

427 self._set_default() 

428 

429 def _set_default(self): 

430 for attr, value in self.__dict__.items(): 

431 if isinstance(value, (str, int, float, bool)): 

432 setattr(self, attr, [value]) 

433 

434 return self 

435 

436 

437class RTFBody(TableAttributes): 

438 """Class for RTF document body settings""" 

439 

440 model_config = ConfigDict(arbitrary_types_allowed=True) 

441 

442 as_colheader: bool = Field( 

443 default=True, description="Whether to display column headers" 

444 ) 

445 group_by: Sequence[str] | None = Field( 

446 default=None, description="Column name to group rows by" 

447 ) 

448 page_by: Sequence[str] | None = Field( 

449 default=None, description="Column name to create page breaks by" 

450 ) 

451 new_page: bool = Field(default=False, description="Force new page before table") 

452 pageby_header: bool = Field(default=True, description="Repeat headers on new pages") 

453 pageby_row: str = Field( 

454 default="column", 

455 description="Page break handling for rows ('column' or 'value')", 

456 ) 

457 subline_by: Sequence[str] | None = Field( 

458 default=None, description="Column name to create sublines by" 

459 ) 

460 last_row: bool = Field( 

461 default=True, 

462 description="Whether the table contains the last row of the final table", 

463 ) 

464 

465 @field_validator("group_by", "page_by", "subline_by", mode="before") 

466 def convert_text(cls, v): 

467 if v is not None: 

468 if isinstance(v, str): 

469 return [v] 

470 return v 

471 

472 def __init__(self, **data): 

473 defaults = { 

474 "border_left": ["single"], 

475 "border_right": ["single"], 

476 "border_first": ["single"], 

477 "border_last": ["single"], 

478 "border_width": [15], 

479 "cell_height": [0.15], 

480 "cell_justification": ["c"], 

481 "cell_vertical_justification": ["top"], 

482 "text_font": [1], 

483 "text_font_size": [9], 

484 "text_indent_first": [0], 

485 "text_indent_left": [0], 

486 "text_indent_right": [0], 

487 "text_space": [1], 

488 "text_space_before": [15], 

489 "text_space_after": [15], 

490 "text_hyphenation": [False], 

491 "text_convert": [True], 

492 } 

493 

494 # Update defaults with any provided values 

495 defaults.update(data) 

496 super().__init__(**defaults) 

497 self._set_default() 

498 

499 def _set_default(self): 

500 for attr, value in self.__dict__.items(): 

501 if isinstance(value, (str, int, float, bool)) and attr not in [ 

502 "as_colheader", 

503 "page_by", 

504 "new_page", 

505 "pageby_header", 

506 "pageby_row", 

507 "subline_by", 

508 "last_row", 

509 ]: 

510 setattr(self, attr, [value]) 

511 

512 self.border_top = self.border_top or [""] 

513 self.border_bottom = self.border_bottom or [""] 

514 self.border_left = self.border_left or ["single"] 

515 self.border_right = self.border_right or ["single"] 

516 self.border_first = self.border_first or ["single"] 

517 self.border_last = self.border_last or ["single"] 

518 self.cell_vertical_justification = self.cell_vertical_justification or [ 

519 "center" 

520 ] 

521 self.text_justification = self.text_justification or ["c"] 

522 

523 if self.page_by is None: 

524 if self.new_page: 

525 raise ValueError( 

526 "`new_page` must be `False` if `page_by` is not specified" 

527 ) 

528 

529 return self