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,1734 @@
|
|
|
1
|
+
"""Plugin rendering fenced snippet blocks into standalone PDF assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
import contextlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from bs4 import BeautifulSoup
|
|
17
|
+
from bs4.element import NavigableString, Tag
|
|
18
|
+
from PIL import Image, ImageDraw
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from texsmith.adapters.handlers._helpers import (
|
|
22
|
+
coerce_attribute,
|
|
23
|
+
gather_classes,
|
|
24
|
+
mark_processed,
|
|
25
|
+
)
|
|
26
|
+
from texsmith.adapters.markdown import (
|
|
27
|
+
DEFAULT_MARKDOWN_EXTENSIONS,
|
|
28
|
+
render_markdown,
|
|
29
|
+
split_front_matter,
|
|
30
|
+
)
|
|
31
|
+
from texsmith.api.document import (
|
|
32
|
+
Document,
|
|
33
|
+
DocumentRenderOptions,
|
|
34
|
+
DocumentSlots,
|
|
35
|
+
TitleStrategy,
|
|
36
|
+
_resolve_title_strategy,
|
|
37
|
+
front_matter_has_title,
|
|
38
|
+
)
|
|
39
|
+
from texsmith.api.pipeline import RenderSettings
|
|
40
|
+
from texsmith.api.templates import TemplateSession
|
|
41
|
+
from texsmith.core.context import RenderContext
|
|
42
|
+
from texsmith.core.conversion.inputs import InputKind
|
|
43
|
+
from texsmith.core.diagnostics import DiagnosticEmitter
|
|
44
|
+
from texsmith.core.exceptions import AssetMissingError, InvalidNodeError, LatexRenderingError
|
|
45
|
+
from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
|
|
46
|
+
from texsmith.core.rules import RenderPhase, renders
|
|
47
|
+
from texsmith.core.templates import TemplateError, TemplateRuntime, load_template_runtime
|
|
48
|
+
from texsmith.core.user_dir import get_user_dir
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SNIPPET_DIR = "snippets"
|
|
52
|
+
_SNIPPET_PREFIX = "snippet-"
|
|
53
|
+
_TRUE_VALUES = {"1", "true", "on", "yes"}
|
|
54
|
+
_FALSE_VALUES = {"0", "false", "off", "no"}
|
|
55
|
+
_SNIPPET_CACHE_NAMESPACE = "snippets"
|
|
56
|
+
_SNIPPET_CACHE_FILENAME = "metadata.json"
|
|
57
|
+
_SNIPPET_CACHE_VERSION = 3
|
|
58
|
+
_log = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class SnippetBlock:
|
|
63
|
+
"""Parsed representation of a snippet fence."""
|
|
64
|
+
|
|
65
|
+
content: str | None
|
|
66
|
+
sources: list[Path]
|
|
67
|
+
layout: tuple[int, int] | None
|
|
68
|
+
preview_dogear: bool
|
|
69
|
+
preview_fold_size: float | None
|
|
70
|
+
template_id: str | None
|
|
71
|
+
cwd: Path | None
|
|
72
|
+
caption: str | None
|
|
73
|
+
label: str | None
|
|
74
|
+
figure_width: str | None
|
|
75
|
+
template_overrides: dict[str, Any]
|
|
76
|
+
digest: str
|
|
77
|
+
bibliography_files: list[Path]
|
|
78
|
+
promote_title: bool
|
|
79
|
+
drop_title: bool
|
|
80
|
+
suppress_title_metadata: bool
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def asset_basename(self) -> str:
|
|
84
|
+
return f"{_SNIPPET_PREFIX}{self.digest}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class _SnippetAssets:
|
|
89
|
+
pdf: Path
|
|
90
|
+
png: Path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_SNIPPET_RUNTIME: TemplateRuntime | None = None
|
|
94
|
+
_SNIPPET_CACHE: _SnippetCache | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class _SnippetCache:
|
|
99
|
+
"""Disk-backed cache storing rendered snippet artefacts."""
|
|
100
|
+
|
|
101
|
+
root: Path
|
|
102
|
+
metadata_path: Path
|
|
103
|
+
metadata: dict[str, Any]
|
|
104
|
+
dirty: bool = False
|
|
105
|
+
|
|
106
|
+
def lookup(self, digest: str, template_version: str | None) -> _SnippetAssets | None:
|
|
107
|
+
"""Return cached assets when they exist and match the signature."""
|
|
108
|
+
entries = self._entries()
|
|
109
|
+
payload = entries.get(digest)
|
|
110
|
+
if not isinstance(payload, dict):
|
|
111
|
+
self.discard(digest)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
signature = payload.get("signature")
|
|
115
|
+
if signature and signature != digest:
|
|
116
|
+
self.discard(digest)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
recorded_version = payload.get("template_version")
|
|
120
|
+
if template_version and recorded_version and recorded_version != template_version:
|
|
121
|
+
self.discard(digest)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
pdf_name = payload.get("pdf") or asset_filename(digest, ".pdf")
|
|
125
|
+
png_name = payload.get("png") or asset_filename(digest, ".png")
|
|
126
|
+
pdf_path = (self.root / pdf_name).resolve()
|
|
127
|
+
png_path = (self.root / png_name).resolve()
|
|
128
|
+
if not pdf_path.exists() or not png_path.exists():
|
|
129
|
+
self.discard(digest)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
return _SnippetAssets(pdf=pdf_path, png=png_path)
|
|
133
|
+
|
|
134
|
+
def store(
|
|
135
|
+
self,
|
|
136
|
+
digest: str,
|
|
137
|
+
pdf_path: Path,
|
|
138
|
+
png_path: Path,
|
|
139
|
+
*,
|
|
140
|
+
template_version: str | None,
|
|
141
|
+
block: SnippetBlock | None = None,
|
|
142
|
+
source_path: Path | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Persist compiled assets in the cache directory."""
|
|
145
|
+
entries = self._entries()
|
|
146
|
+
cached_pdf = (self.root / asset_filename(digest, ".pdf")).resolve()
|
|
147
|
+
cached_png = (self.root / asset_filename(digest, ".png")).resolve()
|
|
148
|
+
cached_pdf.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
cached_md = (self.root / asset_filename(digest, ".md")).resolve()
|
|
150
|
+
|
|
151
|
+
cached_md_path: Path | None = None
|
|
152
|
+
if block is not None and block.content is not None:
|
|
153
|
+
try:
|
|
154
|
+
cached_md.write_text(block.content, encoding="utf-8")
|
|
155
|
+
cached_md_path = cached_md
|
|
156
|
+
except OSError:
|
|
157
|
+
cached_md_path = None
|
|
158
|
+
|
|
159
|
+
attributes: dict[str, Any] | None = None
|
|
160
|
+
if block is not None:
|
|
161
|
+
attributes = {
|
|
162
|
+
"caption": block.caption,
|
|
163
|
+
"label": block.label,
|
|
164
|
+
"figure_width": block.figure_width,
|
|
165
|
+
"layout": block.layout,
|
|
166
|
+
"overrides": block.template_overrides,
|
|
167
|
+
"cwd": str(block.cwd) if block.cwd else None,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
linked_files: dict[str, str] = {
|
|
171
|
+
"pdf": cached_pdf.name,
|
|
172
|
+
"png": cached_png.name,
|
|
173
|
+
}
|
|
174
|
+
if cached_md_path is not None:
|
|
175
|
+
linked_files["md"] = cached_md_path.name
|
|
176
|
+
|
|
177
|
+
source_hint = str(source_path) if source_path is not None else None
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
source_pdf = Path(pdf_path).resolve()
|
|
181
|
+
source_png = Path(png_path).resolve()
|
|
182
|
+
if source_pdf != cached_pdf:
|
|
183
|
+
shutil.copy2(source_pdf, cached_pdf)
|
|
184
|
+
if source_png != cached_png:
|
|
185
|
+
shutil.copy2(source_png, cached_png)
|
|
186
|
+
except OSError:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
entries[digest] = {
|
|
190
|
+
"signature": digest,
|
|
191
|
+
"pdf": cached_pdf.name,
|
|
192
|
+
"png": cached_png.name,
|
|
193
|
+
"template_version": template_version,
|
|
194
|
+
"files": linked_files,
|
|
195
|
+
"attributes": attributes,
|
|
196
|
+
"source": source_hint,
|
|
197
|
+
}
|
|
198
|
+
self.dirty = True
|
|
199
|
+
|
|
200
|
+
def discard(self, digest: str) -> None:
|
|
201
|
+
"""Remove a cache entry when it becomes invalid."""
|
|
202
|
+
entries = self._entries()
|
|
203
|
+
if digest in entries:
|
|
204
|
+
entries.pop(digest, None)
|
|
205
|
+
self.dirty = True
|
|
206
|
+
|
|
207
|
+
def flush(self) -> None:
|
|
208
|
+
"""Persist metadata to disk when modified."""
|
|
209
|
+
if not self.dirty:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
payload = {
|
|
213
|
+
"version": _SNIPPET_CACHE_VERSION,
|
|
214
|
+
"entries": self._entries(),
|
|
215
|
+
}
|
|
216
|
+
tmp_path = self.metadata_path.with_suffix(".tmp")
|
|
217
|
+
try:
|
|
218
|
+
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
219
|
+
tmp_path.replace(self.metadata_path)
|
|
220
|
+
self.dirty = False
|
|
221
|
+
except OSError:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
def _entries(self) -> dict[str, Any]:
|
|
225
|
+
entries = self.metadata.setdefault("entries", {})
|
|
226
|
+
if not isinstance(entries, dict):
|
|
227
|
+
entries = {}
|
|
228
|
+
self.metadata["entries"] = entries
|
|
229
|
+
return entries
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _resolve_runtime() -> TemplateRuntime:
|
|
233
|
+
global _SNIPPET_RUNTIME
|
|
234
|
+
if _SNIPPET_RUNTIME is None:
|
|
235
|
+
_SNIPPET_RUNTIME = load_template_runtime("snippet")
|
|
236
|
+
return _SNIPPET_RUNTIME
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _resolve_cache_root() -> Path | None:
|
|
240
|
+
try:
|
|
241
|
+
return get_user_dir().cache_dir(_SNIPPET_CACHE_NAMESPACE)
|
|
242
|
+
except OSError:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _load_cache_metadata(path: Path) -> dict[str, Any]:
|
|
247
|
+
default = {"version": _SNIPPET_CACHE_VERSION, "entries": {}}
|
|
248
|
+
try:
|
|
249
|
+
raw = path.read_text(encoding="utf-8")
|
|
250
|
+
except FileNotFoundError:
|
|
251
|
+
return default
|
|
252
|
+
except OSError:
|
|
253
|
+
return default
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
payload = json.loads(raw)
|
|
257
|
+
except json.JSONDecodeError:
|
|
258
|
+
return default
|
|
259
|
+
|
|
260
|
+
if payload.get("version") != _SNIPPET_CACHE_VERSION:
|
|
261
|
+
return default
|
|
262
|
+
|
|
263
|
+
entries = payload.get("entries")
|
|
264
|
+
if not isinstance(entries, dict):
|
|
265
|
+
payload["entries"] = {}
|
|
266
|
+
return payload
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _resolve_cache() -> _SnippetCache | None:
|
|
270
|
+
global _SNIPPET_CACHE
|
|
271
|
+
if _SNIPPET_CACHE is not None:
|
|
272
|
+
return _SNIPPET_CACHE
|
|
273
|
+
|
|
274
|
+
root = _resolve_cache_root()
|
|
275
|
+
if root is None:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
metadata_path = root / _SNIPPET_CACHE_FILENAME
|
|
279
|
+
metadata = _load_cache_metadata(metadata_path)
|
|
280
|
+
_SNIPPET_CACHE = _SnippetCache(root=root, metadata_path=metadata_path, metadata=metadata)
|
|
281
|
+
return _SNIPPET_CACHE
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_caches() -> list[_SnippetCache]:
|
|
285
|
+
"""Return the user-level cache only."""
|
|
286
|
+
cache = _resolve_cache()
|
|
287
|
+
return [cache] if cache is not None else []
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _resolve_bibliography_files(
|
|
291
|
+
files: list[str],
|
|
292
|
+
cwd: str | Path | None,
|
|
293
|
+
host_path: Path | None,
|
|
294
|
+
) -> list[Path]:
|
|
295
|
+
"""Resolve bibliography file paths relative to the snippet host document."""
|
|
296
|
+
if not files:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
base_dir: Path | None = None
|
|
300
|
+
cwd_path = Path(cwd).expanduser() if cwd else None
|
|
301
|
+
if cwd_path is not None:
|
|
302
|
+
if cwd_path.is_absolute():
|
|
303
|
+
base_dir = cwd_path
|
|
304
|
+
elif host_path is not None:
|
|
305
|
+
try:
|
|
306
|
+
base_dir = (host_path.parent / cwd_path).resolve()
|
|
307
|
+
except OSError:
|
|
308
|
+
base_dir = host_path.parent
|
|
309
|
+
else:
|
|
310
|
+
try:
|
|
311
|
+
base_dir = cwd_path.resolve()
|
|
312
|
+
except OSError:
|
|
313
|
+
base_dir = None
|
|
314
|
+
elif host_path is not None:
|
|
315
|
+
base_dir = host_path.parent
|
|
316
|
+
|
|
317
|
+
resolved: list[Path] = []
|
|
318
|
+
for entry in files:
|
|
319
|
+
candidate = Path(entry)
|
|
320
|
+
if not candidate.is_absolute():
|
|
321
|
+
if base_dir is None:
|
|
322
|
+
try:
|
|
323
|
+
base_dir = Path.cwd()
|
|
324
|
+
except OSError:
|
|
325
|
+
base_dir = None
|
|
326
|
+
if base_dir is not None:
|
|
327
|
+
candidate = base_dir / candidate
|
|
328
|
+
with contextlib.suppress(OSError):
|
|
329
|
+
candidate = candidate.resolve()
|
|
330
|
+
resolved.append(candidate)
|
|
331
|
+
return resolved
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _resolve_host_path(document_path: Path | str | None, source_dir: Path | None) -> Path | None:
|
|
335
|
+
"""Normalise the host document path to an absolute path when possible."""
|
|
336
|
+
if document_path is None:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
candidate = Path(document_path)
|
|
341
|
+
except TypeError:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
if candidate.is_absolute():
|
|
345
|
+
return candidate
|
|
346
|
+
|
|
347
|
+
if source_dir is not None:
|
|
348
|
+
try:
|
|
349
|
+
return (Path(source_dir) / candidate).resolve()
|
|
350
|
+
except OSError:
|
|
351
|
+
return Path(source_dir) / candidate
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
return candidate.resolve()
|
|
355
|
+
except OSError:
|
|
356
|
+
return candidate
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _resolve_base_dir(block: SnippetBlock, host_path: Path | None) -> Path:
|
|
360
|
+
"""Resolve the effective base directory for snippet assets."""
|
|
361
|
+
if block.cwd:
|
|
362
|
+
base = block.cwd
|
|
363
|
+
if not base.is_absolute() and host_path is not None:
|
|
364
|
+
try:
|
|
365
|
+
return (host_path.parent / base).resolve()
|
|
366
|
+
except OSError:
|
|
367
|
+
return host_path.parent
|
|
368
|
+
try:
|
|
369
|
+
return base.resolve()
|
|
370
|
+
except OSError:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
if host_path is not None:
|
|
374
|
+
return host_path.parent
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
return Path.cwd()
|
|
378
|
+
except OSError:
|
|
379
|
+
return Path()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _resolve_template_runtime(
|
|
383
|
+
block: SnippetBlock, documents: list[Document], base_dir: Path
|
|
384
|
+
) -> TemplateRuntime:
|
|
385
|
+
"""Select the template runtime from front matter when provided."""
|
|
386
|
+
template_id: str | None = block.template_id
|
|
387
|
+
|
|
388
|
+
def _template_from_front_matter(front_matter: Mapping[str, Any]) -> str | None:
|
|
389
|
+
payload = dict(front_matter)
|
|
390
|
+
with contextlib.suppress(PressMetadataError):
|
|
391
|
+
normalise_press_metadata(payload)
|
|
392
|
+
raw = payload.get("template")
|
|
393
|
+
if raw:
|
|
394
|
+
return str(raw).strip()
|
|
395
|
+
press_section = payload.get("press")
|
|
396
|
+
if isinstance(press_section, Mapping):
|
|
397
|
+
inner = press_section.get("template")
|
|
398
|
+
if inner:
|
|
399
|
+
return str(inner).strip()
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
for document in documents:
|
|
403
|
+
front_matter = document.front_matter
|
|
404
|
+
if not isinstance(front_matter, Mapping):
|
|
405
|
+
continue
|
|
406
|
+
candidate = _template_from_front_matter(front_matter)
|
|
407
|
+
if candidate:
|
|
408
|
+
template_id = candidate
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
if not template_id:
|
|
412
|
+
return _resolve_runtime()
|
|
413
|
+
|
|
414
|
+
# Try resolving relative to the snippet cwd / host directory first.
|
|
415
|
+
candidates: list[str] = []
|
|
416
|
+
tpl_path = Path(template_id)
|
|
417
|
+
if not tpl_path.is_absolute():
|
|
418
|
+
candidates.append(str((base_dir / tpl_path).resolve()))
|
|
419
|
+
candidates.append(template_id)
|
|
420
|
+
|
|
421
|
+
last_exc: Exception | None = None
|
|
422
|
+
for candidate in candidates:
|
|
423
|
+
try:
|
|
424
|
+
return load_template_runtime(candidate)
|
|
425
|
+
except TemplateError as exc:
|
|
426
|
+
last_exc = exc
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
if last_exc is not None:
|
|
430
|
+
raise last_exc
|
|
431
|
+
|
|
432
|
+
return _resolve_runtime()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _snippet_template_version() -> str | None:
|
|
436
|
+
runtime = _resolve_runtime()
|
|
437
|
+
info = getattr(runtime.instance, "info", None)
|
|
438
|
+
if info is None:
|
|
439
|
+
return None
|
|
440
|
+
version = getattr(info, "version", None)
|
|
441
|
+
return str(version) if version is not None else None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _resolve_emitter(context: RenderContext) -> DiagnosticEmitter | None:
|
|
445
|
+
emitter = context.runtime.get("emitter")
|
|
446
|
+
if isinstance(emitter, DiagnosticEmitter):
|
|
447
|
+
return emitter
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def asset_filename(digest: str, suffix: str) -> str:
|
|
452
|
+
"""Return the deterministic filename for a snippet artefact."""
|
|
453
|
+
return f"{_SNIPPET_PREFIX}{digest}{suffix}"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _hash_payload(
|
|
457
|
+
content: str,
|
|
458
|
+
overrides: Mapping[str, Any],
|
|
459
|
+
*,
|
|
460
|
+
cwd: str | None = None,
|
|
461
|
+
template_id: str | None = None,
|
|
462
|
+
layout: tuple[int, int] | None = None,
|
|
463
|
+
sources: list[Path] | None = None,
|
|
464
|
+
promote_title: bool = True,
|
|
465
|
+
drop_title: bool = False,
|
|
466
|
+
suppress_title: bool = False,
|
|
467
|
+
transparent_corner: bool = False,
|
|
468
|
+
fold_size: float | None = None,
|
|
469
|
+
) -> str:
|
|
470
|
+
def _hash_file(path: Path) -> str:
|
|
471
|
+
try:
|
|
472
|
+
data = path.read_bytes()
|
|
473
|
+
except OSError:
|
|
474
|
+
return ""
|
|
475
|
+
return hashlib.sha256(data).hexdigest()
|
|
476
|
+
|
|
477
|
+
def _normalise(value: Any) -> Any:
|
|
478
|
+
if isinstance(value, Path):
|
|
479
|
+
return str(value)
|
|
480
|
+
if isinstance(value, Mapping):
|
|
481
|
+
return {str(k): _normalise(v) for k, v in value.items()}
|
|
482
|
+
if isinstance(value, (list, tuple)):
|
|
483
|
+
return [_normalise(item) for item in value]
|
|
484
|
+
return value
|
|
485
|
+
|
|
486
|
+
payload = {
|
|
487
|
+
"content": content,
|
|
488
|
+
"overrides": _normalise(dict(overrides)),
|
|
489
|
+
"cwd": cwd,
|
|
490
|
+
"template": template_id,
|
|
491
|
+
"layout": layout,
|
|
492
|
+
"sources": [
|
|
493
|
+
{"path": str(path), "sha256": _hash_file(path)} for path in list(sources or [])
|
|
494
|
+
],
|
|
495
|
+
"promote_title": promote_title,
|
|
496
|
+
"drop_title": drop_title,
|
|
497
|
+
"suppress_title": suppress_title,
|
|
498
|
+
"transparent_corner": transparent_corner,
|
|
499
|
+
"fold_size": fold_size,
|
|
500
|
+
}
|
|
501
|
+
encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
502
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _coerce_bool_option(value: Any, default: bool) -> bool:
|
|
506
|
+
if value is None:
|
|
507
|
+
return default
|
|
508
|
+
if isinstance(value, bool):
|
|
509
|
+
return value
|
|
510
|
+
if isinstance(value, (int, float)):
|
|
511
|
+
return bool(value)
|
|
512
|
+
if isinstance(value, str):
|
|
513
|
+
token = value.strip().lower()
|
|
514
|
+
if token in _TRUE_VALUES:
|
|
515
|
+
return True
|
|
516
|
+
if token in _FALSE_VALUES:
|
|
517
|
+
return False
|
|
518
|
+
return default
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _detect_language(element: Tag) -> str | None:
|
|
522
|
+
"""Return the declared language from the fenced block classes."""
|
|
523
|
+
candidates = [element, element.find("pre"), element.find("code")]
|
|
524
|
+
for candidate in candidates:
|
|
525
|
+
if candidate is None:
|
|
526
|
+
continue
|
|
527
|
+
for cls in gather_classes(candidate.get("class")):
|
|
528
|
+
if cls.startswith("language-"):
|
|
529
|
+
return cls[len("language-") :]
|
|
530
|
+
if cls in {"yaml", "yml", "md", "markdown"}:
|
|
531
|
+
return cls
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _frame_dogear_enabled(overrides: Mapping[str, Any]) -> bool:
|
|
536
|
+
"""Detect whether the page frame dogear should be shown for a snippet preview."""
|
|
537
|
+
|
|
538
|
+
def _coerce_frame(value: Any) -> tuple[bool, bool]:
|
|
539
|
+
if value is None:
|
|
540
|
+
return False, False
|
|
541
|
+
if isinstance(value, Mapping):
|
|
542
|
+
enabled = _coerce_bool_option(value.get("enabled"), True)
|
|
543
|
+
mode = value.get("mode")
|
|
544
|
+
dogear = _coerce_bool_option(value.get("dogear"), True)
|
|
545
|
+
if isinstance(mode, str):
|
|
546
|
+
token = mode.strip().lower()
|
|
547
|
+
if token == "border":
|
|
548
|
+
dogear = False
|
|
549
|
+
enabled = True
|
|
550
|
+
elif token in {"dogear", "fold"}:
|
|
551
|
+
dogear = True
|
|
552
|
+
enabled = True
|
|
553
|
+
if not enabled:
|
|
554
|
+
return False, False
|
|
555
|
+
return True, dogear
|
|
556
|
+
if isinstance(value, (bool, int, float)):
|
|
557
|
+
flag = bool(value)
|
|
558
|
+
return flag, flag
|
|
559
|
+
if isinstance(value, str):
|
|
560
|
+
token = value.strip().lower()
|
|
561
|
+
if not token or token in {"false", "off", "no", "0", "none"}:
|
|
562
|
+
return False, False
|
|
563
|
+
if token == "border":
|
|
564
|
+
return True, False
|
|
565
|
+
if token in {"dogear", "true", "yes", "on", "1"}:
|
|
566
|
+
return True, True
|
|
567
|
+
return False, False
|
|
568
|
+
|
|
569
|
+
press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
|
|
570
|
+
frame_value = overrides.get("frame")
|
|
571
|
+
if frame_value is None and isinstance(press_section, Mapping):
|
|
572
|
+
frame_value = press_section.get("frame")
|
|
573
|
+
enabled, dogear = _coerce_frame(frame_value)
|
|
574
|
+
if enabled and dogear:
|
|
575
|
+
return True
|
|
576
|
+
|
|
577
|
+
fragments_value = overrides.get("fragments")
|
|
578
|
+
if fragments_value is None and isinstance(press_section, Mapping):
|
|
579
|
+
fragments_value = press_section.get("fragments")
|
|
580
|
+
return isinstance(fragments_value, list) and any(str(f) == "ts-frame" for f in fragments_value)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _frame_fold_size_px(
|
|
584
|
+
overrides: Mapping[str, Any], image_size: tuple[int, int], *, default_mm: float = 10.0
|
|
585
|
+
) -> int:
|
|
586
|
+
"""Resolve the frame fold size (in pixels) from press.frame settings."""
|
|
587
|
+
|
|
588
|
+
def _parse_length(value: str | None, fallback_mm: float) -> float:
|
|
589
|
+
if not value:
|
|
590
|
+
return fallback_mm
|
|
591
|
+
raw = str(value).strip().lower()
|
|
592
|
+
if not raw:
|
|
593
|
+
return fallback_mm
|
|
594
|
+
unit = "mm"
|
|
595
|
+
numeric = raw
|
|
596
|
+
for candidate in ("mm", "cm", "in", "pt"):
|
|
597
|
+
if raw.endswith(candidate):
|
|
598
|
+
unit = candidate
|
|
599
|
+
numeric = raw[: -len(candidate)]
|
|
600
|
+
break
|
|
601
|
+
try:
|
|
602
|
+
mag = float(numeric)
|
|
603
|
+
except ValueError:
|
|
604
|
+
return fallback_mm
|
|
605
|
+
if unit == "cm":
|
|
606
|
+
mag *= 10.0
|
|
607
|
+
elif unit == "in":
|
|
608
|
+
mag *= 25.4
|
|
609
|
+
elif unit == "pt":
|
|
610
|
+
mag *= 25.4 / 72.27
|
|
611
|
+
return max(mag, 0.0)
|
|
612
|
+
|
|
613
|
+
press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
|
|
614
|
+
frame_value = overrides.get("frame")
|
|
615
|
+
if frame_value is None and isinstance(press_section, Mapping):
|
|
616
|
+
frame_value = press_section.get("frame")
|
|
617
|
+
|
|
618
|
+
fold_spec: str | None = None
|
|
619
|
+
if isinstance(frame_value, Mapping):
|
|
620
|
+
fold_spec = (
|
|
621
|
+
frame_value.get("fold-size")
|
|
622
|
+
or frame_value.get("fold_size")
|
|
623
|
+
or frame_value.get("fold")
|
|
624
|
+
or frame_value.get("foldsize")
|
|
625
|
+
)
|
|
626
|
+
elif isinstance(frame_value, (int, float, str)):
|
|
627
|
+
# When a bare value is supplied, respect it as fold size if it looks like a length.
|
|
628
|
+
token = str(frame_value).strip().lower()
|
|
629
|
+
if any(ch.isdigit() for ch in token):
|
|
630
|
+
fold_spec = token
|
|
631
|
+
|
|
632
|
+
fold_mm = _parse_length(fold_spec, default_mm)
|
|
633
|
+
px_per_mm = 220.0 / 25.4 # match DPI used in _pdf_to_png_grid
|
|
634
|
+
estimated_px = round(fold_mm * px_per_mm)
|
|
635
|
+
width, height = image_size
|
|
636
|
+
max_dim = max(min(width, height) * 0.2, 24)
|
|
637
|
+
return int(max(12, min(estimated_px, max_dim)))
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _load_yaml_mapping(payload: str) -> dict[str, Any]:
|
|
641
|
+
"""Parse a YAML string into a mapping, enforcing a dictionary output."""
|
|
642
|
+
if not payload.strip():
|
|
643
|
+
return {}
|
|
644
|
+
try:
|
|
645
|
+
loaded = yaml.safe_load(payload)
|
|
646
|
+
except yaml.YAMLError as exc:
|
|
647
|
+
raise InvalidNodeError(f"Invalid YAML snippet payload: {exc}") from exc
|
|
648
|
+
if loaded is None:
|
|
649
|
+
return {}
|
|
650
|
+
if not isinstance(loaded, Mapping):
|
|
651
|
+
raise InvalidNodeError("Snippet configuration must be a YAML mapping.")
|
|
652
|
+
return dict(loaded)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _resolve_layout_value(value: Any) -> tuple[int, int] | None:
|
|
656
|
+
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
657
|
+
try:
|
|
658
|
+
return (int(value[0]), int(value[1]))
|
|
659
|
+
except (ValueError, TypeError):
|
|
660
|
+
return None
|
|
661
|
+
if isinstance(value, str):
|
|
662
|
+
return _parse_layout(value)
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _resolve_base_dir_value(value: str | Path | None, host_path: Path | None) -> Path | None:
|
|
667
|
+
if value is None:
|
|
668
|
+
return host_path.parent if host_path is not None else None
|
|
669
|
+
candidate = Path(value).expanduser()
|
|
670
|
+
if candidate.is_absolute():
|
|
671
|
+
return candidate
|
|
672
|
+
if host_path is not None:
|
|
673
|
+
try:
|
|
674
|
+
return (host_path.parent / candidate).resolve()
|
|
675
|
+
except OSError:
|
|
676
|
+
return host_path.parent / candidate
|
|
677
|
+
try:
|
|
678
|
+
return candidate.resolve()
|
|
679
|
+
except OSError:
|
|
680
|
+
return candidate
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _resolve_sources(raw_sources: Any, base_dir: Path | None) -> list[Path]:
|
|
684
|
+
if raw_sources is None:
|
|
685
|
+
return []
|
|
686
|
+
if not isinstance(raw_sources, list):
|
|
687
|
+
raise InvalidNodeError("The 'sources' field must be a list of paths.")
|
|
688
|
+
|
|
689
|
+
resolved: list[Path] = []
|
|
690
|
+
for entry in raw_sources:
|
|
691
|
+
if entry is None:
|
|
692
|
+
continue
|
|
693
|
+
candidate = Path(str(entry))
|
|
694
|
+
if not candidate.is_absolute():
|
|
695
|
+
if base_dir is None:
|
|
696
|
+
raise InvalidNodeError("Relative snippet sources require a host document path.")
|
|
697
|
+
candidate = base_dir / candidate
|
|
698
|
+
with contextlib.suppress(OSError):
|
|
699
|
+
candidate = candidate.resolve()
|
|
700
|
+
if not candidate.exists():
|
|
701
|
+
raise InvalidNodeError(f"Snippet source '{candidate}' does not exist.")
|
|
702
|
+
resolved.append(candidate)
|
|
703
|
+
return resolved
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _merge_press_section(overrides: dict[str, Any], fragments: Any) -> None:
|
|
707
|
+
if fragments is None:
|
|
708
|
+
return
|
|
709
|
+
press_section = overrides.setdefault("press", {})
|
|
710
|
+
if not isinstance(press_section, dict):
|
|
711
|
+
overrides["press"] = {"fragments": fragments}
|
|
712
|
+
return
|
|
713
|
+
existing = press_section.get("fragments")
|
|
714
|
+
if existing is None:
|
|
715
|
+
press_section["fragments"] = fragments
|
|
716
|
+
elif isinstance(existing, dict) and isinstance(fragments, Mapping):
|
|
717
|
+
press_section["fragments"] = {**existing, **dict(fragments)}
|
|
718
|
+
elif isinstance(existing, list) and isinstance(fragments, list):
|
|
719
|
+
press_section["fragments"] = [*existing, *fragments]
|
|
720
|
+
else:
|
|
721
|
+
press_section["fragments"] = fragments
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _extract_snippet_block(element: Tag, host_path: Path | None = None) -> SnippetBlock | None:
|
|
725
|
+
pre_element = element.find("pre")
|
|
726
|
+
code_element = element.find("code")
|
|
727
|
+
|
|
728
|
+
classes = set(gather_classes(element.get("class")))
|
|
729
|
+
classes.update(gather_classes(pre_element.get("class") if pre_element else None))
|
|
730
|
+
classes.update(gather_classes(code_element.get("class") if code_element else None))
|
|
731
|
+
if "snippet" not in classes:
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
if code_element is None:
|
|
735
|
+
raise InvalidNodeError("Snippet block is missing an inner <code> element.")
|
|
736
|
+
raw_content = code_element.get_text(strip=False)
|
|
737
|
+
|
|
738
|
+
def _attr(name: str) -> Any:
|
|
739
|
+
for candidate in (element, pre_element, code_element):
|
|
740
|
+
if candidate is None:
|
|
741
|
+
continue
|
|
742
|
+
for key in (name, f"data-{name}"):
|
|
743
|
+
value = candidate.get(key)
|
|
744
|
+
if value is not None:
|
|
745
|
+
return value
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
def _meta_attrs() -> dict[str, str]:
|
|
749
|
+
raw_meta = _attr("meta") or _attr("data-meta")
|
|
750
|
+
if not raw_meta or not isinstance(raw_meta, str):
|
|
751
|
+
return {}
|
|
752
|
+
result: dict[str, str] = {}
|
|
753
|
+
import shlex
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
tokens = shlex.split(raw_meta)
|
|
757
|
+
except ValueError:
|
|
758
|
+
tokens = raw_meta.split()
|
|
759
|
+
for token in tokens:
|
|
760
|
+
if "=" not in token:
|
|
761
|
+
continue
|
|
762
|
+
key, val = token.split("=", 1)
|
|
763
|
+
result[key.strip()] = val.strip().strip('"').strip("'")
|
|
764
|
+
return result
|
|
765
|
+
|
|
766
|
+
caption = coerce_attribute(_attr("caption")) or None
|
|
767
|
+
label = coerce_attribute(_attr("label")) or None
|
|
768
|
+
figure_width = coerce_attribute(_attr("width")) or None
|
|
769
|
+
layout_literal: Any = coerce_attribute(_attr("layout")) or None
|
|
770
|
+
config_literal = coerce_attribute(_attr("config")) or None
|
|
771
|
+
|
|
772
|
+
config_from_file: dict[str, Any] = {}
|
|
773
|
+
if config_literal:
|
|
774
|
+
config_path = Path(config_literal)
|
|
775
|
+
if not config_path.is_absolute():
|
|
776
|
+
if host_path is None:
|
|
777
|
+
raise InvalidNodeError("Relative snippet configuration requires a host document.")
|
|
778
|
+
config_path = (host_path.parent / config_path).resolve()
|
|
779
|
+
try:
|
|
780
|
+
config_payload = config_path.read_text(encoding="utf-8")
|
|
781
|
+
except OSError as exc:
|
|
782
|
+
raise InvalidNodeError(
|
|
783
|
+
f"Unable to read snippet configuration '{config_path}': {exc}"
|
|
784
|
+
) from exc
|
|
785
|
+
config_from_file = _load_yaml_mapping(config_payload)
|
|
786
|
+
|
|
787
|
+
language = _detect_language(element)
|
|
788
|
+
config_from_body: dict[str, Any] = {}
|
|
789
|
+
inline_content: str | None = None
|
|
790
|
+
if language in {"yaml", "yml"}:
|
|
791
|
+
config_from_body = _load_yaml_mapping(raw_content)
|
|
792
|
+
else:
|
|
793
|
+
metadata, body = split_front_matter(raw_content)
|
|
794
|
+
config_from_body = dict(metadata or {})
|
|
795
|
+
if body.strip():
|
|
796
|
+
inline_content = body
|
|
797
|
+
if not config_from_body and inline_content is not None:
|
|
798
|
+
# Fallback: accept pure-YAML snippet bodies even when the language class is missing.
|
|
799
|
+
try:
|
|
800
|
+
parsed_yaml = _load_yaml_mapping(raw_content)
|
|
801
|
+
except InvalidNodeError:
|
|
802
|
+
parsed_yaml = None
|
|
803
|
+
if isinstance(parsed_yaml, dict) and parsed_yaml:
|
|
804
|
+
config_from_body = parsed_yaml
|
|
805
|
+
inline_content = None
|
|
806
|
+
|
|
807
|
+
merged_config: dict[str, Any] = {**config_from_file, **config_from_body}
|
|
808
|
+
meta_attrs = _meta_attrs()
|
|
809
|
+
figure_width = (
|
|
810
|
+
coerce_attribute(merged_config.pop("width", figure_width))
|
|
811
|
+
or coerce_attribute(meta_attrs.get("width"))
|
|
812
|
+
or figure_width
|
|
813
|
+
)
|
|
814
|
+
caption = coerce_attribute(merged_config.pop("caption", caption)) or caption
|
|
815
|
+
label = coerce_attribute(merged_config.pop("label", label)) or label
|
|
816
|
+
layout_literal = merged_config.pop("layout", layout_literal) or layout_literal
|
|
817
|
+
template_id_raw = merged_config.pop("template", None)
|
|
818
|
+
template_id = coerce_attribute(template_id_raw) or None
|
|
819
|
+
cwd_value = merged_config.pop("cwd", None)
|
|
820
|
+
drop_title_value = _coerce_bool_option(merged_config.pop("drop_title", False), False)
|
|
821
|
+
promote_title_value = _coerce_bool_option(
|
|
822
|
+
merged_config.pop("promote_title", None),
|
|
823
|
+
True,
|
|
824
|
+
)
|
|
825
|
+
suppress_title_value = _coerce_bool_option(
|
|
826
|
+
merged_config.pop("suppress_title_metadata", merged_config.pop("suppress_title", None)),
|
|
827
|
+
False,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
base_dir = _resolve_base_dir_value(cwd_value, host_path)
|
|
831
|
+
sources = _resolve_sources(merged_config.pop("sources", None), base_dir)
|
|
832
|
+
bibliography_files = [
|
|
833
|
+
path for path in sources if path.suffix.lower() in {".bib", ".bibtex", ".ris"}
|
|
834
|
+
]
|
|
835
|
+
if inline_content is None and not sources:
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
fragments = merged_config.pop("fragments", None)
|
|
839
|
+
press_overrides = merged_config.pop("press", None)
|
|
840
|
+
template_overrides: dict[str, Any] = dict(merged_config)
|
|
841
|
+
if press_overrides is not None:
|
|
842
|
+
if not isinstance(press_overrides, Mapping):
|
|
843
|
+
raise InvalidNodeError("The 'press' section must be a mapping.")
|
|
844
|
+
template_overrides["press"] = dict(press_overrides)
|
|
845
|
+
_merge_press_section(template_overrides, fragments)
|
|
846
|
+
|
|
847
|
+
layout = _resolve_layout_value(layout_literal)
|
|
848
|
+
preview_dogear = _frame_dogear_enabled(template_overrides)
|
|
849
|
+
preview_fold_size = None
|
|
850
|
+
if preview_dogear:
|
|
851
|
+
preview_fold_size = _frame_fold_size_px(template_overrides, (0, 0))
|
|
852
|
+
if inline_content is not None and not sources and template_id is None:
|
|
853
|
+
template_id = "snippet"
|
|
854
|
+
preview_dogear = True
|
|
855
|
+
elif inline_content is None and sources and template_id is None:
|
|
856
|
+
template_id = "article"
|
|
857
|
+
digest = _hash_payload(
|
|
858
|
+
inline_content or "",
|
|
859
|
+
template_overrides,
|
|
860
|
+
cwd=str(base_dir) if base_dir else None,
|
|
861
|
+
template_id=template_id,
|
|
862
|
+
layout=layout,
|
|
863
|
+
sources=sources,
|
|
864
|
+
promote_title=promote_title_value,
|
|
865
|
+
drop_title=drop_title_value,
|
|
866
|
+
suppress_title=suppress_title_value,
|
|
867
|
+
transparent_corner=preview_dogear,
|
|
868
|
+
fold_size=preview_fold_size,
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
return SnippetBlock(
|
|
872
|
+
content=inline_content,
|
|
873
|
+
sources=sources,
|
|
874
|
+
layout=layout,
|
|
875
|
+
preview_dogear=preview_dogear,
|
|
876
|
+
preview_fold_size=preview_fold_size,
|
|
877
|
+
template_id=template_id,
|
|
878
|
+
cwd=base_dir,
|
|
879
|
+
caption=caption,
|
|
880
|
+
label=label,
|
|
881
|
+
figure_width=figure_width,
|
|
882
|
+
template_overrides=template_overrides,
|
|
883
|
+
digest=digest,
|
|
884
|
+
bibliography_files=bibliography_files,
|
|
885
|
+
promote_title=promote_title_value,
|
|
886
|
+
drop_title=drop_title_value,
|
|
887
|
+
suppress_title_metadata=suppress_title_value,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _build_document(
|
|
892
|
+
block: SnippetBlock,
|
|
893
|
+
*,
|
|
894
|
+
host_dir: Path,
|
|
895
|
+
host_name: str,
|
|
896
|
+
) -> Document | None:
|
|
897
|
+
if not block.content:
|
|
898
|
+
return None
|
|
899
|
+
return _build_document_from_markup(
|
|
900
|
+
block.content,
|
|
901
|
+
host_dir / f"{host_name}-{block.asset_basename}.md",
|
|
902
|
+
base_dir=host_dir,
|
|
903
|
+
promote_title=block.promote_title,
|
|
904
|
+
drop_title=block.drop_title,
|
|
905
|
+
suppress_title=block.suppress_title_metadata,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _build_document_from_markup(
|
|
910
|
+
content: str,
|
|
911
|
+
source_path: Path,
|
|
912
|
+
*,
|
|
913
|
+
base_dir: Path,
|
|
914
|
+
promote_title: bool,
|
|
915
|
+
drop_title: bool,
|
|
916
|
+
suppress_title: bool,
|
|
917
|
+
) -> Document:
|
|
918
|
+
rendered = render_markdown(
|
|
919
|
+
content,
|
|
920
|
+
extensions=list(DEFAULT_MARKDOWN_EXTENSIONS),
|
|
921
|
+
base_path=base_dir,
|
|
922
|
+
)
|
|
923
|
+
title_strategy = _resolve_title_strategy(
|
|
924
|
+
explicit=TitleStrategy.DROP if drop_title else None,
|
|
925
|
+
promote_title=promote_title,
|
|
926
|
+
strip_heading=drop_title,
|
|
927
|
+
has_declared_title=front_matter_has_title(rendered.front_matter),
|
|
928
|
+
)
|
|
929
|
+
options = DocumentRenderOptions(
|
|
930
|
+
base_level=0,
|
|
931
|
+
title_strategy=title_strategy,
|
|
932
|
+
numbered=False,
|
|
933
|
+
suppress_title_metadata=suppress_title,
|
|
934
|
+
)
|
|
935
|
+
document = Document(
|
|
936
|
+
source_path=source_path,
|
|
937
|
+
kind=InputKind.MARKDOWN,
|
|
938
|
+
_html=rendered.html,
|
|
939
|
+
_front_matter=rendered.front_matter,
|
|
940
|
+
options=options,
|
|
941
|
+
slots=DocumentSlots(),
|
|
942
|
+
)
|
|
943
|
+
document._initialise_slots_from_front_matter() # noqa: SLF001
|
|
944
|
+
return document
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _build_document_from_yaml(
|
|
948
|
+
content: str,
|
|
949
|
+
source_path: Path,
|
|
950
|
+
*,
|
|
951
|
+
promote_title: bool,
|
|
952
|
+
drop_title: bool,
|
|
953
|
+
suppress_title: bool,
|
|
954
|
+
) -> Document:
|
|
955
|
+
"""Create a Document using YAML front matter only (no body)."""
|
|
956
|
+
docs: list[Mapping[str, Any]] = []
|
|
957
|
+
try:
|
|
958
|
+
for doc in yaml.safe_load_all(content):
|
|
959
|
+
if isinstance(doc, Mapping):
|
|
960
|
+
docs.append(doc)
|
|
961
|
+
except yaml.YAMLError as exc: # pragma: no cover - defensive
|
|
962
|
+
raise InvalidNodeError(f"Invalid YAML snippet source '{source_path}': {exc}") from exc
|
|
963
|
+
|
|
964
|
+
payload: Mapping[str, Any] = docs[0] if docs else {}
|
|
965
|
+
if not isinstance(payload, Mapping):
|
|
966
|
+
raise InvalidNodeError(
|
|
967
|
+
f"Snippet source '{source_path}' must contain a YAML mapping, got {type(payload)}."
|
|
968
|
+
)
|
|
969
|
+
title_strategy = _resolve_title_strategy(
|
|
970
|
+
explicit=TitleStrategy.DROP if drop_title else None,
|
|
971
|
+
promote_title=promote_title,
|
|
972
|
+
strip_heading=drop_title,
|
|
973
|
+
has_declared_title=front_matter_has_title(payload),
|
|
974
|
+
)
|
|
975
|
+
options = DocumentRenderOptions(
|
|
976
|
+
base_level=0,
|
|
977
|
+
title_strategy=title_strategy,
|
|
978
|
+
numbered=False,
|
|
979
|
+
suppress_title_metadata=suppress_title,
|
|
980
|
+
)
|
|
981
|
+
document = Document(
|
|
982
|
+
source_path=source_path,
|
|
983
|
+
kind=InputKind.MARKDOWN,
|
|
984
|
+
_html="",
|
|
985
|
+
_front_matter=dict(payload),
|
|
986
|
+
options=options,
|
|
987
|
+
slots=DocumentSlots(),
|
|
988
|
+
)
|
|
989
|
+
document._initialise_slots_from_front_matter() # noqa: SLF001
|
|
990
|
+
return document
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _build_documents_from_sources(
|
|
994
|
+
sources: list[Path],
|
|
995
|
+
*,
|
|
996
|
+
promote_title: bool,
|
|
997
|
+
drop_title: bool,
|
|
998
|
+
suppress_title: bool,
|
|
999
|
+
) -> list[Document]:
|
|
1000
|
+
documents: list[Document] = []
|
|
1001
|
+
for path in sources:
|
|
1002
|
+
suffix = path.suffix.lower()
|
|
1003
|
+
if suffix in {".md", ".markdown", ".mkd"}:
|
|
1004
|
+
documents.append(
|
|
1005
|
+
Document.from_markdown(
|
|
1006
|
+
path,
|
|
1007
|
+
base_level=0,
|
|
1008
|
+
title_strategy=None,
|
|
1009
|
+
promote_title=promote_title,
|
|
1010
|
+
strip_heading=drop_title,
|
|
1011
|
+
suppress_title=suppress_title,
|
|
1012
|
+
numbered=False,
|
|
1013
|
+
)
|
|
1014
|
+
)
|
|
1015
|
+
continue
|
|
1016
|
+
if suffix in {".yml", ".yaml"}:
|
|
1017
|
+
try:
|
|
1018
|
+
yaml_content = path.read_text(encoding="utf-8")
|
|
1019
|
+
except OSError as exc:
|
|
1020
|
+
raise InvalidNodeError(f"Unable to read snippet source '{path}': {exc}") from exc
|
|
1021
|
+
documents.append(
|
|
1022
|
+
_build_document_from_yaml(
|
|
1023
|
+
yaml_content,
|
|
1024
|
+
path,
|
|
1025
|
+
promote_title=promote_title,
|
|
1026
|
+
drop_title=drop_title,
|
|
1027
|
+
suppress_title=suppress_title,
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
continue
|
|
1031
|
+
raise InvalidNodeError(
|
|
1032
|
+
f"Unsupported snippet source '{path}'. Only Markdown or YAML inputs are allowed."
|
|
1033
|
+
)
|
|
1034
|
+
return documents
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _compile_pdf(render_result: Any) -> Path:
|
|
1038
|
+
from texsmith.adapters.latex.engines import (
|
|
1039
|
+
EngineResult,
|
|
1040
|
+
build_engine_command,
|
|
1041
|
+
build_tex_env,
|
|
1042
|
+
compute_features,
|
|
1043
|
+
ensure_command_paths,
|
|
1044
|
+
missing_dependencies,
|
|
1045
|
+
parse_latex_log,
|
|
1046
|
+
resolve_engine,
|
|
1047
|
+
run_engine_command,
|
|
1048
|
+
)
|
|
1049
|
+
from texsmith.adapters.latex.pyxindy import is_available as pyxindy_available
|
|
1050
|
+
from texsmith.adapters.latex.tectonic import (
|
|
1051
|
+
BiberAcquisitionError,
|
|
1052
|
+
MakeglossariesAcquisitionError,
|
|
1053
|
+
TectonicAcquisitionError,
|
|
1054
|
+
select_biber_binary,
|
|
1055
|
+
select_makeglossaries,
|
|
1056
|
+
select_tectonic_binary,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
engine_choice = resolve_engine("tectonic", render_result.template_engine)
|
|
1060
|
+
template_context = getattr(render_result, "template_context", None) or getattr(
|
|
1061
|
+
render_result, "context", None
|
|
1062
|
+
)
|
|
1063
|
+
features = compute_features(
|
|
1064
|
+
requires_shell_escape=render_result.requires_shell_escape,
|
|
1065
|
+
bibliography=render_result.has_bibliography,
|
|
1066
|
+
document_state=render_result.document_state,
|
|
1067
|
+
template_context=template_context,
|
|
1068
|
+
)
|
|
1069
|
+
biber_binary: Path | None = None
|
|
1070
|
+
makeglossaries_binary: Path | None = None
|
|
1071
|
+
bundled_bin: Path | None = None
|
|
1072
|
+
try:
|
|
1073
|
+
selection = select_tectonic_binary(False, console=None)
|
|
1074
|
+
if features.bibliography:
|
|
1075
|
+
biber_binary = select_biber_binary(console=None)
|
|
1076
|
+
bundled_bin = biber_binary.parent
|
|
1077
|
+
if features.has_glossary and not pyxindy_available():
|
|
1078
|
+
glossaries = select_makeglossaries(console=None)
|
|
1079
|
+
makeglossaries_binary = glossaries.path
|
|
1080
|
+
if glossaries.source == "bundled":
|
|
1081
|
+
bundled_bin = bundled_bin or glossaries.path.parent
|
|
1082
|
+
except (TectonicAcquisitionError, BiberAcquisitionError, MakeglossariesAcquisitionError) as exc:
|
|
1083
|
+
raise AssetMissingError(str(exc)) from exc
|
|
1084
|
+
tectonic_binary = selection.path
|
|
1085
|
+
|
|
1086
|
+
available_bins: dict[str, Path] = {}
|
|
1087
|
+
if biber_binary:
|
|
1088
|
+
available_bins["biber"] = biber_binary
|
|
1089
|
+
if makeglossaries_binary:
|
|
1090
|
+
available_bins["makeglossaries"] = makeglossaries_binary
|
|
1091
|
+
|
|
1092
|
+
missing = missing_dependencies(
|
|
1093
|
+
engine_choice,
|
|
1094
|
+
features,
|
|
1095
|
+
use_system_tectonic=False,
|
|
1096
|
+
available_binaries=available_bins or None,
|
|
1097
|
+
)
|
|
1098
|
+
if missing:
|
|
1099
|
+
formatted = ", ".join(sorted(missing))
|
|
1100
|
+
raise AssetMissingError(f"Missing LaTeX tools for snippet rendering: {formatted}")
|
|
1101
|
+
|
|
1102
|
+
command_plan = ensure_command_paths(
|
|
1103
|
+
build_engine_command(
|
|
1104
|
+
engine_choice,
|
|
1105
|
+
features,
|
|
1106
|
+
main_tex_path=render_result.main_tex_path,
|
|
1107
|
+
tectonic_binary=tectonic_binary,
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
env = build_tex_env(
|
|
1111
|
+
render_result.main_tex_path.parent,
|
|
1112
|
+
isolate_cache=True,
|
|
1113
|
+
extra_path=bundled_bin,
|
|
1114
|
+
biber_path=biber_binary,
|
|
1115
|
+
)
|
|
1116
|
+
result: EngineResult = run_engine_command(
|
|
1117
|
+
command_plan,
|
|
1118
|
+
backend=engine_choice.backend,
|
|
1119
|
+
workdir=render_result.main_tex_path.parent,
|
|
1120
|
+
env=env,
|
|
1121
|
+
console=None,
|
|
1122
|
+
classic_output=True,
|
|
1123
|
+
features=features,
|
|
1124
|
+
)
|
|
1125
|
+
if result.returncode != 0:
|
|
1126
|
+
log_path = command_plan.log_path
|
|
1127
|
+
messages = result.messages or parse_latex_log(log_path)
|
|
1128
|
+
detail = messages[0].summary if messages else f"{engine_choice.label} failed"
|
|
1129
|
+
raise LatexRenderingError(f"Failed to compile snippet: {detail} (log: {log_path})")
|
|
1130
|
+
|
|
1131
|
+
return command_plan.pdf_path
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _load_pymupdf() -> object:
|
|
1135
|
+
try:
|
|
1136
|
+
import pymupdf as fitz # type: ignore[import-not-found]
|
|
1137
|
+
except ModuleNotFoundError:
|
|
1138
|
+
try:
|
|
1139
|
+
import fitz # type: ignore[import-not-found]
|
|
1140
|
+
except ModuleNotFoundError as exc: # pragma: no cover - optional dependency
|
|
1141
|
+
raise AssetMissingError(
|
|
1142
|
+
"PyMuPDF is required to generate snippet previews. Install the 'pymupdf' package."
|
|
1143
|
+
) from exc
|
|
1144
|
+
|
|
1145
|
+
if not hasattr(fitz, "open"): # type: ignore[attr-defined]
|
|
1146
|
+
raise AssetMissingError(
|
|
1147
|
+
"A conflicting 'fitz' package is installed. Remove it and install 'pymupdf' instead."
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
return fitz # type: ignore[return-value]
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _pdf_to_png(pdf_path: Path, png_path: Path, *, transparent_corner: bool = False) -> None:
|
|
1154
|
+
fitz = _load_pymupdf()
|
|
1155
|
+
|
|
1156
|
+
with fitz.open(pdf_path) as document:
|
|
1157
|
+
if document.page_count == 0:
|
|
1158
|
+
raise LatexRenderingError(f"Snippet PDF '{pdf_path}' did not produce any pages.")
|
|
1159
|
+
page = document.load_page(0)
|
|
1160
|
+
pixmap = page.get_pixmap(dpi=220)
|
|
1161
|
+
|
|
1162
|
+
mode = "RGBA" if pixmap.alpha else "RGB"
|
|
1163
|
+
image = Image.frombytes(mode, (pixmap.width, pixmap.height), pixmap.samples)
|
|
1164
|
+
if transparent_corner:
|
|
1165
|
+
image = _apply_dogear_transparency(image)
|
|
1166
|
+
image.save(png_path)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _pdf_to_png_grid(
|
|
1170
|
+
pdf_path: Path,
|
|
1171
|
+
png_path: Path,
|
|
1172
|
+
*,
|
|
1173
|
+
layout: tuple[int, int] | None = None,
|
|
1174
|
+
transparent_corner: bool = False,
|
|
1175
|
+
spacing: int | None = None,
|
|
1176
|
+
decorate_page: Callable[[Image.Image], Image.Image] | None = None,
|
|
1177
|
+
fold_size: int | None = None,
|
|
1178
|
+
) -> None:
|
|
1179
|
+
fitz = _load_pymupdf()
|
|
1180
|
+
|
|
1181
|
+
with fitz.open(pdf_path) as document:
|
|
1182
|
+
page_count = document.page_count
|
|
1183
|
+
if page_count == 0:
|
|
1184
|
+
raise LatexRenderingError(f"Snippet PDF '{pdf_path}' did not produce any pages.")
|
|
1185
|
+
|
|
1186
|
+
cols, rows = (1, 1)
|
|
1187
|
+
if layout:
|
|
1188
|
+
c, r = layout
|
|
1189
|
+
if c > 0:
|
|
1190
|
+
cols = c
|
|
1191
|
+
if r > 0:
|
|
1192
|
+
rows = r
|
|
1193
|
+
pages_needed = cols * rows
|
|
1194
|
+
if pages_needed <= 0:
|
|
1195
|
+
cols = 1
|
|
1196
|
+
rows = 1
|
|
1197
|
+
pages_needed = 1
|
|
1198
|
+
use_pages = min(page_count, pages_needed)
|
|
1199
|
+
|
|
1200
|
+
images: list[Image.Image] = []
|
|
1201
|
+
for index in range(use_pages):
|
|
1202
|
+
page = document.load_page(index)
|
|
1203
|
+
pixmap = page.get_pixmap(dpi=220)
|
|
1204
|
+
mode = "RGBA" if pixmap.alpha else "RGB"
|
|
1205
|
+
images.append(Image.frombytes(mode, (pixmap.width, pixmap.height), pixmap.samples))
|
|
1206
|
+
|
|
1207
|
+
if not images:
|
|
1208
|
+
raise LatexRenderingError(f"Snippet PDF '{pdf_path}' did not produce any pages.")
|
|
1209
|
+
|
|
1210
|
+
page_w, page_h = images[0].size
|
|
1211
|
+
inferred_spacing = 0
|
|
1212
|
+
if spacing is None and cols * rows > 1:
|
|
1213
|
+
inferred_spacing = max(round(min(page_w, page_h) * 0.04), 18)
|
|
1214
|
+
elif spacing:
|
|
1215
|
+
inferred_spacing = max(spacing, 0)
|
|
1216
|
+
|
|
1217
|
+
total_w = cols * page_w + inferred_spacing * (cols - 1)
|
|
1218
|
+
total_h = rows * page_h + inferred_spacing * (rows - 1)
|
|
1219
|
+
canvas = Image.new("RGBA", (total_w, total_h), (255, 255, 255, 0))
|
|
1220
|
+
|
|
1221
|
+
for idx, img in enumerate(images):
|
|
1222
|
+
if transparent_corner:
|
|
1223
|
+
img = _apply_dogear_transparency(img, fold_size=fold_size)
|
|
1224
|
+
if decorate_page is not None:
|
|
1225
|
+
img = decorate_page(img)
|
|
1226
|
+
|
|
1227
|
+
r = idx // cols
|
|
1228
|
+
c = idx % cols
|
|
1229
|
+
x = c * (page_w + inferred_spacing)
|
|
1230
|
+
y = r * (page_h + inferred_spacing)
|
|
1231
|
+
mask = img if "A" in img.getbands() else None
|
|
1232
|
+
canvas.paste(img, (x, y), mask=mask)
|
|
1233
|
+
|
|
1234
|
+
canvas.save(png_path)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _apply_dogear_transparency(image: Image.Image, *, fold_size: int | None = None) -> Image.Image:
|
|
1238
|
+
rgba = image.convert("RGBA")
|
|
1239
|
+
width, height = rgba.size
|
|
1240
|
+
if width <= 0 or height <= 0:
|
|
1241
|
+
return rgba
|
|
1242
|
+
magenta = (255, 0, 255, 255)
|
|
1243
|
+
if fold_size is not None:
|
|
1244
|
+
_ = fold_size # keep signature meaningful; floodfill is boundary-aware.
|
|
1245
|
+
seed_x = width - 2 if width > 1 else 0
|
|
1246
|
+
seed_y = 1 if height > 1 else 0
|
|
1247
|
+
try:
|
|
1248
|
+
ImageDraw.floodfill(rgba, (seed_x, seed_y), magenta, thresh=8)
|
|
1249
|
+
except Exception:
|
|
1250
|
+
return rgba
|
|
1251
|
+
|
|
1252
|
+
pixels = []
|
|
1253
|
+
for r, g, b, a in rgba.getdata():
|
|
1254
|
+
if r == 255 and g == 0 and b == 255:
|
|
1255
|
+
pixels.append((r, g, b, 0))
|
|
1256
|
+
else:
|
|
1257
|
+
pixels.append((r, g, b, a))
|
|
1258
|
+
rgba.putdata(pixels)
|
|
1259
|
+
return rgba
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _parse_layout(value: str | None) -> tuple[int, int] | None:
|
|
1263
|
+
"""Parse layout string like '2x2', '3', or '1x3' into (cols, rows)."""
|
|
1264
|
+
if not value:
|
|
1265
|
+
return None
|
|
1266
|
+
raw = value.strip().lower()
|
|
1267
|
+
if not raw:
|
|
1268
|
+
return None
|
|
1269
|
+
if "x" in raw:
|
|
1270
|
+
parts = raw.split("x", 1)
|
|
1271
|
+
try:
|
|
1272
|
+
cols = int(parts[0])
|
|
1273
|
+
rows = int(parts[1])
|
|
1274
|
+
except ValueError:
|
|
1275
|
+
return None
|
|
1276
|
+
return (cols, rows)
|
|
1277
|
+
try:
|
|
1278
|
+
cols = int(raw)
|
|
1279
|
+
except ValueError:
|
|
1280
|
+
return None
|
|
1281
|
+
return (cols, 1)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _overlay_dogear_frame(
|
|
1285
|
+
image: Image.Image,
|
|
1286
|
+
*,
|
|
1287
|
+
margin: int | None = None,
|
|
1288
|
+
dogear: int | None = None,
|
|
1289
|
+
border_width: int | None = None,
|
|
1290
|
+
border_color: tuple[int, int, int, int] = (0, 0, 0, 255),
|
|
1291
|
+
dogear_enabled: bool = True,
|
|
1292
|
+
) -> Image.Image:
|
|
1293
|
+
"""Draw a frame with a folded corner directly onto the PNG."""
|
|
1294
|
+
base = image.convert("RGBA")
|
|
1295
|
+
width, height = base.size
|
|
1296
|
+
if width <= 0 or height <= 0:
|
|
1297
|
+
return base
|
|
1298
|
+
|
|
1299
|
+
size_hint = min(width, height)
|
|
1300
|
+
m = 0 if margin is None else max(margin, 0)
|
|
1301
|
+
fold = dogear if dogear is not None else max(int(size_hint * 0.07), 18)
|
|
1302
|
+
stroke = border_width if border_width is not None else max(int(size_hint * 0.0025), 1)
|
|
1303
|
+
|
|
1304
|
+
x0, y0 = m, m
|
|
1305
|
+
x1, y1 = width - 1 - m, height - 1 - m
|
|
1306
|
+
|
|
1307
|
+
scale = 6
|
|
1308
|
+
overlay = Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
|
|
1309
|
+
draw = ImageDraw.Draw(overlay)
|
|
1310
|
+
bw = max(1, stroke * scale)
|
|
1311
|
+
|
|
1312
|
+
def sx(val: float) -> int:
|
|
1313
|
+
return round(val * scale)
|
|
1314
|
+
|
|
1315
|
+
def sy(val: float) -> int:
|
|
1316
|
+
return round(val * scale)
|
|
1317
|
+
|
|
1318
|
+
draw.line([(sx(x0), sy(y0)), (sx(x1 - fold), sy(y0))], fill=border_color, width=bw)
|
|
1319
|
+
draw.line([(sx(x1), sy(y0 + fold)), (sx(x1), sy(y1))], fill=border_color, width=bw)
|
|
1320
|
+
draw.line([(sx(x1), sy(y1)), (sx(x0), sy(y1))], fill=border_color, width=bw)
|
|
1321
|
+
draw.line([(sx(x0), sy(y1)), (sx(x0), sy(y0))], fill=border_color, width=bw)
|
|
1322
|
+
|
|
1323
|
+
mask = Image.new("L", overlay.size, color=255)
|
|
1324
|
+
mask_draw = ImageDraw.Draw(mask)
|
|
1325
|
+
|
|
1326
|
+
if dogear_enabled:
|
|
1327
|
+
corner = (x1 - fold, y0 + fold)
|
|
1328
|
+
bdown = (x1, y0 + fold)
|
|
1329
|
+
bleft = (x1 - fold, y0)
|
|
1330
|
+
|
|
1331
|
+
def bezier_points(
|
|
1332
|
+
p0: tuple[float, float],
|
|
1333
|
+
p1: tuple[float, float],
|
|
1334
|
+
p2: tuple[float, float],
|
|
1335
|
+
p3: tuple[float, float],
|
|
1336
|
+
steps: int = 192,
|
|
1337
|
+
) -> list[tuple[int, int]]:
|
|
1338
|
+
pts = []
|
|
1339
|
+
for i in range(steps + 1):
|
|
1340
|
+
t = i / steps
|
|
1341
|
+
mt = 1 - t
|
|
1342
|
+
x = (
|
|
1343
|
+
mt * mt * mt * p0[0]
|
|
1344
|
+
+ 3 * mt * mt * t * p1[0]
|
|
1345
|
+
+ 3 * mt * t * t * p2[0]
|
|
1346
|
+
+ t * t * t * p3[0]
|
|
1347
|
+
)
|
|
1348
|
+
y = (
|
|
1349
|
+
mt * mt * mt * p0[1]
|
|
1350
|
+
+ 3 * mt * mt * t * p1[1]
|
|
1351
|
+
+ 3 * mt * t * t * p2[1]
|
|
1352
|
+
+ t * t * t * p3[1]
|
|
1353
|
+
)
|
|
1354
|
+
pts.append((sx(x), sy(y)))
|
|
1355
|
+
return pts
|
|
1356
|
+
|
|
1357
|
+
spline1 = bezier_points(
|
|
1358
|
+
corner,
|
|
1359
|
+
(corner[0] + 0.3 * fold, corner[1] - 0.1 * fold),
|
|
1360
|
+
(bdown[0] - 0.3 * fold, bdown[1] + 0.1 * fold),
|
|
1361
|
+
bdown,
|
|
1362
|
+
)
|
|
1363
|
+
spline2 = bezier_points(
|
|
1364
|
+
bleft,
|
|
1365
|
+
(bleft[0] + 0.1 * fold, bleft[1] - 0.3 * fold),
|
|
1366
|
+
(corner[0] - 0.1 * fold, corner[1] + 0.3 * fold),
|
|
1367
|
+
corner,
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
draw.line(spline1, fill=border_color, width=bw, joint="curve")
|
|
1371
|
+
draw.line(spline2, fill=border_color, width=bw, joint="curve")
|
|
1372
|
+
|
|
1373
|
+
mask_draw.polygon(
|
|
1374
|
+
[
|
|
1375
|
+
(sx(x1 - fold), sy(y0)),
|
|
1376
|
+
(sx(x1 + 1), sy(y0)),
|
|
1377
|
+
(sx(x1 + 1), sy(y0 + fold + 1)),
|
|
1378
|
+
],
|
|
1379
|
+
fill=0,
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
composited = Image.alpha_composite(base, overlay.resize(base.size, Image.LANCZOS))
|
|
1383
|
+
if dogear_enabled:
|
|
1384
|
+
mask_small = mask.resize(base.size, Image.LANCZOS)
|
|
1385
|
+
composited.putalpha(mask_small)
|
|
1386
|
+
return composited
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def ensure_snippet_assets(
|
|
1390
|
+
block: SnippetBlock,
|
|
1391
|
+
*,
|
|
1392
|
+
output_dir: Path,
|
|
1393
|
+
source_path: Path | str | None = None,
|
|
1394
|
+
emitter: DiagnosticEmitter | None = None,
|
|
1395
|
+
) -> _SnippetAssets:
|
|
1396
|
+
"""Render snippet assets into the provided directory when missing."""
|
|
1397
|
+
destination = Path(output_dir).resolve()
|
|
1398
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
1399
|
+
pdf_path = destination / asset_filename(block.digest, ".pdf")
|
|
1400
|
+
png_path = destination / asset_filename(block.digest, ".png")
|
|
1401
|
+
|
|
1402
|
+
host_path = Path(source_path) if source_path is not None else destination / "snippet.md"
|
|
1403
|
+
host_dir = _resolve_base_dir(block, host_path)
|
|
1404
|
+
host_name = host_path.stem or "snippet"
|
|
1405
|
+
|
|
1406
|
+
documents: list[Document] = []
|
|
1407
|
+
inline_document = _build_document(block, host_dir=host_dir, host_name=host_name)
|
|
1408
|
+
if inline_document is not None:
|
|
1409
|
+
documents.append(inline_document)
|
|
1410
|
+
|
|
1411
|
+
bibliography_files = list(block.bibliography_files)
|
|
1412
|
+
document_sources: list[Path] = []
|
|
1413
|
+
for path in block.sources:
|
|
1414
|
+
suffix = path.suffix.lower()
|
|
1415
|
+
if suffix in {".bib", ".bibtex", ".ris"}:
|
|
1416
|
+
if path not in bibliography_files:
|
|
1417
|
+
bibliography_files.append(path)
|
|
1418
|
+
continue
|
|
1419
|
+
document_sources.append(path)
|
|
1420
|
+
|
|
1421
|
+
if document_sources:
|
|
1422
|
+
documents.extend(
|
|
1423
|
+
_build_documents_from_sources(
|
|
1424
|
+
document_sources,
|
|
1425
|
+
promote_title=block.promote_title,
|
|
1426
|
+
drop_title=block.drop_title,
|
|
1427
|
+
suppress_title=block.suppress_title_metadata,
|
|
1428
|
+
)
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
if not documents:
|
|
1432
|
+
raise InvalidNodeError("Snippet block is empty; provide inline content or sources.")
|
|
1433
|
+
|
|
1434
|
+
runtime = _resolve_template_runtime(block, documents, host_dir)
|
|
1435
|
+
merged_overrides = _merge_fragment_defaults(block.template_overrides, runtime)
|
|
1436
|
+
dogear_enabled = _frame_dogear_enabled(merged_overrides) or block.preview_dogear
|
|
1437
|
+
preview_fold_px: int | None = None
|
|
1438
|
+
if dogear_enabled:
|
|
1439
|
+
preview_fold_px = _frame_fold_size_px(
|
|
1440
|
+
merged_overrides,
|
|
1441
|
+
(0, 0),
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
caches = _resolve_caches()
|
|
1445
|
+
template_version = None
|
|
1446
|
+
if caches:
|
|
1447
|
+
info = getattr(runtime.instance, "info", None)
|
|
1448
|
+
if info is not None:
|
|
1449
|
+
version = getattr(info, "version", None)
|
|
1450
|
+
template_version = str(version) if version is not None else None
|
|
1451
|
+
|
|
1452
|
+
pdf_missing = not pdf_path.exists()
|
|
1453
|
+
png_missing = not png_path.exists()
|
|
1454
|
+
assets = _SnippetAssets(pdf=pdf_path, png=png_path)
|
|
1455
|
+
|
|
1456
|
+
def _flush_caches() -> None:
|
|
1457
|
+
for cache in caches:
|
|
1458
|
+
cache.flush()
|
|
1459
|
+
|
|
1460
|
+
def _store_in_caches() -> None:
|
|
1461
|
+
if not caches:
|
|
1462
|
+
return
|
|
1463
|
+
for cache in caches:
|
|
1464
|
+
cache.store(
|
|
1465
|
+
block.digest,
|
|
1466
|
+
pdf_path,
|
|
1467
|
+
png_path,
|
|
1468
|
+
template_version=template_version,
|
|
1469
|
+
block=block,
|
|
1470
|
+
source_path=Path(source_path) if source_path is not None else None,
|
|
1471
|
+
)
|
|
1472
|
+
_flush_caches()
|
|
1473
|
+
|
|
1474
|
+
if not pdf_missing and not png_missing:
|
|
1475
|
+
_store_in_caches()
|
|
1476
|
+
return assets
|
|
1477
|
+
|
|
1478
|
+
if caches and (pdf_missing or png_missing):
|
|
1479
|
+
for cache in caches:
|
|
1480
|
+
cached_assets = cache.lookup(block.digest, template_version=template_version)
|
|
1481
|
+
if cached_assets is None:
|
|
1482
|
+
continue
|
|
1483
|
+
try:
|
|
1484
|
+
if pdf_missing:
|
|
1485
|
+
shutil.copy2(cached_assets.pdf, pdf_path)
|
|
1486
|
+
pdf_missing = False
|
|
1487
|
+
if png_missing:
|
|
1488
|
+
shutil.copy2(cached_assets.png, png_path)
|
|
1489
|
+
png_missing = False
|
|
1490
|
+
except OSError:
|
|
1491
|
+
cache.discard(block.digest)
|
|
1492
|
+
continue
|
|
1493
|
+
if not pdf_missing and not png_missing:
|
|
1494
|
+
_store_in_caches()
|
|
1495
|
+
return assets
|
|
1496
|
+
_flush_caches()
|
|
1497
|
+
|
|
1498
|
+
if not pdf_missing and png_missing:
|
|
1499
|
+
_pdf_to_png_grid(
|
|
1500
|
+
pdf_path,
|
|
1501
|
+
png_path,
|
|
1502
|
+
layout=block.layout,
|
|
1503
|
+
transparent_corner=dogear_enabled,
|
|
1504
|
+
spacing=None if block.layout else 0,
|
|
1505
|
+
fold_size=preview_fold_px,
|
|
1506
|
+
)
|
|
1507
|
+
png_missing = False
|
|
1508
|
+
_store_in_caches()
|
|
1509
|
+
return assets
|
|
1510
|
+
|
|
1511
|
+
_announce_build(block, source_path, emitter)
|
|
1512
|
+
|
|
1513
|
+
settings = RenderSettings(
|
|
1514
|
+
copy_assets=True,
|
|
1515
|
+
convert_assets=False,
|
|
1516
|
+
hash_assets=False,
|
|
1517
|
+
manifest=False,
|
|
1518
|
+
)
|
|
1519
|
+
session = TemplateSession(runtime=runtime, settings=settings, emitter=emitter)
|
|
1520
|
+
for document in documents:
|
|
1521
|
+
session.add_document(document)
|
|
1522
|
+
if bibliography_files:
|
|
1523
|
+
session.add_bibliography(*bibliography_files)
|
|
1524
|
+
if merged_overrides:
|
|
1525
|
+
session.update_options(merged_overrides)
|
|
1526
|
+
|
|
1527
|
+
work_dir = destination / f".build-{block.digest}"
|
|
1528
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
1529
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
1530
|
+
|
|
1531
|
+
debug_dir: Path | None = None
|
|
1532
|
+
try:
|
|
1533
|
+
render_result = session.render(work_dir)
|
|
1534
|
+
compiled_pdf = _compile_pdf(render_result)
|
|
1535
|
+
shutil.copy2(compiled_pdf, pdf_path)
|
|
1536
|
+
except Exception as exc:
|
|
1537
|
+
# Preserve the work directory for post-mortem inspection when compilation fails.
|
|
1538
|
+
try:
|
|
1539
|
+
cache_root = _resolve_cache_root() or Path(tempfile.gettempdir()) / "texsmith"
|
|
1540
|
+
debug_dir = (cache_root / "snippet-fail" / block.digest).resolve()
|
|
1541
|
+
if debug_dir.exists():
|
|
1542
|
+
shutil.rmtree(debug_dir, ignore_errors=True)
|
|
1543
|
+
shutil.copytree(work_dir, debug_dir, dirs_exist_ok=True)
|
|
1544
|
+
except Exception:
|
|
1545
|
+
debug_dir = None
|
|
1546
|
+
if debug_dir is not None:
|
|
1547
|
+
raise exc.__class__(f"{exc} (debug: {debug_dir})") from exc
|
|
1548
|
+
raise
|
|
1549
|
+
else:
|
|
1550
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
1551
|
+
|
|
1552
|
+
total_cells = 1
|
|
1553
|
+
if block.layout:
|
|
1554
|
+
cols, rows = block.layout
|
|
1555
|
+
total_cells = max(cols, 1) * max(rows, 1)
|
|
1556
|
+
_pdf_to_png_grid(
|
|
1557
|
+
pdf_path,
|
|
1558
|
+
png_path,
|
|
1559
|
+
layout=block.layout,
|
|
1560
|
+
transparent_corner=dogear_enabled,
|
|
1561
|
+
spacing=None if total_cells > 1 else 0,
|
|
1562
|
+
decorate_page=None,
|
|
1563
|
+
fold_size=preview_fold_px,
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
_store_in_caches()
|
|
1567
|
+
return assets
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _render_snippet_assets(block: SnippetBlock, context: RenderContext) -> _SnippetAssets:
|
|
1571
|
+
emitter = _resolve_emitter(context)
|
|
1572
|
+
document_path = context.runtime.get("document_path")
|
|
1573
|
+
source_dir = context.runtime.get("source_dir")
|
|
1574
|
+
host_path = _resolve_host_path(document_path, source_dir)
|
|
1575
|
+
return ensure_snippet_assets(
|
|
1576
|
+
block,
|
|
1577
|
+
output_dir=context.assets.output_root / SNIPPET_DIR,
|
|
1578
|
+
source_path=host_path or (context.assets.output_root / "snippet.md"),
|
|
1579
|
+
emitter=emitter,
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _render_figure(
|
|
1584
|
+
context: RenderContext, assets: _SnippetAssets, block: SnippetBlock
|
|
1585
|
+
) -> NavigableString:
|
|
1586
|
+
template_name = context.runtime.get("figure_template", "figure")
|
|
1587
|
+
formatter = getattr(context.formatter, template_name)
|
|
1588
|
+
# Prefer PDF for LaTeX; PNGs are optional previews and may be missing.
|
|
1589
|
+
figure_source = assets.pdf
|
|
1590
|
+
latex_path = context.assets.latex_path(figure_source)
|
|
1591
|
+
latex = formatter(
|
|
1592
|
+
path=latex_path,
|
|
1593
|
+
caption=block.caption,
|
|
1594
|
+
shortcaption=block.caption,
|
|
1595
|
+
label=block.label,
|
|
1596
|
+
width=block.figure_width,
|
|
1597
|
+
adjustbox=True,
|
|
1598
|
+
)
|
|
1599
|
+
return mark_processed(NavigableString(latex))
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _merge_fragment_defaults(
|
|
1603
|
+
overrides: Mapping[str, Any] | None, runtime: TemplateRuntime | None
|
|
1604
|
+
) -> dict[str, Any]:
|
|
1605
|
+
"""Ensure explicit fragment overrides keep template defaults such as ts-extra."""
|
|
1606
|
+
|
|
1607
|
+
def _as_list(value: Any) -> list[str]:
|
|
1608
|
+
if value is None:
|
|
1609
|
+
return []
|
|
1610
|
+
if isinstance(value, list):
|
|
1611
|
+
return [str(item) for item in value]
|
|
1612
|
+
if isinstance(value, Mapping):
|
|
1613
|
+
return [str(key) for key in value]
|
|
1614
|
+
return [str(value)]
|
|
1615
|
+
|
|
1616
|
+
if not overrides:
|
|
1617
|
+
return {}
|
|
1618
|
+
|
|
1619
|
+
merged: dict[str, Any] = dict(overrides)
|
|
1620
|
+
press_section = merged.get("press") if isinstance(merged.get("press"), Mapping) else None
|
|
1621
|
+
|
|
1622
|
+
provided = merged.get("fragments")
|
|
1623
|
+
if provided is None and isinstance(press_section, Mapping):
|
|
1624
|
+
provided = press_section.get("fragments")
|
|
1625
|
+
|
|
1626
|
+
defaults = _as_list(runtime.extras.get("fragments") if runtime and runtime.extras else [])
|
|
1627
|
+
requested = _as_list(provided)
|
|
1628
|
+
|
|
1629
|
+
if defaults or requested:
|
|
1630
|
+
merged["fragments"] = list(dict.fromkeys([*defaults, *requested]))
|
|
1631
|
+
if isinstance(press_section, Mapping):
|
|
1632
|
+
updated_press = dict(press_section)
|
|
1633
|
+
updated_press.setdefault("fragments", provided)
|
|
1634
|
+
merged["press"] = updated_press
|
|
1635
|
+
|
|
1636
|
+
return merged
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
@renders(
|
|
1640
|
+
"div",
|
|
1641
|
+
"pre",
|
|
1642
|
+
phase=RenderPhase.PRE,
|
|
1643
|
+
priority=32,
|
|
1644
|
+
name="snippet_blocks",
|
|
1645
|
+
nestable=False,
|
|
1646
|
+
)
|
|
1647
|
+
def render_snippet_block(element: Tag, context: RenderContext) -> None:
|
|
1648
|
+
"""Convert `.snippet` code fences into rendered figures."""
|
|
1649
|
+
document_path = context.runtime.get("document_path")
|
|
1650
|
+
source_dir = context.runtime.get("source_dir")
|
|
1651
|
+
host_path = _resolve_host_path(document_path, source_dir)
|
|
1652
|
+
block = _extract_snippet_block(element, host_path=host_path)
|
|
1653
|
+
if block is None:
|
|
1654
|
+
return
|
|
1655
|
+
|
|
1656
|
+
assets = _render_snippet_assets(block, context)
|
|
1657
|
+
asset_key = f"snippet::{block.digest}"
|
|
1658
|
+
context.assets.register(asset_key, assets.pdf)
|
|
1659
|
+
|
|
1660
|
+
node = _render_figure(context, assets, block)
|
|
1661
|
+
context.suppress_children(element)
|
|
1662
|
+
element.replace_with(node)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def rewrite_html_snippets(
|
|
1666
|
+
html: str,
|
|
1667
|
+
resolver: Callable[[SnippetBlock], tuple[str, str]],
|
|
1668
|
+
*,
|
|
1669
|
+
source_path: Path | str | None = None,
|
|
1670
|
+
) -> str:
|
|
1671
|
+
"""Replace snippet fences in an HTML fragment with linked previews."""
|
|
1672
|
+
if "snippet" not in html:
|
|
1673
|
+
return html
|
|
1674
|
+
host_path = Path(source_path) if source_path is not None else None
|
|
1675
|
+
|
|
1676
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
1677
|
+
mutated = False
|
|
1678
|
+
for element in soup.find_all(["div", "pre"]):
|
|
1679
|
+
block = _extract_snippet_block(element, host_path=host_path)
|
|
1680
|
+
if block is None:
|
|
1681
|
+
continue
|
|
1682
|
+
pdf_url, png_url = resolver(block)
|
|
1683
|
+
anchor = soup.new_tag(
|
|
1684
|
+
"a",
|
|
1685
|
+
href=pdf_url,
|
|
1686
|
+
target="_blank",
|
|
1687
|
+
rel="noopener noreferrer",
|
|
1688
|
+
)
|
|
1689
|
+
image_attrs = {"src": png_url, "alt": block.caption or "Snippet", "class": ["ts-snippet"]}
|
|
1690
|
+
if block.figure_width:
|
|
1691
|
+
image_attrs["width"] = block.figure_width
|
|
1692
|
+
image = soup.new_tag("img", **image_attrs)
|
|
1693
|
+
anchor.append(image)
|
|
1694
|
+
element.replace_with(anchor)
|
|
1695
|
+
mutated = True
|
|
1696
|
+
|
|
1697
|
+
return str(soup) if mutated else html
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def register(renderer: Any) -> None:
|
|
1701
|
+
"""Register the snippet handler on a renderer instance."""
|
|
1702
|
+
renderer.register(render_snippet_block)
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _announce_build(
|
|
1706
|
+
block: SnippetBlock, host_path: Path | str | None, emitter: DiagnosticEmitter | None
|
|
1707
|
+
) -> None:
|
|
1708
|
+
"""Emit an informational message when a snippet is actually compiled."""
|
|
1709
|
+
source_hint = f" from {host_path}" if host_path else ""
|
|
1710
|
+
short_digest = block.digest[:10] + "..." if block.digest else block.asset_basename
|
|
1711
|
+
_log.info("texsmith: building snippet %s%s", short_digest, source_hint)
|
|
1712
|
+
if emitter is not None:
|
|
1713
|
+
try:
|
|
1714
|
+
emitter.event(
|
|
1715
|
+
"snippet_build",
|
|
1716
|
+
{
|
|
1717
|
+
"digest": block.digest,
|
|
1718
|
+
"source": str(host_path) if host_path else "",
|
|
1719
|
+
"destination": block.asset_basename,
|
|
1720
|
+
},
|
|
1721
|
+
)
|
|
1722
|
+
except Exception:
|
|
1723
|
+
_log.debug("failed to emit snippet_build event", exc_info=True)
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
__all__ = [
|
|
1727
|
+
"SNIPPET_DIR",
|
|
1728
|
+
"SnippetBlock",
|
|
1729
|
+
"asset_filename",
|
|
1730
|
+
"ensure_snippet_assets",
|
|
1731
|
+
"register",
|
|
1732
|
+
"render_snippet_block",
|
|
1733
|
+
"rewrite_html_snippets",
|
|
1734
|
+
]
|