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,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar, Generic, Literal, TypeVar
|
|
8
|
+
|
|
9
|
+
from texsmith.core.templates.manifest import TemplateAttributeSpec, TemplateError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
FragmentKind = Literal["package", "input", "inline"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class FragmentPiece:
|
|
17
|
+
"""One renderable fragment component."""
|
|
18
|
+
|
|
19
|
+
template_path: Path
|
|
20
|
+
kind: FragmentKind = "package"
|
|
21
|
+
slot: str = "extra_packages"
|
|
22
|
+
output_name: str | None = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_mapping(cls, payload: Mapping[str, Any], *, base_dir: Path) -> FragmentPiece:
|
|
26
|
+
"""Build a piece from a TOML entry or Python mapping."""
|
|
27
|
+
if not isinstance(payload, Mapping):
|
|
28
|
+
raise TemplateError("Fragment file entries must be mappings.")
|
|
29
|
+
|
|
30
|
+
path_value = payload.get("path") or payload.get("template")
|
|
31
|
+
if not path_value or not isinstance(path_value, str):
|
|
32
|
+
raise TemplateError("Fragment file entries require a 'path' or 'template' string.")
|
|
33
|
+
|
|
34
|
+
candidate_path = Path(path_value)
|
|
35
|
+
resolved_path = (
|
|
36
|
+
candidate_path
|
|
37
|
+
if candidate_path.is_absolute()
|
|
38
|
+
else (base_dir / candidate_path).resolve()
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if not resolved_path.exists():
|
|
42
|
+
raise TemplateError(f"Fragment file is missing: {resolved_path}")
|
|
43
|
+
|
|
44
|
+
kind_raw = payload.get("type", "package")
|
|
45
|
+
if kind_raw not in ("package", "input", "inline"):
|
|
46
|
+
raise TemplateError(
|
|
47
|
+
f"Unknown fragment file type '{kind_raw}'. Expected one of: package, input, inline."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
slot = str(payload.get("slot", "extra_packages"))
|
|
51
|
+
output_name = payload.get("output") if isinstance(payload.get("output"), str) else None
|
|
52
|
+
|
|
53
|
+
return cls(
|
|
54
|
+
template_path=resolved_path,
|
|
55
|
+
kind=kind_raw, # type: ignore[arg-type]
|
|
56
|
+
slot=slot,
|
|
57
|
+
output_name=output_name,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _ensure_suffix(self, name: str) -> str:
|
|
61
|
+
suffix = ".sty" if self.kind == "package" else ".tex"
|
|
62
|
+
return name if name.endswith(suffix) else f"{name}{suffix}"
|
|
63
|
+
|
|
64
|
+
def output_filename(self, fragment_name: str) -> str | None:
|
|
65
|
+
"""Return the rendered filename, if applicable."""
|
|
66
|
+
if self.kind == "inline":
|
|
67
|
+
return None
|
|
68
|
+
base = self.output_name or fragment_name
|
|
69
|
+
normalised = Path(base).name
|
|
70
|
+
return self._ensure_suffix(normalised)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
C = TypeVar("C")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BaseFragment(ABC, Generic[C]):
|
|
77
|
+
"""Base contract for object-oriented fragments."""
|
|
78
|
+
|
|
79
|
+
name: ClassVar[str]
|
|
80
|
+
description: ClassVar[str]
|
|
81
|
+
pieces: ClassVar[list[FragmentPiece]]
|
|
82
|
+
attributes: ClassVar[dict[str, TemplateAttributeSpec]]
|
|
83
|
+
config_cls: ClassVar[type[C]]
|
|
84
|
+
context_defaults: ClassVar[dict[str, Any]] = {}
|
|
85
|
+
partials: ClassVar[Mapping[str, Path | str] | Sequence[Path | str]] = ()
|
|
86
|
+
required_partials: ClassVar[Sequence[str]] = ()
|
|
87
|
+
source: ClassVar[Path | None] = None
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def build_config(
|
|
91
|
+
self, context: Mapping[str, Any], overrides: Mapping[str, Any] | None = None
|
|
92
|
+
) -> C:
|
|
93
|
+
"""Return a normalized config instance from the provided context."""
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def inject(
|
|
98
|
+
self, config: C, context: dict[str, Any], overrides: Mapping[str, Any] | None = None
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Inject Jinja/templating context variables derived from the config."""
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def should_render(self, config: C) -> bool:
|
|
105
|
+
"""Return whether this fragment should render given the config."""
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
def render_context(
|
|
109
|
+
self, context: dict[str, Any], overrides: Mapping[str, Any] | None = None
|
|
110
|
+
) -> C:
|
|
111
|
+
"""Build config and inject into a mutable context; return the config."""
|
|
112
|
+
config = self.build_config(context, overrides=overrides)
|
|
113
|
+
self.inject(config, context, overrides=overrides)
|
|
114
|
+
return config
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["BaseFragment", "FragmentKind", "FragmentPiece"]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Helpers for normalising common press metadata across inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, MutableMapping, Sequence
|
|
6
|
+
import copy
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["PressMetadataError", "normalise_press_metadata"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PressMetadataError(ValueError):
|
|
18
|
+
"""Raised when press metadata fields contain invalid values."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _AuthorEntry(BaseModel):
|
|
22
|
+
"""Validated representation of a press author entry."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="ignore")
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
affiliation: str | None = None
|
|
28
|
+
|
|
29
|
+
@field_validator("name", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def _coerce_name(cls, value: Any) -> str:
|
|
32
|
+
if value is None:
|
|
33
|
+
raise ValueError("Author name cannot be empty.")
|
|
34
|
+
candidate = value if isinstance(value, str) else str(value)
|
|
35
|
+
stripped = candidate.strip()
|
|
36
|
+
if not stripped:
|
|
37
|
+
raise ValueError("Author name cannot be empty.")
|
|
38
|
+
return stripped
|
|
39
|
+
|
|
40
|
+
@field_validator("affiliation", mode="before")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _coerce_affiliation(cls, value: Any) -> str | None:
|
|
43
|
+
if value is None:
|
|
44
|
+
return None
|
|
45
|
+
candidate = value if isinstance(value, str) else str(value)
|
|
46
|
+
stripped = candidate.strip()
|
|
47
|
+
return stripped or None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_SPECIAL_FIELDS = ("title", "subtitle", "date")
|
|
51
|
+
_ROOT_SKIP_KEYS = {"press", "slots", "entrypoints"}
|
|
52
|
+
_NESTED_ALIAS_MAP: dict[tuple[str, str], str] = {
|
|
53
|
+
("glossary", "style"): "glossary_style",
|
|
54
|
+
("cover", "color"): "covercolor",
|
|
55
|
+
("cover", "logo"): "logo",
|
|
56
|
+
("imprint", "thanks"): "imprint_thanks",
|
|
57
|
+
("imprint", "license"): "imprint_license",
|
|
58
|
+
("imprint", "copyright"): "imprint_copyright",
|
|
59
|
+
("override", "preamble"): "preamble",
|
|
60
|
+
("snippet", "width"): "width",
|
|
61
|
+
("snippet", "margin"): "margin",
|
|
62
|
+
("snippet", "dogear"): "dogear",
|
|
63
|
+
("snippet", "border"): "border",
|
|
64
|
+
("from", "name"): "from_name",
|
|
65
|
+
("from", "address"): "from_address",
|
|
66
|
+
("from", "city"): "from_location",
|
|
67
|
+
("from", "location"): "from_location",
|
|
68
|
+
("to", "name"): "to_name",
|
|
69
|
+
("to", "address"): "to_address",
|
|
70
|
+
("signature", "align"): "signature_align",
|
|
71
|
+
}
|
|
72
|
+
_DIRECT_ALIAS_MAP = {
|
|
73
|
+
"ps": "postscript",
|
|
74
|
+
"postscript": "postscript",
|
|
75
|
+
"myref": "reference",
|
|
76
|
+
"my_ref": "reference",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalise_press_metadata(metadata: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
|
|
81
|
+
"""Flatten press metadata into the root mapping while preserving compatibility."""
|
|
82
|
+
press_section = metadata.pop("press", None)
|
|
83
|
+
press_payload: dict[str, Any] = (
|
|
84
|
+
dict(press_section) if isinstance(press_section, Mapping) else {}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
root_payload = dict(metadata)
|
|
88
|
+
_hoist_dotted_press_keys(root_payload, press_payload)
|
|
89
|
+
|
|
90
|
+
merged = _merge_press_overrides(press_payload, root_payload)
|
|
91
|
+
_coerce_common_strings(merged, press_payload)
|
|
92
|
+
_normalise_press_authors(merged)
|
|
93
|
+
_flatten_press_aliases(merged)
|
|
94
|
+
|
|
95
|
+
metadata.clear()
|
|
96
|
+
metadata.update(merged)
|
|
97
|
+
|
|
98
|
+
press_view = _build_press_view(merged)
|
|
99
|
+
if press_view:
|
|
100
|
+
metadata["press"] = press_view
|
|
101
|
+
|
|
102
|
+
return press_view
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _merge_press_overrides(
|
|
106
|
+
press_payload: Mapping[str, Any], root_payload: Mapping[str, Any]
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
merged: dict[str, Any] = copy.deepcopy(press_payload)
|
|
109
|
+
|
|
110
|
+
def _merge(target: MutableMapping[str, Any], source: Mapping[str, Any]) -> None:
|
|
111
|
+
for key, value in source.items():
|
|
112
|
+
existing = target.get(key)
|
|
113
|
+
if isinstance(existing, MutableMapping) and isinstance(value, Mapping):
|
|
114
|
+
nested: dict[str, Any] = dict(existing)
|
|
115
|
+
_merge(nested, value)
|
|
116
|
+
target[key] = nested
|
|
117
|
+
continue
|
|
118
|
+
target[key] = copy.deepcopy(value)
|
|
119
|
+
|
|
120
|
+
_merge(merged, root_payload)
|
|
121
|
+
return merged
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _coerce_common_strings(
|
|
125
|
+
merged: MutableMapping[str, Any], press_payload: Mapping[str, Any]
|
|
126
|
+
) -> None:
|
|
127
|
+
for field in _SPECIAL_FIELDS:
|
|
128
|
+
existing = _coerce_metadata_string(merged.get(field))
|
|
129
|
+
source = _coerce_metadata_string(press_payload.get(field))
|
|
130
|
+
if existing is None and source is not None:
|
|
131
|
+
merged[field] = source
|
|
132
|
+
elif existing is not None:
|
|
133
|
+
if source is not None and source != existing:
|
|
134
|
+
warnings.warn(
|
|
135
|
+
f"Overriding press.{field} with root front matter value '{existing}'.",
|
|
136
|
+
stacklevel=2,
|
|
137
|
+
)
|
|
138
|
+
merged[field] = existing
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _coerce_metadata_string(value: Any) -> str | None:
|
|
142
|
+
if value is None:
|
|
143
|
+
return None
|
|
144
|
+
if isinstance(value, str):
|
|
145
|
+
stripped = value.strip()
|
|
146
|
+
return stripped or None
|
|
147
|
+
if isinstance(value, (datetime, date)):
|
|
148
|
+
return value.isoformat()
|
|
149
|
+
candidate = str(value).strip()
|
|
150
|
+
return candidate or None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _normalise_press_authors(metadata: MutableMapping[str, Any]) -> None:
|
|
154
|
+
sources: list[Any] = []
|
|
155
|
+
|
|
156
|
+
authors_present = "authors" in metadata
|
|
157
|
+
author_present = "author" in metadata
|
|
158
|
+
|
|
159
|
+
if authors_present:
|
|
160
|
+
sources.append(metadata.get("authors"))
|
|
161
|
+
if author_present:
|
|
162
|
+
sources.append(metadata.get("author"))
|
|
163
|
+
|
|
164
|
+
if not sources:
|
|
165
|
+
if authors_present:
|
|
166
|
+
metadata["authors"] = []
|
|
167
|
+
if author_present:
|
|
168
|
+
metadata.pop("author", None)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
entries: list[dict[str, str | None]] = []
|
|
172
|
+
for source in sources:
|
|
173
|
+
entries.extend(_flatten_author_entries(source))
|
|
174
|
+
|
|
175
|
+
if not entries:
|
|
176
|
+
metadata.setdefault("authors", [])
|
|
177
|
+
metadata.pop("author", None)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# Remove stale scalar author values to keep press metadata canonical.
|
|
181
|
+
metadata.pop("author", None)
|
|
182
|
+
|
|
183
|
+
normalized: list[dict[str, str | None]] = []
|
|
184
|
+
seen: set[tuple[str | None, str | None]] = set()
|
|
185
|
+
for entry in entries:
|
|
186
|
+
try:
|
|
187
|
+
candidate = _AuthorEntry.model_validate(entry).model_dump()
|
|
188
|
+
except ValidationError as exc:
|
|
189
|
+
raise PressMetadataError(
|
|
190
|
+
"Invalid author metadata. Provide names and optional affiliations."
|
|
191
|
+
) from exc
|
|
192
|
+
key = (candidate.get("name"), candidate.get("affiliation"))
|
|
193
|
+
if key in seen:
|
|
194
|
+
continue
|
|
195
|
+
seen.add(key)
|
|
196
|
+
normalized.append(candidate)
|
|
197
|
+
|
|
198
|
+
metadata["authors"] = normalized
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _flatten_press_aliases(press_payload: MutableMapping[str, Any]) -> None:
|
|
202
|
+
for (section, key), target in _NESTED_ALIAS_MAP.items():
|
|
203
|
+
nested = press_payload.get(section)
|
|
204
|
+
if not isinstance(nested, Mapping):
|
|
205
|
+
continue
|
|
206
|
+
if key not in nested:
|
|
207
|
+
continue
|
|
208
|
+
value = nested[key]
|
|
209
|
+
press_payload[target] = value
|
|
210
|
+
|
|
211
|
+
for source, target in _DIRECT_ALIAS_MAP.items():
|
|
212
|
+
if source not in press_payload:
|
|
213
|
+
continue
|
|
214
|
+
press_payload[target] = press_payload[source]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _hoist_dotted_press_keys(
|
|
218
|
+
root_payload: MutableMapping[str, Any], press_payload: MutableMapping[str, Any]
|
|
219
|
+
) -> None:
|
|
220
|
+
dotted_keys = [
|
|
221
|
+
key
|
|
222
|
+
for key in list(root_payload.keys())
|
|
223
|
+
if isinstance(key, str) and key.startswith("press.")
|
|
224
|
+
]
|
|
225
|
+
for dotted in dotted_keys:
|
|
226
|
+
value = root_payload.pop(dotted)
|
|
227
|
+
segments = [segment for segment in dotted.split(".")[1:] if segment]
|
|
228
|
+
if not segments:
|
|
229
|
+
continue
|
|
230
|
+
target = press_payload
|
|
231
|
+
for segment in segments[:-1]:
|
|
232
|
+
existing = target.get(segment)
|
|
233
|
+
nested = dict(existing) if isinstance(existing, MutableMapping) else {}
|
|
234
|
+
target[segment] = nested
|
|
235
|
+
target = nested
|
|
236
|
+
target[segments[-1]] = value
|
|
237
|
+
|
|
238
|
+
slash_keys = [
|
|
239
|
+
key
|
|
240
|
+
for key in list(root_payload.keys())
|
|
241
|
+
if isinstance(key, str) and key.startswith("press/")
|
|
242
|
+
]
|
|
243
|
+
for entry in slash_keys:
|
|
244
|
+
value = root_payload.pop(entry)
|
|
245
|
+
suffix = entry.split("/", 1)[1]
|
|
246
|
+
if suffix:
|
|
247
|
+
press_payload[suffix] = value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build_press_view(merged: Mapping[str, Any]) -> dict[str, Any]:
|
|
251
|
+
return {
|
|
252
|
+
key: copy.deepcopy(value)
|
|
253
|
+
for key, value in merged.items()
|
|
254
|
+
if key not in _ROOT_SKIP_KEYS and not str(key).startswith("_")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _flatten_author_entries(payload: Any) -> list[dict[str, Any]]:
|
|
259
|
+
if payload is None:
|
|
260
|
+
return []
|
|
261
|
+
if isinstance(payload, Mapping):
|
|
262
|
+
return _validate_mapping_entry(payload)
|
|
263
|
+
if isinstance(payload, str):
|
|
264
|
+
return [{"name": payload}]
|
|
265
|
+
if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
|
|
266
|
+
flattened: list[dict[str, Any]] = []
|
|
267
|
+
for item in payload:
|
|
268
|
+
flattened.extend(_flatten_author_entries(item))
|
|
269
|
+
return flattened
|
|
270
|
+
return [{"name": payload}]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _validate_mapping_entry(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
274
|
+
if "name" in payload or "affiliation" in payload:
|
|
275
|
+
return [dict(payload)]
|
|
276
|
+
if "author" in payload:
|
|
277
|
+
normalised = dict(payload)
|
|
278
|
+
normalised.setdefault("name", normalised.get("author"))
|
|
279
|
+
return [normalised]
|
|
280
|
+
raise PressMetadataError("Author objects must declare at least a 'name' field.")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Utility helpers for resolving simple mustache-style placeholders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .diagnostics import DiagnosticEmitter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_MUSTACHE_RE = re.compile(r"\{\{\s*([^\}\s][^\}]*)\s*\}\}")
|
|
13
|
+
_MISSING = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _lookup(context: Mapping[str, Any] | None, path: str) -> Any:
|
|
17
|
+
current: Any = context
|
|
18
|
+
for part in path.split("."):
|
|
19
|
+
if not isinstance(current, Mapping) or part not in current:
|
|
20
|
+
return _MISSING
|
|
21
|
+
current = current.get(part)
|
|
22
|
+
return current
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_value(path: str, contexts: Sequence[Mapping[str, Any] | None]) -> Any:
|
|
26
|
+
for context in contexts:
|
|
27
|
+
value = _lookup(context, path)
|
|
28
|
+
if value is not _MISSING:
|
|
29
|
+
return value
|
|
30
|
+
return _MISSING
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def replace_mustaches(
|
|
34
|
+
text: str,
|
|
35
|
+
contexts: Sequence[Mapping[str, Any] | None],
|
|
36
|
+
*,
|
|
37
|
+
emitter: DiagnosticEmitter | None = None,
|
|
38
|
+
source: str | None = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Replace ``{{path.to.value}}`` placeholders in ``text`` using ``contexts``."""
|
|
41
|
+
|
|
42
|
+
def _warn(message: str) -> None:
|
|
43
|
+
if emitter:
|
|
44
|
+
emitter.warning(message)
|
|
45
|
+
|
|
46
|
+
def _replacement(match: re.Match[str]) -> str:
|
|
47
|
+
raw_path = match.group(1).strip()
|
|
48
|
+
value = _resolve_value(raw_path, contexts)
|
|
49
|
+
if value is _MISSING or value is None or (isinstance(value, str) and not value.strip()):
|
|
50
|
+
location = f" in {source}" if source else ""
|
|
51
|
+
_warn(f"Unresolved mustache '{{{{{raw_path}}}}}'{location}; leaving placeholder as-is.")
|
|
52
|
+
return match.group(0)
|
|
53
|
+
return str(value)
|
|
54
|
+
|
|
55
|
+
return _MUSTACHE_RE.sub(_replacement, text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def replace_mustaches_in_structure(
|
|
59
|
+
payload: Any,
|
|
60
|
+
contexts: Sequence[Mapping[str, Any] | None],
|
|
61
|
+
*,
|
|
62
|
+
emitter: DiagnosticEmitter | None = None,
|
|
63
|
+
source: str | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Recursively resolve mustache placeholders inside mappings/sequences/strings."""
|
|
66
|
+
if isinstance(payload, str):
|
|
67
|
+
return replace_mustaches(payload, contexts, emitter=emitter, source=source)
|
|
68
|
+
if isinstance(payload, Mapping):
|
|
69
|
+
return {
|
|
70
|
+
key: replace_mustaches_in_structure(value, contexts, emitter=emitter, source=source)
|
|
71
|
+
for key, value in payload.items()
|
|
72
|
+
}
|
|
73
|
+
if isinstance(payload, list):
|
|
74
|
+
return [
|
|
75
|
+
replace_mustaches_in_structure(item, contexts, emitter=emitter, source=source)
|
|
76
|
+
for item in payload
|
|
77
|
+
]
|
|
78
|
+
if isinstance(payload, tuple):
|
|
79
|
+
return tuple(
|
|
80
|
+
replace_mustaches_in_structure(item, contexts, emitter=emitter, source=source)
|
|
81
|
+
for item in payload
|
|
82
|
+
)
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["replace_mustaches", "replace_mustaches_in_structure"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared helpers for managing LaTeX partial identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from texsmith.adapters.latex.formatter import LaTeXFormatter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalise_partial_key(value: str) -> str:
|
|
11
|
+
"""Return a formatter-friendly identifier for a partial entry."""
|
|
12
|
+
candidate = str(value or "").strip()
|
|
13
|
+
if not candidate:
|
|
14
|
+
return ""
|
|
15
|
+
path = Path(candidate)
|
|
16
|
+
base = path.with_suffix("").as_posix()
|
|
17
|
+
return LaTeXFormatter.normalise_key(base)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["normalise_partial_key"]
|