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
texsmith/api/document.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Document abstractions consumed by the TeXSmith public API.
|
|
2
|
+
|
|
3
|
+
Architecture
|
|
4
|
+
: `Document` models inputs alongside slot overrides and rendering toggles.
|
|
5
|
+
Instances are intentionally lightweight so they can be duplicated when
|
|
6
|
+
entering template sessions without costly reparsing.
|
|
7
|
+
: `DocumentRenderOptions` captures heading offsets, numbering, and other knobs
|
|
8
|
+
that influence the LaTeX output. Keeping these options separate from the core
|
|
9
|
+
document data allows caller-specific tweaks while preserving the original
|
|
10
|
+
source.
|
|
11
|
+
|
|
12
|
+
Implementation Rationale
|
|
13
|
+
: Conversions often need multiple passes over the same document, such as preview
|
|
14
|
+
and templated export. By storing canonicalised HTML and front-matter snapshots
|
|
15
|
+
we avoid repeated Markdown or HTML parsing.
|
|
16
|
+
: A dedicated abstraction makes it easy to inspect or mutate front matter in
|
|
17
|
+
higher layers without leaking the underlying `DocumentContext` type used
|
|
18
|
+
deeper inside the conversion engine.
|
|
19
|
+
|
|
20
|
+
Usage Example
|
|
21
|
+
:
|
|
22
|
+
>>> from pathlib import Path
|
|
23
|
+
>>> from tempfile import TemporaryDirectory
|
|
24
|
+
>>> from texsmith.api.document import Document
|
|
25
|
+
>>> with TemporaryDirectory() as tmpdir:
|
|
26
|
+
... source = Path(tmpdir) / "chapter.md"
|
|
27
|
+
... _ = source.write_text("# Chapter\\nBody")
|
|
28
|
+
... doc = Document.from_markdown(source, base_level="section")
|
|
29
|
+
... context = doc.to_context()
|
|
30
|
+
... context.name
|
|
31
|
+
'chapter'
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Iterable, Mapping
|
|
37
|
+
import contextlib
|
|
38
|
+
import copy
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from enum import Enum
|
|
41
|
+
from html.parser import HTMLParser
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, ClassVar
|
|
44
|
+
|
|
45
|
+
from ..adapters.markdown import (
|
|
46
|
+
DEFAULT_MARKDOWN_EXTENSIONS,
|
|
47
|
+
MarkdownConversionError,
|
|
48
|
+
render_markdown,
|
|
49
|
+
)
|
|
50
|
+
from ..core.conversion.debug import ConversionError, debug_enabled
|
|
51
|
+
from ..core.conversion.inputs import (
|
|
52
|
+
DOCUMENT_SELECTOR_SENTINEL,
|
|
53
|
+
InputKind,
|
|
54
|
+
build_document_context,
|
|
55
|
+
extract_content,
|
|
56
|
+
extract_front_matter_slots,
|
|
57
|
+
)
|
|
58
|
+
from ..core.conversion_contexts import DocumentContext
|
|
59
|
+
from ..core.diagnostics import DiagnosticEmitter, NullEmitter
|
|
60
|
+
from ..core.metadata import PressMetadataError, normalise_press_metadata
|
|
61
|
+
from ..core.templates.runtime import coerce_base_level
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"Document",
|
|
66
|
+
"DocumentRenderOptions",
|
|
67
|
+
"DocumentSlots",
|
|
68
|
+
"TitleStrategy",
|
|
69
|
+
"front_matter_has_title",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TitleStrategy(str, Enum):
|
|
74
|
+
"""Strategy describing how the first document heading should be handled."""
|
|
75
|
+
|
|
76
|
+
KEEP = "keep"
|
|
77
|
+
DROP = "drop"
|
|
78
|
+
PROMOTE_METADATA = "promote_metadata"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_title_strategy(
|
|
82
|
+
*,
|
|
83
|
+
explicit: TitleStrategy | None,
|
|
84
|
+
promote_title: bool,
|
|
85
|
+
strip_heading: bool,
|
|
86
|
+
has_declared_title: bool,
|
|
87
|
+
) -> TitleStrategy:
|
|
88
|
+
"""Determine the effective title strategy from caller preferences."""
|
|
89
|
+
if explicit is not None:
|
|
90
|
+
return explicit
|
|
91
|
+
if strip_heading:
|
|
92
|
+
return TitleStrategy.DROP
|
|
93
|
+
if promote_title and not has_declared_title:
|
|
94
|
+
return TitleStrategy.PROMOTE_METADATA
|
|
95
|
+
return TitleStrategy.KEEP
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def front_matter_has_title(metadata: Mapping[str, Any] | None) -> bool:
|
|
99
|
+
"""Return ``True`` when the mapping declares a title."""
|
|
100
|
+
if not isinstance(metadata, Mapping):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
payload = dict(metadata)
|
|
104
|
+
with contextlib.suppress(PressMetadataError):
|
|
105
|
+
normalise_press_metadata(payload)
|
|
106
|
+
|
|
107
|
+
title = payload.get("title")
|
|
108
|
+
return bool(isinstance(title, str) and title.strip())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _coerce_bool(value: Any) -> bool | None:
|
|
112
|
+
"""Coerce loose truthy/falsey values from front matter."""
|
|
113
|
+
if isinstance(value, bool):
|
|
114
|
+
return value
|
|
115
|
+
if isinstance(value, (int, float)):
|
|
116
|
+
return bool(value)
|
|
117
|
+
if isinstance(value, str):
|
|
118
|
+
candidate = value.strip().lower()
|
|
119
|
+
if candidate in {"true", "yes", "on", "1"}:
|
|
120
|
+
return True
|
|
121
|
+
if candidate in {"false", "no", "off", "0"}:
|
|
122
|
+
return False
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _front_matter_numbered(metadata: Mapping[str, Any] | None) -> bool | None:
|
|
127
|
+
"""Extract a numbered flag from normalised front matter."""
|
|
128
|
+
if not isinstance(metadata, Mapping):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
payload = dict(metadata)
|
|
132
|
+
with contextlib.suppress(PressMetadataError):
|
|
133
|
+
normalise_press_metadata(payload)
|
|
134
|
+
return _coerce_bool(payload.get("numbered"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class DocumentRenderOptions:
|
|
139
|
+
"""Rendering options applied when preparing a document context."""
|
|
140
|
+
|
|
141
|
+
base_level: int = 0
|
|
142
|
+
title_strategy: TitleStrategy = TitleStrategy.KEEP
|
|
143
|
+
numbered: bool = True
|
|
144
|
+
suppress_title_metadata: bool = False
|
|
145
|
+
|
|
146
|
+
def copy(self) -> DocumentRenderOptions:
|
|
147
|
+
"""Return a deep copy of the options so mutations never leak across documents."""
|
|
148
|
+
return DocumentRenderOptions(
|
|
149
|
+
base_level=self.base_level,
|
|
150
|
+
title_strategy=self.title_strategy,
|
|
151
|
+
numbered=self.numbered,
|
|
152
|
+
suppress_title_metadata=self.suppress_title_metadata,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DocumentSlots:
|
|
157
|
+
"""Container tracking slot selectors and inclusion directives."""
|
|
158
|
+
|
|
159
|
+
__slots__ = ("_inclusions", "_selectors")
|
|
160
|
+
|
|
161
|
+
_WILDCARDS: ClassVar[set[str]] = {
|
|
162
|
+
DOCUMENT_SELECTOR_SENTINEL,
|
|
163
|
+
DOCUMENT_SELECTOR_SENTINEL.lower(),
|
|
164
|
+
"*",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
selectors: Mapping[str, str] | None = None,
|
|
170
|
+
inclusions: Iterable[str] | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
self._selectors: dict[str, str] = dict(selectors or {})
|
|
173
|
+
self._inclusions: set[str] = {slot.strip() for slot in inclusions or [] if slot}
|
|
174
|
+
|
|
175
|
+
def copy(self) -> DocumentSlots:
|
|
176
|
+
"""Return a detached clone so callers can mutate slots without side effects."""
|
|
177
|
+
return DocumentSlots(self._selectors, self._inclusions)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_mapping(cls, mapping: Mapping[str, str] | None) -> DocumentSlots:
|
|
181
|
+
"""Build slots from stored metadata, normalising empty mappings to defaults."""
|
|
182
|
+
slots = cls()
|
|
183
|
+
if not mapping:
|
|
184
|
+
return slots
|
|
185
|
+
for slot, selector in mapping.items():
|
|
186
|
+
slots.add(slot, selector=selector)
|
|
187
|
+
return slots
|
|
188
|
+
|
|
189
|
+
def merge(self, other: DocumentSlots) -> DocumentSlots:
|
|
190
|
+
"""Combine selectors and inclusions to preserve caller overrides."""
|
|
191
|
+
if other is self:
|
|
192
|
+
return self
|
|
193
|
+
self._selectors.update(other._selectors)
|
|
194
|
+
self._inclusions.update(other._inclusions)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def add(
|
|
198
|
+
self,
|
|
199
|
+
slot: str,
|
|
200
|
+
*,
|
|
201
|
+
selector: str | None = None,
|
|
202
|
+
include_document: bool | None = None,
|
|
203
|
+
) -> DocumentSlots:
|
|
204
|
+
"""Register a slot directive, preserving explicit inclusion intent."""
|
|
205
|
+
slot_name = slot.strip()
|
|
206
|
+
if not slot_name:
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
token = selector.strip() if isinstance(selector, str) else None
|
|
210
|
+
include_flag = include_document
|
|
211
|
+
|
|
212
|
+
if token:
|
|
213
|
+
if token.lower() in self._WILDCARDS:
|
|
214
|
+
include_flag = True if include_document is not False else include_document
|
|
215
|
+
token = None
|
|
216
|
+
else:
|
|
217
|
+
self._selectors[slot_name] = token
|
|
218
|
+
elif selector is None and include_flag is None:
|
|
219
|
+
include_flag = True
|
|
220
|
+
|
|
221
|
+
if include_flag is True:
|
|
222
|
+
self._inclusions.add(slot_name)
|
|
223
|
+
elif include_flag is False:
|
|
224
|
+
self._inclusions.discard(slot_name)
|
|
225
|
+
|
|
226
|
+
if selector is None and include_flag is False:
|
|
227
|
+
self._selectors.pop(slot_name, None)
|
|
228
|
+
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def includes(self) -> set[str]:
|
|
232
|
+
"""Return slot names flagged for whole-document inclusion."""
|
|
233
|
+
return set(self._inclusions)
|
|
234
|
+
|
|
235
|
+
def selectors(self) -> dict[str, str]:
|
|
236
|
+
"""Return selectors mapped to slots, leaving the original untouched."""
|
|
237
|
+
return dict(self._selectors)
|
|
238
|
+
|
|
239
|
+
def to_request_mapping(self) -> dict[str, str]:
|
|
240
|
+
"""Render selectors into the request mapping expected by the engine."""
|
|
241
|
+
mapping = dict(self._selectors)
|
|
242
|
+
for slot in self._inclusions:
|
|
243
|
+
mapping.setdefault(slot, DOCUMENT_SELECTOR_SENTINEL)
|
|
244
|
+
return mapping
|
|
245
|
+
|
|
246
|
+
def __bool__(self) -> bool: # pragma: no cover - trivial
|
|
247
|
+
return bool(self._selectors or self._inclusions)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(slots=True)
|
|
251
|
+
class Document:
|
|
252
|
+
"""Renderable document used by the high-level API."""
|
|
253
|
+
|
|
254
|
+
source_path: Path
|
|
255
|
+
kind: InputKind
|
|
256
|
+
_html: str
|
|
257
|
+
_front_matter: Mapping[str, Any]
|
|
258
|
+
options: DocumentRenderOptions = field(default_factory=DocumentRenderOptions)
|
|
259
|
+
slots: DocumentSlots = field(default_factory=DocumentSlots)
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def from_markdown(
|
|
263
|
+
cls,
|
|
264
|
+
path: Path,
|
|
265
|
+
*,
|
|
266
|
+
extensions: Iterable[str] | None = None,
|
|
267
|
+
promote_title: bool = False,
|
|
268
|
+
strip_heading: bool = False,
|
|
269
|
+
suppress_title: bool = False,
|
|
270
|
+
base_level: int | str = 0,
|
|
271
|
+
title_strategy: TitleStrategy | None = None,
|
|
272
|
+
numbered: bool = True,
|
|
273
|
+
emitter: DiagnosticEmitter | None = None,
|
|
274
|
+
) -> Document:
|
|
275
|
+
"""Create a document from a Markdown file while caching HTML for reuse."""
|
|
276
|
+
active_emitter = emitter or NullEmitter()
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
rendered = render_markdown(
|
|
280
|
+
path.read_text(encoding="utf-8"),
|
|
281
|
+
list(extensions or DEFAULT_MARKDOWN_EXTENSIONS),
|
|
282
|
+
base_path=path.parent,
|
|
283
|
+
)
|
|
284
|
+
except (OSError, MarkdownConversionError) as exc:
|
|
285
|
+
message = f"Failed to convert Markdown source '{path}': {exc}"
|
|
286
|
+
active_emitter.error(message, exc if isinstance(exc, Exception) else None)
|
|
287
|
+
raise ConversionError(message) from (
|
|
288
|
+
exc if isinstance(exc, Exception) else ConversionError(message)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
resolved_base_level = coerce_base_level(base_level, allow_none=False)
|
|
293
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
294
|
+
message = f"Invalid base level '{base_level}': {exc}"
|
|
295
|
+
active_emitter.error(message, exc if isinstance(exc, Exception) else None)
|
|
296
|
+
raise ConversionError(message) from (
|
|
297
|
+
exc if isinstance(exc, Exception) else ConversionError(message)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
declared_title = front_matter_has_title(rendered.front_matter)
|
|
301
|
+
strategy = _resolve_title_strategy(
|
|
302
|
+
explicit=title_strategy,
|
|
303
|
+
promote_title=promote_title,
|
|
304
|
+
strip_heading=strip_heading,
|
|
305
|
+
has_declared_title=declared_title,
|
|
306
|
+
)
|
|
307
|
+
front_numbered = _front_matter_numbered(rendered.front_matter)
|
|
308
|
+
numbered_flag = numbered if front_numbered is None else front_numbered
|
|
309
|
+
options = DocumentRenderOptions(
|
|
310
|
+
base_level=resolved_base_level,
|
|
311
|
+
title_strategy=strategy,
|
|
312
|
+
numbered=numbered_flag,
|
|
313
|
+
suppress_title_metadata=suppress_title,
|
|
314
|
+
)
|
|
315
|
+
document = cls(
|
|
316
|
+
source_path=path,
|
|
317
|
+
kind=InputKind.MARKDOWN,
|
|
318
|
+
_html=rendered.html,
|
|
319
|
+
_front_matter=rendered.front_matter,
|
|
320
|
+
options=options,
|
|
321
|
+
slots=DocumentSlots(),
|
|
322
|
+
)
|
|
323
|
+
document._initialise_slots_from_front_matter()
|
|
324
|
+
return document
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def from_html(
|
|
328
|
+
cls,
|
|
329
|
+
path: Path,
|
|
330
|
+
*,
|
|
331
|
+
selector: str = "article.md-content__inner",
|
|
332
|
+
promote_title: bool = False,
|
|
333
|
+
strip_heading: bool = False,
|
|
334
|
+
suppress_title: bool = False,
|
|
335
|
+
base_level: int | str = 0,
|
|
336
|
+
title_strategy: TitleStrategy | None = None,
|
|
337
|
+
numbered: bool = True,
|
|
338
|
+
full_document: bool = False,
|
|
339
|
+
emitter: DiagnosticEmitter | None = None,
|
|
340
|
+
) -> Document:
|
|
341
|
+
"""Create a document from an HTML file, extracting only the renderable region."""
|
|
342
|
+
active_emitter = emitter or NullEmitter()
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
payload = path.read_text(encoding="utf-8")
|
|
346
|
+
except OSError as exc:
|
|
347
|
+
message = f"Failed to read HTML document '{path}': {exc}"
|
|
348
|
+
active_emitter.error(message, exc)
|
|
349
|
+
raise ConversionError(message) from exc
|
|
350
|
+
|
|
351
|
+
html = payload
|
|
352
|
+
if not full_document:
|
|
353
|
+
try:
|
|
354
|
+
html = extract_content(payload, selector)
|
|
355
|
+
except ValueError as exc:
|
|
356
|
+
if debug_enabled(active_emitter):
|
|
357
|
+
message = (
|
|
358
|
+
f"CSS selector '{selector}' was not found in '{path.name}'. "
|
|
359
|
+
"Falling back to the full document."
|
|
360
|
+
)
|
|
361
|
+
active_emitter.warning(message, exc)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
resolved_base_level = coerce_base_level(base_level, allow_none=False)
|
|
365
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
366
|
+
message = f"Invalid base level '{base_level}': {exc}"
|
|
367
|
+
active_emitter.error(message, exc if isinstance(exc, Exception) else None)
|
|
368
|
+
raise ConversionError(message) from (
|
|
369
|
+
exc if isinstance(exc, Exception) else ConversionError(message)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
strategy = _resolve_title_strategy(
|
|
373
|
+
explicit=title_strategy,
|
|
374
|
+
promote_title=promote_title,
|
|
375
|
+
strip_heading=strip_heading,
|
|
376
|
+
has_declared_title=False,
|
|
377
|
+
)
|
|
378
|
+
options = DocumentRenderOptions(
|
|
379
|
+
base_level=resolved_base_level,
|
|
380
|
+
title_strategy=strategy,
|
|
381
|
+
numbered=numbered,
|
|
382
|
+
suppress_title_metadata=suppress_title,
|
|
383
|
+
)
|
|
384
|
+
document = cls(
|
|
385
|
+
source_path=path,
|
|
386
|
+
kind=InputKind.HTML,
|
|
387
|
+
_html=html,
|
|
388
|
+
_front_matter={},
|
|
389
|
+
options=options,
|
|
390
|
+
slots=DocumentSlots(),
|
|
391
|
+
)
|
|
392
|
+
document._initialise_slots_from_front_matter()
|
|
393
|
+
return document
|
|
394
|
+
|
|
395
|
+
def copy(self) -> Document:
|
|
396
|
+
"""Return a deep copy of the document to isolate later slot or metadata changes."""
|
|
397
|
+
return Document(
|
|
398
|
+
source_path=self.source_path,
|
|
399
|
+
kind=self.kind,
|
|
400
|
+
_html=self._html,
|
|
401
|
+
_front_matter=copy.deepcopy(self._front_matter),
|
|
402
|
+
options=self.options.copy(),
|
|
403
|
+
slots=self.slots.copy(),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def html(self) -> str:
|
|
408
|
+
"""Return the intermediate HTML content."""
|
|
409
|
+
return self._html
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def front_matter(self) -> Mapping[str, Any]:
|
|
413
|
+
"""Return a deep copy of the front-matter mapping to keep stored state immutable."""
|
|
414
|
+
return copy.deepcopy(self._front_matter)
|
|
415
|
+
|
|
416
|
+
def set_front_matter(self, values: Mapping[str, Any]) -> None:
|
|
417
|
+
"""Replace the stored front matter with a deep copy to guard against caller mutation."""
|
|
418
|
+
self._front_matter = copy.deepcopy(values)
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def drop_title(self) -> bool:
|
|
422
|
+
"""Indicate whether the document title should be dropped."""
|
|
423
|
+
if self.options.title_strategy is TitleStrategy.DROP:
|
|
424
|
+
return True
|
|
425
|
+
if self.options.title_strategy is TitleStrategy.PROMOTE_METADATA:
|
|
426
|
+
title, should_drop = self._extract_promoted_title()
|
|
427
|
+
if self.options.suppress_title_metadata:
|
|
428
|
+
return False
|
|
429
|
+
return bool(title and should_drop)
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
@drop_title.setter
|
|
433
|
+
def drop_title(self, value: bool) -> None:
|
|
434
|
+
"""Set whether the document title should be dropped."""
|
|
435
|
+
if value:
|
|
436
|
+
if self.options.title_strategy is TitleStrategy.PROMOTE_METADATA:
|
|
437
|
+
return
|
|
438
|
+
self.options.title_strategy = TitleStrategy.DROP
|
|
439
|
+
else:
|
|
440
|
+
if self.options.title_strategy is TitleStrategy.DROP:
|
|
441
|
+
self.options.title_strategy = TitleStrategy.KEEP
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def title_from_heading(self) -> bool:
|
|
445
|
+
"""Indicate whether the title should be extracted from the first heading."""
|
|
446
|
+
return self.options.title_strategy is TitleStrategy.PROMOTE_METADATA
|
|
447
|
+
|
|
448
|
+
@title_from_heading.setter
|
|
449
|
+
def title_from_heading(self, value: bool) -> None:
|
|
450
|
+
"""Set whether the title should be extracted from the first heading."""
|
|
451
|
+
if value:
|
|
452
|
+
self.options.title_strategy = TitleStrategy.PROMOTE_METADATA
|
|
453
|
+
elif self.options.title_strategy is TitleStrategy.PROMOTE_METADATA:
|
|
454
|
+
self.options.title_strategy = TitleStrategy.KEEP
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def numbered(self) -> bool:
|
|
458
|
+
"""Indicate whether the document is numbered."""
|
|
459
|
+
return self.options.numbered
|
|
460
|
+
|
|
461
|
+
@numbered.setter
|
|
462
|
+
def numbered(self, value: bool) -> None:
|
|
463
|
+
"""Set whether the document is numbered."""
|
|
464
|
+
self.options.numbered = bool(value)
|
|
465
|
+
|
|
466
|
+
def assign_slot(
|
|
467
|
+
self,
|
|
468
|
+
slot: str,
|
|
469
|
+
selector: str | None = None,
|
|
470
|
+
*,
|
|
471
|
+
include_document: bool | None = None,
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Map the document or a selector subset into a template slot."""
|
|
474
|
+
self.slots.add(slot, selector=selector, include_document=include_document)
|
|
475
|
+
|
|
476
|
+
def _initialise_slots_from_front_matter(self) -> None:
|
|
477
|
+
if isinstance(self._front_matter, Mapping):
|
|
478
|
+
payload = dict(self._front_matter)
|
|
479
|
+
with contextlib.suppress(PressMetadataError):
|
|
480
|
+
normalise_press_metadata(payload)
|
|
481
|
+
base_mapping = extract_front_matter_slots(payload)
|
|
482
|
+
if base_mapping:
|
|
483
|
+
self.slots.merge(DocumentSlots.from_mapping(base_mapping))
|
|
484
|
+
|
|
485
|
+
_HEADING_TAGS: ClassVar[set[str]] = {"h1", "h2", "h3", "h4", "h5", "h6"}
|
|
486
|
+
|
|
487
|
+
class _HeadingLevelScanner(HTMLParser):
|
|
488
|
+
__slots__ = ("minimum_level",)
|
|
489
|
+
|
|
490
|
+
def __init__(self) -> None:
|
|
491
|
+
super().__init__()
|
|
492
|
+
self.minimum_level: int | None = None
|
|
493
|
+
|
|
494
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
495
|
+
if not tag:
|
|
496
|
+
return
|
|
497
|
+
name = tag.lower()
|
|
498
|
+
if not name.startswith("h") or len(name) < 2 or not name[1].isdigit():
|
|
499
|
+
return
|
|
500
|
+
try:
|
|
501
|
+
level = int(name[1])
|
|
502
|
+
except ValueError:
|
|
503
|
+
return
|
|
504
|
+
if not 1 <= level <= 6:
|
|
505
|
+
return
|
|
506
|
+
if self.minimum_level is None or level < self.minimum_level:
|
|
507
|
+
self.minimum_level = level
|
|
508
|
+
|
|
509
|
+
@classmethod
|
|
510
|
+
def _resolve_heading_alignment(cls, html: str, strategy: TitleStrategy) -> int:
|
|
511
|
+
if strategy is not TitleStrategy.KEEP:
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
scanner = cls._HeadingLevelScanner()
|
|
515
|
+
try:
|
|
516
|
+
scanner.feed(html)
|
|
517
|
+
finally:
|
|
518
|
+
scanner.close()
|
|
519
|
+
|
|
520
|
+
minimum = scanner.minimum_level
|
|
521
|
+
if minimum is None or minimum <= 1:
|
|
522
|
+
return 0
|
|
523
|
+
return minimum - 1
|
|
524
|
+
|
|
525
|
+
class _HeadingInspector(HTMLParser):
|
|
526
|
+
__slots__ = ("_depth", "_resolved", "first_level", "level_counts", "parts")
|
|
527
|
+
|
|
528
|
+
def __init__(self) -> None:
|
|
529
|
+
super().__init__()
|
|
530
|
+
self._depth = 0
|
|
531
|
+
self._resolved = False
|
|
532
|
+
self.first_level: int | None = None
|
|
533
|
+
self.level_counts: dict[int, int] = {}
|
|
534
|
+
self.parts: list[str] = []
|
|
535
|
+
|
|
536
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
537
|
+
name = tag.lower()
|
|
538
|
+
if not name.startswith("h") or len(name) < 2 or not name[1].isdigit():
|
|
539
|
+
if self._depth:
|
|
540
|
+
self._depth += 1
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
level = int(name[1])
|
|
545
|
+
except ValueError:
|
|
546
|
+
if self._depth:
|
|
547
|
+
self._depth += 1
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
if not 1 <= level <= 6:
|
|
551
|
+
if self._depth:
|
|
552
|
+
self._depth += 1
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
self.level_counts[level] = self.level_counts.get(level, 0) + 1
|
|
556
|
+
if self._resolved:
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
if self.first_level is None:
|
|
560
|
+
self.first_level = level
|
|
561
|
+
self._depth = 1
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if self._depth:
|
|
565
|
+
self._depth += 1
|
|
566
|
+
|
|
567
|
+
def handle_endtag(self, tag: str) -> None:
|
|
568
|
+
if not self._depth:
|
|
569
|
+
return
|
|
570
|
+
self._depth -= 1
|
|
571
|
+
if self._depth == 0:
|
|
572
|
+
self._resolved = True
|
|
573
|
+
|
|
574
|
+
def handle_data(self, data: str) -> None:
|
|
575
|
+
if self._depth and not self._resolved:
|
|
576
|
+
self.parts.append(data)
|
|
577
|
+
|
|
578
|
+
def _extract_promoted_title(self) -> tuple[str | None, bool]:
|
|
579
|
+
"""Return the promoted title and whether the heading should be dropped."""
|
|
580
|
+
inspector = self._HeadingInspector()
|
|
581
|
+
try:
|
|
582
|
+
inspector.feed(self._html)
|
|
583
|
+
finally:
|
|
584
|
+
inspector.close()
|
|
585
|
+
|
|
586
|
+
if inspector.first_level is None:
|
|
587
|
+
return None, False
|
|
588
|
+
|
|
589
|
+
level_count = inspector.level_counts.get(inspector.first_level, 0)
|
|
590
|
+
if level_count != 1:
|
|
591
|
+
return None, False
|
|
592
|
+
|
|
593
|
+
text = "".join(inspector.parts).strip()
|
|
594
|
+
return (text or None, bool(text))
|
|
595
|
+
|
|
596
|
+
def _first_heading_level(self) -> int | None:
|
|
597
|
+
"""Return the level of the first heading in the document, if any."""
|
|
598
|
+
inspector = self._HeadingInspector()
|
|
599
|
+
try:
|
|
600
|
+
inspector.feed(self._html)
|
|
601
|
+
finally:
|
|
602
|
+
inspector.close()
|
|
603
|
+
return inspector.first_level
|
|
604
|
+
|
|
605
|
+
def first_heading_level(self) -> int | None:
|
|
606
|
+
"""Public accessor for the first heading level in the document."""
|
|
607
|
+
return self._first_heading_level()
|
|
608
|
+
|
|
609
|
+
def to_context(self) -> DocumentContext:
|
|
610
|
+
"""Build a fresh DocumentContext for conversion, aligning slots with engine expectations."""
|
|
611
|
+
strategy = self.options.title_strategy
|
|
612
|
+
suppress_title = self.options.suppress_title_metadata
|
|
613
|
+
promote_to_metadata = strategy is TitleStrategy.PROMOTE_METADATA and not suppress_title
|
|
614
|
+
extracted_title = None
|
|
615
|
+
drop_title_flag = strategy is TitleStrategy.DROP
|
|
616
|
+
|
|
617
|
+
if promote_to_metadata:
|
|
618
|
+
extracted_title, drop_title_flag = self._extract_promoted_title()
|
|
619
|
+
|
|
620
|
+
base_level = self.options.base_level
|
|
621
|
+
front_matter = copy.deepcopy(self._front_matter)
|
|
622
|
+
if suppress_title and isinstance(front_matter, dict):
|
|
623
|
+
front_matter.pop("title", None)
|
|
624
|
+
front_matter.pop("press.title", None)
|
|
625
|
+
press_section = front_matter.get("press")
|
|
626
|
+
if isinstance(press_section, dict):
|
|
627
|
+
press_section.pop("title", None)
|
|
628
|
+
|
|
629
|
+
context = build_document_context(
|
|
630
|
+
name=self.source_path.stem,
|
|
631
|
+
source_path=self.source_path,
|
|
632
|
+
html=self._html,
|
|
633
|
+
front_matter=front_matter,
|
|
634
|
+
base_level=base_level,
|
|
635
|
+
drop_title=drop_title_flag,
|
|
636
|
+
numbered=self.options.numbered,
|
|
637
|
+
title_from_heading=bool(extracted_title),
|
|
638
|
+
extracted_title=extracted_title,
|
|
639
|
+
)
|
|
640
|
+
base_slots = DocumentSlots.from_mapping(context.slot_requests)
|
|
641
|
+
combined_slots = base_slots.copy().merge(self.slots)
|
|
642
|
+
self.slots = combined_slots.copy()
|
|
643
|
+
context.slot_requests = combined_slots.to_request_mapping()
|
|
644
|
+
context.slot_inclusions = combined_slots.includes()
|
|
645
|
+
return context
|