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/service.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Conversion orchestration utilities for CLI and embedding integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
import copy
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from texsmith.adapters.latex.engines import (
|
|
14
|
+
EngineResult,
|
|
15
|
+
build_engine_command,
|
|
16
|
+
build_tex_env,
|
|
17
|
+
compute_features,
|
|
18
|
+
ensure_command_paths,
|
|
19
|
+
missing_dependencies,
|
|
20
|
+
resolve_engine,
|
|
21
|
+
run_engine_command,
|
|
22
|
+
)
|
|
23
|
+
from texsmith.adapters.latex.pyxindy import is_available as pyxindy_available
|
|
24
|
+
from texsmith.adapters.latex.tectonic import (
|
|
25
|
+
BiberAcquisitionError,
|
|
26
|
+
MakeglossariesAcquisitionError,
|
|
27
|
+
TectonicAcquisitionError,
|
|
28
|
+
select_biber_binary,
|
|
29
|
+
select_makeglossaries,
|
|
30
|
+
select_tectonic_binary,
|
|
31
|
+
)
|
|
32
|
+
from texsmith.adapters.markdown import split_front_matter
|
|
33
|
+
from texsmith.core.conversion.debug import ConversionError, ensure_emitter
|
|
34
|
+
from texsmith.core.conversion.inputs import (
|
|
35
|
+
InputKind,
|
|
36
|
+
UnsupportedInputError,
|
|
37
|
+
extract_front_matter_slots,
|
|
38
|
+
)
|
|
39
|
+
from texsmith.core.diagnostics import DiagnosticEmitter
|
|
40
|
+
from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
|
|
41
|
+
|
|
42
|
+
from .document import Document, DocumentSlots, TitleStrategy, front_matter_has_title
|
|
43
|
+
from .pipeline import ConversionBundle, RenderSettings, convert_documents
|
|
44
|
+
from .templates import TemplateRenderResult, TemplateSession, get_template
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"ConversionRequest",
|
|
49
|
+
"ConversionResponse",
|
|
50
|
+
"ConversionService",
|
|
51
|
+
"SlotAssignment",
|
|
52
|
+
"SplitInputsResult",
|
|
53
|
+
"classify_input_source",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class SlotAssignment:
|
|
59
|
+
"""Directive mapping a document onto a template slot."""
|
|
60
|
+
|
|
61
|
+
slot: str
|
|
62
|
+
selector: str | None
|
|
63
|
+
include_document: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class SplitInputsResult:
|
|
68
|
+
"""Structured partitioning of input paths."""
|
|
69
|
+
|
|
70
|
+
documents: list[Path]
|
|
71
|
+
bibliography_files: list[Path]
|
|
72
|
+
front_matter: Mapping[str, Any] | None = None
|
|
73
|
+
front_matter_path: Path | None = None
|
|
74
|
+
|
|
75
|
+
def __iter__(self) -> Iterable[object]: # pragma: no cover - convenience iterator
|
|
76
|
+
yield self.documents
|
|
77
|
+
yield self.bibliography_files
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class ConversionRequest:
|
|
82
|
+
"""Immutable description of a conversion run."""
|
|
83
|
+
|
|
84
|
+
documents: Sequence[Path]
|
|
85
|
+
bibliography_files: Sequence[Path] = field(default_factory=list)
|
|
86
|
+
embed_fragments: bool = False
|
|
87
|
+
front_matter: Mapping[str, Any] | None = None
|
|
88
|
+
front_matter_path: Path | None = None
|
|
89
|
+
slot_assignments: Mapping[Path, Sequence[SlotAssignment]] = field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
selector: str = "article.md-content__inner"
|
|
92
|
+
full_document: bool = False
|
|
93
|
+
base_level: int = 0
|
|
94
|
+
strip_heading_all: bool = False
|
|
95
|
+
strip_heading_first_document: bool = False
|
|
96
|
+
promote_title: bool = True
|
|
97
|
+
suppress_title: bool = False
|
|
98
|
+
numbered: bool = True
|
|
99
|
+
markdown_extensions: Sequence[str] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
parser: str | None = None
|
|
102
|
+
disable_fallback_converters: bool = False
|
|
103
|
+
copy_assets: bool = True
|
|
104
|
+
convert_assets: bool = False
|
|
105
|
+
hash_assets: bool = False
|
|
106
|
+
manifest: bool = False
|
|
107
|
+
persist_debug_html: bool = False
|
|
108
|
+
language: str | None = None
|
|
109
|
+
legacy_latex_accents: bool = False
|
|
110
|
+
diagrams_backend: str | None = None
|
|
111
|
+
|
|
112
|
+
template: str | None = None
|
|
113
|
+
render_dir: Path | None = None
|
|
114
|
+
template_options: Mapping[str, Any] = field(default_factory=dict)
|
|
115
|
+
enable_fragments: Sequence[str] = field(default_factory=tuple)
|
|
116
|
+
disable_fragments: Sequence[str] = field(default_factory=tuple)
|
|
117
|
+
|
|
118
|
+
emitter: DiagnosticEmitter | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True)
|
|
122
|
+
class ConversionResponse:
|
|
123
|
+
"""Captured outcome of :class:`ConversionService` execution."""
|
|
124
|
+
|
|
125
|
+
request: ConversionRequest
|
|
126
|
+
documents: list[Document]
|
|
127
|
+
bibliography_files: list[Path]
|
|
128
|
+
result: ConversionBundle | TemplateRenderResult
|
|
129
|
+
emitter: DiagnosticEmitter | None = None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_template(self) -> bool:
|
|
133
|
+
"""Return True when the response contains template output rather than raw fragments."""
|
|
134
|
+
return isinstance(self.result, TemplateRenderResult)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def bundle(self) -> ConversionBundle:
|
|
138
|
+
"""Expose the conversion bundle while guarding against template misuse."""
|
|
139
|
+
if isinstance(self.result, ConversionBundle):
|
|
140
|
+
return self.result
|
|
141
|
+
raise TypeError("ConversionResponse does not contain a ConversionBundle.")
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def render_result(self) -> TemplateRenderResult:
|
|
145
|
+
"""Expose the template render result while guarding against bundle misuse."""
|
|
146
|
+
if isinstance(self.result, TemplateRenderResult):
|
|
147
|
+
return self.result
|
|
148
|
+
raise TypeError("ConversionResponse does not contain a TemplateRenderResult.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(slots=True)
|
|
152
|
+
class _PreparedBatch:
|
|
153
|
+
documents: list[Document]
|
|
154
|
+
document_map: dict[Path, Document]
|
|
155
|
+
emitter: DiagnosticEmitter
|
|
156
|
+
bibliography_files: list[Path]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ConversionService:
|
|
160
|
+
"""High-level façade that encapsulates document preparation and execution."""
|
|
161
|
+
|
|
162
|
+
def split_inputs(
|
|
163
|
+
self,
|
|
164
|
+
inputs: Iterable[Path],
|
|
165
|
+
extra_bibliography: Iterable[Path] = (),
|
|
166
|
+
) -> SplitInputsResult:
|
|
167
|
+
"""Separate document inputs, bibliography files, and optional front matter to keep downstream parsing deterministic."""
|
|
168
|
+
inline_bibliography: list[Path] = []
|
|
169
|
+
documents: list[Path] = []
|
|
170
|
+
front_matter: Mapping[str, Any] | None = None
|
|
171
|
+
front_matter_path: Path | None = None
|
|
172
|
+
|
|
173
|
+
for candidate in inputs:
|
|
174
|
+
suffix = candidate.suffix.lower()
|
|
175
|
+
if suffix in {".bib", ".bibtex"}:
|
|
176
|
+
inline_bibliography.append(candidate)
|
|
177
|
+
continue
|
|
178
|
+
if front_matter is None:
|
|
179
|
+
loaded_front_matter = _load_front_matter_file(candidate)
|
|
180
|
+
if loaded_front_matter is not _NOT_FRONT_MATTER:
|
|
181
|
+
front_matter = loaded_front_matter
|
|
182
|
+
front_matter_path = candidate
|
|
183
|
+
continue
|
|
184
|
+
documents.append(candidate)
|
|
185
|
+
|
|
186
|
+
bibliography_paths = _deduplicate_paths([*inline_bibliography, *extra_bibliography])
|
|
187
|
+
if not documents and front_matter_path is not None:
|
|
188
|
+
# Treat a lone front-matter file as an input document so templates can operate on
|
|
189
|
+
# YAML sources without requiring additional Markdown/HTML content.
|
|
190
|
+
documents.append(front_matter_path)
|
|
191
|
+
front_matter = None
|
|
192
|
+
front_matter_path = None
|
|
193
|
+
|
|
194
|
+
return SplitInputsResult(
|
|
195
|
+
documents=documents,
|
|
196
|
+
bibliography_files=bibliography_paths,
|
|
197
|
+
front_matter=front_matter,
|
|
198
|
+
front_matter_path=front_matter_path,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def prepare_documents(self, request: ConversionRequest) -> _PreparedBatch:
|
|
202
|
+
"""Normalise input sources into :class:`Document` instances so conversion steps operate on consistent objects."""
|
|
203
|
+
emitter = ensure_emitter(request.emitter)
|
|
204
|
+
documents: list[Document] = []
|
|
205
|
+
mapping: dict[Path, Document] = {}
|
|
206
|
+
shared_front_matter = _normalise_front_matter(request.front_matter)
|
|
207
|
+
|
|
208
|
+
for index, path in enumerate(request.documents):
|
|
209
|
+
input_kind = classify_input_source(path)
|
|
210
|
+
extract_title = request.promote_title and index == 0 and not request.suppress_title
|
|
211
|
+
effective_strip = request.strip_heading_all or (
|
|
212
|
+
request.strip_heading_first_document and index == 0
|
|
213
|
+
)
|
|
214
|
+
strategy: TitleStrategy | None = None
|
|
215
|
+
if effective_strip:
|
|
216
|
+
strategy = TitleStrategy.DROP
|
|
217
|
+
extract_title = False
|
|
218
|
+
elif not extract_title:
|
|
219
|
+
strategy = TitleStrategy.KEEP
|
|
220
|
+
else:
|
|
221
|
+
strategy = None
|
|
222
|
+
|
|
223
|
+
if input_kind is InputKind.MARKDOWN:
|
|
224
|
+
document = Document.from_markdown(
|
|
225
|
+
path,
|
|
226
|
+
extensions=list(request.markdown_extensions),
|
|
227
|
+
base_level=request.base_level,
|
|
228
|
+
promote_title=extract_title,
|
|
229
|
+
strip_heading=effective_strip,
|
|
230
|
+
suppress_title=request.suppress_title,
|
|
231
|
+
title_strategy=strategy,
|
|
232
|
+
numbered=request.numbered,
|
|
233
|
+
emitter=emitter,
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
document = Document.from_html(
|
|
237
|
+
path,
|
|
238
|
+
selector=request.selector,
|
|
239
|
+
base_level=request.base_level,
|
|
240
|
+
promote_title=extract_title,
|
|
241
|
+
strip_heading=effective_strip,
|
|
242
|
+
suppress_title=request.suppress_title,
|
|
243
|
+
title_strategy=strategy,
|
|
244
|
+
numbered=request.numbered,
|
|
245
|
+
full_document=request.full_document,
|
|
246
|
+
emitter=emitter,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
documents.append(document)
|
|
250
|
+
mapping[path] = document
|
|
251
|
+
|
|
252
|
+
press_sources = _collect_press_sources(documents, shared_front_matter, request)
|
|
253
|
+
if len(press_sources) > 1:
|
|
254
|
+
raise ConversionError(
|
|
255
|
+
"Multiple sources declare press metadata; only one is allowed for a conversion: "
|
|
256
|
+
+ ", ".join(press_sources)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
_apply_shared_front_matter(documents, shared_front_matter)
|
|
260
|
+
|
|
261
|
+
for path, directives in request.slot_assignments.items():
|
|
262
|
+
document = mapping.get(path)
|
|
263
|
+
if document is None or not directives:
|
|
264
|
+
continue
|
|
265
|
+
for directive in directives:
|
|
266
|
+
document.assign_slot(
|
|
267
|
+
directive.slot,
|
|
268
|
+
selector=directive.selector,
|
|
269
|
+
include_document=directive.include_document,
|
|
270
|
+
)
|
|
271
|
+
bibliography = _deduplicate_paths(request.bibliography_files)
|
|
272
|
+
return _PreparedBatch(
|
|
273
|
+
documents=documents,
|
|
274
|
+
document_map=mapping,
|
|
275
|
+
emitter=emitter,
|
|
276
|
+
bibliography_files=bibliography,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def execute(
|
|
280
|
+
self,
|
|
281
|
+
request: ConversionRequest,
|
|
282
|
+
*,
|
|
283
|
+
prepared: _PreparedBatch | None = None,
|
|
284
|
+
) -> ConversionResponse:
|
|
285
|
+
"""Execute a conversion workflow and return a structured response, routing to template or raw conversion paths as needed."""
|
|
286
|
+
batch = prepared or self.prepare_documents(request)
|
|
287
|
+
settings = self._build_render_settings(request)
|
|
288
|
+
emitter = batch.emitter
|
|
289
|
+
|
|
290
|
+
if request.template is None:
|
|
291
|
+
bundle = convert_documents(
|
|
292
|
+
batch.documents,
|
|
293
|
+
output_dir=request.render_dir,
|
|
294
|
+
settings=settings,
|
|
295
|
+
emitter=emitter,
|
|
296
|
+
bibliography_files=batch.bibliography_files,
|
|
297
|
+
)
|
|
298
|
+
return ConversionResponse(
|
|
299
|
+
request=request,
|
|
300
|
+
documents=batch.documents,
|
|
301
|
+
bibliography_files=batch.bibliography_files,
|
|
302
|
+
result=bundle,
|
|
303
|
+
emitter=emitter,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
session = self._initialise_template_session(
|
|
307
|
+
request.template,
|
|
308
|
+
settings=settings,
|
|
309
|
+
emitter=emitter,
|
|
310
|
+
)
|
|
311
|
+
overrides: dict[str, Any] = {}
|
|
312
|
+
if request.template_options:
|
|
313
|
+
overrides.update(_normalise_template_options(request.template_options))
|
|
314
|
+
fragments_override = _resolve_fragment_overrides(
|
|
315
|
+
session,
|
|
316
|
+
overrides.get("fragments"),
|
|
317
|
+
request.enable_fragments,
|
|
318
|
+
request.disable_fragments,
|
|
319
|
+
)
|
|
320
|
+
if fragments_override is not None:
|
|
321
|
+
overrides["fragments"] = fragments_override
|
|
322
|
+
if overrides:
|
|
323
|
+
session.update_options(overrides)
|
|
324
|
+
if batch.bibliography_files:
|
|
325
|
+
session.add_bibliography(*batch.bibliography_files)
|
|
326
|
+
for document in batch.documents:
|
|
327
|
+
session.add_document(document)
|
|
328
|
+
|
|
329
|
+
target_dir = (request.render_dir or Path("build")).resolve()
|
|
330
|
+
render_result = session.render(target_dir, embed_fragments=request.embed_fragments)
|
|
331
|
+
return ConversionResponse(
|
|
332
|
+
request=request,
|
|
333
|
+
documents=batch.documents,
|
|
334
|
+
bibliography_files=batch.bibliography_files,
|
|
335
|
+
result=render_result,
|
|
336
|
+
emitter=emitter,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def build_pdf(
|
|
340
|
+
self,
|
|
341
|
+
render_result: TemplateRenderResult,
|
|
342
|
+
*,
|
|
343
|
+
engine: str | None = "tectonic",
|
|
344
|
+
classic_output: bool = False,
|
|
345
|
+
isolate_cache: bool = False,
|
|
346
|
+
env: Mapping[str, str] | None = None,
|
|
347
|
+
console: Any | None = None,
|
|
348
|
+
verbosity: int = 0,
|
|
349
|
+
use_system_tectonic: bool = False,
|
|
350
|
+
) -> EngineResult:
|
|
351
|
+
"""Compile a rendered template into a PDF using the requested engine, selecting dependencies on demand."""
|
|
352
|
+
template_context = getattr(render_result, "template_context", None) or getattr(
|
|
353
|
+
render_result, "context", None
|
|
354
|
+
)
|
|
355
|
+
features = compute_features(
|
|
356
|
+
requires_shell_escape=render_result.requires_shell_escape,
|
|
357
|
+
bibliography=render_result.has_bibliography,
|
|
358
|
+
document_state=render_result.document_state,
|
|
359
|
+
template_context=template_context,
|
|
360
|
+
)
|
|
361
|
+
choice = resolve_engine(engine, render_result.template_engine)
|
|
362
|
+
tectonic_binary: Path | None = None
|
|
363
|
+
biber_binary: Path | None = None
|
|
364
|
+
makeglossaries_binary: Path | None = None
|
|
365
|
+
bundled_bin: Path | None = None
|
|
366
|
+
if choice.backend == "tectonic":
|
|
367
|
+
try:
|
|
368
|
+
selection = select_tectonic_binary(use_system_tectonic, console=console)
|
|
369
|
+
if features.bibliography and not use_system_tectonic:
|
|
370
|
+
biber_binary = select_biber_binary(console=console)
|
|
371
|
+
bundled_bin = biber_binary.parent
|
|
372
|
+
if features.has_glossary and not pyxindy_available():
|
|
373
|
+
glossaries = select_makeglossaries(console=console)
|
|
374
|
+
makeglossaries_binary = glossaries.path
|
|
375
|
+
if glossaries.source == "bundled":
|
|
376
|
+
bundled_bin = bundled_bin or glossaries.path.parent
|
|
377
|
+
except (
|
|
378
|
+
TectonicAcquisitionError,
|
|
379
|
+
BiberAcquisitionError,
|
|
380
|
+
MakeglossariesAcquisitionError,
|
|
381
|
+
) as exc:
|
|
382
|
+
raise ConversionError(str(exc)) from exc
|
|
383
|
+
tectonic_binary = selection.path
|
|
384
|
+
|
|
385
|
+
available_bins: dict[str, Path] = {}
|
|
386
|
+
if biber_binary:
|
|
387
|
+
available_bins["biber"] = biber_binary
|
|
388
|
+
if makeglossaries_binary:
|
|
389
|
+
available_bins["makeglossaries"] = makeglossaries_binary
|
|
390
|
+
|
|
391
|
+
missing = missing_dependencies(
|
|
392
|
+
choice,
|
|
393
|
+
features,
|
|
394
|
+
use_system_tectonic=use_system_tectonic,
|
|
395
|
+
available_binaries=available_bins or None,
|
|
396
|
+
)
|
|
397
|
+
if missing:
|
|
398
|
+
formatted = ", ".join(sorted(missing))
|
|
399
|
+
raise ConversionError(f"Missing required LaTeX tools for '{choice.label}': {formatted}")
|
|
400
|
+
|
|
401
|
+
command_plan = ensure_command_paths(
|
|
402
|
+
build_engine_command(
|
|
403
|
+
choice,
|
|
404
|
+
features,
|
|
405
|
+
main_tex_path=render_result.main_tex_path,
|
|
406
|
+
tectonic_binary=tectonic_binary,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
base_env = build_tex_env(
|
|
410
|
+
render_result.main_tex_path.parent,
|
|
411
|
+
isolate_cache=isolate_cache,
|
|
412
|
+
extra_path=bundled_bin,
|
|
413
|
+
biber_path=biber_binary,
|
|
414
|
+
)
|
|
415
|
+
merged_env = dict(base_env)
|
|
416
|
+
if env:
|
|
417
|
+
merged_env.update(env)
|
|
418
|
+
|
|
419
|
+
return run_engine_command(
|
|
420
|
+
command_plan,
|
|
421
|
+
backend=choice.backend,
|
|
422
|
+
workdir=render_result.main_tex_path.parent,
|
|
423
|
+
env=merged_env,
|
|
424
|
+
console=console,
|
|
425
|
+
verbosity=verbosity,
|
|
426
|
+
classic_output=classic_output,
|
|
427
|
+
features=features,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _build_render_settings(request: ConversionRequest) -> RenderSettings:
|
|
432
|
+
return RenderSettings(
|
|
433
|
+
parser=request.parser,
|
|
434
|
+
disable_fallback_converters=request.disable_fallback_converters,
|
|
435
|
+
copy_assets=request.copy_assets,
|
|
436
|
+
convert_assets=request.convert_assets,
|
|
437
|
+
hash_assets=request.hash_assets,
|
|
438
|
+
manifest=request.manifest,
|
|
439
|
+
persist_debug_html=request.persist_debug_html,
|
|
440
|
+
language=request.language,
|
|
441
|
+
legacy_latex_accents=request.legacy_latex_accents,
|
|
442
|
+
diagrams_backend=request.diagrams_backend,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _initialise_template_session(
|
|
447
|
+
template: str,
|
|
448
|
+
*,
|
|
449
|
+
settings: RenderSettings,
|
|
450
|
+
emitter: DiagnosticEmitter,
|
|
451
|
+
) -> TemplateSession:
|
|
452
|
+
return get_template(
|
|
453
|
+
template,
|
|
454
|
+
settings=settings,
|
|
455
|
+
emitter=emitter,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
_NOT_FRONT_MATTER = object()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _load_front_matter_file(path: Path) -> Mapping[str, Any] | object:
|
|
463
|
+
"""Return parsed front matter when the path looks like a metadata file."""
|
|
464
|
+
suffix = path.suffix.lower()
|
|
465
|
+
if suffix not in {".yml", ".yaml"}:
|
|
466
|
+
return _NOT_FRONT_MATTER
|
|
467
|
+
name_lower = path.name.lower()
|
|
468
|
+
if name_lower in {"mkdocs.yml", "mkdocs.yaml"}:
|
|
469
|
+
return _NOT_FRONT_MATTER
|
|
470
|
+
try:
|
|
471
|
+
payload = path.read_text(encoding="utf-8")
|
|
472
|
+
except OSError as exc:
|
|
473
|
+
raise ConversionError(f"Failed to read front matter source '{path}': {exc}") from exc
|
|
474
|
+
|
|
475
|
+
if not payload.strip():
|
|
476
|
+
return {}
|
|
477
|
+
|
|
478
|
+
if payload.lstrip().startswith("---"):
|
|
479
|
+
metadata, body = split_front_matter(payload)
|
|
480
|
+
if body.strip():
|
|
481
|
+
return _NOT_FRONT_MATTER
|
|
482
|
+
return metadata
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
parsed = yaml.safe_load(payload)
|
|
486
|
+
except yaml.YAMLError as exc:
|
|
487
|
+
raise ConversionError(f"Invalid YAML front matter in '{path}': {exc}") from exc
|
|
488
|
+
return dict(parsed) if isinstance(parsed, Mapping) else _NOT_FRONT_MATTER
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _normalise_front_matter(data: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
|
|
492
|
+
if data is None:
|
|
493
|
+
return None
|
|
494
|
+
if not isinstance(data, Mapping):
|
|
495
|
+
raise ConversionError("Front matter must be a mapping when provided programmatically.")
|
|
496
|
+
return copy.deepcopy(dict(data))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _normalise_template_options(options: Mapping[str, Any]) -> dict[str, Any]:
|
|
500
|
+
payload = copy.deepcopy(dict(options))
|
|
501
|
+
if _requires_press_normalisation(payload):
|
|
502
|
+
try:
|
|
503
|
+
normalise_press_metadata(payload)
|
|
504
|
+
except PressMetadataError as exc:
|
|
505
|
+
raise ConversionError(str(exc)) from exc
|
|
506
|
+
return payload
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _resolve_fragment_overrides(
|
|
510
|
+
runtime: TemplateSession,
|
|
511
|
+
override_fragments: Any,
|
|
512
|
+
enable: Sequence[str],
|
|
513
|
+
disable: Sequence[str],
|
|
514
|
+
) -> list[str] | None:
|
|
515
|
+
"""Compute the final fragment list after applying enable/disable toggles."""
|
|
516
|
+
|
|
517
|
+
def _clean_list(values: Sequence[str] | None) -> list[str]:
|
|
518
|
+
seen: set[str] = set()
|
|
519
|
+
cleaned: list[str] = []
|
|
520
|
+
if not values:
|
|
521
|
+
return cleaned
|
|
522
|
+
for entry in values:
|
|
523
|
+
name = str(entry).strip()
|
|
524
|
+
if not name or name in seen:
|
|
525
|
+
continue
|
|
526
|
+
seen.add(name)
|
|
527
|
+
cleaned.append(name)
|
|
528
|
+
return cleaned
|
|
529
|
+
|
|
530
|
+
base: list[str] = []
|
|
531
|
+
if isinstance(override_fragments, list):
|
|
532
|
+
base = _clean_list(override_fragments)
|
|
533
|
+
elif runtime.runtime and isinstance(getattr(runtime.runtime, "extras", None), Mapping):
|
|
534
|
+
base = _clean_list(runtime.runtime.extras.get("fragments"))
|
|
535
|
+
|
|
536
|
+
enable_list = _clean_list(enable)
|
|
537
|
+
disable_list = _clean_list(disable)
|
|
538
|
+
|
|
539
|
+
if not base and not enable_list and not disable_list:
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
result = [entry for entry in base if entry not in disable_list]
|
|
543
|
+
for entry in enable_list:
|
|
544
|
+
if entry not in result:
|
|
545
|
+
result.append(entry)
|
|
546
|
+
return result
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _requires_press_normalisation(options: Mapping[str, Any]) -> bool:
|
|
550
|
+
"""Return True when template options include press metadata."""
|
|
551
|
+
for key in options:
|
|
552
|
+
if not isinstance(key, str):
|
|
553
|
+
continue
|
|
554
|
+
if key == "press" or key.startswith("press."):
|
|
555
|
+
return True
|
|
556
|
+
if key in {"title", "subtitle", "date", "authors", "author", "language"}:
|
|
557
|
+
return True
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _front_matter_declares_press(metadata: Mapping[str, Any] | None) -> bool:
|
|
562
|
+
if not isinstance(metadata, Mapping):
|
|
563
|
+
return False
|
|
564
|
+
press_section = metadata.get("press")
|
|
565
|
+
if isinstance(press_section, Mapping):
|
|
566
|
+
return True
|
|
567
|
+
return any(isinstance(key, str) and key.startswith("press.") for key in metadata)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _merge_front_matter(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
571
|
+
merged: dict[str, Any] = copy.deepcopy(dict(base))
|
|
572
|
+
for key, value in override.items():
|
|
573
|
+
if key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
|
|
574
|
+
merged[key] = _merge_front_matter(merged[key], value)
|
|
575
|
+
else:
|
|
576
|
+
merged[key] = copy.deepcopy(value)
|
|
577
|
+
return merged
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _apply_shared_front_matter(
|
|
581
|
+
documents: list[Document],
|
|
582
|
+
shared_front_matter: Mapping[str, Any] | None,
|
|
583
|
+
) -> None:
|
|
584
|
+
if not shared_front_matter:
|
|
585
|
+
return
|
|
586
|
+
for document in documents:
|
|
587
|
+
merged = _merge_front_matter(shared_front_matter, document.front_matter)
|
|
588
|
+
normalised = dict(merged)
|
|
589
|
+
try:
|
|
590
|
+
normalise_press_metadata(normalised)
|
|
591
|
+
except PressMetadataError as exc:
|
|
592
|
+
raise ConversionError(str(exc)) from exc
|
|
593
|
+
document.set_front_matter(merged)
|
|
594
|
+
if (
|
|
595
|
+
document.options.title_strategy is TitleStrategy.PROMOTE_METADATA
|
|
596
|
+
and front_matter_has_title(merged)
|
|
597
|
+
):
|
|
598
|
+
document.options.title_strategy = TitleStrategy.KEEP
|
|
599
|
+
document.slots = DocumentSlots()
|
|
600
|
+
base_mapping = extract_front_matter_slots(normalised)
|
|
601
|
+
if base_mapping:
|
|
602
|
+
document.slots.merge(DocumentSlots.from_mapping(base_mapping))
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _describe_front_matter_source(path: Path | None) -> str:
|
|
606
|
+
return str(path) if path is not None else "front matter"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _collect_press_sources(
|
|
610
|
+
documents: list[Document],
|
|
611
|
+
shared_front_matter: Mapping[str, Any] | None,
|
|
612
|
+
request: ConversionRequest,
|
|
613
|
+
) -> list[str]:
|
|
614
|
+
sources: list[str] = []
|
|
615
|
+
if shared_front_matter and _front_matter_declares_press(shared_front_matter):
|
|
616
|
+
sources.append(_describe_front_matter_source(request.front_matter_path))
|
|
617
|
+
for document in documents:
|
|
618
|
+
if _front_matter_declares_press(document.front_matter):
|
|
619
|
+
sources.append(str(document.source_path))
|
|
620
|
+
return sources
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def classify_input_source(path: Path) -> InputKind:
|
|
624
|
+
"""Determine the document kind based on filename suffix, rejecting unsupported types early."""
|
|
625
|
+
suffix = path.suffix.lower()
|
|
626
|
+
if suffix in {".md", ".markdown"}:
|
|
627
|
+
return InputKind.MARKDOWN
|
|
628
|
+
if suffix in {".yaml", ".yml"}:
|
|
629
|
+
if path.name.lower() in {"mkdocs.yml", "mkdocs.yaml"}:
|
|
630
|
+
raise UnsupportedInputError("MkDocs configuration files are not supported.")
|
|
631
|
+
return InputKind.MARKDOWN
|
|
632
|
+
if suffix in {".html", ".htm"}:
|
|
633
|
+
return InputKind.HTML
|
|
634
|
+
raise UnsupportedInputError(
|
|
635
|
+
f"Unsupported input file type '{suffix or '<none>'}'. "
|
|
636
|
+
"Provide a Markdown source (.md) or HTML document (.html)."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _deduplicate_paths(values: Iterable[Path]) -> list[Path]:
|
|
641
|
+
seen: set[Path] = set()
|
|
642
|
+
result: list[Path] = []
|
|
643
|
+
for path in values:
|
|
644
|
+
if path in seen:
|
|
645
|
+
continue
|
|
646
|
+
seen.add(path)
|
|
647
|
+
result.append(path)
|
|
648
|
+
return result
|