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.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- 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.
|
|
Binary file
|
|
Binary file
|