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,87 @@
|
|
|
1
|
+
"""Debug and diagnostic helpers used throughout the conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..diagnostics import DiagnosticEmitter, LoggingEmitter, NullEmitter
|
|
10
|
+
from ..exceptions import LatexRenderingError, exception_hint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConversionError(Exception):
|
|
14
|
+
"""Raised when a conversion fails and cannot recover."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_emitter(emitter: DiagnosticEmitter | None) -> DiagnosticEmitter:
|
|
18
|
+
"""Return a usable emitter, defaulting to the null implementation."""
|
|
19
|
+
return emitter if emitter is not None else NullEmitter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def debug_enabled(emitter: DiagnosticEmitter | None) -> bool:
|
|
23
|
+
"""Return whether debug mode is active for the given emitter."""
|
|
24
|
+
return bool(emitter and getattr(emitter, "debug_enabled", False))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def raise_conversion_error(
|
|
28
|
+
emitter: DiagnosticEmitter | None,
|
|
29
|
+
message: str,
|
|
30
|
+
exc: Exception,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Emit an error diagnostic before raising a conversion failure."""
|
|
33
|
+
ensure_emitter(emitter).error(message, exc)
|
|
34
|
+
error = ConversionError(message)
|
|
35
|
+
error._texsmith_logged = True # noqa: SLF001
|
|
36
|
+
raise error from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_event(
|
|
40
|
+
emitter: DiagnosticEmitter | None,
|
|
41
|
+
event: str,
|
|
42
|
+
payload: Mapping[str, Any],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Forward a structured diagnostic event."""
|
|
45
|
+
ensure_emitter(emitter).event(event, payload)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def persist_debug_artifacts(output_dir: Path, source: Path, html: str) -> None:
|
|
49
|
+
"""Persist intermediate HTML snapshots to aid debugging."""
|
|
50
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
debug_path = output_dir / f"{source.stem}.debug.html"
|
|
52
|
+
debug_path.write_text(html, encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_rendering_error(error: LatexRenderingError) -> str:
|
|
56
|
+
"""Format a human-readable rendering failure summary."""
|
|
57
|
+
cause = error.__cause__
|
|
58
|
+
if cause is None:
|
|
59
|
+
return str(error)
|
|
60
|
+
return f"LaTeX rendering failed: {cause}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_user_friendly_render_error(error: LatexRenderingError) -> str:
|
|
64
|
+
"""Return a concise rendering failure summary suitable for end users."""
|
|
65
|
+
summary = "LaTeX rendering failed"
|
|
66
|
+
hint_source = error.__cause__ or error
|
|
67
|
+
hint = exception_hint(hint_source)
|
|
68
|
+
if hint:
|
|
69
|
+
summary = f"{summary}: {hint}"
|
|
70
|
+
if summary.endswith("."):
|
|
71
|
+
summary = summary.rstrip(".")
|
|
72
|
+
return f"{summary}. Re-run with --debug for technical details."
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"ConversionError",
|
|
77
|
+
"DiagnosticEmitter",
|
|
78
|
+
"LoggingEmitter",
|
|
79
|
+
"NullEmitter",
|
|
80
|
+
"debug_enabled",
|
|
81
|
+
"ensure_emitter",
|
|
82
|
+
"format_rendering_error",
|
|
83
|
+
"format_user_friendly_render_error",
|
|
84
|
+
"persist_debug_artifacts",
|
|
85
|
+
"raise_conversion_error",
|
|
86
|
+
"record_event",
|
|
87
|
+
]
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Input parsing utilities shared across the conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bs4 import BeautifulSoup, FeatureNotFound
|
|
14
|
+
|
|
15
|
+
from ..conversion.debug import ConversionError
|
|
16
|
+
from ..conversion_contexts import DocumentContext
|
|
17
|
+
from ..metadata import PressMetadataError, normalise_press_metadata
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DOCUMENT_SELECTOR_SENTINEL = "@document"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnsupportedInputError(Exception):
|
|
24
|
+
"""Raised when a CLI input argument cannot be processed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InputKind(Enum):
|
|
28
|
+
"""Supported input modalities handled by the conversion pipeline."""
|
|
29
|
+
|
|
30
|
+
MARKDOWN = "markdown"
|
|
31
|
+
HTML = "html"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InlineBibliographyValidationError(ValueError):
|
|
35
|
+
"""Raised when inline bibliography entries contain invalid data."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class InlineBibliographyEntry:
|
|
40
|
+
"""Validated representation of a front-matter bibliography entry."""
|
|
41
|
+
|
|
42
|
+
key: str
|
|
43
|
+
doi: str | None = None
|
|
44
|
+
entry_type: str | None = None
|
|
45
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
persons: dict[str, list[str]] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_manual(self) -> bool:
|
|
50
|
+
"""Return True when the entry embeds explicit bibliographic fields."""
|
|
51
|
+
return self.entry_type is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_document_context(
|
|
55
|
+
*,
|
|
56
|
+
name: str,
|
|
57
|
+
source_path: Path,
|
|
58
|
+
html: str,
|
|
59
|
+
front_matter: Mapping[str, Any] | None,
|
|
60
|
+
base_level: int,
|
|
61
|
+
drop_title: bool,
|
|
62
|
+
numbered: bool,
|
|
63
|
+
title_from_heading: bool = False,
|
|
64
|
+
extracted_title: str | None = None,
|
|
65
|
+
) -> DocumentContext:
|
|
66
|
+
"""Construct a document context enriched with metadata and slot requests."""
|
|
67
|
+
metadata = dict(front_matter or {})
|
|
68
|
+
try:
|
|
69
|
+
press_payload = normalise_press_metadata(metadata)
|
|
70
|
+
except PressMetadataError as exc:
|
|
71
|
+
raise ConversionError(str(exc)) from exc
|
|
72
|
+
metadata.setdefault("_source_dir", str(source_path.parent))
|
|
73
|
+
metadata.setdefault("_source_path", str(source_path))
|
|
74
|
+
if press_payload:
|
|
75
|
+
press_payload.setdefault("_source_dir", str(source_path.parent))
|
|
76
|
+
press_payload.setdefault("_source_path", str(source_path))
|
|
77
|
+
slot_requests = extract_front_matter_slots(metadata)
|
|
78
|
+
|
|
79
|
+
return DocumentContext(
|
|
80
|
+
name=name,
|
|
81
|
+
source_path=source_path,
|
|
82
|
+
html=html,
|
|
83
|
+
base_level=base_level,
|
|
84
|
+
numbered=numbered,
|
|
85
|
+
drop_title=drop_title,
|
|
86
|
+
title_from_heading=title_from_heading,
|
|
87
|
+
extracted_title=extracted_title,
|
|
88
|
+
front_matter=metadata,
|
|
89
|
+
slot_requests=slot_requests,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def coerce_slot_selector(payload: Any) -> str | None:
|
|
94
|
+
"""Normalise a selector definition coming from front matter."""
|
|
95
|
+
if isinstance(payload, str):
|
|
96
|
+
candidate = payload.strip()
|
|
97
|
+
return candidate or None
|
|
98
|
+
if isinstance(payload, Mapping):
|
|
99
|
+
for key in ("label", "title", "section"):
|
|
100
|
+
value = payload.get(key)
|
|
101
|
+
if isinstance(value, str) and value.strip():
|
|
102
|
+
return value.strip()
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_slot_mapping(raw: Any) -> dict[str, str]:
|
|
107
|
+
"""Parse slot mappings declared in front matter structures."""
|
|
108
|
+
overrides: dict[str, str] = {}
|
|
109
|
+
if not raw:
|
|
110
|
+
return overrides
|
|
111
|
+
|
|
112
|
+
if isinstance(raw, Mapping):
|
|
113
|
+
for slot_name, payload in raw.items():
|
|
114
|
+
if not isinstance(slot_name, str):
|
|
115
|
+
continue
|
|
116
|
+
selector = coerce_slot_selector(payload)
|
|
117
|
+
if selector:
|
|
118
|
+
key = slot_name.strip()
|
|
119
|
+
if key:
|
|
120
|
+
overrides[key] = selector
|
|
121
|
+
return overrides
|
|
122
|
+
|
|
123
|
+
if isinstance(raw, Iterable) and not isinstance(raw, str | bytes):
|
|
124
|
+
for entry in raw:
|
|
125
|
+
if not isinstance(entry, Mapping):
|
|
126
|
+
continue
|
|
127
|
+
slot_name = entry.get("target") or entry.get("slot")
|
|
128
|
+
if not isinstance(slot_name, str):
|
|
129
|
+
continue
|
|
130
|
+
selector = entry.get("label") or entry.get("title") or entry.get("section")
|
|
131
|
+
selector_value = coerce_slot_selector(selector)
|
|
132
|
+
if not selector_value:
|
|
133
|
+
selector_value = coerce_slot_selector(entry)
|
|
134
|
+
slot_key = slot_name.strip()
|
|
135
|
+
if slot_key and selector_value:
|
|
136
|
+
overrides[slot_key] = selector_value
|
|
137
|
+
return overrides
|
|
138
|
+
|
|
139
|
+
if isinstance(raw, str):
|
|
140
|
+
entry = raw.strip()
|
|
141
|
+
if entry and ":" in entry:
|
|
142
|
+
name, selector = entry.split(":", 1)
|
|
143
|
+
name = name.strip()
|
|
144
|
+
selector = selector.strip()
|
|
145
|
+
if name and selector:
|
|
146
|
+
overrides[name] = selector
|
|
147
|
+
return overrides
|
|
148
|
+
|
|
149
|
+
return overrides
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def extract_front_matter_slots(front_matter: Mapping[str, Any]) -> dict[str, str]:
|
|
153
|
+
"""Collect slot overrides defined in document front matter."""
|
|
154
|
+
overrides: dict[str, str] = {}
|
|
155
|
+
|
|
156
|
+
root_slots = front_matter.get("slots") or front_matter.get("entrypoints")
|
|
157
|
+
overrides.update(parse_slot_mapping(root_slots))
|
|
158
|
+
|
|
159
|
+
return overrides
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_ISO_YEAR_RE = re.compile(r"^(?P<year>\d{4})$")
|
|
163
|
+
_ISO_YEAR_MONTH_RE = re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})$")
|
|
164
|
+
_ISO_DATE_RE = re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$")
|
|
165
|
+
|
|
166
|
+
_COMMON_ALLOWED_FIELDS = {
|
|
167
|
+
"title",
|
|
168
|
+
"subtitle",
|
|
169
|
+
"date",
|
|
170
|
+
"year",
|
|
171
|
+
"month",
|
|
172
|
+
"day",
|
|
173
|
+
"note",
|
|
174
|
+
"url",
|
|
175
|
+
"doi",
|
|
176
|
+
}
|
|
177
|
+
_MISC_ALLOWED_FIELDS = _COMMON_ALLOWED_FIELDS | {"howpublished", "publisher", "address"}
|
|
178
|
+
_ARTICLE_ALLOWED_FIELDS = _COMMON_ALLOWED_FIELDS | {
|
|
179
|
+
"journal",
|
|
180
|
+
"volume",
|
|
181
|
+
"number",
|
|
182
|
+
"pages",
|
|
183
|
+
"publisher",
|
|
184
|
+
"address",
|
|
185
|
+
"issn",
|
|
186
|
+
}
|
|
187
|
+
_BOOK_ALLOWED_FIELDS = _COMMON_ALLOWED_FIELDS | {
|
|
188
|
+
"publisher",
|
|
189
|
+
"address",
|
|
190
|
+
"edition",
|
|
191
|
+
"series",
|
|
192
|
+
"volume",
|
|
193
|
+
"number",
|
|
194
|
+
"pages",
|
|
195
|
+
"isbn",
|
|
196
|
+
}
|
|
197
|
+
_INLINE_BIBLIOGRAPHY_SCHEMAS: dict[str, dict[str, set[str]]] = {
|
|
198
|
+
"misc": {
|
|
199
|
+
"required": {"title"},
|
|
200
|
+
"allowed": _MISC_ALLOWED_FIELDS,
|
|
201
|
+
},
|
|
202
|
+
"article": {
|
|
203
|
+
"required": {"title", "journal"},
|
|
204
|
+
"allowed": _ARTICLE_ALLOWED_FIELDS,
|
|
205
|
+
},
|
|
206
|
+
"book": {
|
|
207
|
+
"required": {"title", "publisher"},
|
|
208
|
+
"allowed": _BOOK_ALLOWED_FIELDS,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
_PERSON_KEYS = {"author", "authors"}
|
|
212
|
+
_RESERVED_KEYS = {"type", *(_PERSON_KEYS)}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def extract_front_matter_bibliography(
|
|
216
|
+
front_matter: Mapping[str, Any] | None,
|
|
217
|
+
) -> dict[str, InlineBibliographyEntry]:
|
|
218
|
+
"""Return inline bibliography entries declared in the document front matter."""
|
|
219
|
+
if not isinstance(front_matter, Mapping):
|
|
220
|
+
return {}
|
|
221
|
+
|
|
222
|
+
bibliography: dict[str, InlineBibliographyEntry] = {}
|
|
223
|
+
container = front_matter.get("bibliography")
|
|
224
|
+
if isinstance(container, Mapping):
|
|
225
|
+
for key, value in container.items():
|
|
226
|
+
if not isinstance(key, str):
|
|
227
|
+
continue
|
|
228
|
+
entry = _parse_inline_bibliography_entry(key, value)
|
|
229
|
+
bibliography[key] = entry
|
|
230
|
+
|
|
231
|
+
return bibliography
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_inline_bibliography_entry(key: str, value: Any) -> InlineBibliographyEntry:
|
|
235
|
+
if isinstance(value, str):
|
|
236
|
+
doi = _coerce_bibliography_doi(value)
|
|
237
|
+
if not doi:
|
|
238
|
+
raise InlineBibliographyValidationError(
|
|
239
|
+
f"Bibliography entry '{key}' must not be empty."
|
|
240
|
+
)
|
|
241
|
+
return InlineBibliographyEntry(key=key, doi=doi)
|
|
242
|
+
|
|
243
|
+
if isinstance(value, Mapping):
|
|
244
|
+
if "type" in value:
|
|
245
|
+
return _parse_manual_bibliography_mapping(key, value)
|
|
246
|
+
doi = _coerce_bibliography_doi(value)
|
|
247
|
+
if not doi:
|
|
248
|
+
raise InlineBibliographyValidationError(
|
|
249
|
+
f"Bibliography entry '{key}' must define a DOI or a 'type'."
|
|
250
|
+
)
|
|
251
|
+
return InlineBibliographyEntry(key=key, doi=doi)
|
|
252
|
+
|
|
253
|
+
raise InlineBibliographyValidationError(
|
|
254
|
+
f"Bibliography entry '{key}' must be a string DOI or a mapping of fields."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_manual_bibliography_mapping(
|
|
259
|
+
key: str,
|
|
260
|
+
payload: Mapping[str, Any],
|
|
261
|
+
) -> InlineBibliographyEntry:
|
|
262
|
+
raw_type = payload.get("type")
|
|
263
|
+
if not isinstance(raw_type, str) or not raw_type.strip():
|
|
264
|
+
raise InlineBibliographyValidationError(
|
|
265
|
+
f"Bibliography entry '{key}' must define a textual 'type'."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
entry_type = raw_type.strip().lower()
|
|
269
|
+
schema = _INLINE_BIBLIOGRAPHY_SCHEMAS.get(entry_type)
|
|
270
|
+
if schema is None:
|
|
271
|
+
allowed = ", ".join(sorted(_INLINE_BIBLIOGRAPHY_SCHEMAS))
|
|
272
|
+
raise InlineBibliographyValidationError(
|
|
273
|
+
f"Bibliography entry '{key}' declares unsupported type '{entry_type}'. "
|
|
274
|
+
f"Allowed types: {allowed}."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
allowed_fields = schema["allowed"]
|
|
278
|
+
required_fields = schema["required"]
|
|
279
|
+
|
|
280
|
+
invalid_fields = sorted(
|
|
281
|
+
field_name
|
|
282
|
+
for field_name in payload
|
|
283
|
+
if field_name not in allowed_fields and field_name not in _RESERVED_KEYS
|
|
284
|
+
)
|
|
285
|
+
if invalid_fields:
|
|
286
|
+
raise InlineBibliographyValidationError(
|
|
287
|
+
f"Bibliography entry '{key}' ({entry_type}) contains unsupported field(s): "
|
|
288
|
+
+ ", ".join(invalid_fields)
|
|
289
|
+
+ "."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
persons: dict[str, list[str]] = {}
|
|
293
|
+
author_values: list[str] = []
|
|
294
|
+
if "author" in payload:
|
|
295
|
+
author_values.extend(_coerce_person_list(key, "author", payload.get("author")))
|
|
296
|
+
if "authors" in payload:
|
|
297
|
+
author_values.extend(_coerce_person_list(key, "authors", payload.get("authors")))
|
|
298
|
+
if author_values:
|
|
299
|
+
persons["author"] = author_values
|
|
300
|
+
|
|
301
|
+
fields: dict[str, str] = {}
|
|
302
|
+
for field_name, raw_value in payload.items():
|
|
303
|
+
if field_name in _RESERVED_KEYS:
|
|
304
|
+
continue
|
|
305
|
+
if field_name == "date":
|
|
306
|
+
date_value = _coerce_bibliography_field_value(key, field_name, raw_value)
|
|
307
|
+
if date_value:
|
|
308
|
+
fields["date"] = date_value
|
|
309
|
+
derived = _derive_date_components(key, date_value)
|
|
310
|
+
for derived_name, derived_value in derived.items():
|
|
311
|
+
fields.setdefault(derived_name, derived_value)
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
field_value = _coerce_bibliography_field_value(key, field_name, raw_value)
|
|
315
|
+
if field_value is not None:
|
|
316
|
+
fields[field_name] = field_value
|
|
317
|
+
|
|
318
|
+
for required in required_fields:
|
|
319
|
+
if required not in fields or not fields[required]:
|
|
320
|
+
raise InlineBibliographyValidationError(
|
|
321
|
+
f"Bibliography entry '{key}' ({entry_type}) is missing required field '{required}'."
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return InlineBibliographyEntry(
|
|
325
|
+
key=key,
|
|
326
|
+
entry_type=entry_type,
|
|
327
|
+
fields=fields,
|
|
328
|
+
persons=persons,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _coerce_bibliography_doi(value: Any) -> str | None:
|
|
333
|
+
if isinstance(value, str):
|
|
334
|
+
stripped = value.strip()
|
|
335
|
+
return stripped or None
|
|
336
|
+
if isinstance(value, Mapping):
|
|
337
|
+
candidate = value.get("doi")
|
|
338
|
+
if isinstance(candidate, str):
|
|
339
|
+
stripped = candidate.strip()
|
|
340
|
+
if stripped:
|
|
341
|
+
return stripped
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _coerce_person_list(
|
|
346
|
+
key: str,
|
|
347
|
+
field: str,
|
|
348
|
+
value: Any,
|
|
349
|
+
) -> list[str]:
|
|
350
|
+
if value is None:
|
|
351
|
+
return []
|
|
352
|
+
if isinstance(value, str):
|
|
353
|
+
candidate = value.strip()
|
|
354
|
+
if not candidate:
|
|
355
|
+
raise InlineBibliographyValidationError(
|
|
356
|
+
f"Bibliography entry '{key}' field '{field}' must not be empty."
|
|
357
|
+
)
|
|
358
|
+
return [candidate]
|
|
359
|
+
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
|
|
360
|
+
result: list[str] = []
|
|
361
|
+
for item in value:
|
|
362
|
+
if not isinstance(item, str):
|
|
363
|
+
raise InlineBibliographyValidationError(
|
|
364
|
+
f"Bibliography entry '{key}' field '{field}' must contain only strings."
|
|
365
|
+
)
|
|
366
|
+
candidate = item.strip()
|
|
367
|
+
if not candidate:
|
|
368
|
+
raise InlineBibliographyValidationError(
|
|
369
|
+
f"Bibliography entry '{key}' field '{field}' contains an empty value."
|
|
370
|
+
)
|
|
371
|
+
result.append(candidate)
|
|
372
|
+
if not result:
|
|
373
|
+
raise InlineBibliographyValidationError(
|
|
374
|
+
f"Bibliography entry '{key}' field '{field}' must define at least one value."
|
|
375
|
+
)
|
|
376
|
+
return result
|
|
377
|
+
raise InlineBibliographyValidationError(
|
|
378
|
+
f"Bibliography entry '{key}' field '{field}' must be a string or list of strings."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _coerce_bibliography_field_value(key: str, field: str, value: Any) -> str | None:
|
|
383
|
+
if value is None:
|
|
384
|
+
return None
|
|
385
|
+
if isinstance(value, str):
|
|
386
|
+
candidate = value.strip()
|
|
387
|
+
return candidate or None
|
|
388
|
+
if isinstance(value, datetime):
|
|
389
|
+
return value.date().isoformat()
|
|
390
|
+
if isinstance(value, date):
|
|
391
|
+
return value.isoformat()
|
|
392
|
+
if isinstance(value, (int, float)):
|
|
393
|
+
if isinstance(value, float) and not value.is_integer():
|
|
394
|
+
raise InlineBibliographyValidationError(
|
|
395
|
+
f"Bibliography entry '{key}' field '{field}' must be an integer when numeric."
|
|
396
|
+
)
|
|
397
|
+
return str(int(value))
|
|
398
|
+
raise InlineBibliographyValidationError(
|
|
399
|
+
f"Bibliography entry '{key}' field '{field}' must be a string or integer."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _derive_date_components(key: str, value: str) -> dict[str, str]:
|
|
404
|
+
candidate = value.strip()
|
|
405
|
+
if not candidate:
|
|
406
|
+
return {}
|
|
407
|
+
|
|
408
|
+
match = _ISO_DATE_RE.match(candidate)
|
|
409
|
+
if match:
|
|
410
|
+
year = match.group("year")
|
|
411
|
+
month = match.group("month")
|
|
412
|
+
day = match.group("day")
|
|
413
|
+
_validate_month(key, month)
|
|
414
|
+
_validate_day(key, day)
|
|
415
|
+
return {"year": year, "month": month, "day": day}
|
|
416
|
+
|
|
417
|
+
match = _ISO_YEAR_MONTH_RE.match(candidate)
|
|
418
|
+
if match:
|
|
419
|
+
year = match.group("year")
|
|
420
|
+
month = match.group("month")
|
|
421
|
+
_validate_month(key, month)
|
|
422
|
+
return {"year": year, "month": month}
|
|
423
|
+
|
|
424
|
+
match = _ISO_YEAR_RE.match(candidate)
|
|
425
|
+
if match:
|
|
426
|
+
return {"year": match.group("year")}
|
|
427
|
+
|
|
428
|
+
raise InlineBibliographyValidationError(
|
|
429
|
+
f"Bibliography entry '{key}' field 'date' must follow ISO formats YYYY, YYYY-MM, or YYYY-MM-DD."
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _validate_month(key: str, value: str) -> None:
|
|
434
|
+
month_int = int(value)
|
|
435
|
+
if not 1 <= month_int <= 12:
|
|
436
|
+
raise InlineBibliographyValidationError(
|
|
437
|
+
f"Bibliography entry '{key}' field 'date' contains an invalid month '{value}'."
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _validate_day(key: str, value: str) -> None:
|
|
442
|
+
day_int = int(value)
|
|
443
|
+
if not 1 <= day_int <= 31:
|
|
444
|
+
raise InlineBibliographyValidationError(
|
|
445
|
+
f"Bibliography entry '{key}' field 'date' contains an invalid day '{value}'."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def extract_content(html: str, selector: str) -> str:
|
|
450
|
+
"""Extract and return the inner HTML for the first element matching selector."""
|
|
451
|
+
try:
|
|
452
|
+
soup = BeautifulSoup(html, "lxml")
|
|
453
|
+
except FeatureNotFound:
|
|
454
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
455
|
+
|
|
456
|
+
element = soup.select_one(selector)
|
|
457
|
+
if element is None:
|
|
458
|
+
raise ValueError(f"Unable to locate content using selector '{selector}'.")
|
|
459
|
+
return element.decode_contents()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
__all__ = [
|
|
463
|
+
"DOCUMENT_SELECTOR_SENTINEL",
|
|
464
|
+
"InlineBibliographyEntry",
|
|
465
|
+
"InlineBibliographyValidationError",
|
|
466
|
+
"InputKind",
|
|
467
|
+
"UnsupportedInputError",
|
|
468
|
+
"build_document_context",
|
|
469
|
+
"coerce_slot_selector",
|
|
470
|
+
"extract_content",
|
|
471
|
+
"extract_front_matter_bibliography",
|
|
472
|
+
"extract_front_matter_slots",
|
|
473
|
+
"parse_slot_mapping",
|
|
474
|
+
]
|