typoprint 0.1.0__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.
- typoprint/__init__.py +40 -0
- typoprint/__main__.py +3 -0
- typoprint/_ordering.py +215 -0
- typoprint/cli.py +242 -0
- typoprint/config.py +45 -0
- typoprint/models.py +139 -0
- typoprint/output.py +28 -0
- typoprint/planning.py +99 -0
- typoprint/py.typed +0 -0
- typoprint/rendering.py +358 -0
- typoprint/resources/fonts/source-sans-3/NOTICE.txt +12 -0
- typoprint/resources/fonts/source-sans-3/OFL.txt +93 -0
- typoprint/resources/fonts/source-sans-3/SourceSans3-Bold.otf +0 -0
- typoprint/resources/fonts/source-sans-3/SourceSans3-BoldIt.otf +0 -0
- typoprint/resources/fonts/source-sans-3/SourceSans3-It.otf +0 -0
- typoprint/resources/fonts/source-sans-3/SourceSans3-Regular.otf +0 -0
- typoprint/resources/packages/local/typoprint/0.1.0/diagrams.typ +37 -0
- typoprint/resources/packages/local/typoprint/0.1.0/editorial.typ +156 -0
- typoprint/resources/packages/local/typoprint/0.1.0/foundations.typ +73 -0
- typoprint/resources/packages/local/typoprint/0.1.0/lib.typ +225 -0
- typoprint/resources/packages/local/typoprint/0.1.0/report.typ +130 -0
- typoprint/resources/packages/local/typoprint/0.1.0/typst.toml +7 -0
- typoprint/resources/packages/preview/cmarker/0.1.10/LICENSE +7 -0
- typoprint/resources/packages/preview/cmarker/0.1.10/lib.typ +462 -0
- typoprint/resources/packages/preview/cmarker/0.1.10/plugin.wasm +0 -0
- typoprint/resources/packages/preview/cmarker/0.1.10/typst.toml +11 -0
- typoprint/resources/packages/preview/merman/0.5.0/LICENSE +231 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cose-base-v1/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cose-base-v2/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cytoscape/LICENSE +19 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cytoscape-cose-bilkent/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cytoscape-fcose/LICENSE +19 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/d3-shape/LICENSE +13 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/dagre/LICENSE +19 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/dompurify/LICENSE +202 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/eclipse-elk/LICENSE.md +264 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/elkjs/LICENSE.md +264 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/fmin/LICENSE +27 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/graphlib/LICENSE +19 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/layout-base-v1/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/layout-base-v2/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/mermaid/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/non-layered-tidy-tree-layout/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/rough-rs/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/roughjs/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/sanitize-url/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/unicode-linebreak/LICENSE +201 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/venn-js/LICENSE +22 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/wasm-minimal-protocol/LICENSE +24 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/zenuml-core/LICENSE +21 -0
- typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_NOTICES.md +289 -0
- typoprint/resources/packages/preview/merman/0.5.0/lib.typ +1 -0
- typoprint/resources/packages/preview/merman/0.5.0/merman_typst_plugin.wasm +0 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/capabilities.typ +5 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/context.typ +1 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/errors.typ +33 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/exports.typ +5 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/figure.typ +143 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/image.typ +50 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/options.typ +566 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/plugin.typ +1 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/raw.typ +22 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/render.typ +137 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/source.typ +7 -0
- typoprint/resources/packages/preview/merman/0.5.0/src/units.typ +51 -0
- typoprint/resources/packages/preview/merman/0.5.0/typst.toml +12 -0
- typoprint-0.1.0.dist-info/METADATA +263 -0
- typoprint-0.1.0.dist-info/RECORD +73 -0
- typoprint-0.1.0.dist-info/WHEEL +5 -0
- typoprint-0.1.0.dist-info/entry_points.txt +2 -0
- typoprint-0.1.0.dist-info/licenses/LICENSE +21 -0
- typoprint-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +23 -0
- typoprint-0.1.0.dist-info/top_level.txt +1 -0
typoprint/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typoprint: offline Markdown-to-PDF for Python applications and the CLI."""
|
|
2
|
+
|
|
3
|
+
from typoprint.config import load_settings
|
|
4
|
+
from typoprint.models import (
|
|
5
|
+
Chapter,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
Document,
|
|
8
|
+
PlannedFile,
|
|
9
|
+
PrintPlan,
|
|
10
|
+
RenderError,
|
|
11
|
+
RenderResult,
|
|
12
|
+
RuleError,
|
|
13
|
+
Settings,
|
|
14
|
+
Transform,
|
|
15
|
+
TypoprintError,
|
|
16
|
+
Validator,
|
|
17
|
+
)
|
|
18
|
+
from typoprint.output import write_pdf
|
|
19
|
+
from typoprint.planning import plan_files
|
|
20
|
+
from typoprint.rendering import render, render_files
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Chapter",
|
|
24
|
+
"ConfigurationError",
|
|
25
|
+
"Document",
|
|
26
|
+
"PlannedFile",
|
|
27
|
+
"PrintPlan",
|
|
28
|
+
"RenderError",
|
|
29
|
+
"RenderResult",
|
|
30
|
+
"RuleError",
|
|
31
|
+
"Settings",
|
|
32
|
+
"Transform",
|
|
33
|
+
"TypoprintError",
|
|
34
|
+
"Validator",
|
|
35
|
+
"load_settings",
|
|
36
|
+
"plan_files",
|
|
37
|
+
"render",
|
|
38
|
+
"render_files",
|
|
39
|
+
"write_pdf",
|
|
40
|
+
]
|
typoprint/__main__.py
ADDED
typoprint/_ordering.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Tables of contents: one Markdown file fixing the order and parts of printed Markdown.
|
|
2
|
+
|
|
3
|
+
The order is a pure function of the file's text and the files found under its folder:
|
|
4
|
+
the file comes first, each `##` heading opens a part whose links place pages in reading
|
|
5
|
+
order, a folder link adds the rest of that folder in natural order, and files placed
|
|
6
|
+
nowhere come last.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import posixpath
|
|
12
|
+
import re
|
|
13
|
+
import unicodedata
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
from urllib.parse import unquote
|
|
18
|
+
|
|
19
|
+
from typoprint.models import Language as Language
|
|
20
|
+
from typoprint.models import RenderError as RenderError
|
|
21
|
+
|
|
22
|
+
UNPLACED_TITLE: dict[Language, str] = {"fr": "Autres pages", "en": "Other pages"}
|
|
23
|
+
_DIGITS = re.compile(r"([0-9]+)")
|
|
24
|
+
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
25
|
+
_PART = re.compile(r"^ {0,3}##[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")
|
|
26
|
+
_CODE_SPAN = re.compile(r"(`+).+?\1")
|
|
27
|
+
# Inline links and images: `[text](target "title")`; the leading `!` marks an image.
|
|
28
|
+
_LINK = re.compile(r"(!?)\[[^\]]*\]\(\s*<?([^\s)>]*)>?[^)]*\)")
|
|
29
|
+
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
30
|
+
|
|
31
|
+
NaturalKey = tuple[tuple[tuple[int, int, str], ...], str]
|
|
32
|
+
ReadingKey = tuple[tuple[int, int, NaturalKey], ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TocPart(NamedTuple):
|
|
36
|
+
title: str
|
|
37
|
+
# Link targets in reading order, as written.
|
|
38
|
+
targets: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PlannedGroup(NamedTuple):
|
|
42
|
+
title: str
|
|
43
|
+
pages: tuple[PurePosixPath, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OrderedMap(NamedTuple):
|
|
47
|
+
root: Path
|
|
48
|
+
# The table of contents itself, unless excluded.
|
|
49
|
+
intro: PurePosixPath | None
|
|
50
|
+
groups: tuple[PlannedGroup, ...]
|
|
51
|
+
# Found under the root but placed nowhere: printed last, in natural order.
|
|
52
|
+
unplaced: tuple[PurePosixPath, ...]
|
|
53
|
+
|
|
54
|
+
def chapters(self, lang: Language) -> list[tuple[Path, str | None]]:
|
|
55
|
+
"""Files to print with the part each one opens."""
|
|
56
|
+
chapters: list[tuple[Path, str | None]] = []
|
|
57
|
+
if self.intro is not None:
|
|
58
|
+
chapters.append((self.root / self.intro, None))
|
|
59
|
+
for title, pages in (*self.groups, (UNPLACED_TITLE[lang], self.unplaced)):
|
|
60
|
+
chapters.extend(
|
|
61
|
+
(self.root / path, title if index == 0 else None)
|
|
62
|
+
for index, path in enumerate(pages)
|
|
63
|
+
)
|
|
64
|
+
return chapters
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def natural_key(name: str) -> NaturalKey:
|
|
68
|
+
"""Numbers by value, text without case, after NFC; the exact name breaks ties."""
|
|
69
|
+
text = unicodedata.normalize("NFC", name)
|
|
70
|
+
chunks = tuple(
|
|
71
|
+
(0, int(chunk), "") if index % 2 else (1, 0, chunk.casefold())
|
|
72
|
+
for index, chunk in enumerate(_DIGITS.split(text))
|
|
73
|
+
if chunk
|
|
74
|
+
)
|
|
75
|
+
return chunks, text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reading_order(relative: PurePosixPath) -> ReadingKey:
|
|
79
|
+
# In each folder: README.md, the other files, then the subfolders.
|
|
80
|
+
*folders, name = relative.parts
|
|
81
|
+
return tuple((1, 0, natural_key(folder)) for folder in folders) + (
|
|
82
|
+
(0, 0 if name.casefold() == "readme.md" else 1, natural_key(name)),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def exclusion_patterns(patterns: Iterable[str]) -> list[str]:
|
|
87
|
+
return [pattern.replace("\\", "/").strip("/") for pattern in patterns]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def excluded(relative: PurePosixPath, patterns: Sequence[str]) -> bool:
|
|
91
|
+
# A pattern names a file or one of its folders, relative to the given directory.
|
|
92
|
+
candidates = [relative, *relative.parents[:-1]]
|
|
93
|
+
return any(
|
|
94
|
+
candidate.full_match(pattern)
|
|
95
|
+
for pattern in patterns
|
|
96
|
+
for candidate in candidates
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_toc(text: str) -> tuple[TocPart, ...]:
|
|
101
|
+
"""`##` headings and the link targets under them, outside code, in reading order."""
|
|
102
|
+
parts: list[TocPart] = []
|
|
103
|
+
fence: str | None = None
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
marker = _FENCE.match(line)
|
|
106
|
+
if fence is not None:
|
|
107
|
+
# A fence closes on the same character, at least as long, alone on its line.
|
|
108
|
+
if (
|
|
109
|
+
marker
|
|
110
|
+
and marker[1][0] == fence[0]
|
|
111
|
+
and len(marker[1]) >= len(fence)
|
|
112
|
+
and not marker[2].strip()
|
|
113
|
+
):
|
|
114
|
+
fence = None
|
|
115
|
+
continue
|
|
116
|
+
if marker:
|
|
117
|
+
fence = marker[1]
|
|
118
|
+
elif heading := _PART.match(line):
|
|
119
|
+
parts.append(TocPart(heading[1], ()))
|
|
120
|
+
elif parts:
|
|
121
|
+
targets = tuple(
|
|
122
|
+
target
|
|
123
|
+
for image, target in _LINK.findall(_CODE_SPAN.sub("", line))
|
|
124
|
+
if not image
|
|
125
|
+
)
|
|
126
|
+
parts[-1] = parts[-1]._replace(targets=parts[-1].targets + targets)
|
|
127
|
+
return tuple(parts)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _key(text: str) -> PurePosixPath:
|
|
131
|
+
# Paths compare in NFC with `/`; files keep their on-disk spelling for reading.
|
|
132
|
+
return PurePosixPath(unicodedata.normalize("NFC", text.replace("\\", "/")))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _local_target(target: str) -> PurePosixPath | None:
|
|
136
|
+
"""The path a link names under the table's folder, else None: web, anchor, outside."""
|
|
137
|
+
if _SCHEME.match(target) or target.startswith(("#", "/")):
|
|
138
|
+
return None
|
|
139
|
+
path = unquote(target.split("#", 1)[0].split("?", 1)[0])
|
|
140
|
+
if not path:
|
|
141
|
+
return None
|
|
142
|
+
normalized = posixpath.normpath(path.replace("\\", "/"))
|
|
143
|
+
if normalized == ".." or normalized.startswith("../"):
|
|
144
|
+
return None
|
|
145
|
+
return _key(normalized)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def plan_order(path: Path, exclude: Sequence[str] = ()) -> OrderedMap:
|
|
149
|
+
"""Resolve a Markdown table of contents against the files found under its folder."""
|
|
150
|
+
try:
|
|
151
|
+
text = path.read_text(encoding="utf-8").removeprefix("")
|
|
152
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
153
|
+
raise RenderError(f"{path} : cannot read contents map ({error})") from error
|
|
154
|
+
toc = read_toc(text)
|
|
155
|
+
root = path.resolve().parent
|
|
156
|
+
patterns = exclusion_patterns(exclude)
|
|
157
|
+
found = {
|
|
158
|
+
_key(relative.as_posix()): PurePosixPath(relative.as_posix())
|
|
159
|
+
for relative in (
|
|
160
|
+
child.relative_to(root)
|
|
161
|
+
for child in root.rglob("*.md")
|
|
162
|
+
if child.is_file() and child.suffix == ".md"
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
printable = sorted(
|
|
166
|
+
(actual for key, actual in found.items() if not excluded(key, patterns)),
|
|
167
|
+
key=reading_order,
|
|
168
|
+
)
|
|
169
|
+
available = {_key(actual.as_posix()) for actual in printable}
|
|
170
|
+
intro_key = _key(path.resolve().name)
|
|
171
|
+
intro = found[intro_key] if intro_key in available else None
|
|
172
|
+
placed: set[PurePosixPath] = {intro_key}
|
|
173
|
+
|
|
174
|
+
def missing(target: str, what: str) -> RenderError:
|
|
175
|
+
return RenderError(
|
|
176
|
+
f"{path} : {target} ne désigne aucun {what} under {root} "
|
|
177
|
+
"(exact spelling and case required)"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
groups: list[PlannedGroup] = []
|
|
181
|
+
for part in toc:
|
|
182
|
+
pages: list[PurePosixPath] = []
|
|
183
|
+
for target in part.targets:
|
|
184
|
+
key = _local_target(target)
|
|
185
|
+
if key is None:
|
|
186
|
+
continue
|
|
187
|
+
if key.suffix == ".md":
|
|
188
|
+
if key in available:
|
|
189
|
+
matches = [found[key]]
|
|
190
|
+
elif excluded(key, patterns):
|
|
191
|
+
# Listed for the map only: the exclusions keep it out of the PDF.
|
|
192
|
+
continue
|
|
193
|
+
else:
|
|
194
|
+
raise missing(target, "printable Markdown file")
|
|
195
|
+
elif (root / key).is_dir() or target.split("#", 1)[0].endswith("/"):
|
|
196
|
+
# A folder brings its pages not yet placed, in reading order.
|
|
197
|
+
inside = key == PurePosixPath(".")
|
|
198
|
+
if not inside and not any(key in other.parents for other in found):
|
|
199
|
+
raise missing(target, "Markdown folder")
|
|
200
|
+
matches = [
|
|
201
|
+
actual
|
|
202
|
+
for actual in printable
|
|
203
|
+
if inside or key in _key(actual.as_posix()).parents
|
|
204
|
+
]
|
|
205
|
+
else:
|
|
206
|
+
continue
|
|
207
|
+
for actual in matches:
|
|
208
|
+
if _key(actual.as_posix()) not in placed:
|
|
209
|
+
placed.add(_key(actual.as_posix()))
|
|
210
|
+
pages.append(actual)
|
|
211
|
+
groups.append(PlannedGroup(part.title, tuple(pages)))
|
|
212
|
+
unplaced = tuple(
|
|
213
|
+
actual for actual in printable if _key(actual.as_posix()) not in placed
|
|
214
|
+
)
|
|
215
|
+
return OrderedMap(root, intro, tuple(groups), unplaced)
|
typoprint/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Thin command-line adapter over the public library."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from typoprint.config import load_settings
|
|
12
|
+
from typoprint.models import (
|
|
13
|
+
Chapter,
|
|
14
|
+
ConfigurationError,
|
|
15
|
+
Document,
|
|
16
|
+
Settings,
|
|
17
|
+
TypoprintError,
|
|
18
|
+
)
|
|
19
|
+
from typoprint.output import write_pdf
|
|
20
|
+
from typoprint.planning import plan_files
|
|
21
|
+
from typoprint.rendering import (
|
|
22
|
+
document_from_plan,
|
|
23
|
+
inspect_document,
|
|
24
|
+
render,
|
|
25
|
+
render_files,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parser() -> argparse.ArgumentParser:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="typoprint",
|
|
32
|
+
description=f"Typoprint {version('typoprint')} — Markdown in. Beautiful PDFs out.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"paths", nargs="*", help="Markdown files or folders; '-' reads UTF-8 stdin"
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--version", action="version", version=version("typoprint"))
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"-o", "--output", help="PDF destination; '-' writes binary PDF to stdout"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--order", type=Path, help="Markdown contents map; ## headings define parts"
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-r", "--recursive", action="store_true", help="Include subfolders"
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--exclude",
|
|
49
|
+
action="append",
|
|
50
|
+
default=[],
|
|
51
|
+
metavar="GLOB",
|
|
52
|
+
help="Exclude a relative path or directory (repeatable)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--by-folder", action="store_true", help="Open a part for each folder"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--strict", action="store_true", help="With --order, refuse unplaced pages"
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--dry-run",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="Print JSON with input order and resolved settings, without a PDF",
|
|
64
|
+
)
|
|
65
|
+
config = parser.add_mutually_exclusive_group()
|
|
66
|
+
config.add_argument(
|
|
67
|
+
"--config",
|
|
68
|
+
type=Path,
|
|
69
|
+
help="Project TOML file (default: ./typoprint.toml if present)",
|
|
70
|
+
)
|
|
71
|
+
config.add_argument(
|
|
72
|
+
"--no-config", action="store_true", help="Ignore the project configuration"
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("--preset", help="Named preset in the selected configuration")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--template", help="editorial, report, or a directory containing template.typ"
|
|
77
|
+
)
|
|
78
|
+
for name in ("title", "subtitle", "author"):
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
f"--{name}", help=f"Document {name}; overrides front matter"
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--meta",
|
|
84
|
+
action="append",
|
|
85
|
+
default=[],
|
|
86
|
+
metavar="KEY=VALUE",
|
|
87
|
+
help="Set title, subtitle, author, date, or version (repeatable)",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--lang", choices=("fr", "en"), help="Typography language (default: fr)"
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--paper", choices=("a4", "us-letter"), help="Paper format (default: a4)"
|
|
94
|
+
)
|
|
95
|
+
for name in ("cover", "toc"):
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
f"--{name}", action=argparse.BooleanOptionalAction, default=None
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--toc-depth",
|
|
101
|
+
type=int,
|
|
102
|
+
choices=range(1, 7),
|
|
103
|
+
help="Contents levels (default: 2)",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--link-base", help="HTTP(S) folder URL publishing the current directory"
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--font-path",
|
|
110
|
+
type=Path,
|
|
111
|
+
action="append",
|
|
112
|
+
help="Additional local font directory (repeatable)",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--template-option",
|
|
116
|
+
action="append",
|
|
117
|
+
default=[],
|
|
118
|
+
metavar="KEY=JSON",
|
|
119
|
+
help="Namespaced template option (repeatable)",
|
|
120
|
+
)
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _settings(args: argparse.Namespace) -> Settings:
|
|
125
|
+
path = args.config
|
|
126
|
+
if path is None and not args.no_config and Path("typoprint.toml").is_file():
|
|
127
|
+
path = Path("typoprint.toml")
|
|
128
|
+
if args.preset is not None and (path is None or args.no_config):
|
|
129
|
+
raise ConfigurationError("--preset requires a project configuration")
|
|
130
|
+
base = load_settings(path, preset=args.preset) if path is not None else Settings()
|
|
131
|
+
data = {
|
|
132
|
+
key: value
|
|
133
|
+
for key, value in vars(args).items()
|
|
134
|
+
if key in Settings.model_fields and value is not None
|
|
135
|
+
}
|
|
136
|
+
for item in args.meta:
|
|
137
|
+
key, separator, value = item.partition("=")
|
|
138
|
+
if (
|
|
139
|
+
not separator
|
|
140
|
+
or key not in ("title", "subtitle", "author", "date", "version")
|
|
141
|
+
or not value.strip()
|
|
142
|
+
):
|
|
143
|
+
raise ConfigurationError("--meta expects a supported KEY=VALUE")
|
|
144
|
+
if key in data:
|
|
145
|
+
raise ConfigurationError(f"Metadata field supplied twice: {key}")
|
|
146
|
+
data[key] = value
|
|
147
|
+
if args.font_path is not None:
|
|
148
|
+
data["font_paths"] = args.font_path
|
|
149
|
+
if args.template_option:
|
|
150
|
+
options = {}
|
|
151
|
+
for item in args.template_option:
|
|
152
|
+
key, separator, value = item.partition("=")
|
|
153
|
+
if not separator or not key or key in options:
|
|
154
|
+
raise ConfigurationError("--template-option expects a unique KEY=JSON")
|
|
155
|
+
try:
|
|
156
|
+
options[key] = json.loads(value)
|
|
157
|
+
except ValueError as error:
|
|
158
|
+
raise ConfigurationError(
|
|
159
|
+
f"Invalid JSON for template option {key}"
|
|
160
|
+
) from error
|
|
161
|
+
data["template_options"] = options
|
|
162
|
+
return base.with_overrides(Settings.model_validate(data))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
parser = _parser()
|
|
167
|
+
args = parser.parse_args(argv)
|
|
168
|
+
try:
|
|
169
|
+
settings = _settings(args)
|
|
170
|
+
if "-" in args.paths:
|
|
171
|
+
if (
|
|
172
|
+
args.paths != ["-"]
|
|
173
|
+
or args.order is not None
|
|
174
|
+
or args.recursive
|
|
175
|
+
or args.exclude
|
|
176
|
+
or args.by_folder
|
|
177
|
+
or args.strict
|
|
178
|
+
):
|
|
179
|
+
raise ConfigurationError(
|
|
180
|
+
"stdin cannot be combined with file discovery options"
|
|
181
|
+
)
|
|
182
|
+
document = Document(
|
|
183
|
+
chapters=(
|
|
184
|
+
Chapter(markdown=sys.stdin.buffer.read().decode("utf-8-sig")),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
plan = None
|
|
188
|
+
default_name = "document"
|
|
189
|
+
else:
|
|
190
|
+
plan = plan_files(
|
|
191
|
+
args.paths,
|
|
192
|
+
order=args.order,
|
|
193
|
+
recursive=args.recursive,
|
|
194
|
+
exclude=args.exclude,
|
|
195
|
+
by_folder=args.by_folder,
|
|
196
|
+
strict=args.strict,
|
|
197
|
+
lang=settings.lang,
|
|
198
|
+
)
|
|
199
|
+
document = document_from_plan(plan)
|
|
200
|
+
first = Path(args.paths[0]) if args.paths else plan.root
|
|
201
|
+
default_name = first.name if first.is_dir() else first.stem
|
|
202
|
+
if args.dry_run:
|
|
203
|
+
resolved = inspect_document(document, settings)
|
|
204
|
+
resolved.pop("toc-depth", None)
|
|
205
|
+
resolved.pop("chapter-level", None)
|
|
206
|
+
print(
|
|
207
|
+
json.dumps(
|
|
208
|
+
{
|
|
209
|
+
"files": [
|
|
210
|
+
{"name": chapter.name, "part": chapter.part}
|
|
211
|
+
for chapter in document.chapters
|
|
212
|
+
],
|
|
213
|
+
"settings": resolved,
|
|
214
|
+
"warnings": list(plan.warnings) if plan is not None else [],
|
|
215
|
+
},
|
|
216
|
+
ensure_ascii=False,
|
|
217
|
+
indent=2,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
return 0
|
|
221
|
+
output = args.output or f"{default_name}.pdf"
|
|
222
|
+
if output != "-" and plan is not None:
|
|
223
|
+
destination = Path(output).resolve()
|
|
224
|
+
if destination in {entry.path.resolve() for entry in plan.files}:
|
|
225
|
+
raise ConfigurationError("PDF output would overwrite an input file")
|
|
226
|
+
result = (
|
|
227
|
+
render(document, settings=settings)
|
|
228
|
+
if plan is None
|
|
229
|
+
else render_files(plan, settings=settings)
|
|
230
|
+
)
|
|
231
|
+
for warning in result.warnings:
|
|
232
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
233
|
+
if output == "-":
|
|
234
|
+
sys.stdout.buffer.write(result.content)
|
|
235
|
+
sys.stdout.buffer.flush()
|
|
236
|
+
else:
|
|
237
|
+
destination = write_pdf(result, output)
|
|
238
|
+
print(destination, file=sys.stderr)
|
|
239
|
+
return 0
|
|
240
|
+
except (TypoprintError, ValidationError, OSError, UnicodeError) as error:
|
|
241
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
242
|
+
return 2
|
typoprint/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Explicit project settings and named presets; no process-wide configuration."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, ValidationError
|
|
7
|
+
|
|
8
|
+
from typoprint.models import ConfigurationError, FrozenModel, Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectConfig(FrozenModel):
|
|
12
|
+
defaults: Settings = Field(default_factory=Settings)
|
|
13
|
+
presets: dict[str, Settings] = Field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _relative_paths(settings: Settings, directory: Path) -> Settings:
|
|
17
|
+
data = settings.model_dump(exclude_unset=True)
|
|
18
|
+
if settings.template not in ("editorial", "report"):
|
|
19
|
+
data["template"] = str((directory / settings.template).resolve())
|
|
20
|
+
if "font_paths" in data:
|
|
21
|
+
data["font_paths"] = tuple(
|
|
22
|
+
(directory / path).resolve() for path in settings.font_paths
|
|
23
|
+
)
|
|
24
|
+
return Settings.model_validate(data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_settings(path: str | Path, *, preset: str | None = None) -> Settings:
|
|
28
|
+
"""Load defaults and one preset. Relative resources belong to the TOML file."""
|
|
29
|
+
source = Path(path).resolve()
|
|
30
|
+
try:
|
|
31
|
+
config = ProjectConfig.model_validate(
|
|
32
|
+
tomllib.loads(source.read_text(encoding="utf-8-sig"))
|
|
33
|
+
)
|
|
34
|
+
except (OSError, ValueError, ValidationError) as error:
|
|
35
|
+
raise ConfigurationError(
|
|
36
|
+
f"Cannot load settings from {source}: {error}"
|
|
37
|
+
) from error
|
|
38
|
+
settings = _relative_paths(config.defaults, source.parent)
|
|
39
|
+
if preset is not None:
|
|
40
|
+
if preset not in config.presets:
|
|
41
|
+
raise ConfigurationError(f"Unknown preset: {preset}")
|
|
42
|
+
settings = settings.with_overrides(
|
|
43
|
+
_relative_paths(config.presets[preset], source.parent)
|
|
44
|
+
)
|
|
45
|
+
return settings
|
typoprint/models.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Public values for planning, configuration, and rendering."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
|
8
|
+
|
|
9
|
+
Language = Literal["fr", "en"]
|
|
10
|
+
Paper = Literal["a4", "us-letter"]
|
|
11
|
+
METADATA_FIELDS = ("title", "subtitle", "author", "date", "version")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TypoprintError(ValueError):
|
|
15
|
+
"""Base error safe to catch at a transport boundary."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigurationError(TypoprintError):
|
|
19
|
+
"""Invalid settings, template bundle, or configuration file."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RenderError(TypoprintError):
|
|
23
|
+
"""Invalid inputs or a failed PDF compilation.
|
|
24
|
+
|
|
25
|
+
Compiler diagnostics may quote source text. Applications decide whether
|
|
26
|
+
to expose these details; the library never logs document contents.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RuleError(TypoprintError):
|
|
31
|
+
"""A developer-supplied transformation or validation rule refused input."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FrozenModel(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def relative_name(value: str) -> str:
|
|
39
|
+
"""A portable, non-traversing resource path, never a host filesystem path."""
|
|
40
|
+
if (
|
|
41
|
+
not isinstance(value, str)
|
|
42
|
+
or not value
|
|
43
|
+
or "\\" in value
|
|
44
|
+
or ":" in value
|
|
45
|
+
or "\x00" in value
|
|
46
|
+
or value.startswith("/")
|
|
47
|
+
or any(part in ("", ".", "..") for part in value.split("/"))
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("Expected a relative POSIX path without '.', '..', or a drive")
|
|
50
|
+
return PurePosixPath(value).as_posix()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Chapter(FrozenModel):
|
|
54
|
+
name: str = "document.md"
|
|
55
|
+
markdown: str
|
|
56
|
+
part: str | None = None
|
|
57
|
+
|
|
58
|
+
_name = field_validator("name")(relative_name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Document(FrozenModel):
|
|
62
|
+
chapters: tuple[Chapter, ...] = Field(min_length=1)
|
|
63
|
+
|
|
64
|
+
@field_validator("chapters")
|
|
65
|
+
@classmethod
|
|
66
|
+
def unique_names(cls, chapters: tuple[Chapter, ...]) -> tuple[Chapter, ...]:
|
|
67
|
+
if len({chapter.name for chapter in chapters}) != len(chapters):
|
|
68
|
+
raise ValueError("Chapter names must be unique")
|
|
69
|
+
return chapters
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Settings(FrozenModel):
|
|
73
|
+
"""Rendering settings. Fields explicitly set survive layered merging.
|
|
74
|
+
|
|
75
|
+
None for an optional metadata field means inherit front matter. False
|
|
76
|
+
disables automatic cover/contents choices explicitly.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
template: str = "editorial"
|
|
80
|
+
title: str | None = None
|
|
81
|
+
subtitle: str | None = None
|
|
82
|
+
author: str | None = None
|
|
83
|
+
date: str | None = None
|
|
84
|
+
version: str | None = None
|
|
85
|
+
lang: Language = "fr"
|
|
86
|
+
paper: Paper = "a4"
|
|
87
|
+
toc: bool | None = None
|
|
88
|
+
toc_depth: int = Field(default=2, ge=1, le=6)
|
|
89
|
+
cover: bool = False
|
|
90
|
+
link_base: str | None = None
|
|
91
|
+
font_paths: tuple[Path, ...] = ()
|
|
92
|
+
template_options: dict[str, JsonValue] = Field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
@field_validator("template")
|
|
95
|
+
@classmethod
|
|
96
|
+
def nonempty_template(cls, value: str) -> str:
|
|
97
|
+
if not value.strip():
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"Template must name a built-in layout or a bundle directory"
|
|
100
|
+
)
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
@field_validator("link_base")
|
|
104
|
+
@classmethod
|
|
105
|
+
def web_folder(cls, value: str | None) -> str | None:
|
|
106
|
+
if value is not None:
|
|
107
|
+
if not value.startswith(("https://", "http://")) or any(
|
|
108
|
+
char.isspace() for char in value
|
|
109
|
+
):
|
|
110
|
+
raise ValueError("link_base must be an HTTP(S) folder URL")
|
|
111
|
+
return value.rstrip("/") + "/"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def with_overrides(self, overrides: "Settings") -> "Settings":
|
|
115
|
+
"""Merge only explicitly supplied fields; nested options replace as a unit."""
|
|
116
|
+
return Settings.model_validate(
|
|
117
|
+
self.model_dump(exclude_unset=True)
|
|
118
|
+
| overrides.model_dump(exclude_unset=True)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RenderResult(FrozenModel):
|
|
123
|
+
content: bytes
|
|
124
|
+
warnings: tuple[str, ...] = ()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PlannedFile(FrozenModel):
|
|
128
|
+
path: Path
|
|
129
|
+
part: str | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class PrintPlan(FrozenModel):
|
|
133
|
+
root: Path
|
|
134
|
+
files: tuple[PlannedFile, ...] = Field(min_length=1)
|
|
135
|
+
warnings: tuple[str, ...] = ()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
Transform = Callable[[str], str]
|
|
139
|
+
Validator = Callable[[str], None]
|