quirework 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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
"""Airgap-ready artifacts for blind reader panels.
|
|
2
|
+
|
|
3
|
+
From a single fresh render, build the two artifact shapes the panels read:
|
|
4
|
+
|
|
5
|
+
- `book.pdf` -- the whole book, figures embedded (Path B: whole-book panels)
|
|
6
|
+
- `chapters/NN-slug.pdf` -- one PDF per reading segment (Path C: sequential reveal)
|
|
7
|
+
- `manifest.json` -- per-segment title, filename, page count, and the source SHA
|
|
8
|
+
|
|
9
|
+
Segments are sliced out of `book.pdf`, never rendered a second time. They retain
|
|
10
|
+
the published trim, typography, figures, running furniture, and part dividers.
|
|
11
|
+
A standalone chapter render would be a different edition and could make a
|
|
12
|
+
reader report a packaging defect as a defect in the canon.
|
|
13
|
+
|
|
14
|
+
The slice is taken from the PDF's **own outline**, not from a page-range table
|
|
15
|
+
and not by fingerprinting page text: `bookstage.book_outline` builds the same
|
|
16
|
+
labels Quarto writes as bookmarks, so the two are compared entry by entry and a
|
|
17
|
+
mismatch is a hard error rather than a kit that is off by a chapter. A template
|
|
18
|
+
that bookmarks no Part divider, as Quarto 1.9's Typst book template does, leaves
|
|
19
|
+
the dividers' pages to the links on the book's Contents page, which must agree
|
|
20
|
+
with every chapter's bookmark (`entry_pages`).
|
|
21
|
+
|
|
22
|
+
Segments are the units a reader actually consumes, which need not be one per file:
|
|
23
|
+
|
|
24
|
+
- unlisted front matter is one block, with any pages the template prints
|
|
25
|
+
before the book, such as a cover, a title page and its Contents;
|
|
26
|
+
- a Part divider RIDES on the chapter it opens, as it is bound in the book --
|
|
27
|
+
the reader turns the divider and the chapter starts;
|
|
28
|
+
- back matter is one block;
|
|
29
|
+
- each remaining chapter is its own segment.
|
|
30
|
+
|
|
31
|
+
`--run-dir` assembles the neutral directory a blind seat is pointed at: the body
|
|
32
|
+
segments only (preface through the last chapter), no `book.pdf`, and a manifest
|
|
33
|
+
describing exactly what was copied.
|
|
34
|
+
|
|
35
|
+
This module only builds the artifacts. It never launches a persona: the agent
|
|
36
|
+
drives the blind runs under the project procedure. The kit lives under `build/` (inside the
|
|
37
|
+
repo, git-ignored), so a blind seat must never read it in place -- assemble a run
|
|
38
|
+
dir out of it, or the repo's `AGENTS.md` leaks into the read.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import shutil
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from quirework.project import current_project
|
|
53
|
+
from quirework.publish import bookstage as _bookstage
|
|
54
|
+
from quirework.publish import render as _render
|
|
55
|
+
from quirework.delivery import text as _text
|
|
56
|
+
|
|
57
|
+
# the kit output tree (git-ignored)
|
|
58
|
+
# own render stage (see `build`)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _settings():
|
|
62
|
+
"""Resolve paths from the current operation, without import-time state."""
|
|
63
|
+
from types import SimpleNamespace
|
|
64
|
+
|
|
65
|
+
BUILD_KIT = current_project().path("publish.build", "build") / "panel-kit"
|
|
66
|
+
STAGE_KIT = current_project().path("publish.build", "build") / "panel-stage"
|
|
67
|
+
return SimpleNamespace(BUILD_KIT=BUILD_KIT, STAGE_KIT=STAGE_KIT)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pypdf():
|
|
71
|
+
"""The PDF library, or a message saying how to get it. Reading the outline and
|
|
72
|
+
cutting the segments are both structural operations on the rendered book, so
|
|
73
|
+
this is a real dependency of the kit rather than the soft fallback it is in
|
|
74
|
+
`page_count` -- it ships in the `docs` extra beside Quarto."""
|
|
75
|
+
try:
|
|
76
|
+
import pypdf
|
|
77
|
+
except ModuleNotFoundError:
|
|
78
|
+
raise SystemExit(
|
|
79
|
+
"packet: pypdf is required to slice the book (`pip install -e '.[docs]'`)."
|
|
80
|
+
) from None
|
|
81
|
+
return pypdf
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def slug(title: str) -> str:
|
|
85
|
+
"""A segment's filename stem, by the book's own chapter-naming rule: lowercased
|
|
86
|
+
and hyphenated, truncated at the first comma or colon."""
|
|
87
|
+
head = re.split(r"[,:]", title, maxsplit=1)[0]
|
|
88
|
+
return re.sub(r"[^a-z0-9]+", "-", head.lower()).strip("-") or "segment"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def page_count(pdf: Path) -> int:
|
|
92
|
+
"""Page count via poppler's `pdfinfo`, falling back to `pypdf`. Raises if neither
|
|
93
|
+
is available rather than ship a kit with a silent 0 -- the whole-book count drives
|
|
94
|
+
the panels' coverage gate ("read all N pages")."""
|
|
95
|
+
info = shutil.which("pdfinfo")
|
|
96
|
+
if info:
|
|
97
|
+
out = subprocess.run([info, str(pdf)], capture_output=True, text=True)
|
|
98
|
+
for line in out.stdout.splitlines():
|
|
99
|
+
if line.startswith("Pages:"):
|
|
100
|
+
return int(line.split(":", 1)[1].strip())
|
|
101
|
+
try:
|
|
102
|
+
import pypdf
|
|
103
|
+
|
|
104
|
+
return len(pypdf.PdfReader(str(pdf)).pages)
|
|
105
|
+
except ModuleNotFoundError:
|
|
106
|
+
pass
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"cannot count pages of {pdf.name}: install poppler (pdfinfo) or pypdf"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def outline_pages(pdf: Path) -> list[tuple[str, int]]:
|
|
113
|
+
"""The rendered book's own top-level bookmarks: `(title, first page)`, in order.
|
|
114
|
+
|
|
115
|
+
Quarto writes one per listed heading, from the same reading order and the
|
|
116
|
+
same H1s `bookstage.book_rows` walks, which is why the two can be checked
|
|
117
|
+
against each other. Whether a Part divider gets one depends on the template:
|
|
118
|
+
the Typst book template of Quarto 1.9 bookmarks none (see `entry_pages`).
|
|
119
|
+
Nested entries (a chapter's sections) are skipped: only the book's top level
|
|
120
|
+
cuts a segment."""
|
|
121
|
+
pypdf = _pypdf()
|
|
122
|
+
reader = pypdf.PdfReader(str(pdf))
|
|
123
|
+
rows: list[tuple[str, int]] = []
|
|
124
|
+
for item in reader.outline:
|
|
125
|
+
if isinstance(item, list): # a nested level, not a book entry
|
|
126
|
+
continue
|
|
127
|
+
rows.append((str(item.title), reader.get_destination_page_number(item) + 1))
|
|
128
|
+
return rows
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def heading_pages(pdf: Path) -> list[tuple[str, int]] | None:
|
|
132
|
+
"""The top-level headings the PDF's tags record: `(title, page)`, in order.
|
|
133
|
+
|
|
134
|
+
None for a PDF with no tags. The outline bookmarks only what the Contents
|
|
135
|
+
lists, and Typst writes a named destination only where something links, so
|
|
136
|
+
an unlisted page, such as a back cover, appears in neither. A tagged PDF,
|
|
137
|
+
which Typst writes by default from 0.14, records every heading for screen
|
|
138
|
+
readers in its structure tree, each with its page and its title. That is
|
|
139
|
+
the compiler's own account of the book, not a guess from a page's text.
|
|
140
|
+
A heading the tags give no title reads as an empty one."""
|
|
141
|
+
pypdf = _pypdf()
|
|
142
|
+
reader = pypdf.PdfReader(str(pdf))
|
|
143
|
+
tree = reader.trailer["/Root"].get("/StructTreeRoot")
|
|
144
|
+
if tree is None:
|
|
145
|
+
return None
|
|
146
|
+
number = {p.indirect_reference.idnum: n for n, p in enumerate(reader.pages, 1)}
|
|
147
|
+
|
|
148
|
+
def kids(node) -> list:
|
|
149
|
+
k = node.get("/K")
|
|
150
|
+
k = k.get_object() if hasattr(k, "get_object") else k
|
|
151
|
+
return list(k) if isinstance(k, list) else [] if k is None else [k]
|
|
152
|
+
|
|
153
|
+
def page_of(node) -> int | None:
|
|
154
|
+
pg = node.get("/Pg")
|
|
155
|
+
if pg is not None:
|
|
156
|
+
return number.get(pg.idnum)
|
|
157
|
+
for kid in kids(node):
|
|
158
|
+
kid = kid.get_object() if hasattr(kid, "get_object") else kid
|
|
159
|
+
if isinstance(kid, dict) and (found := page_of(kid)) is not None:
|
|
160
|
+
return found
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
found: list[tuple[str, int]] = []
|
|
164
|
+
stack = [tree.get_object()]
|
|
165
|
+
while stack:
|
|
166
|
+
node = stack.pop()
|
|
167
|
+
if node.get("/S") == "/H1":
|
|
168
|
+
page = page_of(node)
|
|
169
|
+
if page is not None:
|
|
170
|
+
found.append((str(node.get("/T", "")), page))
|
|
171
|
+
continue
|
|
172
|
+
for kid in reversed(kids(node)):
|
|
173
|
+
kid = kid.get_object() if hasattr(kid, "get_object") else kid
|
|
174
|
+
if isinstance(kid, dict):
|
|
175
|
+
stack.append(kid)
|
|
176
|
+
return found
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def contents_pages(pdf: Path, before: int) -> list[int]:
|
|
180
|
+
"""The pages the book's Contents page links to, in the order it lists them.
|
|
181
|
+
|
|
182
|
+
Read from the internal links on the pages before `before`, the first
|
|
183
|
+
bookmarked entry's page, which is where a book's Contents page sits; the
|
|
184
|
+
body's own cross-references are never read. A title and its page number
|
|
185
|
+
are two links to one place and count once."""
|
|
186
|
+
pypdf = _pypdf()
|
|
187
|
+
reader = pypdf.PdfReader(str(pdf))
|
|
188
|
+
numbers = {
|
|
189
|
+
(p.indirect_reference.idnum, p.indirect_reference.generation): n + 1
|
|
190
|
+
for n, p in enumerate(reader.pages)
|
|
191
|
+
}
|
|
192
|
+
named = reader.named_destinations
|
|
193
|
+
found: list[tuple[object, int]] = []
|
|
194
|
+
for page in reader.pages[: before - 1]:
|
|
195
|
+
links = []
|
|
196
|
+
for annot in page.get("/Annots") or []:
|
|
197
|
+
annot = annot.get_object()
|
|
198
|
+
if annot.get("/Subtype") != "/Link":
|
|
199
|
+
continue
|
|
200
|
+
action = annot.get("/A")
|
|
201
|
+
dest = (
|
|
202
|
+
annot.get("/Dest") if action is None else action.get_object().get("/D")
|
|
203
|
+
)
|
|
204
|
+
if dest is None: # a link to the web, not into the book
|
|
205
|
+
continue
|
|
206
|
+
if isinstance(dest, str):
|
|
207
|
+
if dest not in named:
|
|
208
|
+
continue
|
|
209
|
+
key, target = dest, reader.get_destination_page_number(named[dest]) + 1
|
|
210
|
+
else:
|
|
211
|
+
ref = dest[0].indirect_reference or dest[0]
|
|
212
|
+
target = numbers.get((ref.idnum, ref.generation))
|
|
213
|
+
if target is None:
|
|
214
|
+
continue
|
|
215
|
+
key = (target, *(str(v) for v in dest[1:]))
|
|
216
|
+
left, _, _, top = (float(v) for v in annot["/Rect"])
|
|
217
|
+
links.append((-top, left, key, target))
|
|
218
|
+
found.extend((key, target) for _, _, key, target in sorted(links))
|
|
219
|
+
pages: list[int] = []
|
|
220
|
+
last = None
|
|
221
|
+
for key, target in found:
|
|
222
|
+
if key != last:
|
|
223
|
+
pages.append(target)
|
|
224
|
+
last = key
|
|
225
|
+
return pages
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# A chapter's bookmark carries its printed number however the template writes
|
|
229
|
+
# it: "4 Title" in one, "4. Title" in Quarto 1.9's Typst book template.
|
|
230
|
+
_NUMBERED = re.compile(r"^(\d+)\.?\s+")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _label(text: str) -> str:
|
|
234
|
+
return _NUMBERED.sub(r"\1 ", text.strip())
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def entry_pages(
|
|
238
|
+
outline: list[tuple[str, str]],
|
|
239
|
+
bookmarks: list[tuple[str, int]],
|
|
240
|
+
contents: list[int],
|
|
241
|
+
) -> list[int]:
|
|
242
|
+
"""The first page of every listed entry, checked against the book's order.
|
|
243
|
+
|
|
244
|
+
`outline` is `bookstage.book_outline`'s `(kind, label)`; `bookmarks` the
|
|
245
|
+
rendered book's `outline_pages`; `contents` its `contents_pages`. A template
|
|
246
|
+
that bookmarks every Part answers alone. One that bookmarks only chapters,
|
|
247
|
+
as Quarto 1.9's Typst book template does, leaves the Parts to the Contents
|
|
248
|
+
page, which links every entry; its link for each chapter must then land on
|
|
249
|
+
that chapter's bookmarked page, or a Part's page is not to be trusted.
|
|
250
|
+
Raises ValueError, naming the first disagreement."""
|
|
251
|
+
want = [_label(label) for _, label in outline]
|
|
252
|
+
titles = [_label(title) for title, _ in bookmarks]
|
|
253
|
+
parts = {label for kind, label in outline if kind == "part"}
|
|
254
|
+
chapters = [label for (kind, _), label in zip(outline, want) if kind == "chapter"]
|
|
255
|
+
expected = want if not parts or parts & set(titles) else chapters
|
|
256
|
+
if titles != expected:
|
|
257
|
+
for n, (a, b) in enumerate(zip(expected, titles)):
|
|
258
|
+
if a != b:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"packet: the rendered outline diverges from the book's "
|
|
261
|
+
f"reading order at entry {n + 1}: {a!r} vs {b!r}"
|
|
262
|
+
)
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"packet: the book lists {len(expected)} entries, its rendered "
|
|
265
|
+
f"outline {len(titles)}"
|
|
266
|
+
)
|
|
267
|
+
if expected is want:
|
|
268
|
+
return [page for _, page in bookmarks]
|
|
269
|
+
if len(contents) != len(want):
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"packet: the rendered book bookmarks no Part, and its Contents page "
|
|
272
|
+
f"links {len(contents)} entries where the book lists {len(want)}, so "
|
|
273
|
+
"the kit cannot place its Parts"
|
|
274
|
+
)
|
|
275
|
+
marks = iter(page for _, page in bookmarks)
|
|
276
|
+
for (kind, _), label, page in zip(outline, want, contents):
|
|
277
|
+
if kind == "chapter" and page != (mark := next(marks)):
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"packet: the Contents page sends {label!r} to page {page}, its "
|
|
280
|
+
f"bookmark to page {mark}"
|
|
281
|
+
)
|
|
282
|
+
return contents
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass
|
|
286
|
+
class Segment:
|
|
287
|
+
"""One unit of the kit: a slice of `book.pdf` a reader consumes in one sitting."""
|
|
288
|
+
|
|
289
|
+
kind: str # "front-matter" | "chapter" | "back-matter"
|
|
290
|
+
stem: str # filename stem (a chapter's `.qmd` stem)
|
|
291
|
+
title: str
|
|
292
|
+
start: int # first page in book.pdf, 1-based inclusive
|
|
293
|
+
end: int = 0 # last page, filled in once the next segment is known
|
|
294
|
+
number: int | None = None # the printed chapter number, None for everything else
|
|
295
|
+
part: str | None = None # the Part this segment sits in
|
|
296
|
+
opens_part: str | None = None # the Part whose divider page rides on this segment
|
|
297
|
+
contains: list[str] = field(default_factory=list) # a block's constituent pages
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def pages(self) -> int:
|
|
301
|
+
return self.end - self.start + 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def body_span(rows: list[_bookstage.Row]) -> tuple[int, int]:
|
|
305
|
+
"""The book's body as `(head, tail)`, the slice `rows[head:tail]`.
|
|
306
|
+
|
|
307
|
+
The body runs from the first listed entry through the last. The unlisted
|
|
308
|
+
pages before it are front matter, such as a welcome or a copyright page.
|
|
309
|
+
The unlisted pages after it are back matter, such as a back cover written
|
|
310
|
+
the way the authoring guide shows, `.unnumbered .unlisted` after the last
|
|
311
|
+
chapter. A reader turns past both, and the Contents lists neither.
|
|
312
|
+
|
|
313
|
+
The packet slices on this and a self-driven directory leaves out what it
|
|
314
|
+
excludes, so the two cannot disagree about where a book starts and ends.
|
|
315
|
+
When each kept its own copy, one handed a reader the back cover as a
|
|
316
|
+
chapter and the other refused the book."""
|
|
317
|
+
listed = [i for i, r in enumerate(rows) if r.kind == "part" or r.listed]
|
|
318
|
+
if not listed:
|
|
319
|
+
return len(rows), len(rows)
|
|
320
|
+
return listed[0], listed[-1] + 1
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def body_parts(rows: list[_bookstage.Row]) -> set[str | None]:
|
|
324
|
+
"""The Parts that hold a numbered chapter, None standing for none at all.
|
|
325
|
+
Any other Part is matter, such as a "Back matter" Part of sources and a
|
|
326
|
+
colophon, and a packet collapses it into one block."""
|
|
327
|
+
return {r.part for r in rows if r.kind == "chapter" and r.number is not None}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def back_matter_start(
|
|
331
|
+
headings: list[tuple[str, int]] | None,
|
|
332
|
+
rows: list[_bookstage.Row],
|
|
333
|
+
starts: list[int],
|
|
334
|
+
) -> int | None:
|
|
335
|
+
"""The page the unlisted pages after the body start on, from the PDF's
|
|
336
|
+
tagged `headings`, or None when the tags do not show it.
|
|
337
|
+
|
|
338
|
+
`rows` and `starts` are what `plan_segments` takes. The tags are trusted
|
|
339
|
+
only where they agree with the outline: every listed chapter's heading
|
|
340
|
+
must be tagged, in order, on the page its bookmark names. Otherwise the
|
|
341
|
+
two describe different books, and this raises rather than cut the back
|
|
342
|
+
matter off at a guess."""
|
|
343
|
+
_, tail = body_span(rows)
|
|
344
|
+
if tail == len(rows) or headings is None:
|
|
345
|
+
return None
|
|
346
|
+
key = _bookstage.heading_key
|
|
347
|
+
listed = [i for i, r in enumerate(rows) if r.kind == "part" or r.listed]
|
|
348
|
+
seen = -1
|
|
349
|
+
for i, page in zip(listed, starts):
|
|
350
|
+
if rows[i].kind != "chapter":
|
|
351
|
+
continue
|
|
352
|
+
want = key(rows[i].title)
|
|
353
|
+
at = next(
|
|
354
|
+
(j for j in range(seen + 1, len(headings)) if key(headings[j][0]) == want),
|
|
355
|
+
None,
|
|
356
|
+
)
|
|
357
|
+
if at is None or headings[at][1] != page:
|
|
358
|
+
tagged = "untagged" if at is None else f"tagged on page {headings[at][1]}"
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"packet: the PDF bookmarks {rows[i].title} on page {page} but has "
|
|
361
|
+
f"it {tagged}, so its tags cannot place the back matter"
|
|
362
|
+
)
|
|
363
|
+
seen = at
|
|
364
|
+
first = key(rows[tail].title)
|
|
365
|
+
return next((p for title, p in headings[seen + 1 :] if key(title) == first), None)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def plan_segments(
|
|
369
|
+
rows: list[_bookstage.Row],
|
|
370
|
+
starts: list[int],
|
|
371
|
+
total: int,
|
|
372
|
+
back_start: int | None = None,
|
|
373
|
+
) -> list[Segment]:
|
|
374
|
+
"""Group the book's reading order into the segments a reader consumes.
|
|
375
|
+
|
|
376
|
+
`rows` is `bookstage.book_rows` (parts intact); `starts` is the first page of
|
|
377
|
+
each row that the PDF outline lists, in the same order; `total` is the book's
|
|
378
|
+
page count. `back_start` is the page the unlisted pages after the body begin
|
|
379
|
+
on, which no outline records; a book with such pages needs it. Pure, so the
|
|
380
|
+
grouping rules are testable without rendering a book.
|
|
381
|
+
|
|
382
|
+
A Part divider is not a segment. It rides on the chapter it opens, because
|
|
383
|
+
that is where it is bound and where the reader turns it. A Part with no
|
|
384
|
+
numbered chapter in it is matter rather than body, so it collapses -- divider
|
|
385
|
+
and all its pages -- into one block; that rule is what makes the back matter
|
|
386
|
+
a single file without naming it in code. The unlisted pages after the body
|
|
387
|
+
(`body_span`) are one back-matter block too, or join the matter Part they
|
|
388
|
+
follow."""
|
|
389
|
+
listed = [i for i, r in enumerate(rows) if r.kind == "part" or r.listed]
|
|
390
|
+
if len(listed) != len(starts):
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"packet: the book lists {len(listed)} entries but its "
|
|
393
|
+
f"PDF outline has {len(starts)}"
|
|
394
|
+
)
|
|
395
|
+
if not listed:
|
|
396
|
+
raise ValueError("packet: the book has no listed entries to slice on")
|
|
397
|
+
page_of = dict(zip(listed, starts))
|
|
398
|
+
head, tail = body_span(rows)
|
|
399
|
+
kept = body_parts(rows)
|
|
400
|
+
matter_parts = {r.title for r in rows if r.kind == "part"} - kept
|
|
401
|
+
for r in rows[head + 1 : tail]:
|
|
402
|
+
if r.kind == "chapter" and not r.listed and r.part not in matter_parts:
|
|
403
|
+
raise ValueError(
|
|
404
|
+
"packet: an .unlisted page inside the body has no segment "
|
|
405
|
+
f"to sit in: {r.title}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
segs: list[Segment] = []
|
|
409
|
+
|
|
410
|
+
# The pages before the first entry: the book's unlisted ones, and any the
|
|
411
|
+
# template prints itself, such as a cover, a title page and its Contents.
|
|
412
|
+
if head > 0 or page_of[head] > 1:
|
|
413
|
+
segs.append(
|
|
414
|
+
Segment(
|
|
415
|
+
"front-matter",
|
|
416
|
+
"front-matter",
|
|
417
|
+
"Front matter",
|
|
418
|
+
start=1,
|
|
419
|
+
contains=[r.title for r in rows[:head]],
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
pending: tuple[str, int] | None = None # a Part divider waiting for its chapter
|
|
424
|
+
i = head
|
|
425
|
+
while i < tail:
|
|
426
|
+
r = rows[i]
|
|
427
|
+
if r.kind == "part":
|
|
428
|
+
if r.title in kept:
|
|
429
|
+
pending = (r.title, page_of[i])
|
|
430
|
+
i += 1
|
|
431
|
+
continue
|
|
432
|
+
segs.append(
|
|
433
|
+
Segment( # matter, not body: one block
|
|
434
|
+
"back-matter",
|
|
435
|
+
slug(r.title),
|
|
436
|
+
r.title,
|
|
437
|
+
start=page_of[i],
|
|
438
|
+
contains=[c.title for c in rows[i + 1 :] if c.part == r.title],
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
i += 1
|
|
442
|
+
while i < tail and rows[i].part == r.title:
|
|
443
|
+
i += 1
|
|
444
|
+
pending = None
|
|
445
|
+
continue
|
|
446
|
+
assert r.rel is not None
|
|
447
|
+
opens, start = pending or (None, page_of[i])
|
|
448
|
+
segs.append(
|
|
449
|
+
Segment(
|
|
450
|
+
"chapter",
|
|
451
|
+
Path(r.rel).stem,
|
|
452
|
+
r.title,
|
|
453
|
+
start=start,
|
|
454
|
+
number=r.number,
|
|
455
|
+
part=r.part,
|
|
456
|
+
opens_part=opens,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
pending = None
|
|
460
|
+
i += 1
|
|
461
|
+
|
|
462
|
+
# The unlisted pages after the body. After a matter Part they are its own
|
|
463
|
+
# last pages, or follow it, and its block already runs to the end of the
|
|
464
|
+
# book. After a chapter they need a block, and a page to start it on.
|
|
465
|
+
trailing = rows[tail:]
|
|
466
|
+
if trailing and segs[-1].kind == "back-matter":
|
|
467
|
+
last = segs[-1]
|
|
468
|
+
last.contains += [r.title for r in trailing if r.part != last.title]
|
|
469
|
+
elif trailing:
|
|
470
|
+
if back_start is None:
|
|
471
|
+
raise ValueError(
|
|
472
|
+
"packet: the book ends with unlisted pages that neither the PDF's "
|
|
473
|
+
f"outline nor its tags place: {', '.join(r.title for r in trailing)}"
|
|
474
|
+
)
|
|
475
|
+
segs.append(
|
|
476
|
+
Segment(
|
|
477
|
+
"back-matter",
|
|
478
|
+
"back-matter",
|
|
479
|
+
"Back matter",
|
|
480
|
+
start=back_start,
|
|
481
|
+
contains=[r.title for r in trailing],
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
for this, nxt in zip(segs, segs[1:]):
|
|
486
|
+
this.end = nxt.start - 1
|
|
487
|
+
segs[-1].end = total
|
|
488
|
+
if segs[0].start != 1 or any(s.end < s.start for s in segs):
|
|
489
|
+
raise ValueError("packet: the segment plan does not cover the book")
|
|
490
|
+
return segs
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _source_sha() -> tuple[str, bool]:
|
|
494
|
+
"""(short HEAD SHA, working-tree-dirty) for configured book sources.
|
|
495
|
+
The kit renders the working tree, so a dirty flag says the read includes
|
|
496
|
+
uncommitted edits. Returns ("unknown", False) outside a git checkout."""
|
|
497
|
+
revision = _render.source_revision()
|
|
498
|
+
if revision is None:
|
|
499
|
+
return "unknown", False
|
|
500
|
+
return revision["short"], revision["modified"]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _entry(seq: int, seg: Segment) -> dict:
|
|
504
|
+
"""One `manifest.json` row. `number` is the number PRINTED on the page (null for
|
|
505
|
+
the preface and the matter blocks), so a finding can be matched back to the book
|
|
506
|
+
without counting files; `opens_part` names the divider a segment carries."""
|
|
507
|
+
row = {
|
|
508
|
+
"seq": seq,
|
|
509
|
+
"file": f"chapters/{seq:02d}-{seg.stem}.pdf",
|
|
510
|
+
"kind": seg.kind,
|
|
511
|
+
"stem": seg.stem,
|
|
512
|
+
"title": seg.title,
|
|
513
|
+
"number": seg.number,
|
|
514
|
+
"part": seg.part,
|
|
515
|
+
"opens_part": seg.opens_part,
|
|
516
|
+
"pages": seg.pages,
|
|
517
|
+
"book_pages": [seg.start, seg.end],
|
|
518
|
+
}
|
|
519
|
+
if seg.contains:
|
|
520
|
+
row["contains"] = seg.contains
|
|
521
|
+
return row
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _cut(reader, seg: Segment, dest: Path) -> None:
|
|
525
|
+
"""Write one segment's pages out as their own PDF."""
|
|
526
|
+
pypdf = _pypdf()
|
|
527
|
+
writer = pypdf.PdfWriter()
|
|
528
|
+
for n in range(seg.start - 1, seg.end):
|
|
529
|
+
writer.add_page(reader.pages[n])
|
|
530
|
+
with dest.open("wb") as fh:
|
|
531
|
+
writer.write(fh)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def build(out_dir: Path) -> int:
|
|
535
|
+
quarto = _render.find_quarto()
|
|
536
|
+
if not quarto:
|
|
537
|
+
print(
|
|
538
|
+
"packet: quarto not found. Install it (`pip install -e '.[docs]'`).",
|
|
539
|
+
file=sys.stderr,
|
|
540
|
+
)
|
|
541
|
+
return 127
|
|
542
|
+
pypdf = _pypdf()
|
|
543
|
+
|
|
544
|
+
# Own stage: the whole-book typst render below writes a _site, and Quarto
|
|
545
|
+
# cleans other formats' output from a shared _site — on build/book/ that
|
|
546
|
+
# deleted the site render's HTML out from under it.
|
|
547
|
+
rc = _render.stage_book_reader(_settings().STAGE_KIT)
|
|
548
|
+
if rc:
|
|
549
|
+
return rc
|
|
550
|
+
stage = _settings().STAGE_KIT
|
|
551
|
+
|
|
552
|
+
rc = subprocess.call(
|
|
553
|
+
[quarto, "render", "--to", "typst", "--output-dir", "_site"], cwd=stage
|
|
554
|
+
)
|
|
555
|
+
if rc:
|
|
556
|
+
return rc
|
|
557
|
+
book_pdfs = list((stage / "_site").glob("*.pdf"))
|
|
558
|
+
if not book_pdfs:
|
|
559
|
+
print("packet: whole-book render produced no PDF", file=sys.stderr)
|
|
560
|
+
return 1
|
|
561
|
+
book_pdf = max(book_pdfs, key=lambda p: p.stat().st_mtime)
|
|
562
|
+
|
|
563
|
+
# The alignment guard: the book's own reading order against the bookmarks the
|
|
564
|
+
# renderer wrote from it. Equal, or the slice is off by a chapter and nobody
|
|
565
|
+
# finds out until a reader reports a book that skips one.
|
|
566
|
+
rows = _bookstage.book_rows(stage)
|
|
567
|
+
outline = [(kind, label) for kind, label, _ in _bookstage.book_outline(stage)]
|
|
568
|
+
got = outline_pages(book_pdf)
|
|
569
|
+
total = page_count(book_pdf)
|
|
570
|
+
try:
|
|
571
|
+
contents = contents_pages(book_pdf, got[0][1] if got else total + 1)
|
|
572
|
+
starts = entry_pages(outline, got, contents)
|
|
573
|
+
# Unlisted pages after the body have no bookmark; the tags place them.
|
|
574
|
+
back = None
|
|
575
|
+
if body_span(rows)[1] < len(rows):
|
|
576
|
+
back = back_matter_start(heading_pages(book_pdf), rows, starts)
|
|
577
|
+
segs = plan_segments(rows, starts, total, back)
|
|
578
|
+
except ValueError as exc:
|
|
579
|
+
print(exc, file=sys.stderr)
|
|
580
|
+
return 1
|
|
581
|
+
|
|
582
|
+
if out_dir.exists():
|
|
583
|
+
shutil.rmtree(out_dir) # never let a dropped segment linger
|
|
584
|
+
chapters_dir = out_dir / "chapters"
|
|
585
|
+
chapters_dir.mkdir(parents=True)
|
|
586
|
+
shutil.copyfile(book_pdf, out_dir / "book.pdf")
|
|
587
|
+
|
|
588
|
+
reader = pypdf.PdfReader(str(out_dir / "book.pdf"))
|
|
589
|
+
entries = [_entry(n, s) for n, s in enumerate(segs, start=1)]
|
|
590
|
+
for seg, entry in zip(segs, entries):
|
|
591
|
+
_cut(reader, seg, out_dir / entry["file"])
|
|
592
|
+
|
|
593
|
+
sha, dirty = _source_sha()
|
|
594
|
+
manifest = {
|
|
595
|
+
"source_sha": sha,
|
|
596
|
+
"dirty": dirty,
|
|
597
|
+
"book": {"file": "book.pdf", "pages": total},
|
|
598
|
+
"chapters": entries,
|
|
599
|
+
}
|
|
600
|
+
(out_dir / "manifest.json").write_text(
|
|
601
|
+
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
dividers = [e["opens_part"] for e in entries if e["opens_part"]]
|
|
605
|
+
print(
|
|
606
|
+
f"packet: {_shown(out_dir)}/ -- book.pdf ({total} pp), "
|
|
607
|
+
f"{len(entries)} segment PDF(s) sliced from it, manifest.json"
|
|
608
|
+
+ (" [dirty tree]" if dirty else "")
|
|
609
|
+
)
|
|
610
|
+
print(
|
|
611
|
+
f"packet: {len(dividers)} Part divider(s) ride on the chapter they open "
|
|
612
|
+
f"({', '.join(dividers) or '-'})"
|
|
613
|
+
)
|
|
614
|
+
print("assemble a run dir out of it (`--run-dir`); never point a seat at build/.")
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def assemble(kit_dir: Path, run_dir: Path, matter: bool = False) -> int:
|
|
619
|
+
"""Copy a kit's segments into a neutral run dir, with a manifest of exactly those.
|
|
620
|
+
|
|
621
|
+
The body only by default -- the preface through the last chapter. The front and
|
|
622
|
+
back matter are each one block and most seats have no business reading either:
|
|
623
|
+
a trade reader, a high-schooler or a persona seat spends its tokens on the book,
|
|
624
|
+
not on the copyright page and the colophon. `--with-matter` ships them for the
|
|
625
|
+
reads that do want them.
|
|
626
|
+
|
|
627
|
+
No `book.pdf`: a sequential read must not be able to read ahead. And the
|
|
628
|
+
kept segments are **renumbered from 1**, because a run dir that opened at
|
|
629
|
+
`02-preface.pdf` would tell the seat, in the file name, that something was
|
|
630
|
+
withheld from it. Nothing is lost by it: a row still carries the `stem`, the
|
|
631
|
+
printed chapter `number` and the `book_pages` it was cut from."""
|
|
632
|
+
manifest_path = kit_dir / "manifest.json"
|
|
633
|
+
if not manifest_path.is_file():
|
|
634
|
+
print(f"packet: no kit at {_shown(kit_dir)}/ (build it first)", file=sys.stderr)
|
|
635
|
+
return 1
|
|
636
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
637
|
+
kept = [e for e in manifest["chapters"] if matter or e["kind"] == "chapter"]
|
|
638
|
+
if not kept:
|
|
639
|
+
print("packet: nothing to assemble", file=sys.stderr)
|
|
640
|
+
return 1
|
|
641
|
+
entries = []
|
|
642
|
+
for seq, entry in enumerate(kept, start=1):
|
|
643
|
+
row = dict(entry, seq=seq, file=f"chapters/{seq:02d}-{entry['stem']}.pdf")
|
|
644
|
+
entries.append((entry["file"], row))
|
|
645
|
+
|
|
646
|
+
if run_dir.exists() and any(run_dir.iterdir()):
|
|
647
|
+
print(
|
|
648
|
+
f"packet: {run_dir} is not empty -- a run dir is assembled fresh, "
|
|
649
|
+
"so name a new one rather than reusing a read's directory",
|
|
650
|
+
file=sys.stderr,
|
|
651
|
+
)
|
|
652
|
+
return 1
|
|
653
|
+
(run_dir / "chapters").mkdir(parents=True, exist_ok=True)
|
|
654
|
+
for src_rel, row in entries:
|
|
655
|
+
src = kit_dir / src_rel
|
|
656
|
+
if not src.is_file():
|
|
657
|
+
print(f"packet: {src_rel} is missing from the kit", file=sys.stderr)
|
|
658
|
+
return 1
|
|
659
|
+
shutil.copyfile(src, run_dir / row["file"])
|
|
660
|
+
|
|
661
|
+
rows = [row for _, row in entries]
|
|
662
|
+
out = {
|
|
663
|
+
"source_sha": manifest["source_sha"],
|
|
664
|
+
"dirty": manifest["dirty"],
|
|
665
|
+
"chapters": rows,
|
|
666
|
+
}
|
|
667
|
+
(run_dir / "manifest.json").write_text(
|
|
668
|
+
json.dumps(out, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
pages = sum(r["pages"] for r in rows)
|
|
672
|
+
print(
|
|
673
|
+
f"packet: {run_dir}/ -- {len(rows)} segment(s), {pages} pp, "
|
|
674
|
+
"manifest.json, no book.pdf" + (" [dirty tree]" if manifest["dirty"] else "")
|
|
675
|
+
)
|
|
676
|
+
print(
|
|
677
|
+
"airgap check: `ls -a` it -- chapters/, manifest.json, and whatever "
|
|
678
|
+
"prompt the SOP writes in, and nothing else."
|
|
679
|
+
)
|
|
680
|
+
return 0
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _fail(message: str, code: int = 1) -> int:
|
|
684
|
+
"""Report a fatal packet error after everything already printed.
|
|
685
|
+
|
|
686
|
+
Progress goes to stdout and errors to stderr, and stdout is buffered
|
|
687
|
+
when it is not a terminal, so without the flush an error would print
|
|
688
|
+
ahead of the progress lines that came before it and read as if the
|
|
689
|
+
build had gone on past it.
|
|
690
|
+
"""
|
|
691
|
+
sys.stdout.flush()
|
|
692
|
+
print(f"packet: {message}", file=sys.stderr)
|
|
693
|
+
return code
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def manifest_path(out: Path) -> Path:
|
|
697
|
+
"""Where a prose directory's provenance lives: beside it, never in it.
|
|
698
|
+
|
|
699
|
+
The directory itself is what a seat mounts, and it must hold its
|
|
700
|
+
chapters and figures and nothing else, so its record of which book it
|
|
701
|
+
came from sits one level up, where the seat is not given access.
|
|
702
|
+
"""
|
|
703
|
+
return out.parent / f"{out.name}.manifest.json"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def part_heads(rows) -> list[str]:
|
|
707
|
+
"""The Part line each body chapter of a prose directory opens with.
|
|
708
|
+
|
|
709
|
+
A print reader turns a divider page before a Part's first chapter, and from
|
|
710
|
+
then on knows which Part they are in. A prose directory has no pages, so a
|
|
711
|
+
seat reading it could not say where one Part ended, and its remarks about
|
|
712
|
+
the book's structure described the delivery instead of the book. Each
|
|
713
|
+
chapter file therefore carries what the pages would have shown: the first
|
|
714
|
+
chapter of a Part opens with the divider, its Part's title on its own above
|
|
715
|
+
a rule, and every later chapter of that Part opens with a running head
|
|
716
|
+
naming it. A chapter outside any Part, such as a preface, carries neither.
|
|
717
|
+
|
|
718
|
+
The titles are the printed ones from `book_rows`, so the directory cannot
|
|
719
|
+
name a Part differently from the divider in the book.
|
|
720
|
+
"""
|
|
721
|
+
heads, current = [], None
|
|
722
|
+
for row in rows:
|
|
723
|
+
if row.part is None:
|
|
724
|
+
heads.append("")
|
|
725
|
+
elif row.part != current:
|
|
726
|
+
heads.append(f"# {row.part}\n\n---\n\n")
|
|
727
|
+
else:
|
|
728
|
+
heads.append(f"*{row.part}*\n\n")
|
|
729
|
+
current = row.part
|
|
730
|
+
return heads
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def prose_dir(out: Path) -> int:
|
|
734
|
+
"""Build the directory a SELF-DRIVEN seat reads: clean chapter text, real names.
|
|
735
|
+
|
|
736
|
+
The packet's PDFs are for a seat that is handed its pages. A self-driven seat
|
|
737
|
+
instead walks a directory with its own tools, one chapter at a time, and that
|
|
738
|
+
needs two things the packet cannot give it. The chapter text has to be the
|
|
739
|
+
reader-facing projection rather than the source, or the marks and anchors
|
|
740
|
+
leak. And the figure files have to keep the names the chapter text
|
|
741
|
+
references, which frozen `file` delivery deliberately destroys: it renames
|
|
742
|
+
every material to `material-NNN.ext` so a filename cannot leak information,
|
|
743
|
+
and a chapter asking for `doubling.png` in a directory of `material-024.png`
|
|
744
|
+
stops the read.
|
|
745
|
+
|
|
746
|
+
Reading order and chapter numbering come from `bookstage.book_rows`, the one
|
|
747
|
+
owner, so a self-driven read cannot disagree with the printed book about
|
|
748
|
+
which chapter is which. Matter is excluded, as it is for an assembled run
|
|
749
|
+
directory.
|
|
750
|
+
"""
|
|
751
|
+
stage = _settings().STAGE_KIT
|
|
752
|
+
rc = _render.stage_book_reader(stage)
|
|
753
|
+
if rc:
|
|
754
|
+
return rc
|
|
755
|
+
# The body, by the rules the packet slices on (`body_span`, `body_parts`),
|
|
756
|
+
# because `kind == "chapter"` alone also matches a copyright page, a back
|
|
757
|
+
# cover or a colophon. What is left is what a reader reads.
|
|
758
|
+
all_rows = _bookstage.book_rows(stage)
|
|
759
|
+
head, tail = body_span(all_rows)
|
|
760
|
+
kept = body_parts(all_rows)
|
|
761
|
+
rows = [
|
|
762
|
+
r
|
|
763
|
+
for r in all_rows[head:tail]
|
|
764
|
+
if r.kind == "chapter" and r.rel and (r.part is None or r.part in kept)
|
|
765
|
+
]
|
|
766
|
+
# The back matter the directory leaves out, by title. A reader knows it is
|
|
767
|
+
# there, as a print reader sees the pages after the last chapter, so the
|
|
768
|
+
# plan names it to the seat, without the pages.
|
|
769
|
+
back = [
|
|
770
|
+
r.title
|
|
771
|
+
for n, r in enumerate(all_rows)
|
|
772
|
+
if r.kind == "chapter"
|
|
773
|
+
and n >= head
|
|
774
|
+
and (n >= tail or (r.part is not None and r.part not in kept))
|
|
775
|
+
]
|
|
776
|
+
if out.exists() and any(out.iterdir()):
|
|
777
|
+
return _fail(f"{_shown(out)} is not empty -- name a fresh directory")
|
|
778
|
+
(out / "chapters").mkdir(parents=True, exist_ok=True)
|
|
779
|
+
(out / "figures").mkdir(exist_ok=True)
|
|
780
|
+
figures = 0
|
|
781
|
+
parts = {}
|
|
782
|
+
for i, (row, head) in enumerate(zip(rows, part_heads(rows)), start=1):
|
|
783
|
+
source = stage / row.rel
|
|
784
|
+
# No alt text: the seat opens the figure files themselves, and a
|
|
785
|
+
# description beside each embed let one rate plates it never opened.
|
|
786
|
+
text, refs = _text.reader_markdown(
|
|
787
|
+
source.read_text(encoding="utf-8"), alt=False
|
|
788
|
+
)
|
|
789
|
+
stem = Path(row.rel).stem
|
|
790
|
+
name = f"{i:02d}-{stem}.md"
|
|
791
|
+
(out / "chapters" / name).write_text(head + text, encoding="utf-8")
|
|
792
|
+
parts[name] = row.part
|
|
793
|
+
for ref in refs:
|
|
794
|
+
# The embed is relative to the chapter that carries it, which is the
|
|
795
|
+
# only placement the projection itself assumes. Resolving it against
|
|
796
|
+
# a named gallery directory instead would make this command work for
|
|
797
|
+
# one layout and fail silently for the next.
|
|
798
|
+
original = (source.parent / ref["source"]).resolve()
|
|
799
|
+
target = out / "figures" / Path(ref["file"]).name
|
|
800
|
+
if target.exists():
|
|
801
|
+
continue
|
|
802
|
+
if not original.is_file():
|
|
803
|
+
return _fail(
|
|
804
|
+
f"{_shown(source)} references {ref['source']}, "
|
|
805
|
+
"which is not in the stage"
|
|
806
|
+
)
|
|
807
|
+
if original.suffix.lower() == ".svg":
|
|
808
|
+
if not shutil.which("rsvg-convert"):
|
|
809
|
+
return _fail(
|
|
810
|
+
"rsvg-convert not found (Fedora: sudo dnf "
|
|
811
|
+
"install librsvg2-tools)",
|
|
812
|
+
127,
|
|
813
|
+
)
|
|
814
|
+
subprocess.run(
|
|
815
|
+
["rsvg-convert", "-w", "1600", "-o", str(target), str(original)],
|
|
816
|
+
check=True,
|
|
817
|
+
capture_output=True,
|
|
818
|
+
)
|
|
819
|
+
elif original.suffix.lower() == target.suffix.lower():
|
|
820
|
+
shutil.copyfile(original, target)
|
|
821
|
+
else:
|
|
822
|
+
# The projection names every figure `.png`, so anything else
|
|
823
|
+
# would land under a name that misdescribes its bytes.
|
|
824
|
+
return _fail(
|
|
825
|
+
f"{ref['source']} is neither SVG nor PNG; convert "
|
|
826
|
+
"it in the figure set rather than here"
|
|
827
|
+
)
|
|
828
|
+
figures += 1
|
|
829
|
+
# The provenance a mounted read cannot carry inside its own directory:
|
|
830
|
+
# which book state this is, whether it had uncommitted edits, and a digest
|
|
831
|
+
# of exactly these bytes, so a plan can pin what the seat will read and a
|
|
832
|
+
# later reader can check it.
|
|
833
|
+
from quirework.runs.storage import hashes, tree_digest
|
|
834
|
+
|
|
835
|
+
sha, dirty = _source_sha()
|
|
836
|
+
record = manifest_path(out)
|
|
837
|
+
record.write_text(
|
|
838
|
+
json.dumps(
|
|
839
|
+
{
|
|
840
|
+
"source_sha": sha,
|
|
841
|
+
"dirty": dirty,
|
|
842
|
+
"chapters": [p.name for p in sorted((out / "chapters").glob("*.md"))],
|
|
843
|
+
# Which Part each chapter file sits in, so a read's answers
|
|
844
|
+
# about the book's structure can be checked against it.
|
|
845
|
+
"parts": parts,
|
|
846
|
+
"back_matter": back,
|
|
847
|
+
"files": hashes(out),
|
|
848
|
+
"digest": tree_digest(out),
|
|
849
|
+
},
|
|
850
|
+
indent=2,
|
|
851
|
+
ensure_ascii=False,
|
|
852
|
+
)
|
|
853
|
+
+ "\n",
|
|
854
|
+
encoding="utf-8",
|
|
855
|
+
)
|
|
856
|
+
print(
|
|
857
|
+
f"packet: {_shown(out)}/ -- {len(rows)} chapter file(s), "
|
|
858
|
+
f"{figures} figure(s), no book.pdf" + (" [dirty tree]" if dirty else "")
|
|
859
|
+
)
|
|
860
|
+
print(f"packet: provenance in {_shown(record)}")
|
|
861
|
+
print(
|
|
862
|
+
"airgap check: `ls -a` it -- chapters/, figures/, and nothing else. Mount "
|
|
863
|
+
"it read-only at a path that does not name this project; a seat with a "
|
|
864
|
+
"shell probes its surroundings before it reads."
|
|
865
|
+
)
|
|
866
|
+
return 0
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _shown(path: Path) -> Path:
|
|
870
|
+
try:
|
|
871
|
+
return path.relative_to(current_project().root)
|
|
872
|
+
except ValueError:
|
|
873
|
+
return path
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def main(argv=None) -> int:
|
|
877
|
+
ap = argparse.ArgumentParser(
|
|
878
|
+
prog="quirework publish packet",
|
|
879
|
+
description="Build airgap-ready panel artifacts (whole book + one PDF per "
|
|
880
|
+
"reading segment, sliced from it, + manifest).",
|
|
881
|
+
)
|
|
882
|
+
ap.add_argument("--out", default=None, help="kit dir (default: build/panel-kit/)")
|
|
883
|
+
ap.add_argument(
|
|
884
|
+
"--run-dir",
|
|
885
|
+
default=None,
|
|
886
|
+
help="also assemble a neutral run dir here (body segments only)",
|
|
887
|
+
)
|
|
888
|
+
ap.add_argument(
|
|
889
|
+
"--with-matter",
|
|
890
|
+
action="store_true",
|
|
891
|
+
help="--run-dir: include the front- and back-matter blocks",
|
|
892
|
+
)
|
|
893
|
+
ap.add_argument(
|
|
894
|
+
"--prose-dir",
|
|
895
|
+
default=None,
|
|
896
|
+
help="build the self-driven seat's directory here (clean chapter text "
|
|
897
|
+
"+ named figure images) and build no PDFs at all",
|
|
898
|
+
)
|
|
899
|
+
ap.add_argument(
|
|
900
|
+
"--reuse-kit",
|
|
901
|
+
action="store_true",
|
|
902
|
+
help="--run-dir: assemble from the existing kit, no rebuild "
|
|
903
|
+
"(how two seats read the same state)",
|
|
904
|
+
)
|
|
905
|
+
args = ap.parse_args(argv)
|
|
906
|
+
out_dir = Path(args.out).resolve() if args.out else _settings().BUILD_KIT
|
|
907
|
+
if args.prose_dir:
|
|
908
|
+
return prose_dir(Path(args.prose_dir).expanduser().resolve())
|
|
909
|
+
|
|
910
|
+
if not (args.reuse_kit and args.run_dir):
|
|
911
|
+
rc = build(out_dir)
|
|
912
|
+
if rc:
|
|
913
|
+
return rc
|
|
914
|
+
if args.run_dir:
|
|
915
|
+
return assemble(
|
|
916
|
+
out_dir, Path(args.run_dir).expanduser().resolve(), matter=args.with_matter
|
|
917
|
+
)
|
|
918
|
+
return 0
|