chaptr 0.1.1__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.
- chaptr/__init__.py +0 -0
- chaptr/cli.py +264 -0
- chaptr/manual.txt +210 -0
- chaptr/pdf.py +52 -0
- chaptr/render.py +156 -0
- chaptr/themes/base.css +59 -0
- chaptr/themes/modern.css +3 -0
- chaptr/themes/vintage.css +4 -0
- chaptr-0.1.1.dist-info/METADATA +65 -0
- chaptr-0.1.1.dist-info/RECORD +26 -0
- chaptr-0.1.1.dist-info/WHEEL +5 -0
- chaptr-0.1.1.dist-info/entry_points.txt +2 -0
- chaptr-0.1.1.dist-info/top_level.txt +2 -0
- chaptr_docs/00-authors.md +5 -0
- chaptr_docs/00-front-cover.md +5 -0
- chaptr_docs/01-requirements.md +71 -0
- chaptr_docs/02-workflow.md +69 -0
- chaptr_docs/03-configuration.md +133 -0
- chaptr_docs/04-themes.md +94 -0
- chaptr_docs/05-pdf.md +70 -0
- chaptr_docs/06-limitations.md +19 -0
- chaptr_docs/07-roadmap.md +21 -0
- chaptr_docs/99-back-cover.md +5 -0
- chaptr_docs/README.md +40 -0
- chaptr_docs/document.yaml +23 -0
- chaptr_docs/styles.css +2 -0
chaptr/__init__.py
ADDED
|
File without changes
|
chaptr/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import html
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from markdown_it import MarkdownIt
|
|
10
|
+
|
|
11
|
+
from chaptr.render import render_document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BuildError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def substitute(source: str, variables: dict[str, str], filename: Path) -> str:
|
|
19
|
+
lines = source.splitlines(keepends=True)
|
|
20
|
+
protected = set()
|
|
21
|
+
for token in MarkdownIt().parse(source):
|
|
22
|
+
if token.type in {"fence", "code_block"} and token.map:
|
|
23
|
+
protected.update(range(*token.map))
|
|
24
|
+
pattern = re.compile(r"\$\$|\$([A-Za-z_][A-Za-z0-9_]*)")
|
|
25
|
+
|
|
26
|
+
def replace(match: re.Match[str], line_number: int) -> str:
|
|
27
|
+
name = match.group(1)
|
|
28
|
+
if name is None:
|
|
29
|
+
return "$"
|
|
30
|
+
if name not in variables:
|
|
31
|
+
raise BuildError(f"{filename}:{line_number}: undefined variable ${name}")
|
|
32
|
+
return variables[name]
|
|
33
|
+
|
|
34
|
+
result = []
|
|
35
|
+
for line_number, line in enumerate(lines, 1):
|
|
36
|
+
if line_number - 1 in protected:
|
|
37
|
+
result.append(line)
|
|
38
|
+
continue
|
|
39
|
+
parts = re.split(r"(`+[^`]*`+)", line)
|
|
40
|
+
result.append("".join(
|
|
41
|
+
part if index % 2 else pattern.sub(
|
|
42
|
+
lambda match: replace(match, line_number), part
|
|
43
|
+
)
|
|
44
|
+
for index, part in enumerate(parts)
|
|
45
|
+
))
|
|
46
|
+
return "".join(result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path: Path) -> dict:
|
|
50
|
+
config = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
51
|
+
if not isinstance(config, dict):
|
|
52
|
+
raise BuildError("Configuration must be a YAML mapping.")
|
|
53
|
+
chapters = config.get("files")
|
|
54
|
+
if not isinstance(chapters, list) or not chapters or not all(
|
|
55
|
+
isinstance(chapter, str) for chapter in chapters
|
|
56
|
+
):
|
|
57
|
+
raise BuildError("files must be a nonempty list of Markdown paths.")
|
|
58
|
+
variables = config.get("variables", {})
|
|
59
|
+
if not isinstance(variables, dict) or not all(
|
|
60
|
+
isinstance(name, str)
|
|
61
|
+
and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)
|
|
62
|
+
and isinstance(value, (str, int, float, bool))
|
|
63
|
+
for name, value in variables.items()
|
|
64
|
+
):
|
|
65
|
+
raise BuildError("variables must map valid names to scalar values.")
|
|
66
|
+
for name in ("title", "theme", "output", "stylesheet", "author", "subtitle", "front_cover", "back_cover", "author_section"):
|
|
67
|
+
if name in config and not isinstance(config[name], str):
|
|
68
|
+
raise BuildError(f"{name} must be a string.")
|
|
69
|
+
for name in ("title_page", "table_of_contents"):
|
|
70
|
+
if name in config and not isinstance(config[name], bool):
|
|
71
|
+
raise BuildError(f"{name} must be true or false.")
|
|
72
|
+
if "toc_depth" in config and (type(config["toc_depth"]) is not int or not 1 <= config["toc_depth"] <= 6):
|
|
73
|
+
raise BuildError("toc_depth must be an integer from 1 to 6.")
|
|
74
|
+
return config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def theme_css(root: Path, name: str, stylesheet: str | None) -> str:
|
|
78
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
|
79
|
+
raise BuildError("theme must be a simple name, such as modern or vintage.")
|
|
80
|
+
bundled = files("chaptr").joinpath("themes")
|
|
81
|
+
local = root / "themes" / f"{name}.css"
|
|
82
|
+
theme = local if local.is_file() else bundled.joinpath(f"{name}.css")
|
|
83
|
+
if not theme.is_file():
|
|
84
|
+
raise BuildError(f"Unknown theme: {name}")
|
|
85
|
+
css = bundled.joinpath("base.css").read_text(encoding="utf-8")
|
|
86
|
+
css += "\n" + theme.read_text(encoding="utf-8")
|
|
87
|
+
if stylesheet:
|
|
88
|
+
css += "\n" + (root / stylesheet).read_text(encoding="utf-8")
|
|
89
|
+
return css
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build(config_path: Path, format_name: str = "html", theme: str | None = None) -> Path:
|
|
93
|
+
config_path = config_path.resolve()
|
|
94
|
+
root = config_path.parent
|
|
95
|
+
config = load_config(config_path)
|
|
96
|
+
variables = {name: str(value) for name, value in config.get("variables", {}).items()}
|
|
97
|
+
chapters = []
|
|
98
|
+
source_files = []
|
|
99
|
+
pdf_covers = {
|
|
100
|
+
field: root / config[field]
|
|
101
|
+
for field in ("front_cover", "back_cover")
|
|
102
|
+
if config.get(field) and Path(config[field]).suffix.lower() == ".pdf"
|
|
103
|
+
}
|
|
104
|
+
for field, role in (("front_cover", "front-cover"), ("author_section", "author-section")):
|
|
105
|
+
if config.get(field) and field not in pdf_covers:
|
|
106
|
+
source_files.append((config[field], role))
|
|
107
|
+
source_files.extend((filename, "chapter") for filename in config["files"])
|
|
108
|
+
if config.get("back_cover") and "back_cover" not in pdf_covers:
|
|
109
|
+
source_files.append((config["back_cover"], "back-cover"))
|
|
110
|
+
for filename, role in source_files:
|
|
111
|
+
path = root / filename
|
|
112
|
+
source = substitute(path.read_text(encoding="utf-8"), variables, path)
|
|
113
|
+
chapters.append((source, path, role))
|
|
114
|
+
output = root / config.get("output", "build")
|
|
115
|
+
suffix = {"markdown": "md", "html": "html", "pdf": "pdf"}[format_name]
|
|
116
|
+
destination = output / f"document.{suffix}"
|
|
117
|
+
if format_name == "pdf" and any(path.resolve() == destination.resolve() for path in pdf_covers.values()):
|
|
118
|
+
raise BuildError("A PDF cover cannot also be the output document. Choose a different cover path or output directory.")
|
|
119
|
+
if format_name == "markdown":
|
|
120
|
+
content = "\n\n".join(source.rstrip() for source, path, role in chapters) + "\n"
|
|
121
|
+
else:
|
|
122
|
+
css = theme_css(root, theme or config.get("theme", "modern"), config.get("stylesheet"))
|
|
123
|
+
if format_name == "pdf" and "front_cover" in pdf_covers:
|
|
124
|
+
css += "\n@page:first { counter-reset: page 2; }\n"
|
|
125
|
+
title = html.escape(config.get("title", "Document"))
|
|
126
|
+
content = (
|
|
127
|
+
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
128
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
129
|
+
f"<title>{title}</title><style>{css}</style></head>"
|
|
130
|
+
f'<body><main>{render_document(chapters, config)}</main></body></html>\n'
|
|
131
|
+
)
|
|
132
|
+
if format_name == "pdf":
|
|
133
|
+
try:
|
|
134
|
+
from weasyprint import HTML
|
|
135
|
+
except (ImportError, OSError) as error:
|
|
136
|
+
raise BuildError(
|
|
137
|
+
"PDF requires the pdf extra and WeasyPrint's native libraries. "
|
|
138
|
+
"Run 'chaptr help' for PDF requirements. " + str(error)
|
|
139
|
+
) from error
|
|
140
|
+
document = HTML(string=content, base_url=str(root)).write_pdf()
|
|
141
|
+
if pdf_covers:
|
|
142
|
+
try:
|
|
143
|
+
from chaptr.pdf import CoverError, merge_covers
|
|
144
|
+
except ImportError as error:
|
|
145
|
+
raise BuildError("PDF covers require pypdf. Install chaptr's pdf extra.") from error
|
|
146
|
+
try:
|
|
147
|
+
document = merge_covers(document, pdf_covers)
|
|
148
|
+
except CoverError as error:
|
|
149
|
+
raise BuildError(str(error)) from error
|
|
150
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
destination.write_bytes(document)
|
|
152
|
+
else:
|
|
153
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
destination.write_text(content, encoding="utf-8")
|
|
155
|
+
return destination
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def init_project(directory: Path) -> Path:
|
|
159
|
+
root = directory.resolve()
|
|
160
|
+
starter_files = {
|
|
161
|
+
resource.name: resource.read_text(encoding="utf-8")
|
|
162
|
+
for resource in files("chaptr_docs").iterdir()
|
|
163
|
+
if resource.is_file() and Path(resource.name).suffix in {".md", ".yaml", ".css"}
|
|
164
|
+
}
|
|
165
|
+
starter_files[".gitignore"] = "build/\n"
|
|
166
|
+
if root.exists() and not root.is_dir():
|
|
167
|
+
raise BuildError(f"Project directory is not a directory: {root}")
|
|
168
|
+
conflicts = [
|
|
169
|
+
name for name in starter_files
|
|
170
|
+
if (root / name).exists() or (root / name).is_symlink()
|
|
171
|
+
]
|
|
172
|
+
if conflicts:
|
|
173
|
+
raise BuildError("Refusing to overwrite existing project files: " + ", ".join(conflicts))
|
|
174
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
for name, content in starter_files.items():
|
|
176
|
+
with (root / name).open("x", encoding="utf-8") as destination:
|
|
177
|
+
destination.write(content)
|
|
178
|
+
return root / "document.yaml"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> int:
|
|
182
|
+
parser = argparse.ArgumentParser(
|
|
183
|
+
prog="chaptr",
|
|
184
|
+
description="Build independent Markdown document projects with variables and CSS themes.",
|
|
185
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
186
|
+
epilog=(
|
|
187
|
+
"Examples:\n"
|
|
188
|
+
" chaptr init my-document\n"
|
|
189
|
+
" chaptr build --format pdf\n"
|
|
190
|
+
" chaptr build --config path/to/document.yaml\n"
|
|
191
|
+
" chaptr build --help\n\n"
|
|
192
|
+
"Run 'chaptr help' for the full usage manual."
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
196
|
+
initializer = commands.add_parser(
|
|
197
|
+
"init",
|
|
198
|
+
help="Create an editable project containing chaptr's documentation.",
|
|
199
|
+
description="Copy chaptr's documentation, configuration, stylesheet and a build-output ignore rule.",
|
|
200
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
201
|
+
epilog=(
|
|
202
|
+
"Examples:\n"
|
|
203
|
+
" chaptr init\n"
|
|
204
|
+
" chaptr init my-document\n\n"
|
|
205
|
+
"Existing starter files are never overwritten. Read or edit the\n"
|
|
206
|
+
"documentation sections, then run 'chaptr build' from the project directory.\n"
|
|
207
|
+
"Initialization does not require PDF dependencies."
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
initializer.add_argument(
|
|
211
|
+
"directory", nargs="?", type=Path, default=Path("."), metavar="DIRECTORY",
|
|
212
|
+
help="Project directory to create or use (default: current directory).",
|
|
213
|
+
)
|
|
214
|
+
command = commands.add_parser(
|
|
215
|
+
"build",
|
|
216
|
+
help="Build ordered chapters from a project configuration.",
|
|
217
|
+
description="Combine configured Markdown chapters and write one document.",
|
|
218
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
219
|
+
epilog=(
|
|
220
|
+
"Examples:\n"
|
|
221
|
+
" chaptr build\n"
|
|
222
|
+
" chaptr build --format pdf\n"
|
|
223
|
+
" chaptr build --config path/to/document.yaml --format markdown\n"
|
|
224
|
+
" chaptr build --theme vintage\n\n"
|
|
225
|
+
"Project paths are relative to the configuration file. Output defaults\n"
|
|
226
|
+
"to build/document.<extension> within that project; existing output\n"
|
|
227
|
+
"of the same format is replaced. PDF requires WeasyPrint and its\n"
|
|
228
|
+
"native libraries. Run 'chaptr help' for configuration and theme details."
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
command.add_argument(
|
|
232
|
+
"--config", type=Path, default=Path("document.yaml"), metavar="PATH",
|
|
233
|
+
help="YAML project configuration (default: document.yaml in the current directory).",
|
|
234
|
+
)
|
|
235
|
+
command.add_argument(
|
|
236
|
+
"--format", choices=["markdown", "html", "pdf"], default="html",
|
|
237
|
+
help="Output format (default: html); PDF requires optional renderer dependencies.",
|
|
238
|
+
)
|
|
239
|
+
command.add_argument(
|
|
240
|
+
"--theme", metavar="NAME",
|
|
241
|
+
help="Override the configured CSS theme for HTML/PDF; ignored for Markdown.",
|
|
242
|
+
)
|
|
243
|
+
commands.add_parser("help", help="Print the full usage manual.")
|
|
244
|
+
args = parser.parse_args()
|
|
245
|
+
if args.command == "help":
|
|
246
|
+
print(files("chaptr").joinpath("manual.txt").read_text(encoding="utf-8"), end="")
|
|
247
|
+
return 0
|
|
248
|
+
try:
|
|
249
|
+
if args.command == "init":
|
|
250
|
+
config_path = init_project(args.directory)
|
|
251
|
+
print(f"Initialized project at {config_path.parent}")
|
|
252
|
+
print("Start with README.md, then edit document.yaml and the Markdown sections.")
|
|
253
|
+
print(f'Build with: chaptr build --config "{config_path.as_posix()}"')
|
|
254
|
+
return 0
|
|
255
|
+
destination = build(args.config, args.format, args.theme)
|
|
256
|
+
except (BuildError, OSError, yaml.YAMLError) as error:
|
|
257
|
+
print(f"chaptr: {error}", file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
print(destination)
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
raise SystemExit(main())
|
chaptr/manual.txt
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
CHAPTR - Markdown document builder
|
|
2
|
+
|
|
3
|
+
NAME
|
|
4
|
+
chaptr - combine a document project's Markdown chapters into one output file
|
|
5
|
+
|
|
6
|
+
SYNOPSIS
|
|
7
|
+
chaptr --help
|
|
8
|
+
chaptr help
|
|
9
|
+
chaptr init [DIRECTORY]
|
|
10
|
+
chaptr init --help
|
|
11
|
+
chaptr build [--config PATH] [--format {markdown,html,pdf}] [--theme NAME]
|
|
12
|
+
chaptr build --help
|
|
13
|
+
|
|
14
|
+
DESCRIPTION
|
|
15
|
+
Install the CLI once and use it with any number of document projects.
|
|
16
|
+
Each project contains a YAML configuration and Markdown source files.
|
|
17
|
+
|
|
18
|
+
COMMANDS
|
|
19
|
+
init
|
|
20
|
+
Create an editable copy of chaptr's documentation in DIRECTORY or the current
|
|
21
|
+
directory if omitted. Missing directories are created automatically.
|
|
22
|
+
The default project contains the documentation sections, README.md,
|
|
23
|
+
document.yaml, styles.css and a .gitignore excluding build output.
|
|
24
|
+
Read README.md or build the documentation, then adapt it to your content.
|
|
25
|
+
No output is rendered during init. Template selection is not supported
|
|
26
|
+
yet; see the copied roadmap section for future plans.
|
|
27
|
+
|
|
28
|
+
Existing starter files are never overwritten. Conflicts are checked
|
|
29
|
+
before writing; unrelated files are kept. If a starter file already
|
|
30
|
+
exists, choose a different directory. No --force option is provided.
|
|
31
|
+
Initialization is noninteractive and needs no PDF dependencies.
|
|
32
|
+
|
|
33
|
+
build
|
|
34
|
+
Read the configuration, substitute variables and combine chapters in
|
|
35
|
+
the listed order. HTML and PDF use CSS themes; Markdown is unstyled.
|
|
36
|
+
A successful build prints the absolute path of the generated file.
|
|
37
|
+
|
|
38
|
+
help
|
|
39
|
+
Print this manual. No document project or PDF dependencies are needed.
|
|
40
|
+
|
|
41
|
+
OPTIONS
|
|
42
|
+
-h, --help
|
|
43
|
+
Show a short command overview. Use chaptr init --help or chaptr build --help
|
|
44
|
+
for command-specific usage.
|
|
45
|
+
|
|
46
|
+
DIRECTORY (init only)
|
|
47
|
+
Optional project directory to create or use. Default: current directory.
|
|
48
|
+
Quote paths containing spaces.
|
|
49
|
+
|
|
50
|
+
--config PATH
|
|
51
|
+
YAML configuration to load. Default: document.yaml in the current
|
|
52
|
+
directory. Paths inside it are relative to the configuration file,
|
|
53
|
+
regardless of where you run chaptr.
|
|
54
|
+
|
|
55
|
+
--format {markdown,html,pdf}
|
|
56
|
+
Output format. Default: html.
|
|
57
|
+
markdown produces merged source, html produces a styled browser
|
|
58
|
+
document and pdf produces a printable document via WeasyPrint.
|
|
59
|
+
|
|
60
|
+
--theme NAME
|
|
61
|
+
Override the configured theme for this build. Applies to HTML and PDF
|
|
62
|
+
only; ignored for Markdown. Bundled names: modern and vintage.
|
|
63
|
+
|
|
64
|
+
CONFIGURATION
|
|
65
|
+
Minimal document.yaml:
|
|
66
|
+
|
|
67
|
+
files:
|
|
68
|
+
- notes.md
|
|
69
|
+
|
|
70
|
+
Extended document.yaml:
|
|
71
|
+
|
|
72
|
+
title: Product Guide
|
|
73
|
+
theme: modern
|
|
74
|
+
stylesheet: styles.css
|
|
75
|
+
output: build
|
|
76
|
+
files:
|
|
77
|
+
- chapters/introduction.md
|
|
78
|
+
- chapters/requirements.md
|
|
79
|
+
variables:
|
|
80
|
+
product_name: Field Notes
|
|
81
|
+
version: "1.0"
|
|
82
|
+
|
|
83
|
+
files Required, nonempty list of source paths in chapter order.
|
|
84
|
+
title HTML document title; default: Document.
|
|
85
|
+
theme Named CSS theme; default: modern.
|
|
86
|
+
stylesheet Optional extra CSS path, applied after the theme.
|
|
87
|
+
output Output directory; default: build.
|
|
88
|
+
variables Optional mapping of names to text, numbers or booleans.
|
|
89
|
+
title_page Generate a title page; default: false.
|
|
90
|
+
subtitle Plain-text title-page subtitle; default: empty.
|
|
91
|
+
author Plain-text title-page author; default: empty.
|
|
92
|
+
front_cover Markdown or single-page PDF front cover; default: none.
|
|
93
|
+
author_section Markdown file for author details; default: none.
|
|
94
|
+
table_of_contents Generate linked contents; default: false.
|
|
95
|
+
toc_depth Contents heading depth, 1 to 6; default: 2.
|
|
96
|
+
back_cover Markdown or single-page PDF back cover; default: none.
|
|
97
|
+
|
|
98
|
+
DOCUMENT FEATURES
|
|
99
|
+
Put Table: Caption text in a standalone paragraph directly above or below
|
|
100
|
+
a Markdown table. Captioned tables are numbered across the document in source
|
|
101
|
+
order. Tables without captions remain unnumbered. Use one caption per table.
|
|
102
|
+
|
|
103
|
+
Use [^label] references and [^label]: Note text definitions for footnotes.
|
|
104
|
+
Labels are document-wide. Numbered notes appear at the end with return links.
|
|
105
|
+
|
|
106
|
+
Optional page order: front cover, title page, author section, contents,
|
|
107
|
+
chapters, notes and back cover. Cover and author files use ordinary Markdown.
|
|
108
|
+
Do not list them again in files. Contents include only chapter headings.
|
|
109
|
+
HTML contents are linked; PDF contents include page numbers.
|
|
110
|
+
|
|
111
|
+
Title pages, contents and numbered captions are generated for HTML/PDF only.
|
|
112
|
+
Merged Markdown includes authored covers and author sections and preserves
|
|
113
|
+
caption and footnote source syntax.
|
|
114
|
+
|
|
115
|
+
PDF COVERS
|
|
116
|
+
Configure front_cover: covers/front.pdf or back_cover: covers/back.pdf.
|
|
117
|
+
PDF builds attach those pages around the generated content using pypdf.
|
|
118
|
+
Artwork and fonts are preserved without scaling or generated page margins.
|
|
119
|
+
Either cover is optional and Markdown and PDF covers can be mixed.
|
|
120
|
+
|
|
121
|
+
Each PDF cover must be unencrypted, contain exactly one page and match the
|
|
122
|
+
adjacent generated page's size within one PDF point. Invalid covers fail
|
|
123
|
+
before existing output is replaced. Cover files cannot be the output file.
|
|
124
|
+
|
|
125
|
+
An inserted front cover counts as page 1; generated contents page numbers
|
|
126
|
+
match the final PDF's physical pages. Links and bookmarks are preserved.
|
|
127
|
+
HTML and Markdown builds omit PDF covers. Bleed and trim setup is not provided.
|
|
128
|
+
|
|
129
|
+
VARIABLES
|
|
130
|
+
Write $product_name in Markdown to insert its configured value.
|
|
131
|
+
Write $$ for a literal dollar sign, for example $$20 becomes $20.
|
|
132
|
+
Names contain letters, digits or underscores and cannot start with a digit.
|
|
133
|
+
Undefined names stop the build with a source filename and line number.
|
|
134
|
+
Values are inserted as Markdown.
|
|
135
|
+
Code blocks and ordinary inline code are left unchanged.
|
|
136
|
+
|
|
137
|
+
THEMES
|
|
138
|
+
CSS is applied in this order:
|
|
139
|
+
1. Bundled base styling.
|
|
140
|
+
2. Project themes/NAME.css if present, otherwise bundled NAME.css.
|
|
141
|
+
3. The optional stylesheet specified in the configuration.
|
|
142
|
+
|
|
143
|
+
To create a theme, add themes/company.css and set theme: company.
|
|
144
|
+
A local themes/modern.css replaces only that project's modern theme.
|
|
145
|
+
Use @media print for PDF-specific rules and @page for size and margins.
|
|
146
|
+
The base print layout uses A4, 20mm margins and a new page per chapter.
|
|
147
|
+
Relative CSS asset URLs currently resolve from the project root.
|
|
148
|
+
|
|
149
|
+
OUTPUT
|
|
150
|
+
The selected output directory receives one file per build:
|
|
151
|
+
document.md --format markdown
|
|
152
|
+
document.html --format html
|
|
153
|
+
document.pdf --format pdf
|
|
154
|
+
|
|
155
|
+
Rebuilding a format replaces its existing file. Other formats are untouched.
|
|
156
|
+
Open HTML directly in a browser.
|
|
157
|
+
|
|
158
|
+
PDF REQUIREMENTS
|
|
159
|
+
The pdf extra includes WeasyPrint and pypdf for PDF cover assembly.
|
|
160
|
+
Developer installation from the application repository:
|
|
161
|
+
python -m pip install -e ".[pdf]"
|
|
162
|
+
|
|
163
|
+
WeasyPrint also requires native libraries, including Pango. Installing the
|
|
164
|
+
Python package alone may not be sufficient. Platform installation guide:
|
|
165
|
+
https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation
|
|
166
|
+
|
|
167
|
+
If native libraries are missing, PDF builds fail; HTML and Markdown still
|
|
168
|
+
work. Self-contained executable releases are not available yet.
|
|
169
|
+
|
|
170
|
+
EXAMPLES
|
|
171
|
+
Create a document project and build its documentation:
|
|
172
|
+
chaptr init my-document
|
|
173
|
+
cd my-document
|
|
174
|
+
chaptr build
|
|
175
|
+
|
|
176
|
+
Initialize an existing directory or a path containing spaces:
|
|
177
|
+
chaptr init
|
|
178
|
+
chaptr init "My Document"
|
|
179
|
+
|
|
180
|
+
Build HTML from the current document project:
|
|
181
|
+
chaptr build
|
|
182
|
+
|
|
183
|
+
Build its PDF:
|
|
184
|
+
chaptr build --format pdf
|
|
185
|
+
|
|
186
|
+
Build a project from another directory, including a path with spaces:
|
|
187
|
+
chaptr build --config "C:/Documents/Product Guide/document.yaml" --format pdf
|
|
188
|
+
chaptr build --config /home/user/product-guide/document.yaml --format pdf
|
|
189
|
+
|
|
190
|
+
Export merged Markdown or select another theme:
|
|
191
|
+
chaptr build --format markdown
|
|
192
|
+
chaptr build --theme vintage
|
|
193
|
+
|
|
194
|
+
EXIT STATUS
|
|
195
|
+
0 Success, including help output.
|
|
196
|
+
1 Initialization or build failure, such as existing starter files, invalid
|
|
197
|
+
configuration, missing files or variables, unknown themes or missing
|
|
198
|
+
PDF renderer dependencies.
|
|
199
|
+
2 Invalid command-line usage, such as an unknown option or format.
|
|
200
|
+
|
|
201
|
+
LIMITATIONS
|
|
202
|
+
Build trusted local projects only: PDF rendering may read files or fetch URLs.
|
|
203
|
+
Raw source HTML and mathematical notation are not supported.
|
|
204
|
+
Substitution does not yet preserve multiline inline code.
|
|
205
|
+
Cross-file Markdown links are not converted into internal document links.
|
|
206
|
+
Footnotes are collected at the document's end, not at each PDF page's bottom.
|
|
207
|
+
HTML/PDF resolve relative image and link paths against their source chapter.
|
|
208
|
+
HTML uses local file references, which must remain available. Merged Markdown
|
|
209
|
+
retains source link paths, which may require adjustment after merging.
|
|
210
|
+
Browser and WeasyPrint CSS support differ; test print styling in actual PDF.
|
chaptr/pdf.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pypdf import PdfReader, PdfWriter
|
|
5
|
+
from pypdf.errors import PdfReadError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoverError(ValueError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def page_size(page) -> tuple[float, float]:
|
|
13
|
+
scale = float(page.get("/UserUnit", 1))
|
|
14
|
+
width = float(page.mediabox.width) * scale
|
|
15
|
+
height = float(page.mediabox.height) * scale
|
|
16
|
+
return (height, width) if page.rotation % 180 else (width, height)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def merge_covers(content: bytes, covers: dict[str, Path]) -> bytes:
|
|
20
|
+
body = PdfReader(BytesIO(content))
|
|
21
|
+
readers = {}
|
|
22
|
+
for role, path in covers.items():
|
|
23
|
+
try:
|
|
24
|
+
reader = PdfReader(BytesIO(path.read_bytes()))
|
|
25
|
+
if reader.is_encrypted:
|
|
26
|
+
raise CoverError(f"{role}: encrypted PDF covers are not supported: {path}")
|
|
27
|
+
if len(reader.pages) != 1:
|
|
28
|
+
raise CoverError(f"{role}: PDF cover must contain exactly one page: {path}")
|
|
29
|
+
expected = page_size(body.pages[0 if role == "front_cover" else -1])
|
|
30
|
+
actual = page_size(reader.pages[0])
|
|
31
|
+
if any(abs(actual_dimension - expected_dimension) > 1 for actual_dimension, expected_dimension in zip(actual, expected)):
|
|
32
|
+
raise CoverError(
|
|
33
|
+
f"{role}: cover size {actual[0]:.1f} x {actual[1]:.1f} pt "
|
|
34
|
+
f"does not match document size {expected[0]:.1f} x {expected[1]:.1f} pt: {path}"
|
|
35
|
+
)
|
|
36
|
+
readers[role] = reader
|
|
37
|
+
except (OSError, PdfReadError, ValueError) as error:
|
|
38
|
+
if isinstance(error, CoverError):
|
|
39
|
+
raise
|
|
40
|
+
raise CoverError(f"{role}: cannot read PDF cover {path}: {error}") from error
|
|
41
|
+
writer = PdfWriter()
|
|
42
|
+
if "front_cover" in readers:
|
|
43
|
+
writer.append(readers["front_cover"])
|
|
44
|
+
writer.append(body)
|
|
45
|
+
if "back_cover" in readers:
|
|
46
|
+
writer.append(readers["back_cover"])
|
|
47
|
+
if body.metadata:
|
|
48
|
+
writer.add_metadata({key: value for key, value in body.metadata.items() if isinstance(value, str)})
|
|
49
|
+
destination = BytesIO()
|
|
50
|
+
writer.write(destination)
|
|
51
|
+
writer.close()
|
|
52
|
+
return destination.getvalue()
|