Coverage for rendercv/renderer/templater.py: 99%

234 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 00:25 +0000

1""" 

2The `rendercv.renderer.templater` module contains all the necessary classes and 

3functions for templating the Typst and Markdown files from the `RenderCVDataModel` 

4object. 

5""" 

6 

7import copy 

8import pathlib 

9import re 

10from collections.abc import Callable 

11from typing import Optional 

12 

13import jinja2 

14import pydantic 

15 

16from .. import data 

17 

18 

19class TemplatedFile: 

20 """This class is a base class for `TypstFile`, and `MarkdownFile` classes. It 

21 contains the common methods and attributes for both classes. These classes are used 

22 to generate the Typst and Markdown files with the data model and Jinja2 

23 templates. 

24 

25 Args: 

26 data_model: The data model. 

27 environment: The Jinja2 environment. 

28 """ 

29 

30 def __init__( 

31 self, 

32 data_model: data.RenderCVDataModel, 

33 environment: jinja2.Environment, 

34 ): 

35 self.cv = data_model.cv 

36 self.design = data_model.design 

37 self.locale = data_model.locale 

38 self.environment = environment 

39 

40 def template( 

41 self, 

42 theme_name: str, 

43 template_name: str, 

44 extension: str, 

45 entry: Optional[data.Entry] = None, 

46 **kwargs, 

47 ) -> str: 

48 """Template one of the files in the `themes` directory. 

49 

50 Args: 

51 template_name: The name of the template file. 

52 entry: The title of the section. 

53 

54 Returns: 

55 The templated file. 

56 """ 

57 template = self.environment.get_template( 

58 f"{theme_name}/{template_name}.j2.{extension}" 

59 ) 

60 

61 # Loop through the entry attributes and make them "" if they are None: 

62 # This is necessary because otherwise they will be templated as "None" since 

63 # it's the string representation of None. 

64 

65 # Only don't touch the date fields, because only date_string is called and 

66 # setting dates to "" will cause problems. 

67 fields_to_ignore = ["start_date", "end_date", "date"] 

68 

69 if entry is not None and not isinstance(entry, str): 

70 entry_dictionary = entry.model_dump() 

71 for key, value in entry_dictionary.items(): 

72 if value is None and key not in fields_to_ignore: 

73 entry.__setattr__(key, "") 

74 

75 # The arguments of the template can be used in the template file: 

76 return template.render( 

77 cv=self.cv, 

78 design=self.design, 

79 locale=self.locale, 

80 entry=entry, 

81 today=data.format_date(data.get_date_input()), 

82 **kwargs, 

83 ) 

84 

85 def get_full_code(self, main_template_name: str, **kwargs) -> str: 

86 """Combine all the templates to get the full code of the file.""" 

87 main_template = self.environment.get_template(main_template_name) 

88 return main_template.render( 

89 **kwargs, 

90 ) 

91 

92 

93class TypstFile(TemplatedFile): 

94 """This class represents a Typst file. It generates the Typst code with the 

95 data model and Jinja2 templates. 

96 """ 

97 

98 def __init__( 

99 self, 

100 data_model: data.RenderCVDataModel, 

101 environment: jinja2.Environment, 

102 ): 

103 typst_file_data_model = copy.deepcopy(data_model) 

104 

105 if typst_file_data_model.cv.sections_input is not None: 

106 transformed_sections = transform_markdown_sections_to_typst_sections( 

107 typst_file_data_model.cv.sections_input 

108 ) 

109 typst_file_data_model.cv.sections_input = transformed_sections 

110 

111 super().__init__(typst_file_data_model, environment) 

112 

113 def render_templates(self) -> tuple[str, str, list[tuple[str, list[str], str]]]: 

114 """Render and return all the templates for the Typst file. 

115 

116 Returns: 

117 The preamble, header, and sections of the Typst file. 

118 """ 

119 # All the template field names: 

120 all_template_names = [ 

121 "main_column_first_row_template", 

122 "main_column_second_row_template", 

123 "main_column_second_row_without_url_template", 

124 "main_column_second_row_without_journal_template", 

125 "date_and_location_column_template", 

126 "template", 

127 "degree_column_template", 

128 ] 

129 

130 # All the placeholders used in the templates: 

131 sections_input: dict[str, list[pydantic.BaseModel]] = self.cv.sections_input # type: ignore 

132 # Loop through the sections and entries to find all the field names: 

133 placeholder_keys: set[str] = set() 

134 if sections_input: 

135 for section in sections_input.values(): 

136 for entry in section: 

137 if isinstance(entry, str): 

138 break 

139 entry_dictionary = entry.model_dump() 

140 for key in entry_dictionary: 

141 placeholder_keys.add(key.upper()) 

142 

143 pattern = re.compile(r"(?<!^)(?=[A-Z])") 

144 

145 def camel_to_snake(name: str) -> str: 

146 return pattern.sub("_", name).lower() 

147 

148 # Template the preamble, header, and sections: 

149 preamble = self.template("Preamble") 

150 header = self.template("Header") 

151 sections: list[tuple[str, list[str], str]] = [] 

152 for section in self.cv.sections: 

153 section_beginning = self.template( 

154 "SectionBeginning", 

155 section_title=escape_typst_characters(section.title), 

156 entry_type=section.entry_type, 

157 ) 

158 

159 templates = { 

160 template_name: getattr( 

161 getattr( 

162 getattr(self.design, "entry_types", None), 

163 camel_to_snake(section.entry_type), 

164 None, 

165 ), 

166 template_name, 

167 None, 

168 ) 

169 for template_name in all_template_names 

170 } 

171 

172 entries: list[str] = [] 

173 for i, entry in enumerate(section.entries): 

174 # Prepare placeholders: 

175 placeholders = {} 

176 for placeholder_key in placeholder_keys: 

177 components_path = ( 

178 pathlib.Path(__file__).parent.parent / "themes" / "components" 

179 ) 

180 lowercase_placeholder_key = placeholder_key.lower() 

181 if ( 

182 components_path / f"{lowercase_placeholder_key}.j2.typ" 

183 ).exists(): 

184 placeholder_value = super().template( 

185 "components", 

186 lowercase_placeholder_key, 

187 "typ", 

188 entry, 

189 section_title=section.title, 

190 ) 

191 else: 

192 placeholder_value = getattr(entry, placeholder_key, None) 

193 

194 placeholders[placeholder_key] = ( 

195 placeholder_value if placeholder_value != "None" else None 

196 ) 

197 

198 # Substitute the placeholders in the templates: 

199 templates_with_substitutions = { 

200 template_name: ( 

201 input_template_to_typst( 

202 templates[template_name], 

203 placeholders, # type: ignore 

204 ) 

205 if templates.get(template_name) 

206 else None 

207 ) 

208 for template_name in all_template_names 

209 } 

210 

211 entries.append( 

212 self.template( 

213 section.entry_type, 

214 entry=entry, 

215 section_title=section.title, 

216 entry_type=section.entry_type, 

217 is_first_entry=i == 0, 

218 **templates_with_substitutions, # all the templates 

219 ) 

220 ) 

221 section_ending = self.template( 

222 "SectionEnding", 

223 section_title=section.title, 

224 entry_type=section.entry_type, 

225 ) 

226 sections.append((section_beginning, entries, section_ending)) 

227 

228 return preamble, header, sections 

229 

230 def template( 

231 self, 

232 template_name: str, 

233 entry: Optional[data.Entry] = None, 

234 **kwargs, 

235 ) -> str: 

236 """Template one of the files in the `themes` directory. 

237 

238 Args: 

239 template_name: The name of the template file. 

240 entry: The data model of the entry. 

241 

242 Returns: 

243 The templated file. 

244 """ 

245 return super().template( 

246 self.design.theme, 

247 template_name, 

248 "typ", 

249 entry, 

250 **kwargs, 

251 ) 

252 

253 def get_full_code(self) -> str: 

254 """Get the Typst code of the file. 

255 

256 Returns: 

257 The Typst code. 

258 """ 

259 preamble, header, sections = self.render_templates() 

260 code: str = super().get_full_code( 

261 "main.j2.typ", 

262 preamble=preamble, 

263 header=header, 

264 sections=sections, 

265 ) 

266 return code 

267 

268 def create_file(self, file_path: pathlib.Path): 

269 """Write the Typst code to a file.""" 

270 file_path.write_text(self.get_full_code(), encoding="utf-8") 

271 

272 

273class MarkdownFile(TemplatedFile): 

274 """This class represents a Markdown file. It generates the Markdown code with the 

275 data model and Jinja2 templates. Markdown files are generated to produce an HTML 

276 which can be copy-pasted to [Grammarly](https://app.grammarly.com/) for 

277 proofreading. 

278 """ 

279 

280 def render_templates(self) -> tuple[str, list[tuple[str, list[str]]]]: 

281 """Render and return all the templates for the Markdown file. 

282 

283 Returns: 

284 The header and sections of the Markdown file. 

285 """ 

286 # Template the header and sections: 

287 header = self.template("Header") 

288 sections: list[tuple[str, list[str]]] = [] 

289 for section in self.cv.sections: 

290 section_beginning = self.template( 

291 "SectionBeginning", 

292 section_title=section.title, 

293 entry_type=section.entry_type, 

294 ) 

295 entries: list[str] = [] 

296 for i, entry in enumerate(section.entries): 

297 is_first_entry = bool(i == 0) 

298 entries.append( 

299 self.template( 

300 section.entry_type, 

301 entry=entry, 

302 section_title=section.title, 

303 entry_type=section.entry_type, 

304 is_first_entry=is_first_entry, 

305 ) 

306 ) 

307 sections.append((section_beginning, entries)) 

308 

309 result: tuple[str, list[tuple[str, list[str]]]] = (header, sections) 

310 return result 

311 

312 def template( 

313 self, 

314 template_name: str, 

315 entry: Optional[data.Entry] = None, 

316 **kwargs, 

317 ) -> str: 

318 """Template one of the files in the `themes` directory. 

319 

320 Args: 

321 template_name: The name of the template file. 

322 entry: The data model of the entry. 

323 

324 Returns: 

325 The templated file. 

326 """ 

327 return super().template( 

328 "markdown", 

329 template_name, 

330 "md", 

331 entry, 

332 **kwargs, 

333 ) 

334 

335 def get_full_code(self) -> str: 

336 """Get the Markdown code of the file. 

337 

338 Returns: 

339 The Markdown code. 

340 """ 

341 header, sections = self.render_templates() 

342 code: str = super().get_full_code( 

343 "main.j2.md", 

344 header=header, 

345 sections=sections, 

346 ) 

347 return code 

348 

349 def create_file(self, file_path: pathlib.Path): 

350 """Write the Markdown code to a file.""" 

351 file_path.write_text(self.get_full_code(), encoding="utf-8") 

352 

353 

354def input_template_to_typst( 

355 input_template: Optional[str], placeholders: dict[str, Optional[str]] 

356) -> str: 

357 """Convert an input template to Typst. 

358 

359 Args: 

360 input_template: The input template. 

361 placeholders: The placeholders and their values. 

362 

363 Returns: 

364 Typst string. 

365 """ 

366 if input_template is None: 

367 return "" 

368 

369 output = replace_placeholders_with_actual_values( 

370 markdown_to_typst(input_template), 

371 placeholders, 

372 ) 

373 

374 # If \n is escaped, revert: 

375 output = output.replace("\\n", "\n") 

376 

377 # If there are blank italics and bolds, remove them: 

378 output = output.replace("#emph[]", "") 

379 output = output.replace("#strong[]", "") 

380 

381 # Check if there are any letters in the input template. If not, return an empty 

382 if not re.search(r"[a-zA-Z]", input_template): 

383 return "" 

384 

385 # Find italic and bold links and fix them: 

386 # For example: 

387 # Convert `#emph[#link("https://google.com")[italic link]]` to 

388 # `#link("https://google.com")[#emph[italic link]]` 

389 output = re.sub( 

390 r"#emph\[#link\(\"(.*?)\"\)\[(.*?)\]\]", 

391 r'#link("\1")[#emph[\2]]', 

392 output, 

393 ) 

394 output = re.sub( 

395 r"#strong\[#link\(\"(.*?)\"\)\[(.*?)\]\]", 

396 r'#link("\1")[#strong[\2]]', 

397 output, 

398 ) 

399 output = re.sub( 

400 r"#strong\[#emph\[#link\(\"(.*?)\"\)\[(.*?)\]\]\]", 

401 r'#link("\1")[#strong[#emph[\2]]]', 

402 output, 

403 ) 

404 

405 # Replace all multiple \n with a double \n: 

406 output = re.sub(r"\n+", r"\n\n", output) 

407 

408 # Strip whitespace 

409 output = output.strip() 

410 

411 # Strip non-alphanumeric, non-typst characters from the beginning and end of the 

412 # string. For example, when location is not given in a template like this: 

413 # "NAME -- LOCATION", "NAME -- " should become "NAME". 

414 output = re.sub(r"^[^\w\s#\[\]\n\(\)]*", "", output) 

415 output = re.sub(r"[^\w\s#\[\]\n\(\)]*$", "", output) 

416 

417 return output # noqa: RET504 

418 

419 

420def escape_characters(string: str, escape_dictionary: dict[str, str]) -> str: 

421 """Escape characters in a string by using `escape_dictionary`, where keys are 

422 characters to escape and values are their escaped versions. 

423 

424 Example: 

425 ```python 

426 escape_characters("This is a # string.", {"#": "\\#"}) 

427 ``` 

428 returns 

429 `"This is a \\# string."` 

430 

431 Args: 

432 string: The string to escape. 

433 escape_dictionary: The dictionary of escape characters. 

434 

435 Returns: 

436 The escaped string. 

437 """ 

438 

439 translation_map = str.maketrans(escape_dictionary) 

440 

441 # Don't escape urls as hyperref package will do it automatically: 

442 # Find all the links in the sentence: 

443 links = re.findall(r"\[(.*?)\]\((.*?)\)", string) 

444 

445 # Replace the links with a dummy string and save links with escaped characters: 

446 new_links = [] 

447 for i, link in enumerate(links): 

448 placeholder = link[0] 

449 escaped_placeholder = placeholder.translate(translation_map) 

450 url = link[1] 

451 

452 original_link = f"[{placeholder}]({url})" 

453 string = string.replace(original_link, f"!!-link{i}-!!") 

454 

455 new_link = f"[{escaped_placeholder}]({url})" 

456 new_links.append(new_link) 

457 

458 # If there are equations in the sentence, don't escape the special characters: 

459 # Find all the equations in the sentence: 

460 equations = re.findall(r"(\$\$.*?\$\$)", string) 

461 new_equations = [] 

462 for i, equation in enumerate(equations): 

463 string = string.replace(equation, f"!!-equation{i}-!!") 

464 

465 # Keep only one dollar sign for inline equations: 

466 new_equation = equation.replace("$$", "$") 

467 new_equations.append(new_equation) 

468 

469 # Loop through the letters of the sentence and if you find an escape character, 

470 # replace it with their equivalent: 

471 string = string.translate(translation_map) 

472 

473 # Replace !!-link{i}-!!" with the original urls: 

474 for i, new_link in enumerate(new_links): 

475 string = string.replace(f"!!-link{i}-!!", new_link) 

476 

477 # Replace !!-equation{i}-!!" with the original equations: 

478 for i, new_equation in enumerate(new_equations): 

479 string = string.replace(f"!!-equation{i}-!!", new_equation) 

480 

481 return string 

482 

483 

484def escape_typst_characters(string: str) -> str: 

485 """Escape Typst characters in a string by adding a backslash before them. 

486 

487 Example: 

488 ```python 

489 escape_typst_characters("This is a # string.") 

490 ``` 

491 returns 

492 `"This is a \\# string."` 

493 

494 Args: 

495 string: The string to escape. 

496 

497 Returns: 

498 The escaped string. 

499 """ 

500 escape_dictionary = { 

501 "[": "\\[", 

502 "]": "\\]", 

503 "(": "\\(", 

504 ")": "\\)", 

505 "\\": "\\\\", 

506 '"': '\\"', 

507 "#": "\\#", 

508 "$": "\\$", 

509 "@": "\\@", 

510 "%": "\\%", 

511 "~": "\\~", 

512 "_": "\\_", 

513 } 

514 

515 return escape_characters(string, escape_dictionary) 

516 

517 

518def markdown_to_typst(markdown_string: str) -> str: 

519 """Convert a Markdown string to Typst. 

520 

521 Example: 

522 ```python 

523 markdown_to_typst( 

524 "This is a **bold** text with an [*italic link*](https://google.com)." 

525 ) 

526 ``` 

527 

528 returns 

529 

530 `"This is a *bold* text with an #link("https://google.com")[_italic link_]."` 

531 

532 Args: 

533 markdown_string: The Markdown string to convert. 

534 

535 Returns: 

536 The Typst string. 

537 """ 

538 # convert links 

539 links = re.findall(r"\[([^\]\[]*)\]\((.*?)\)", markdown_string) 

540 if links is not None: 

541 for link in links: 

542 link_text = link[0] 

543 link_url = link[1] 

544 

545 old_link_string = f"[{link_text}]({link_url})" 

546 new_link_string = f'#link("{link_url}")[{link_text}]' 

547 

548 markdown_string = markdown_string.replace(old_link_string, new_link_string) 

549 

550 # Process escaped asterisks in the yaml (such that they are actual asterisks, 

551 # and not markers for bold/italics). We need to temporarily replace them with 

552 # a dummy string. 

553 

554 ONE_STAR = "ONE_STAR" 

555 

556 # NOTE: We get a mix of escape levels depending on whether the star is in a quoted 

557 # or unquoted yaml entry. This is a bit of a mess but below seems to work 

558 # as i would instinctively expect. 

559 markdown_string = markdown_string.replace("\\\\*", ONE_STAR) 

560 markdown_string = markdown_string.replace("\\*", ONE_STAR) 

561 

562 # convert bold and italic: 

563 bold_and_italics = re.findall(r"\*\*\*(.+?)\*\*\*", markdown_string) 

564 if bold_and_italics is not None: 

565 for bold_and_italic_text in bold_and_italics: 

566 old_bold_and_italic_text = f"***{bold_and_italic_text}***" 

567 new_bold_and_italic_text = f"#strong[#emph[{bold_and_italic_text}]]" 

568 

569 markdown_string = markdown_string.replace( 

570 old_bold_and_italic_text, new_bold_and_italic_text 

571 ) 

572 

573 # convert bold 

574 bolds = re.findall(r"\*\*(.+?)\*\*", markdown_string) 

575 if bolds is not None: 

576 for bold_text in bolds: 

577 old_bold_text = f"**{bold_text}**" 

578 new_bold_text = f"#strong[{bold_text}]" 

579 markdown_string = markdown_string.replace(old_bold_text, new_bold_text) 

580 

581 # convert italic 

582 italics = re.findall(r"\*(.+?)\*", markdown_string) 

583 if italics is not None: 

584 for italic_text in italics: 

585 old_italic_text = f"*{italic_text}*" 

586 new_italic_text = f"#emph[{italic_text}]" 

587 

588 markdown_string = markdown_string.replace(old_italic_text, new_italic_text) 

589 

590 # Revert normal asterisks then convert them to Typst's asterisks 

591 markdown_string = markdown_string.replace(ONE_STAR, "*") 

592 

593 # convert any remaining asterisks to Typst's asterisk 

594 # - Asterisk with a space can just be replaced. 

595 # - Asterisk without a space needs a zero-width box to delimit it. 

596 TYPST_AST = "#sym.ast.basic" 

597 ZERO_BOX = "#h(0pt, weak: true)" 

598 markdown_string = markdown_string.replace("* ", TYPST_AST + " ") 

599 markdown_string = markdown_string.replace("*", TYPST_AST + ZERO_BOX) 

600 

601 # At this point, the document ought to have absolutely no '*' characters left! 

602 # NOTE: The final typst file might still have some asterisks when specifying a 

603 # size, for example `#v(design-text-font-size * 0.4)` 

604 # XXX: Maybe put this behind some kind of debug flag? -MK 

605 # assert "*" not in markdown_string 

606 

607 return markdown_string # noqa: RET504 

608 

609 

610def transform_markdown_sections_to_something_else_sections( 

611 sections: dict[str, data.SectionContents], 

612 functions_to_apply: list[Callable], 

613) -> Optional[dict[str, data.SectionContents]]: 

614 """ 

615 Recursively loop through sections and update all the strings by applying the 

616 `functions_to_apply` functions, given as an argument. 

617 

618 Args: 

619 sections: Sections with Markdown strings. 

620 functions_to_apply: Functions to apply to the strings. 

621 

622 Returns: 

623 Sections with updated strings. 

624 """ 

625 

626 def apply_functions_to_string(string: str): 

627 for function in functions_to_apply: 

628 string = function(string) 

629 return string 

630 

631 for key, value in sections.items(): 

632 transformed_list = [] 

633 for entry in value: 

634 if isinstance(entry, str): 

635 # Then it means it's a TextEntry. 

636 result = apply_functions_to_string(entry) 

637 transformed_list.append(result) 

638 else: 

639 # Then it means it's one of the other entries. 

640 fields_to_skip = ["doi"] 

641 entry_as_dict = entry.model_dump() 

642 for entry_key, inner_value in entry_as_dict.items(): 

643 if entry_key in fields_to_skip: 

644 continue 

645 if isinstance(inner_value, str): 

646 result = apply_functions_to_string(inner_value) 

647 setattr(entry, entry_key, result) 

648 elif isinstance(inner_value, list): 

649 for j, item in enumerate(inner_value): 

650 if isinstance(item, str): 

651 inner_value[j] = apply_functions_to_string(item) 

652 setattr(entry, entry_key, inner_value) 

653 transformed_list.append(entry) 

654 

655 sections[key] = transformed_list 

656 

657 return sections 

658 

659 

660def transform_markdown_sections_to_typst_sections( 

661 sections: dict[str, data.SectionContents], 

662) -> Optional[dict[str, data.SectionContents]]: 

663 """ 

664 Recursively loop through sections and convert all the Markdown strings (user input 

665 is in Markdown format) to Typst strings. 

666 

667 Args: 

668 sections: Sections with Markdown strings. 

669 

670 Returns: 

671 Sections with Typst strings. 

672 """ 

673 return transform_markdown_sections_to_something_else_sections( 

674 sections, 

675 [escape_typst_characters, markdown_to_typst], 

676 ) 

677 

678 

679def replace_placeholders_with_actual_values( 

680 text: str, 

681 placeholders: dict[str, Optional[str]], 

682) -> str: 

683 """Replace the placeholders in a string with actual values. 

684 

685 This function can be used as a Jinja2 filter in templates. 

686 

687 Args: 

688 text: The text with placeholders. 

689 placeholders: The placeholders and their values. 

690 

691 Returns: 

692 The string with actual values. 

693 """ 

694 for placeholder, value in placeholders.items(): 

695 if value: 

696 text = text.replace(placeholder, str(value)) 

697 else: 

698 text = text.replace(placeholder, "") 

699 

700 return text 

701 

702 

703class Jinja2Environment: 

704 instance: "Jinja2Environment" 

705 environment: jinja2.Environment 

706 current_working_directory: Optional[pathlib.Path] = None 

707 

708 def __new__(cls): 

709 if ( 

710 not hasattr(cls, "instance") 

711 or cls.current_working_directory != pathlib.Path.cwd() 

712 ): 

713 cls.instance = super().__new__(cls) 

714 

715 themes_directory = pathlib.Path(__file__).parent.parent / "themes" 

716 

717 # create a Jinja2 environment: 

718 # we need to add the current working directory because custom themes might be used. 

719 environment = jinja2.Environment( 

720 loader=jinja2.FileSystemLoader([pathlib.Path.cwd(), themes_directory]), 

721 trim_blocks=True, 

722 lstrip_blocks=True, 

723 ) 

724 

725 # set custom delimiters: 

726 environment.block_start_string = "((*" 

727 environment.block_end_string = "*))" 

728 environment.variable_start_string = "<<" 

729 environment.variable_end_string = ">>" 

730 environment.comment_start_string = "((#" 

731 environment.comment_end_string = "#))" 

732 

733 # add custom Jinja2 filters: 

734 environment.filters["replace_placeholders_with_actual_values"] = ( 

735 replace_placeholders_with_actual_values 

736 ) 

737 environment.filters["escape_typst_characters"] = escape_typst_characters 

738 environment.filters["markdown_to_typst"] = markdown_to_typst 

739 environment.filters["make_a_url_clean"] = data.make_a_url_clean 

740 

741 cls.environment = environment 

742 

743 return cls.instance