quirework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|