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.
Files changed (73) hide show
  1. typoprint/__init__.py +40 -0
  2. typoprint/__main__.py +3 -0
  3. typoprint/_ordering.py +215 -0
  4. typoprint/cli.py +242 -0
  5. typoprint/config.py +45 -0
  6. typoprint/models.py +139 -0
  7. typoprint/output.py +28 -0
  8. typoprint/planning.py +99 -0
  9. typoprint/py.typed +0 -0
  10. typoprint/rendering.py +358 -0
  11. typoprint/resources/fonts/source-sans-3/NOTICE.txt +12 -0
  12. typoprint/resources/fonts/source-sans-3/OFL.txt +93 -0
  13. typoprint/resources/fonts/source-sans-3/SourceSans3-Bold.otf +0 -0
  14. typoprint/resources/fonts/source-sans-3/SourceSans3-BoldIt.otf +0 -0
  15. typoprint/resources/fonts/source-sans-3/SourceSans3-It.otf +0 -0
  16. typoprint/resources/fonts/source-sans-3/SourceSans3-Regular.otf +0 -0
  17. typoprint/resources/packages/local/typoprint/0.1.0/diagrams.typ +37 -0
  18. typoprint/resources/packages/local/typoprint/0.1.0/editorial.typ +156 -0
  19. typoprint/resources/packages/local/typoprint/0.1.0/foundations.typ +73 -0
  20. typoprint/resources/packages/local/typoprint/0.1.0/lib.typ +225 -0
  21. typoprint/resources/packages/local/typoprint/0.1.0/report.typ +130 -0
  22. typoprint/resources/packages/local/typoprint/0.1.0/typst.toml +7 -0
  23. typoprint/resources/packages/preview/cmarker/0.1.10/LICENSE +7 -0
  24. typoprint/resources/packages/preview/cmarker/0.1.10/lib.typ +462 -0
  25. typoprint/resources/packages/preview/cmarker/0.1.10/plugin.wasm +0 -0
  26. typoprint/resources/packages/preview/cmarker/0.1.10/typst.toml +11 -0
  27. typoprint/resources/packages/preview/merman/0.5.0/LICENSE +231 -0
  28. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cose-base-v1/LICENSE +21 -0
  29. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cose-base-v2/LICENSE +21 -0
  30. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cytoscape/LICENSE +19 -0
  31. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cytoscape-cose-bilkent/LICENSE +21 -0
  32. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/cytoscape-fcose/LICENSE +19 -0
  33. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/d3-shape/LICENSE +13 -0
  34. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/dagre/LICENSE +19 -0
  35. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/dompurify/LICENSE +202 -0
  36. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/eclipse-elk/LICENSE.md +264 -0
  37. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/elkjs/LICENSE.md +264 -0
  38. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/fmin/LICENSE +27 -0
  39. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/graphlib/LICENSE +19 -0
  40. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/layout-base-v1/LICENSE +21 -0
  41. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/layout-base-v2/LICENSE +21 -0
  42. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/mermaid/LICENSE +21 -0
  43. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/non-layered-tidy-tree-layout/LICENSE +21 -0
  44. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/rough-rs/LICENSE +21 -0
  45. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/roughjs/LICENSE +21 -0
  46. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/sanitize-url/LICENSE +21 -0
  47. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/unicode-linebreak/LICENSE +201 -0
  48. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/venn-js/LICENSE +22 -0
  49. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/wasm-minimal-protocol/LICENSE +24 -0
  50. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_LICENSES/zenuml-core/LICENSE +21 -0
  51. typoprint/resources/packages/preview/merman/0.5.0/THIRD_PARTY_NOTICES.md +289 -0
  52. typoprint/resources/packages/preview/merman/0.5.0/lib.typ +1 -0
  53. typoprint/resources/packages/preview/merman/0.5.0/merman_typst_plugin.wasm +0 -0
  54. typoprint/resources/packages/preview/merman/0.5.0/src/capabilities.typ +5 -0
  55. typoprint/resources/packages/preview/merman/0.5.0/src/context.typ +1 -0
  56. typoprint/resources/packages/preview/merman/0.5.0/src/errors.typ +33 -0
  57. typoprint/resources/packages/preview/merman/0.5.0/src/exports.typ +5 -0
  58. typoprint/resources/packages/preview/merman/0.5.0/src/figure.typ +143 -0
  59. typoprint/resources/packages/preview/merman/0.5.0/src/image.typ +50 -0
  60. typoprint/resources/packages/preview/merman/0.5.0/src/options.typ +566 -0
  61. typoprint/resources/packages/preview/merman/0.5.0/src/plugin.typ +1 -0
  62. typoprint/resources/packages/preview/merman/0.5.0/src/raw.typ +22 -0
  63. typoprint/resources/packages/preview/merman/0.5.0/src/render.typ +137 -0
  64. typoprint/resources/packages/preview/merman/0.5.0/src/source.typ +7 -0
  65. typoprint/resources/packages/preview/merman/0.5.0/src/units.typ +51 -0
  66. typoprint/resources/packages/preview/merman/0.5.0/typst.toml +12 -0
  67. typoprint-0.1.0.dist-info/METADATA +263 -0
  68. typoprint-0.1.0.dist-info/RECORD +73 -0
  69. typoprint-0.1.0.dist-info/WHEEL +5 -0
  70. typoprint-0.1.0.dist-info/entry_points.txt +2 -0
  71. typoprint-0.1.0.dist-info/licenses/LICENSE +21 -0
  72. typoprint-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +23 -0
  73. 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
@@ -0,0 +1,3 @@
1
+ from typoprint.cli import main
2
+
3
+ raise SystemExit(main())
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]