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,1086 @@
1
+ """Staged-book structure utilities shared by render, redpen, and panel-kit.
2
+
3
+ Everything here operates on a staged copy of the book, never on configured
4
+ sources: walking and rewriting the staged `_quarto.yml`'s `book.chapters`
5
+ block (parts flattened for the formats that have no part concept), unnumbering a
6
+ promoted part's H1, copying a figure gallery beside a standalone chapter, capping
7
+ DOCX image widths for Google Docs, and repairing the two conformance defects the
8
+ EPUB writers emit. These are book-structure concepts shared by several commands.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from quirework.project import current_project
14
+ import html
15
+ import json
16
+ import posixpath
17
+ import re
18
+ import shutil
19
+ import zipfile
20
+ from pathlib import Path
21
+ from typing import NamedTuple
22
+
23
+ from quirework.manuscript.quarto import BOOK_CONFIG, is_book, read_items
24
+ from quirework.publish import figures as _figures
25
+
26
+ # --------------------------------------------------------------------------- #
27
+ # book parts (build side): docx has none, so flatten them away. Reading the
28
+ # `book.chapters` block is the manuscript's job (manuscript.quarto); this side
29
+ # only rewrites a staged copy of it.
30
+ # --------------------------------------------------------------------------- #
31
+ _H1_ATTRS = re.compile(r"(?m)^(# .+?)(?:[ \t]*\{(.*?)\})?[ \t]*$")
32
+
33
+
34
+ def _options():
35
+ """Read policy from the selected project for this operation."""
36
+ from types import SimpleNamespace
37
+
38
+ TYPST_PROLOGUES: dict[str, str] = (
39
+ current_project().data("publish.layout_policy", {}).get("typst_prologues", {})
40
+ )
41
+ TYPST_EPILOGUES: dict[str, str] = (
42
+ current_project().data("publish.layout_policy", {}).get("typst_epilogues", {})
43
+ )
44
+ return SimpleNamespace(
45
+ TYPST_EPILOGUES=TYPST_EPILOGUES, TYPST_PROLOGUES=TYPST_PROLOGUES
46
+ )
47
+
48
+
49
+ def _divider_slug(title: str) -> str:
50
+ """A staged divider file's stem, by the book's own chapter-naming rule: the
51
+ title lowercased and hyphenated, truncated at the first comma or colon. So
52
+ "Part One: Departures" is `part-one.qmd`."""
53
+ head = re.split(r"[,:]", title, maxsplit=1)[0]
54
+ return re.sub(r"[^a-z0-9]+", "-", head.lower()).strip("-") or "part"
55
+
56
+
57
+ def _divider_heading(title: str) -> str:
58
+ """A divider H1's inline content: the title, split into its label and its
59
+ name so a stylesheet can set the two apart.
60
+
61
+ A configured print template can apply the same split. A long part title may
62
+ not fit the measure as one line. The label goes above a rule, small and tracked;
63
+ the name below it carries the page. Doing the split HERE, in the markup, is
64
+ what lets the EPUB have it: a stylesheet cannot separate two halves of one
65
+ text node.
66
+
67
+ The heading still carries the WHOLE title, punctuation and all, because the
68
+ nav document, the Contents page and the DOCX all read it as text -- so the
69
+ colon is a span of its own that the EPUB hides, rather than a character
70
+ dropped here. A title with no colon (the back matter) is all name.
71
+ """
72
+ label, sep, name = title.partition(": ")
73
+ if not sep:
74
+ return f"[{title}]{{.part-name}}"
75
+ return f"[{label}]{{.part-label}}[:]{{.part-colon}} [{name}]{{.part-name}}"
76
+
77
+
78
+ def _write_part_divider(stage: Path, title: str) -> str:
79
+ """Materialize a divider chapter for a title-only part; return its relpath.
80
+
81
+ `.unnumbered` so it consumes no chapter number (every format still reads
82
+ 1..N with the dividers between them), and `.part-divider` so the EPUB can
83
+ style it as the page it is."""
84
+ rel = f"{_divider_slug(title)}.qmd"
85
+ dest = stage / rel
86
+ if dest.exists():
87
+ raise ValueError(f"{BOOK_CONFIG}: part divider {rel} collides with a book file")
88
+ dest.write_text(
89
+ f"# {_divider_heading(title)} {{.unnumbered .part-divider}}\n", encoding="utf-8"
90
+ )
91
+ return rel
92
+
93
+
94
+ def flatten_book_parts(stage: Path) -> tuple[list[str], list[str]]:
95
+ """Rewrite the staged `_quarto.yml` so nothing is lost to a `part:`.
96
+
97
+ Word and EPUB book output have no part-divider concept: Quarto drops every
98
+ `part:` from those formats. For a *file*-based part (`part: sections/part-two.qmd`)
99
+ that takes the whole part-opening essay down with it, prose and figures and
100
+ all, without a warning; for a title-only part (`part: "Part One: …"`) it
101
+ silently deletes the division of the book, which is how the EPUB shipped as
102
+ twenty flat chapters with no Parts in it at all. HTML and Typst are
103
+ unaffected and keep their real parts.
104
+
105
+ So each part becomes a chapter in its own place in the reading order: a part
106
+ file is promoted as-is, and a title-only part is **materialized** as a
107
+ one-line divider `.qmd` written into the stage (`_write_part_divider`).
108
+ Both are `.unnumbered`, so no chapter number shifts. Returns (promoted
109
+ files, materialized dividers); the caller says so out loud.
110
+
111
+ Raises ValueError if the staged config is not the shape this understands, so a
112
+ future `_quarto.yml` cannot be silently mangled into a partial book.
113
+ A plain Quarto project, such as an edition that is one memo, has no
114
+ parts to lose and is left as it is."""
115
+ if not is_book(stage):
116
+ return [], []
117
+ lines, ci, end, item_ind, items = read_items(stage)
118
+ pad = " " * item_ind
119
+
120
+ out: list[str] = []
121
+ promoted: list[str] = []
122
+ dividers: list[str] = []
123
+ for it in items:
124
+ if it.kind == "comment":
125
+ out.append(it.value) # a comment rides along verbatim
126
+ continue
127
+ if it.kind == "part-file":
128
+ promoted.append(it.value)
129
+ out.append(f"{pad}- {it.value}")
130
+ elif it.kind == "part-title":
131
+ rel = _write_part_divider(stage, it.value)
132
+ dividers.append(rel)
133
+ out.append(f"{pad}- {rel}")
134
+ else:
135
+ out.append(f"{pad}- {it.value}")
136
+ out.extend(f"{pad}- {f}" for f in it.children)
137
+
138
+ lines[ci + 1 : end] = out
139
+ (stage / BOOK_CONFIG).write_text("\n".join(lines) + "\n", encoding="utf-8")
140
+ return promoted, dividers
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # the Contents page (build side): only Typst can build its own
145
+ # --------------------------------------------------------------------------- #
146
+ CONTENTS_FILE = "contents.qmd"
147
+ # Two divs, not one with both classes: Quarto *consumes* the conditional div
148
+ # once it has evaluated the condition, keeping only its children, so a styling
149
+ # class riding on it never reaches the output. The inner one survives.
150
+ _CONTENTS_OPEN = '::: {.content-visible unless-format="typst"}\n::: {.book-contents}'
151
+ _CONTENTS_CLOSE = ":::\n:::"
152
+
153
+
154
+ def _h1(qmd: Path) -> tuple[str, list[str]] | None:
155
+ """`(title, attrs)` from a staged chapter's H1; None when it has none."""
156
+ m = _H1_ATTRS.search(qmd.read_text(encoding="utf-8"))
157
+ if not m:
158
+ return None
159
+ return m.group(1)[2:].strip(), (m.group(2) or "").split()
160
+
161
+
162
+ def _ensure_h1_id(qmd: Path, stem: str) -> str | None:
163
+ """The explicit id on a staged chapter's H1, written in as `sec-<stem>` when
164
+ it has none. None when the file has no H1.
165
+
166
+ Deriving Pandoc's auto-generated id instead would be a guess, and a wrong
167
+ guess is a dead link in the Contents page that nothing checks. Writing the
168
+ id makes the target the one we linked to, by construction. Stems are unique
169
+ across the book (`chapters --check` invariant 10), so the id is too."""
170
+ text = qmd.read_text(encoding="utf-8")
171
+ m = _H1_ATTRS.search(text)
172
+ if not m:
173
+ return None
174
+ attrs = (m.group(2) or "").split()
175
+ for a in attrs:
176
+ if a.startswith("#"):
177
+ return a[1:]
178
+ ident = f"sec-{stem}"
179
+ qmd.write_text(
180
+ text[: m.start()]
181
+ + f"{m.group(1)} {{{' '.join([f'#{ident}'] + attrs)}}}"
182
+ + text[m.end() :],
183
+ encoding="utf-8",
184
+ )
185
+ return ident
186
+
187
+
188
+ class Row(NamedTuple):
189
+ """One entry of the book's reading order, parts intact.
190
+
191
+ `kind` is `part` (a divider) or `chapter`. `rel` is the staged `.qmd`, and
192
+ is None for a title-only part, which names no file. `title` is the H1 (or
193
+ the part title) with no number on it; `number` is the printed chapter
194
+ number or None (parts, and every `.unnumbered` page); `listed` is False for
195
+ an `.unlisted` page, which appears in no Contents and no PDF outline;
196
+ `part` is the title of the part the row sits in, None outside one."""
197
+
198
+ kind: str
199
+ rel: str | None
200
+ title: str
201
+ number: int | None
202
+ listed: bool
203
+ part: str | None
204
+
205
+
206
+ def book_rows(stage: Path) -> list[Row]:
207
+ """The book's reading order, one row per entry, parts intact.
208
+
209
+ **The one owner of what the book's order and its chapter numbering are.**
210
+ Numbers are assigned the way Quarto assigns them -- sequentially over the
211
+ chapters that are not `.unnumbered`, with parts consuming none -- so
212
+ nothing built from these rows can disagree with the printed running heads.
213
+ `book_outline` (the Contents page) and `panelkit` (the reader kit) are both
214
+ projections of this; a second walker computing its own numbers is how the
215
+ Contents page and the pages it points at drift apart.
216
+
217
+ Read from the **unflattened** structure, so the parts are still parts. Call
218
+ before `flatten_book_parts`, never after."""
219
+ _, _, _, _, items = read_items(stage)
220
+ rows: list[Row] = []
221
+ n = 0
222
+
223
+ def chapter(rel: str, part: str | None) -> None:
224
+ nonlocal n
225
+ head = _h1(stage / rel)
226
+ if head is None:
227
+ return
228
+ title, attrs = head
229
+ number = None
230
+ if ".unnumbered" not in attrs:
231
+ n += 1
232
+ number = n
233
+ rows.append(Row("chapter", rel, title, number, ".unlisted" not in attrs, part))
234
+
235
+ for it in items:
236
+ if it.kind == "comment":
237
+ continue
238
+ if it.kind == "chapter":
239
+ chapter(it.value, None)
240
+ continue
241
+ if it.kind == "part-file":
242
+ head = _h1(stage / it.value)
243
+ title = head[0] if head else it.value
244
+ rows.append(Row("part", it.value, title, None, True, title))
245
+ else:
246
+ title = it.value
247
+ rows.append(Row("part", None, title, None, True, title))
248
+ for kid in it.children:
249
+ chapter(kid, title)
250
+ return rows
251
+
252
+
253
+ def book_outline(
254
+ stage: Path, anchors: bool = False
255
+ ) -> list[tuple[str, str, str | None]]:
256
+ """The book's reading order as Contents rows: `(kind, label, link)`.
257
+
258
+ A projection of `book_rows`: the `.unlisted` pages (the welcome, copyright
259
+ and dedication pages, and the Contents page itself) are left out, exactly as
260
+ they are left out of the print outline and the EPUB nav, and a chapter's
261
+ label carries its printed number.
262
+
263
+ `anchors` picks the link form, which is not cosmetic: **Quarto rewrites a
264
+ `chapter.qmd` link to the output file for HTML only.** In EPUB it ships the
265
+ `.qmd` through verbatim and every row is a dead link. But EPUB and DOCX are
266
+ each built from one Pandoc document, so an in-document `#id` resolves there
267
+ (Pandoc repoints it across the split chapter files), while in a Quarto HTML
268
+ book, where each chapter is its own page, a bare `#id` would only ever find
269
+ the Contents page itself. So: file links for the HTML/Typst stage, anchors
270
+ for the EPUB/DOCX stage.
271
+
272
+ The labels are also what the rendered PDF's own bookmarks read, which is
273
+ what lets `panelkit` slice the book by them (`panelkit.outline_pages`)."""
274
+ rows: list[tuple[str, str, str | None]] = []
275
+ for r in book_rows(stage):
276
+ if r.kind == "part":
277
+ rows.append(("part", r.title, None))
278
+ continue
279
+ if not r.listed:
280
+ continue
281
+ label = f"{r.number} {r.title}" if r.number is not None else r.title
282
+ link = r.rel
283
+ if anchors:
284
+ assert r.rel is not None
285
+ ident = _ensure_h1_id(stage / r.rel, Path(r.rel).stem)
286
+ if ident is None:
287
+ continue
288
+ link = f"#{ident}"
289
+ rows.append(("chapter", label, link))
290
+ return rows
291
+
292
+
293
+ def render_contents(
294
+ stage: Path, anchors: bool = False, contents: str = CONTENTS_FILE
295
+ ) -> int:
296
+ """Write a real Contents page into the staged book. Returns the row count.
297
+
298
+ `contents.qmd` is a raw ```{=typst}` `#outline` block, which is exactly one
299
+ format's table of contents: in EPUB, DOCX and HTML the page rendered as a
300
+ bare "Contents" heading with nothing under it. So the list is generated here
301
+ instead, from the same `_quarto.yml` reading order and the same H1s the book
302
+ is built from, and appended inside a `content-visible unless-format="typst"`
303
+ div -- print keeps the Typst outline, every other format gets this. It is
304
+ rebuilt on every render from the staged sources, so it cannot rot.
305
+
306
+ `anchors` picks the link form for this stage; `book_outline` owns why.
307
+ Entries are hard-line-broken lines rather than a list, so the page reads the
308
+ same in all three formats with no per-format styling: a bulleted list would
309
+ need CSS the DOCX cannot take. No-ops (returning 0) when the stage has no
310
+ contents page."""
311
+ page = stage / contents
312
+ if not page.is_file():
313
+ return 0
314
+ rows = book_outline(stage, anchors=anchors)
315
+ if not rows:
316
+ return 0
317
+
318
+ out: list[str] = [_CONTENTS_OPEN, ""]
319
+ run: list[str] = []
320
+
321
+ def flush() -> None:
322
+ if run:
323
+ # The run is wrapped so a stylesheet can indent a part's chapters
324
+ # under its heading: both are paragraphs otherwise, and no selector
325
+ # can tell "a paragraph that is only bold" from the rest.
326
+ out.extend(["::: {.contents-run}", "\\\n".join(run), ":::", ""])
327
+ run.clear()
328
+
329
+ for kind, label, link in rows:
330
+ text = label.replace("[", r"\[").replace("]", r"\]")
331
+ if kind == "part":
332
+ flush()
333
+ out.extend([f"**{text}**", ""])
334
+ continue
335
+ run.append(f"[{text}]({link})")
336
+ flush()
337
+ out.append(_CONTENTS_CLOSE)
338
+
339
+ page.write_text(
340
+ page.read_text(encoding="utf-8").rstrip("\n") + "\n\n" + "\n".join(out) + "\n",
341
+ encoding="utf-8",
342
+ )
343
+ return len(rows)
344
+
345
+
346
+ # --------------------------------------------------------------------------- #
347
+ # the EPUB style include (build side): the reader gets the rules, not the why
348
+ # --------------------------------------------------------------------------- #
349
+ EPUB_STYLES = "epub-styles.html" # `include-in-header` for epub in BOOK_CONFIG
350
+ _STYLE_BLOCK = re.compile(r"<style\b[^>]*>(.*?)</style>", re.S | re.I)
351
+
352
+
353
+ def _css_without_comments(css: str) -> str:
354
+ """`css` with every `/* ... */` removed, and strings left alone.
355
+
356
+ A regex would be shorter and wrong: a comment opener inside a `url("...")`
357
+ or a `content` string is not a comment, and eating from there to the next
358
+ `*/` would silently swallow real rules. So this scans, tracking whether it
359
+ is inside a quoted string."""
360
+ out: list[str] = []
361
+ i, n, quote = 0, len(css), ""
362
+ while i < n:
363
+ c = css[i]
364
+ if quote:
365
+ out.append(c)
366
+ if c == "\\" and i + 1 < n: # an escaped quote is not the end
367
+ out.append(css[i + 1])
368
+ i += 2
369
+ continue
370
+ if c == quote:
371
+ quote = ""
372
+ i += 1
373
+ elif c in "\"'":
374
+ quote = c
375
+ out.append(c)
376
+ i += 1
377
+ elif css.startswith("/*", i):
378
+ end = css.find("*/", i + 2)
379
+ i = n if end < 0 else end + 2 # unterminated: drop to end of file
380
+ else:
381
+ out.append(c)
382
+ i += 1
383
+ return "".join(out)
384
+
385
+
386
+ def strip_style_comments(stage: Path, rel: str | None = None) -> tuple[int, int]:
387
+ """Reduce a staged style include to its rules. Returns (before, after) bytes.
388
+
389
+ **This file is not loaded once; it is copied into the `<head>` of every
390
+ content document in the EPUB.** So every line of it is paid for ~38 times
391
+ over, and the comments are the bulk of it -- the source runs about 15 KB, of
392
+ which under 3 KB is rules, which is 580 KB of commentary shipped inside a
393
+ book nobody can read it in. The doctrine belongs in the source, where it is
394
+ read and maintained; the reader's copy carries the rules alone.
395
+
396
+ Everything outside the `<style>` element goes with them, which retires a
397
+ hazard rather than only saving bytes: the file's HTML banner comment shipped
398
+ into 38 documents that are parsed as XML, where a double hyphen inside a
399
+ comment is illegal and invalidates every one of them.
400
+
401
+ Raises if the file is absent (a rename must break the render rather than
402
+ silently stop stripping) or if the result's braces do not balance."""
403
+ if rel is None:
404
+ names = (
405
+ current_project()
406
+ .data("publish.render_policy", {})
407
+ .get("epub_style_headers", [])
408
+ )
409
+ counts = [strip_style_comments(stage, name) for name in names]
410
+ return tuple(sum(pair[i] for pair in counts) for i in range(2))
411
+ path = stage / rel
412
+ if not path.is_file():
413
+ raise ValueError(
414
+ f"{rel}: not in the staged book; "
415
+ f"{BOOK_CONFIG} names it as an epub include-in-header"
416
+ )
417
+ src = path.read_text(encoding="utf-8")
418
+ blocks = _STYLE_BLOCK.findall(src)
419
+ if not blocks:
420
+ raise ValueError(f"{rel}: no <style> element to strip")
421
+ rules = "\n".join(_css_without_comments(b) for b in blocks)
422
+ rules = re.sub(r"\n{3,}", "\n\n", rules).strip()
423
+ if rules.count("{") != rules.count("}"):
424
+ raise ValueError(
425
+ f"{rel}: stripping unbalanced the braces; "
426
+ "a comment marker inside a string is the likely cause"
427
+ )
428
+ out = f'<style type="text/css">\n{rules}\n</style>\n'
429
+ path.write_text(out, encoding="utf-8")
430
+ return len(src.encode("utf-8")), len(out.encode("utf-8"))
431
+
432
+
433
+ def unnumber_h1(qmd: Path) -> bool:
434
+ """Mark a file's H1 `.unnumbered`, so a promoted part reads as the divider it
435
+ is rather than as an extra numbered chapter — and the chapter numbers the
436
+ author saw last review round do not all shift by one. Idempotent; False when
437
+ the file has no H1."""
438
+ text = qmd.read_text(encoding="utf-8")
439
+ m = _H1_ATTRS.search(text)
440
+ if not m:
441
+ return False
442
+ attrs = (m.group(2) or "").split()
443
+ if ".unnumbered" not in attrs:
444
+ attrs.append(".unnumbered")
445
+ qmd.write_text(
446
+ text[: m.start()] + f"{m.group(1)} {{{' '.join(attrs)}}}" + text[m.end() :],
447
+ encoding="utf-8",
448
+ )
449
+ return True
450
+
451
+
452
+ def copy_gallery(stage: Path, work: Path, gallery: str = _figures.GALLERY) -> int:
453
+ """Copy a staged figure gallery beside a standalone chapter, under the name its
454
+ embeds use (`illustrations/NAME.svg` by default; pass `figures.GRAY_GALLERY`
455
+ for the `--gray` twin set). Returns the number of SVGs copied; 0 when the
456
+ staged gallery is absent."""
457
+ dest = work / gallery
458
+ dest.mkdir(parents=True, exist_ok=True)
459
+ src = stage / current_project().chapter_subdir / gallery
460
+ if not src.is_dir():
461
+ return 0
462
+ svgs = sorted(src.glob("*.svg"))
463
+ for svg in svgs:
464
+ shutil.copyfile(svg, dest / svg.name)
465
+ return len(svgs)
466
+
467
+
468
+ # Google Docs clips (does not scale) an inline image whose display width sits
469
+ # near the text-column width: the right edge overflows the frame and is cut off,
470
+ # in both Office .docx mode and a native-converted doc, for PNG and SVG alike.
471
+ # 4.5in leaves comfortable slack under a 6.5in column; verified to render whole
472
+ # where the full ~5.8in figure is clipped. (Raising it re-introduces the crop.)
473
+ MAX_IMG_WIDTH_EMU = int(4.5 * 914400) # 914400 EMU = 1 inch
474
+
475
+
476
+ def cap_docx_image_widths(
477
+ docx_path: Path, max_width_emu: int = MAX_IMG_WIDTH_EMU
478
+ ) -> None:
479
+ """Shrink over-wide embedded figures so Google Docs stops cropping them.
480
+
481
+ The docx itself is well-formed -- the crop is Google's layout step, not the
482
+ file (an oversized inline image is clipped, not scaled down). So we scale any
483
+ drawing whose ``wp:extent`` width exceeds ``max_width_emu`` down to it,
484
+ preserving aspect ratio, by rewriting the layout extent and the picture's
485
+ transform extent together. Everything else in the docx is copied verbatim.
486
+
487
+ This replaces an earlier SVG width-injection fix that never worked: the crop
488
+ is format-independent (a PNG-only doc clips identically), so the SVG was never
489
+ the cause.
490
+ """
491
+ doc = "word/document.xml"
492
+ changed = 0
493
+
494
+ def _cap_drawing(m: "re.Match[str]") -> str:
495
+ nonlocal changed
496
+ block = m.group(0)
497
+ ext = re.search(r'<wp:extent\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"', block)
498
+ if not ext:
499
+ return block
500
+ cx, cy = int(ext.group(1)), int(ext.group(2))
501
+ if cx <= max_width_emu:
502
+ return block
503
+ ncx = max_width_emu
504
+ ncy = round(cy * max_width_emu / cx)
505
+
506
+ # wp:extent (layout) and the self-closing a:ext (picture transform) carry
507
+ # the same cx/cy; the extension-list <a:ext uri=...> blocks have no cx and
508
+ # are left untouched.
509
+ def _rescale(tag_re: str, s: str) -> str:
510
+ return re.sub(
511
+ tag_re,
512
+ lambda t: (
513
+ t.group(0)
514
+ .replace(f'cx="{cx}"', f'cx="{ncx}"')
515
+ .replace(f'cy="{cy}"', f'cy="{ncy}"')
516
+ ),
517
+ s,
518
+ )
519
+
520
+ block = _rescale(r"<wp:extent\b[^>]*/>", block)
521
+ block = _rescale(r'<a:ext\b[^>]*\bcx="\d+"[^>]*/>', block)
522
+ changed += 1
523
+ return block
524
+
525
+ with zipfile.ZipFile(docx_path) as zin:
526
+ names = zin.namelist()
527
+ if doc not in names:
528
+ return
529
+ infos = {i.filename: i for i in zin.infolist()}
530
+ blobs = {n: zin.read(n) for n in names}
531
+
532
+ text = re.sub(
533
+ r"<w:drawing>.*?</w:drawing>",
534
+ _cap_drawing,
535
+ blobs[doc].decode("utf-8"),
536
+ flags=re.S,
537
+ )
538
+ if not changed:
539
+ return
540
+ blobs[doc] = text.encode("utf-8")
541
+
542
+ tmp = docx_path.with_suffix(".tmp.docx")
543
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
544
+ for n in names:
545
+ zout.writestr(infos[n], blobs[n])
546
+ tmp.replace(docx_path)
547
+
548
+
549
+ # The style an unlisted chapter's title takes in a DOCX: Heading 1's look, and
550
+ # the outline level of body text, so the contents field passes over it.
551
+ UNLISTED_DOCX_STYLE = "UnlistedHeading1"
552
+ _UNLISTED_DOCX_STYLE_XML = (
553
+ f'<w:style w:type="paragraph" w:customStyle="1" '
554
+ f'w:styleId="{UNLISTED_DOCX_STYLE}"><w:name w:val="Unlisted Heading 1" />'
555
+ '<w:basedOn w:val="Heading1" /><w:next w:val="BodyText" />'
556
+ '<w:pPr><w:outlineLvl w:val="9" /></w:pPr></w:style>'
557
+ )
558
+ _DOCX_HEADING1 = re.compile(r'<w:pStyle w:val="Heading1"\s*/>')
559
+
560
+
561
+ def heading_key(title: str) -> str:
562
+ """What two forms of one title share: its letters and digits.
563
+
564
+ The book's form is Markdown, with a span's brackets and attributes or a
565
+ link's target; Word's or a PDF's is the rendered text, with typographic
566
+ quotes. A key this coarse still tells any two chapters apart, which is
567
+ all the match in `unlist_docx_headings`, or in a reader packet's reading
568
+ of the PDF's tags, asks of it."""
569
+ title = re.sub(r"\{[^}]*\}|\]\([^)]*\)", "", title)
570
+ return re.sub(r"[\W_]+", "", title).lower()
571
+
572
+
573
+ def unlist_docx_headings(docx_path: Path, rows: list[Row]) -> list[str]:
574
+ """Keep a DOCX's `.unlisted` chapters out of its contents.
575
+
576
+ Pandoc writes a DOCX's contents as a field, `TOC \\o "1-3" \\h \\z \\u`,
577
+ which Word fills from heading styles and outline levels when the reader
578
+ updates it, so `.unlisted` reaches no DOCX: a chapter titled in Heading 1
579
+ joins the contents the first time the field is updated. Each unlisted
580
+ chapter's title moves to `UNLISTED_DOCX_STYLE`, based on Heading 1 so it
581
+ looks the same, whose outline level is body text, and the field passes
582
+ over it. Word's navigation pane passes over it too, as the EPUB's does.
583
+
584
+ `rows` is `book_rows` of the book before its parts were flattened, since
585
+ flattening makes each Part one Heading 1 in its place. The rows are
586
+ matched to the Heading 1 paragraphs by position, and every
587
+ pair's titles must agree, or ValueError: a heading that cannot be placed
588
+ is not quietly left in the contents or taken out. Returns the titles
589
+ moved."""
590
+ if all(r.listed for r in rows):
591
+ return []
592
+ doc, styles = "word/document.xml", "word/styles.xml"
593
+ with zipfile.ZipFile(docx_path) as zin:
594
+ names = zin.namelist()
595
+ infos = {i.filename: i for i in zin.infolist()}
596
+ blobs = {n: zin.read(n) for n in names}
597
+ text = blobs[doc].decode("utf-8")
598
+ headings = [
599
+ m
600
+ for m in re.finditer(r"<w:p\b.*?</w:p>", text, flags=re.S)
601
+ if _DOCX_HEADING1.search(m.group(0))
602
+ ]
603
+ if len(headings) != len(rows):
604
+ raise ValueError(
605
+ f"docx: {docx_path.name} has {len(headings)} chapter headings where "
606
+ f"the book has {len(rows)} chapters, so its unlisted ones cannot be "
607
+ "told apart"
608
+ )
609
+ moved: list[str] = []
610
+ out, last = [], 0
611
+ for n, (m, row) in enumerate(zip(headings, rows), start=1):
612
+ runs = re.findall(r"<w:t(?:\s[^>]*)?>([^<]*)</w:t>", m.group(0))
613
+ found = html.unescape("".join(runs)).strip()
614
+ if row.number is not None: # printed before the title it numbers
615
+ found = re.sub(rf"^{row.number}\.?\s+", "", found)
616
+ if heading_key(found) != heading_key(row.title):
617
+ raise ValueError(
618
+ f"docx: {docx_path.name}'s heading {n} reads {found!r} where the "
619
+ f"book has {row.title!r}"
620
+ )
621
+ if row.listed:
622
+ continue
623
+ out += [
624
+ text[last : m.start()],
625
+ _DOCX_HEADING1.sub(
626
+ f'<w:pStyle w:val="{UNLISTED_DOCX_STYLE}" />', m.group(0), count=1
627
+ ),
628
+ ]
629
+ last = m.end()
630
+ moved.append(row.title)
631
+ blobs[doc] = ("".join(out) + text[last:]).encode("utf-8")
632
+ sheet = blobs[styles].decode("utf-8")
633
+ if f'w:styleId="{UNLISTED_DOCX_STYLE}"' not in sheet:
634
+ sheet = sheet.replace("</w:styles>", _UNLISTED_DOCX_STYLE_XML + "</w:styles>")
635
+ blobs[styles] = sheet.encode("utf-8")
636
+ tmp = docx_path.with_suffix(".tmp.docx")
637
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
638
+ for name in names:
639
+ zout.writestr(infos[name], blobs[name])
640
+ tmp.replace(docx_path)
641
+ return moved
642
+
643
+
644
+ # --------------------------------------------------------------------------- #
645
+ # EPUB conformance (build side): what the writers leave invalid
646
+ # --------------------------------------------------------------------------- #
647
+ #
648
+ # Both defects below are INVISIBLE in a reader -- every desktop and device
649
+ # reader opens the book correctly with them in place -- and both are rejected
650
+ # by EPUBCheck, which is what a retailer's ingestion runs. So neither could be
651
+ # found by looking at the book, and `quirework publish epubcheck` is what found them.
652
+ # The fixes are applied to the built artifact for the same reason the DOCX
653
+ # image cap is: the defect is in what a writer emits, not in the source.
654
+
655
+ # `alt` is permitted on <img>, <area> and <input>, and on nothing else. Quarto
656
+ # copies a figure's alt text onto the WRAPPING float div as well as onto the
657
+ # image inside it, so the book shipped 39 invalid attributes. The image keeps
658
+ # its own copy, so removing the div's costs no accessibility text at all.
659
+ ALT_OK = frozenset({"img", "area", "input"})
660
+
661
+ _TAG = re.compile(r"<([a-z][a-z0-9-]*)\b([^>]*)>", re.I)
662
+ _ALT_ATTR = re.compile(r'\salt="[^"]*"')
663
+
664
+ # The faces stay INLINE in the content documents, and the reason is a measured
665
+ # one rather than a preference. Relocating them into the container's own
666
+ # `styles/stylesheet1.css` is the tidier fix on paper -- one copy instead of
667
+ # 38, and a url that resolves from a single place -- but crengine, the engine
668
+ # KOReader and the Kobos run, registers `@font-face` only from a document's own
669
+ # style element. Moved to the stylesheet, it silently stopped registering them
670
+ # and the book fell back to a default serif: valid, and set in the wrong face.
671
+ # That is the failure `fonts.py` refuses to render into, arrived at from the
672
+ # other direction, so the rule here is the same one. Verified by the engine's
673
+ # own "(cause: embedded fonts)" re-init line, which is what disappeared.
674
+ #
675
+ # So the fix is narrower: a face is kept wherever its url actually resolves,
676
+ # and dropped only from the document where it points nowhere. In practice that
677
+ # is `nav.xhtml`, which sits one level above `text/` and is not in the spine,
678
+ # so no reader renders it as a page anyway.
679
+ _FONT_FACE = re.compile(r"@font-face\s*\{[^}]*\}", re.S)
680
+ _SRC_URL = re.compile(r'url\("([^"]+)"\)')
681
+
682
+
683
+ def strip_invalid_alt(xhtml: str) -> tuple[str, int]:
684
+ """Drop `alt` from every element not permitted to carry it."""
685
+ removed = 0
686
+
687
+ def _fix(m: "re.Match[str]") -> str:
688
+ nonlocal removed
689
+ tag, attrs = m.group(1), m.group(2)
690
+ if 'alt="' not in attrs or tag.lower() in ALT_OK:
691
+ return m.group(0)
692
+ pruned = _ALT_ATTR.sub("", attrs)
693
+ if pruned == attrs:
694
+ return m.group(0)
695
+ removed += 1
696
+ return f"<{tag}{pruned}>"
697
+
698
+ return _TAG.sub(_fix, xhtml), removed
699
+
700
+
701
+ def font_face_resolves(block: str, doc_name: str, entries: set[str]) -> bool:
702
+ """Does every `src` url in this face reach a real file, from this document?
703
+
704
+ A css url in a document's own style element resolves against THAT document,
705
+ so the same rule is right in `text/ch001.xhtml` and wrong in `nav.xhtml` one
706
+ level up. The book used to ship two urls per face for exactly that reason --
707
+ `../fonts/` for the content documents and `fonts/` for nav -- and since a
708
+ `src` list is tried in order, it always rendered correctly. But the url that
709
+ did not match a given document's depth resolved nowhere, and a `src` url
710
+ that resolves nowhere is invalid: 4 faces x 38 documents, 152 errors, in a
711
+ book with no visible fault.
712
+ """
713
+ urls = _SRC_URL.findall(block)
714
+ if not urls:
715
+ return True # nothing to resolve; not ours to judge
716
+ base = posixpath.dirname(doc_name)
717
+ return all(posixpath.normpath(posixpath.join(base, u)) in entries for u in urls)
718
+
719
+
720
+ def prune_font_faces(text: str, doc_name: str, entries: set[str]) -> tuple[str, int]:
721
+ """Drop the faces that point nowhere from this document; keep the rest."""
722
+ dropped = 0
723
+
724
+ def _keep(m: "re.Match[str]") -> str:
725
+ nonlocal dropped
726
+ if font_face_resolves(m.group(0), doc_name, entries):
727
+ return m.group(0)
728
+ dropped += 1
729
+ return ""
730
+
731
+ return _FONT_FACE.sub(_keep, text), dropped
732
+
733
+
734
+ def conform_epub(epub_path: Path) -> None:
735
+ """Make the built EPUB valid: drop the stray `alt`s and the dead faces.
736
+
737
+ One pass over the container, then a repack that preserves entry ORDER and
738
+ per-entry compression -- `mimetype` must stay first and stored or the OCF
739
+ layer is invalid, which would trade two defects for a worse one.
740
+ """
741
+ with zipfile.ZipFile(epub_path) as zin:
742
+ names = zin.namelist()
743
+ infos = {i.filename: i for i in zin.infolist()}
744
+ blobs = {n: zin.read(n) for n in names}
745
+
746
+ entries = set(names)
747
+ changed = False
748
+ for name in names:
749
+ if not name.endswith((".xhtml", ".html")):
750
+ continue
751
+ text = blobs[name].decode("utf-8")
752
+ before = text
753
+ text, _ = strip_invalid_alt(text)
754
+ text, _ = prune_font_faces(text, name, entries)
755
+ if text != before:
756
+ blobs[name] = text.encode("utf-8")
757
+ changed = True
758
+
759
+ if not changed:
760
+ return
761
+
762
+ tmp = epub_path.with_suffix(".tmp.epub")
763
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
764
+ for n in names:
765
+ zout.writestr(infos[n], blobs[n])
766
+ tmp.replace(epub_path)
767
+
768
+
769
+ # --------------------------------------------------------------------------- #
770
+ # Typst prologues (build side): the settings that must land mid-document
771
+ # --------------------------------------------------------------------------- #
772
+
773
+ # Some print settings cannot live in the template, because they have to take
774
+ # effect *partway through the book* rather than at the top of it: front matter
775
+ # numbered in roman and the body restarting at arabic 1, and the back matter
776
+ # set smaller than the argument. A template runs once, before any of it.
777
+ #
778
+ # Typst's own scoping is what forces this. A `set` rule inside a function body
779
+ # applies to that function's own content and reverts afterwards, so `part()`
780
+ # cannot switch the numbering of everything that follows it; and hand-rolling
781
+ # the folio in the page footer is not a way out either, because `#outline`
782
+ # formats each contents entry with the `page.numbering` in effect at its target
783
+ # and would print the wrong figures.
784
+ #
785
+ # What DOES work is a raw block sitting in the document flow, where a `set`
786
+ # rule applies to the rest of the body. These blocks are written into staged
787
+ # copies only, so the configured sources keep no build scaffolding
788
+ # and no prose file has to carry a print setting.
789
+ #
790
+ # `{=typst}` means every other format ignores the block, so HTML, EPUB and DOCX
791
+ # are untouched and no guard is needed for them.
792
+
793
+
794
+ # Appended, not prepended, and WHICH END MATTERS. Both statements below are
795
+ # scoped to a page boundary, and putting them at the head of the first chapter
796
+ # put them on the wrong side of it: `set page` applies from the NEXT page, so
797
+ # the chapter that carried the rule kept roman while everything after it went
798
+ # arabic, and the contents printed "Steeping yourself ... xi" between two
799
+ # arabic rows. Issued at the END of the last front-matter page, the rule takes
800
+ # effect on the page after it, which is the first page of the body.
801
+ #
802
+ # `counter(page).update(0)` rather than 1 for the same reason: it applies to
803
+ # the page it lands on, the preface's last, so the part divider becomes 0 and
804
+ # the first chapter becomes 1. Neither of those two prints a folio, so no
805
+ # reader sees a zero.
806
+
807
+
808
+ def inject_typst_prologues(
809
+ stage: Path,
810
+ prologues: dict[str, str] | None = None,
811
+ epilogues: dict[str, str] | None = None,
812
+ ) -> list[str]:
813
+ """Wrap named staged chapters in their Typst-only raw blocks.
814
+
815
+ Returns the chapters actually touched. Missing files are skipped rather
816
+ than raised on: a stage may legitimately hold a subset of the book (a
817
+ single-chapter redpen build, a panel kit), and a block that has nowhere to
818
+ land is not an error there.
819
+ """
820
+ touched: list[str] = []
821
+ for rel, body in (
822
+ _options().TYPST_PROLOGUES if prologues is None else prologues
823
+ ).items():
824
+ f = stage / rel
825
+ if not f.is_file():
826
+ continue
827
+ block = "```{=typst}\n" + body + "```\n\n"
828
+ text = f.read_text(encoding="utf-8")
829
+ if block not in text:
830
+ f.write_text(block + text, encoding="utf-8")
831
+ touched.append(rel)
832
+ for rel, body in (
833
+ _options().TYPST_EPILOGUES if epilogues is None else epilogues
834
+ ).items():
835
+ f = stage / rel
836
+ if not f.is_file():
837
+ continue
838
+ block = "\n```{=typst}\n" + body + "```\n"
839
+ text = f.read_text(encoding="utf-8")
840
+ if block not in text:
841
+ f.write_text(text.rstrip("\n") + "\n" + block, encoding="utf-8")
842
+ touched.append(rel)
843
+ return touched
844
+
845
+
846
+ # The title a staged chapter is reduced to when a format should not carry it.
847
+ # Each format the stage renders must drop it: the configured Typst template
848
+ # matches this exact string and renders nothing, and a Pandoc writer (EPUB,
849
+ # DOCX) needs a post-render filter that removes the marker's unit.
850
+ OMIT_MARKER = "quirework-omit-from-this-format"
851
+
852
+
853
+ def blank_chapters(stage: Path, rels: set[str]) -> list[str]:
854
+ """Empty named chapters in a staged book, leaving their config entry.
855
+
856
+ For a front-matter page that belongs to one format and not another. The
857
+ reader-facing case is the welcome landing: Quarto pins `index.qmd` to
858
+ reading-position 1 in EVERY format (the filename is special-cased, so a
859
+ list position elsewhere is ignored), and it is the web edition's home page,
860
+ but in print it is a two-sentence leaf sitting where a printed book has
861
+ nothing.
862
+
863
+ Two routes were tried and neither works, so the shape of this is forced:
864
+
865
+ - **Quarto's conditional content leaves the heading.** Wrapping the file in
866
+ `content-visible unless-format="typst"` drops the body and keeps the H1,
867
+ because a chapter's opening heading is hoisted out of the div before the
868
+ condition is evaluated. Print then shows a page with just a title on it,
869
+ which is worse than the page it replaced.
870
+ - **The entry cannot be removed.** Deleting it from `book.chapters` fails
871
+ the render outright: "Book contents must include a home page".
872
+
873
+ - **Emptying the file is not enough either.** Quarto synthesizes a chapter
874
+ heading for a file with no H1 of its own, and synthesizes it NUMBERED:
875
+ the blank leaf came back as chapter 1 with a stray numeral on it, and
876
+ every real chapter shifted up by one.
877
+
878
+ So the entry stays, and the file is replaced by an unnumbered, unlisted
879
+ heading carrying `OMIT_MARKER`. Unnumbered keeps it out of the chapter
880
+ count and unlisted keeps it out of the contents. The marker is what the
881
+ configured Typst template matches to render nothing at all, so no page is
882
+ emitted. The parts-less stage blanks its own list the same way, and there the
883
+ book's EPUB and DOCX formats drop the marker with a filter run at
884
+ `post-render`: earlier, Quarto has not yet seen the heading it would
885
+ otherwise synthesize for a file without one, and a filter that removed the
886
+ marker before that would get the synthesized heading back. The marker only
887
+ ever exists in a staged copy.
888
+
889
+ Returns the chapters actually emptied. A plain Quarto project renders
890
+ each document on its own, with no front matter to drop, so nothing in
891
+ one is emptied.
892
+ """
893
+ if not is_book(stage):
894
+ return []
895
+ lines, ci, end, _item_ind, items = read_items(stage)
896
+ known = {it.value for it in items if it.kind == "chapter"}
897
+ for it in items:
898
+ known.update(it.children)
899
+ blanked = []
900
+ for rel in sorted(rels & known):
901
+ f = stage / rel
902
+ if not f.is_file():
903
+ continue
904
+ f.write_text(f"# {OMIT_MARKER} {{.unnumbered .unlisted}}\n", encoding="utf-8")
905
+ blanked.append(rel)
906
+ return blanked
907
+
908
+
909
+ # --------------------------------------------------------------------------- #
910
+ # metadata a stage sets: a reader instrument's render of the book as published
911
+ # --------------------------------------------------------------------------- #
912
+ _METADATA_KEY = re.compile(r"[A-Za-z][\w-]*")
913
+
914
+
915
+ def set_top_metadata(stage: Path, values: dict) -> list[str]:
916
+ """Set each of `values` as a top-level key of the staged `_quarto.yml`.
917
+
918
+ The top level is where a book declares metadata that every chapter and
919
+ every filter reads. A key the file already declares has its value, with
920
+ any block under it, replaced in place, and a new key is added at the end.
921
+ Values are TOML's scalars, strings, booleans and numbers, written as JSON,
922
+ which YAML reads as the same value: `false` stays the boolean a filter
923
+ tests for, never the string. Operates on staged copies only.
924
+
925
+ Raises ValueError for a key YAML would read differently or a value that is
926
+ not a scalar, rather than write a file Quarto reads another way. Returns
927
+ the keys set."""
928
+ for key, value in values.items():
929
+ if not _METADATA_KEY.fullmatch(key):
930
+ raise ValueError(f"metadata key {key!r} is not a plain name")
931
+ if not isinstance(value, (str, bool, int, float)):
932
+ raise ValueError(
933
+ f"metadata {key} must be a string, a number or a boolean, "
934
+ f"not {type(value).__name__}"
935
+ )
936
+ cfg = stage / BOOK_CONFIG
937
+ lines = cfg.read_text(encoding="utf-8").splitlines()
938
+ for key, value in values.items():
939
+ line = f"{key}: {json.dumps(value, ensure_ascii=False)}"
940
+ at = next(
941
+ (i for i, s in enumerate(lines) if re.match(rf"{re.escape(key)}\s*:", s)),
942
+ None,
943
+ )
944
+ if at is None:
945
+ lines.append(line)
946
+ continue
947
+ end = at + 1 # the value's block: indented or blank lines under it
948
+ while end < len(lines) and (not lines[end].strip() or lines[end][0] in " \t"):
949
+ end += 1
950
+ while end > at + 1 and not lines[end - 1].strip():
951
+ end -= 1
952
+ lines[at:end] = [line]
953
+ cfg.write_text("\n".join(lines) + "\n", encoding="utf-8")
954
+ return list(values)
955
+
956
+
957
+ # --------------------------------------------------------------------------- #
958
+ # reading a stage's configuration back, and setting a book option in it
959
+ # --------------------------------------------------------------------------- #
960
+ _YAML_TRUE = frozenset({"true", "True", "TRUE"})
961
+ _YAML_FALSE = frozenset({"false", "False", "FALSE"})
962
+ _SINGLE_QUOTED = re.compile(r"'((?:[^']|'')*)'")
963
+
964
+
965
+ def yaml_scalar(text: str) -> object:
966
+ """A YAML scalar as Quarto's core schema reads it, for the shapes a book's
967
+ configuration writes at its top level: a quoted or plain string, or a
968
+ boolean. Numbers and dates stay the text they were written as, and an empty
969
+ value (a block follows) is None. Only `true` and `false` in the three
970
+ spellings the core schema accepts are booleans, so a quoted "false" is the
971
+ string it looks like, as it is to Quarto."""
972
+ text = text.strip()
973
+ if not text:
974
+ return None
975
+ if text[0] == '"':
976
+ try:
977
+ return json.JSONDecoder().raw_decode(text)[0]
978
+ except ValueError:
979
+ return text
980
+ if text[0] == "'":
981
+ m = _SINGLE_QUOTED.match(text)
982
+ return m.group(1).replace("''", "'") if m else text
983
+ plain = re.split(r"\s+#", text, maxsplit=1)[0].strip()
984
+ if plain in _YAML_TRUE:
985
+ return True
986
+ if plain in _YAML_FALSE:
987
+ return False
988
+ return plain
989
+
990
+
991
+ def top_metadata(stage: Path, key: str) -> object:
992
+ """The value of top-level `key` in the stage's `_quarto.yml`, read as
993
+ `yaml_scalar` reads it, or None when the file does not set it or sets a
994
+ block under it."""
995
+ for line in (stage / BOOK_CONFIG).read_text(encoding="utf-8").splitlines():
996
+ m = re.match(rf"{re.escape(key)}\s*:(.*)$", line)
997
+ if m:
998
+ return yaml_scalar(m.group(1))
999
+ return None
1000
+
1001
+
1002
+ def _book_block(lines: list[str]) -> tuple[int, int, int] | None:
1003
+ """(the `book:` line, the end of its block, its children's indent)."""
1004
+ at = next((i for i, s in enumerate(lines) if re.match(r"book\s*:\s*$", s)), None)
1005
+ if at is None:
1006
+ return None
1007
+ end, indent = at + 1, None
1008
+ while end < len(lines):
1009
+ line = lines[end]
1010
+ if line.strip() and not line.lstrip().startswith("#"):
1011
+ width = len(line) - len(line.lstrip())
1012
+ if width == 0:
1013
+ break
1014
+ indent = width if indent is None else indent
1015
+ elif line.strip() and line[0] not in " \t":
1016
+ break # a comment at column 0 belongs to whatever follows
1017
+ end += 1
1018
+ return at, end, indent or 2
1019
+
1020
+
1021
+ def book_option(stage: Path, key: str) -> object:
1022
+ """The value of `book.KEY` in the stage's `_quarto.yml`, read as
1023
+ `yaml_scalar` reads it, or None when the book does not set it."""
1024
+ lines = (stage / BOOK_CONFIG).read_text(encoding="utf-8").splitlines()
1025
+ block = _book_block(lines)
1026
+ if block is None:
1027
+ return None
1028
+ at, end, indent = block
1029
+ pattern = re.compile(rf" {{{indent}}}{re.escape(key)}\s*:(.*)$")
1030
+ for line in lines[at + 1 : end]:
1031
+ m = pattern.match(line)
1032
+ if m:
1033
+ return yaml_scalar(m.group(1))
1034
+ return None
1035
+
1036
+
1037
+ def set_book_option(stage: Path, key: str, value: str | bool | int | float) -> None:
1038
+ """Set `book.KEY` in the staged `_quarto.yml`, replacing the value and any
1039
+ block under it in place, or adding the key as the block's first child.
1040
+
1041
+ The value is written as JSON, which YAML reads as the same scalar. Raises
1042
+ ValueError for a key YAML would read differently, a value that is not a
1043
+ scalar, or a configuration with no `book:` block to set it in. Operates on
1044
+ staged copies only."""
1045
+ if not _METADATA_KEY.fullmatch(key):
1046
+ raise ValueError(f"book option {key!r} is not a plain name")
1047
+ if not isinstance(value, (str, bool, int, float)):
1048
+ raise ValueError(f"book.{key} must be a scalar, not {type(value).__name__}")
1049
+ cfg = stage / BOOK_CONFIG
1050
+ lines = cfg.read_text(encoding="utf-8").splitlines()
1051
+ block = _book_block(lines)
1052
+ if block is None:
1053
+ raise ValueError(f"{cfg} has no book: block to set {key} in")
1054
+ at, end, indent = block
1055
+ line = f"{' ' * indent}{key}: {json.dumps(value, ensure_ascii=False)}"
1056
+ pattern = re.compile(rf" {{{indent}}}{re.escape(key)}\s*:")
1057
+ found = next((i for i in range(at + 1, end) if pattern.match(lines[i])), None)
1058
+ if found is None:
1059
+ lines.insert(at + 1, line)
1060
+ else:
1061
+ stop = found + 1 # the value's own block, indented deeper than its key
1062
+ while stop < end and (
1063
+ not lines[stop].strip()
1064
+ or len(lines[stop]) - len(lines[stop].lstrip()) > indent
1065
+ ):
1066
+ stop += 1
1067
+ while stop > found + 1 and not lines[stop - 1].strip():
1068
+ stop -= 1
1069
+ lines[found:stop] = [line]
1070
+ cfg.write_text("\n".join(lines) + "\n", encoding="utf-8")
1071
+
1072
+
1073
+ def repack_zip(path: Path, blobs: dict[str, bytes]) -> None:
1074
+ """Rewrite the zip at `path` with some entries' contents replaced.
1075
+
1076
+ Entry order and each entry's compression are kept, which an EPUB needs:
1077
+ `mimetype` has to stay first and stored, or the container is invalid."""
1078
+ with zipfile.ZipFile(path) as zin:
1079
+ infos = zin.infolist()
1080
+ contents = {i.filename: zin.read(i.filename) for i in infos}
1081
+ contents.update(blobs)
1082
+ tmp = path.with_name(path.name + ".tmp")
1083
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
1084
+ for info in infos:
1085
+ zout.writestr(info, contents[info.filename])
1086
+ tmp.replace(path)