texsmith 0.0.2.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,46 @@
1
+ \BLOCK{ set _fragments = fragments | default([]) }
2
+ \BLOCK{ set _code_cfg = code | default({}) }
3
+ \BLOCK{ if _code_cfg is mapping }
4
+ \BLOCK{ set _code_engine = (_code_cfg.get('engine') or 'pygments') | lower }
5
+ \BLOCK{ else }
6
+ \BLOCK{ set _code_engine = (_code_cfg or 'pygments') | string | lower }
7
+ \BLOCK{ endif }
8
+ \BLOCK{ set _code_shell_escape = _code_engine == 'minted' }
9
+ \BLOCK{ set _shell_escape = requires_shell_escape|default(false) or _code_shell_escape }
10
+ $root_filename = '\VAR{root_filename|default("texsmith-output")}';
11
+ \BLOCK{ if 'ts-bibliography' in _fragments }
12
+ $bibtex_use = 2;
13
+ $biber = 'biber %O %B';
14
+ $clean_ext .= ' %R.run.xml %R.blg';
15
+ \BLOCK{ endif }
16
+ \BLOCK{ if latex_engine == "xelatex" }
17
+ $pdf_mode = 5;
18
+ $xelatex = 'xelatex\BLOCK{- if _shell_escape } --shell-escape\BLOCK{- endif } %O %S';
19
+ \BLOCK{ elif latex_engine == "lualatex" }
20
+ $pdf_mode = 4;
21
+ $lualatex = 'lualatex\BLOCK{- if _shell_escape } --shell-escape\BLOCK{- endif } %O %S';
22
+ \BLOCK{ else }
23
+ $pdf_mode = 1;
24
+ $pdflatex = 'pdflatex\BLOCK{- if _shell_escape } --shell-escape\BLOCK{- endif } %O %S';
25
+ \BLOCK{ endif }
26
+ \BLOCK{ if 'ts-index' in _fragments }
27
+ if (system("which makeindex-py >/dev/null 2>&1") == 0) {
28
+ $makeindex = 'makeindex-py %O -o %D %S';
29
+ } elsif (system("which texindy >/dev/null 2>&1") == 0) {
30
+ $makeindex = 'texindy %O -o %D %S';
31
+ } else {
32
+ $makeindex = 'makeindex %O -o %D %S';
33
+ }
34
+ \BLOCK{ endif }
35
+ \BLOCK{ if 'ts-glossary' in _fragments }
36
+ add_cus_dep('glo', 'gls', 0, 'run_makeglossaries');
37
+ add_cus_dep('acn', 'acr', 0, 'run_makeglossaries');
38
+ sub run_makeglossaries {
39
+ my ($base) = @_;
40
+ my $cmd = "makeglossaries-py \"$base\"";
41
+ if (system("which makeglossaries-py >/dev/null 2>&1") != 0) {
42
+ $cmd = "makeglossaries \"$base\"";
43
+ }
44
+ return system($cmd);
45
+ }
46
+ \BLOCK{ endif }
@@ -0,0 +1,59 @@
1
+ # Formal Letter
2
+
3
+ This template provides a unified structure to write letters for different coutries and languages. It exclusively relies on the KOMA-Script `scrlttr2` class and loads the appropriate national layout so the output complies with SN 010130 (Switzerland) or DIN 5008 (Germany). Invoke it through the CLI with `--template letter`. The supported versions are:
4
+
5
+ - English (UK)
6
+ - English (US)
7
+ - French (France or Switzerland via `fr-CH`)
8
+
9
+ ## Attributes
10
+
11
+ - `cursive` (boolean): If true, the letter will be written in a cursive font style (modernline).
12
+ - `language` (string): Specifies the language of the letter. Supported values are `en-UK`, `en-US`, `fr-FR`, or `fr-CH`. Default is `en-UK`.
13
+ - `standard` (string): Selects the letter layout. Use `din`/`din5008` for DIN 5008 and `sn`, `sn-left`, or `sn-right` for SN 010130. Defaults to DIN for English locales and SN 010130 for French locales.
14
+ - `date` (string): The date to be displayed on the letter. If not provided, the current date will be used.
15
+ - `object` (string): The subject of the letter.
16
+ - `opening` (string): Optional override for the salutation inserted via `\opening{}`. If not provided, the Markdown document title (`# Heading`) is injected automatically.
17
+ - `closing` (string): Optional override for the closing formula inserted via `\closing{}`. Defaults to a locale-appropriate sentence (e.g. “Yours faithfully” for UK English).
18
+ - `signature` (string): Optional override for the signature block (defaults to the sender name). When `cursive` is enabled the Modernline font is used automatically.
19
+ - `fold_marks` (boolean): Enables the fold guide marks on the page header/edges. Defaults to `false`.
20
+ - `from` (object):
21
+ - `name` (string): The name of the sender.
22
+ - `address` (string): The address of the sender.
23
+ - `city` (string): Optional location displayed next to the date (used as `\lieu{}` for French letters).
24
+ - `to` (object):
25
+ - `name` (string): The name of the recipient.
26
+ - `address` (string): The address of the recipient.
27
+
28
+ ## Example Usage
29
+
30
+ ```md
31
+ ---
32
+ press:
33
+ template: letter
34
+ cursive: true
35
+ language: fr-CH
36
+ standard: sn
37
+ date: 1928-12-23
38
+ object: Invitation souper de Noël
39
+ from:
40
+ name: Madame Marie-Henriette Dupont
41
+ address: 12, rue des Fleurs, 1204 Genève
42
+ city: Genève
43
+ to:
44
+ name: Monsieur Jean-Marc Martin
45
+ address:
46
+ - 45, avenue des Champs
47
+ - 75008 Paris
48
+ - France
49
+ ---
50
+ Mon très cher Monsieur Martin,
51
+
52
+ Il est des soirs où la solitude s’avance comme un spectre silencieux, effleurant les murailles de la demeure et glissant, perfide, jusqu’au cœur. Noël, jadis si empli de rires enfantins et de clameurs joyeuses, s’annonce cette année sous un voile de mélancolie. Mes enfants, ces oiseaux migrateurs, se sont envolés vers d’autres cieux ; et le deuil encore noir de mon cher défunt ne cesse de teinter mes jours d’une ombre discrète.
53
+
54
+ Aussi, permettez, cher Monsieur, qu’une audace presque inconsidérée me dicte ces lignes. Je me suis dit que peut-être -- par un bienveillant caprice du destin -- vous consentiriez à troubler ma solitude en acceptant de partager avec moi le souper de la Nativité.
55
+
56
+ Je me chargerai, de mes mains mêmes, de préparer un humble festin champêtre : une dinde dodue, élevée au jardin et répondant, avec une ingénuité touchante, au doux nom de Poulette. Elle sera escortée de quelques ortolans, mets délicat s’il en est, que j’aurai accommodés selon une recette d’antan dont j’ai gardé le secret.
57
+
58
+ Je n’ose espérer que la chaleur de mon foyer puisse égaler celle de votre esprit, ni que le parfum du vin chaud rivalise avec celui de votre présence. Mais si le cœur vous en dit, venez donc, cher ami ; et que la neige, complice de cette invitation, vous guide jusqu’à ma porte.
59
+ ```
@@ -0,0 +1,495 @@
1
+ """Letter template integration for Texsmith."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime, timezone
8
+
9
+
10
+ try:
11
+ from datetime import UTC # py311+ noqa: F401
12
+ except ImportError: # pragma: no cover - py310 compatibility
13
+ UTC = timezone.utc
14
+ import logging
15
+ from pathlib import Path
16
+ import re
17
+ import shutil
18
+ from typing import Any, ClassVar
19
+
20
+ from texsmith.adapters.latex.utils import escape_latex_chars
21
+ from texsmith.adapters.transformers import svg2pdf
22
+ from texsmith.core.exceptions import TransformerExecutionError
23
+ from texsmith.core.templates import TemplateError, WrappableTemplate
24
+
25
+
26
+ _PACKAGE_ROOT = Path(__file__).parent.resolve()
27
+ _log = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LanguageProfile:
32
+ """Describe locale-specific behaviour for the letter template."""
33
+
34
+ key: str
35
+ locale: str
36
+ babel: str
37
+ fallback_opening: str
38
+ fallback_closing: str
39
+ default_standard: str
40
+ subject_prefix: str
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class LetterStandard:
45
+ """Describe one of the supported national letter layouts."""
46
+
47
+ key: str
48
+ option: str
49
+
50
+
51
+ _LETTER_STANDARDS: dict[str, LetterStandard] = {
52
+ "din": LetterStandard(key="din", option="DIN"),
53
+ "sn-left": LetterStandard(key="sn-left", option="SNleft"),
54
+ "sn-right": LetterStandard(key="sn-right", option="SNright"),
55
+ "nf": LetterStandard(key="nf", option="NF"),
56
+ }
57
+
58
+ _LETTER_STANDARD_ALIASES: dict[str, str] = {
59
+ "din": "din",
60
+ "din5008": "din",
61
+ "din-5008": "din",
62
+ "de": "din",
63
+ "german": "din",
64
+ "sn": "sn-left",
65
+ "sn010130": "sn-left",
66
+ "sn-010130": "sn-left",
67
+ "snleft": "sn-left",
68
+ "sn-left": "sn-left",
69
+ "snright": "sn-right",
70
+ "sn-right": "sn-right",
71
+ "swiss": "sn-left",
72
+ "nf": "nf",
73
+ }
74
+
75
+
76
+ _LANGUAGE_PROFILES: dict[str, LanguageProfile] = {
77
+ "en-uk": LanguageProfile(
78
+ key="en-uk",
79
+ locale="en-UK",
80
+ babel="british",
81
+ fallback_opening="Dear Sir or Madam,",
82
+ fallback_closing="Yours faithfully,",
83
+ default_standard="din",
84
+ subject_prefix=r"Subject:~",
85
+ ),
86
+ "en-us": LanguageProfile(
87
+ key="en-us",
88
+ locale="en-US",
89
+ babel="english",
90
+ fallback_opening="Dear Sir or Madam,",
91
+ fallback_closing="Sincerely,",
92
+ default_standard="din",
93
+ subject_prefix=r"Subject:~",
94
+ ),
95
+ "fr-fr": LanguageProfile(
96
+ key="fr-fr",
97
+ locale="fr-FR",
98
+ babel="french",
99
+ fallback_opening="Madame, Monsieur,",
100
+ fallback_closing="Je vous prie d'agreer l'expression de mes salutations distinguees.",
101
+ default_standard="sn-left",
102
+ subject_prefix=r"Objet~:~",
103
+ ),
104
+ }
105
+
106
+ _EN_MONTHS = [
107
+ "January",
108
+ "February",
109
+ "March",
110
+ "April",
111
+ "May",
112
+ "June",
113
+ "July",
114
+ "August",
115
+ "September",
116
+ "October",
117
+ "November",
118
+ "December",
119
+ ]
120
+
121
+ _FR_MONTHS = [
122
+ "janvier",
123
+ "février",
124
+ "mars",
125
+ "avril",
126
+ "mai",
127
+ "juin",
128
+ "juillet",
129
+ "août",
130
+ "septembre",
131
+ "octobre",
132
+ "novembre",
133
+ "décembre",
134
+ ]
135
+
136
+
137
+ class Template(WrappableTemplate):
138
+ """Expose the formal letter template as a wrappable template."""
139
+
140
+ def __init__(self) -> None:
141
+ try:
142
+ super().__init__(_PACKAGE_ROOT)
143
+ except TemplateError as exc:
144
+ raise TemplateError(f"Failed to initialise letter template: {exc}") from exc
145
+
146
+ def prepare_context(
147
+ self,
148
+ latex_body: str,
149
+ *,
150
+ overrides: Mapping[str, Any] | None = None,
151
+ ) -> dict[str, Any]:
152
+ context = super().prepare_context(latex_body, overrides=overrides)
153
+ context["ts_extra_disable_hyperref"] = True
154
+
155
+ profile = self._resolve_language_profile(context.get("language"))
156
+ context["language"] = profile.locale
157
+ context["babel_language"] = profile.babel
158
+ letter_standard = self._resolve_letter_standard(
159
+ context.get("standard") or context.get("letter_standard") or context.get("layout"),
160
+ profile,
161
+ )
162
+ context["letter_standard"] = letter_standard.key
163
+ context["letter_standard_option"] = letter_standard.option
164
+
165
+ context["from_name"] = self._coerce_string(context.get("from_name")) or ""
166
+ context["to_name"] = self._coerce_string(context.get("to_name")) or ""
167
+ signature_payload = context.get("signature")
168
+ signature_text_hint, signature_image_hint = self._extract_signature_components(
169
+ signature_payload
170
+ )
171
+ from_lines = self._normalise_lines(context.get("from_address"))
172
+ to_lines = self._normalise_lines(context.get("to_address"))
173
+ context["from_address_lines"] = from_lines
174
+ context["to_address_lines"] = self._inject_name_line(to_lines, context["to_name"])
175
+ back_address_lines = self._normalise_lines(context.get("back_address"))
176
+ context["back_address_lines"] = back_address_lines
177
+ context["has_back_address"] = bool(back_address_lines)
178
+ context["has_sender_address"] = bool(from_lines)
179
+ context["has_recipient_address"] = bool(context["to_address_lines"])
180
+
181
+ cleaned_body = self._strip_plain_pagestyle(latex_body)
182
+ closing_override = self._coerce_string(context.get("closing"))
183
+ body_without_closing = cleaned_body
184
+ inferred_closing: str | None = closing_override
185
+ if not inferred_closing:
186
+ body_without_closing, inferred_closing = self._extract_body_closing(cleaned_body)
187
+ closing_text = inferred_closing or self._resolve_closing(context, profile)
188
+ context["mainmatter"] = body_without_closing.strip()
189
+ context["_texsmith_main_body"] = context["mainmatter"]
190
+ context["closing_text"] = closing_text
191
+ context["has_closing"] = bool(closing_text)
192
+
193
+ context["opening_text"] = self._resolve_opening(context, profile)
194
+ context["date_value"] = self._format_date_value(context.get("date"), profile)
195
+ context["object_value"] = self._coerce_string(context.get("object")) or ""
196
+ context["from_location_value"] = self._coerce_string(context.get("from_location")) or ""
197
+ context["subject_prefix"] = profile.subject_prefix
198
+
199
+ signature_image_path = self._resolve_signature_image(
200
+ signature_image_hint or signature_text_hint,
201
+ context,
202
+ )
203
+ if signature_image_path and signature_image_hint is None:
204
+ signature_text_hint = None
205
+ signature_text = signature_text_hint or context["from_name"]
206
+ if not signature_text:
207
+ raise TemplateError("Sender name is required to compute the letter signature.")
208
+ context["signature_text"] = signature_text
209
+ context["signature_image_path"] = signature_image_path
210
+ context["has_signature_image"] = bool(signature_image_path)
211
+ context["use_cursive_signature"] = bool(context.get("cursive"))
212
+ context["signature_alignment_command"] = self._resolve_signature_alignment(
213
+ context.get("signature_align")
214
+ )
215
+
216
+ context["has_subject"] = bool(context["object_value"])
217
+ fold_marks = bool(context.get("fold_marks"))
218
+ context["fold_marks_enabled"] = fold_marks
219
+ context["foldmarks_option"] = "true" if fold_marks else "false"
220
+
221
+ reference_value = self._coerce_string(context.get("reference")) or ""
222
+ context["reference_value"] = reference_value
223
+ context["reference_fields_enabled"] = bool(context.get("reference_fields"))
224
+ postscript_text = self._coerce_string(context.get("postscript")) or ""
225
+ context["postscript_text"] = postscript_text
226
+ context["has_postscript"] = bool(postscript_text)
227
+
228
+ context.pop("press", None)
229
+
230
+ return context
231
+
232
+ def wrap_document(
233
+ self,
234
+ latex_body: str,
235
+ *,
236
+ overrides: Mapping[str, Any] | None = None,
237
+ context: Mapping[str, Any] | None = None,
238
+ ) -> str:
239
+ """Wrap the rendered body while honouring template-specific adjustments."""
240
+ body_override = None
241
+ if context is not None:
242
+ body_override = context.get("_texsmith_main_body")
243
+ return super().wrap_document(
244
+ body_override or latex_body,
245
+ overrides=overrides,
246
+ context=context,
247
+ )
248
+
249
+ def _resolve_letter_standard(self, value: Any, profile: LanguageProfile) -> LetterStandard:
250
+ candidate = self._coerce_string(value)
251
+ if candidate:
252
+ key = candidate.lower().replace(" ", "-").replace("_", "-").replace(".", "-")
253
+ else:
254
+ key = profile.default_standard
255
+ resolved_key = _LETTER_STANDARD_ALIASES.get(key, key)
256
+ if resolved_key in _LETTER_STANDARDS:
257
+ return _LETTER_STANDARDS[resolved_key]
258
+ return _LETTER_STANDARDS[profile.default_standard]
259
+
260
+ def _resolve_language_profile(self, value: Any) -> LanguageProfile:
261
+ key = value.strip().lower().replace("_", "-") if isinstance(value, str) else ""
262
+
263
+ if not key:
264
+ return _LANGUAGE_PROFILES["en-uk"]
265
+
266
+ alias_map = {
267
+ "en": "en-uk",
268
+ "en-gb": "en-uk",
269
+ "english": "en-uk",
270
+ "english-gb": "en-uk",
271
+ "en-us": "en-us",
272
+ "english-us": "en-us",
273
+ "us": "en-us",
274
+ "fr": "fr-fr",
275
+ "fr-ca": "fr-fr",
276
+ "fr-ch": "fr-fr",
277
+ }
278
+ resolved_key = alias_map.get(key, key)
279
+
280
+ if resolved_key not in _LANGUAGE_PROFILES:
281
+ if resolved_key.startswith("fr"):
282
+ resolved_key = "fr-fr"
283
+ elif resolved_key.startswith("en-us"):
284
+ resolved_key = "en-us"
285
+ elif resolved_key.startswith("en"):
286
+ resolved_key = "en-uk"
287
+ else:
288
+ resolved_key = "en-uk"
289
+
290
+ return _LANGUAGE_PROFILES[resolved_key]
291
+
292
+ def _coerce_string(self, value: Any) -> str | None:
293
+ if value is None:
294
+ return None
295
+ candidate = value.strip() if isinstance(value, str) else str(value).strip()
296
+ return candidate or None
297
+
298
+ def _normalise_lines(self, payload: Any) -> list[str]:
299
+ if payload is None:
300
+ return []
301
+ if isinstance(payload, str):
302
+ return self._split_lines(payload)
303
+ if isinstance(payload, Mapping):
304
+ lines: list[str] = []
305
+ for _, raw_value in payload.items():
306
+ candidate = self._coerce_string(raw_value)
307
+ if candidate:
308
+ lines.extend(self._split_lines(candidate))
309
+ return lines
310
+ if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
311
+ lines = []
312
+ for item in payload:
313
+ if isinstance(item, Mapping):
314
+ lines.extend(self._normalise_lines(item))
315
+ continue
316
+ candidate = self._coerce_string(item)
317
+ if candidate:
318
+ lines.extend(self._split_lines(candidate))
319
+ return lines
320
+ candidate = self._coerce_string(payload)
321
+ return self._split_lines(candidate) if candidate else []
322
+
323
+ def _split_lines(self, value: str) -> list[str]:
324
+ tokens = [line.strip() for line in value.replace("\r", "").splitlines()]
325
+ return [escape_latex_chars(token) for token in tokens if token]
326
+
327
+ def _inject_name_line(self, lines: list[str], name: str) -> list[str]:
328
+ if not name:
329
+ return lines
330
+ if lines and lines[0] == name:
331
+ return lines
332
+ return [name, *lines]
333
+
334
+ def _resolve_opening(self, context: Mapping[str, Any], profile: LanguageProfile) -> str:
335
+ opening_override = self._coerce_string(context.get("opening"))
336
+ if opening_override:
337
+ return opening_override
338
+ title_value = self._coerce_string(context.get("title"))
339
+ if title_value:
340
+ return title_value
341
+ to_name = self._coerce_string(context.get("to_name"))
342
+ if to_name:
343
+ if profile.key.startswith("en"):
344
+ return f"Dear {to_name},"
345
+ return profile.fallback_opening
346
+ return profile.fallback_opening
347
+
348
+ def _resolve_closing(self, context: Mapping[str, Any], profile: LanguageProfile) -> str:
349
+ closing_override = self._coerce_string(context.get("closing"))
350
+ if closing_override:
351
+ return closing_override
352
+ return profile.fallback_closing
353
+
354
+ def _strip_plain_pagestyle(self, payload: str) -> str:
355
+ return re.sub(r"\\thispagestyle\{[^}]+\}\s*", "", payload).strip()
356
+
357
+ def _extract_body_closing(self, payload: str) -> tuple[str, str | None]:
358
+ blocks = list(payload.rstrip().split("\n\n"))
359
+ for index in range(len(blocks) - 1, -1, -1):
360
+ candidate = blocks[index].strip()
361
+ if not candidate:
362
+ continue
363
+ if self._looks_like_closing_block(candidate):
364
+ del blocks[index]
365
+ remainder = "\n\n".join(blocks).strip()
366
+ return (remainder, candidate)
367
+ break
368
+ return (payload, None)
369
+
370
+ def _looks_like_closing_block(self, candidate: str) -> bool:
371
+ if not candidate.endswith(","):
372
+ return False
373
+ if "\\\\" in candidate or "\\begin" in candidate or "\\end" in candidate:
374
+ return False
375
+ return not len(candidate.split()) > 16
376
+
377
+ def _resolve_signature_alignment(self, value: Any) -> str:
378
+ candidate = self._coerce_string(value)
379
+ key = (candidate or "left").lower()
380
+ mapping = {
381
+ "left": r"\raggedleft",
382
+ "right": r"\raggedright",
383
+ "center": r"\centering",
384
+ "centre": r"\centering",
385
+ }
386
+ return mapping.get(key, r"\raggedleft")
387
+
388
+ _SIGNATURE_EXTENSIONS: ClassVar[set[str]] = {
389
+ ".pdf",
390
+ ".png",
391
+ ".jpg",
392
+ ".jpeg",
393
+ ".svg",
394
+ }
395
+
396
+ def _extract_signature_components(self, value: Any) -> tuple[str | None, str | None]:
397
+ if isinstance(value, Mapping):
398
+ text_candidate = value.get("text") or value.get("name")
399
+ image_candidate = value.get("image") or value.get("path")
400
+ return self._coerce_string(text_candidate), self._coerce_string(image_candidate)
401
+ return self._coerce_string(value), None
402
+
403
+ def _resolve_signature_image(
404
+ self,
405
+ value: Any,
406
+ context: Mapping[str, Any],
407
+ ) -> str | None:
408
+ candidate = self._coerce_string(value)
409
+ if not candidate:
410
+ return None
411
+ path = Path(candidate)
412
+ suffix = path.suffix.lower()
413
+ if suffix not in self._SIGNATURE_EXTENSIONS:
414
+ return None
415
+ source_dir = self._coerce_string(context.get("source_dir"))
416
+ if not path.is_absolute():
417
+ if not source_dir:
418
+ return None
419
+ path = (Path(source_dir) / path).resolve()
420
+ if not path.exists():
421
+ raise TemplateError(f"Signature asset '{path}' does not exist.")
422
+ output_dir = self._coerce_string(context.get("output_dir"))
423
+ if _log.isEnabledFor(logging.DEBUG):
424
+ _log.debug(
425
+ "Resolved signature asset",
426
+ extra={
427
+ "candidate": candidate,
428
+ "resolved_path": str(path),
429
+ "output_dir": output_dir,
430
+ },
431
+ )
432
+ mirrored = self._mirror_signature_asset(path, Path(output_dir)) if output_dir else path
433
+ return self._format_latex_path(mirrored)
434
+
435
+ def _mirror_signature_asset(self, source: Path, output_dir: Path) -> Path:
436
+ output_dir = output_dir.resolve()
437
+ asset_root = (output_dir / "assets" / "signatures").resolve()
438
+ asset_root.mkdir(parents=True, exist_ok=True)
439
+ suffix = source.suffix.lower()
440
+ if suffix == ".svg":
441
+ try:
442
+ produced = svg2pdf(source, output_dir=asset_root)
443
+ except TransformerExecutionError as exc: # pragma: no cover - conversion failure
444
+ raise TemplateError(
445
+ f"Failed to convert signature SVG '{source}' to PDF: {exc}"
446
+ ) from exc
447
+ result = produced
448
+ else:
449
+ target = asset_root / source.name
450
+ shutil.copy2(source, target)
451
+ result = target
452
+ try:
453
+ return result.relative_to(output_dir)
454
+ except ValueError:
455
+ return result
456
+
457
+ def _format_latex_path(self, path: Path) -> str:
458
+ posix = path.as_posix()
459
+ return escape_latex_chars(posix)
460
+
461
+ def _format_date_value(self, value: Any, profile: LanguageProfile) -> str:
462
+ parsed = self._parse_date_value(value)
463
+ if parsed is None:
464
+ candidate = self._coerce_string(value)
465
+ return escape_latex_chars(candidate) if candidate else r"\today"
466
+ month_name = self._month_name(parsed.month, profile)
467
+ if profile.key == "en-us":
468
+ formatted = f"{month_name} {parsed.day}, {parsed.year}"
469
+ else:
470
+ formatted = f"{parsed.day} {month_name} {parsed.year}"
471
+ return escape_latex_chars(formatted)
472
+
473
+ def _parse_date_value(self, value: Any) -> date | None:
474
+ candidate = self._coerce_string(value)
475
+ if not candidate:
476
+ return None
477
+ try:
478
+ parsed = datetime.fromisoformat(candidate)
479
+ except ValueError:
480
+ formats = ("%Y/%m/%d", "%d/%m/%Y", "%d.%m.%Y", "%m/%d/%Y")
481
+ for fmt in formats:
482
+ try:
483
+ parsed = datetime.strptime(candidate, fmt).replace(tzinfo=UTC)
484
+ break
485
+ except ValueError:
486
+ continue
487
+ else:
488
+ return None
489
+ return parsed.date()
490
+
491
+ def _month_name(self, month_index: int, profile: LanguageProfile) -> str:
492
+ names = _FR_MONTHS if profile.key.startswith("fr") else _EN_MONTHS
493
+ if 1 <= month_index <= 12:
494
+ return names[month_index - 1]
495
+ return names[0]
@@ -0,0 +1,31 @@
1
+ ---
2
+ press:
3
+ template: letter
4
+ language: fr-CH
5
+ standard: sn
6
+ cursive: false
7
+ date: 1928-12-23
8
+ object: Invitation souper de Noël
9
+ from:
10
+ name: Madame Marie-Henriette Dupont
11
+ address:
12
+ - 12, rue des Fleurs
13
+ - 1204 Genève
14
+ - Suisse
15
+ city: Genève
16
+ to:
17
+ name: Monsieur Jean-Marc Martin
18
+ address:
19
+ - 45, avenue des Champs
20
+ - 75008 Paris
21
+ - France
22
+ ---
23
+ # Mon très cher Monsieur Martin,
24
+
25
+ Il est des soirs où la solitude s’avance comme un spectre silencieux, effleurant les murailles de la demeure et glissant, perfide, jusqu’au cœur. Noël, jadis si empli de rires enfantins et de clameurs joyeuses, s’annonce cette année sous un voile de mélancolie. Mes enfants, ces oiseaux migrateurs, se sont envolés vers d’autres cieux ; et le deuil encore noir de mon cher défunt ne cesse de teinter mes jours d’une ombre discrète.
26
+
27
+ Aussi, permettez, cher Monsieur, qu’une audace presque inconsidérée me dicte ces lignes. Je me suis dit que peut-être -- par un bienveillant caprice du destin -- vous consentiriez à troubler ma solitude en acceptant de partager avec moi le souper de la Nativité.
28
+
29
+ Je me chargerai, de mes mains mêmes, de préparer un humble festin champêtre : une dinde dodue, élevée au jardin et répondant, avec une ingénuité touchante, au doux nom de Poulette. Elle sera escortée de quelques ortolans, mets délicat s’il en est, que j’aurai accommodés selon une recette d’antan dont j’ai gardé le secret.
30
+
31
+ Je n’ose espérer que la chaleur de mon foyer puisse égaler celle de votre esprit, ni que le parfum du vin chaud rivalise avec celui de votre présence. Mais si le cœur vous en dit, venez donc, cher ami ; et que la neige, complice de cette invitation, vous guide jusqu’à ma porte.