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.
Files changed (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. 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