Repository navigation
Expand file tree
/
Copy pathpdf.mjs
More file actions
237 lines (213 loc) · 9.7 KB
/
Copy pathpdf.mjs
File metadata and controls
237 lines (213 loc) · 9.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// Phase 8 WRITE PDF: produce the sparse `<destRoot>-pdf/` tree that
// pagedjs-cli consumes when rendering the PDF book. See builder/PLAN-8.md
// for the full spec and docs/_plugins/pdfify.rb for the canonical
// Jekyll reference.
//
// One entry point: writePdf(pages, staticFiles, site, destRoot,
// { tolerateMissingImages }). Its pure-compute half, deriveBookOutputs,
// assembles book.html and lists the images it uses without touching
// disk.
//
// Internal sections:
//
// §A Top-level orchestration (writePdf entry point)
// §C Static-file lookup
// §D Copy pass (book.html + CSS + images)
// §E Missing-image reporting (port of pdfify.rb's strict mode)
import { promises as fs } from "node:fs";
import path from "node:path";
import { assembleBook, bookCoverage, formatBookCoverage } from "./book.mjs";
import { WRITE_LIMIT, mkdirRec, runLimited, safeWrite, writeFileMkdirp } from "./write.mjs";
const PDF_SUFFIX = "-pdf";
const REQUIRED_CSS = ["assets/css/print.css", "assets/css/tb-highlight.css"];
// The faces print.css declares, copied into the sparse tree so its
// `url("../fonts/...")` resolves under the file:// URL render-book.mjs loads.
// They are read from print.css itself, so a face added to its @font-face
// block is copied without a second list to keep in step.
const PRINT_CSS = "assets/css/print.css";
const FONT_URL = /url\(\s*["']?\.\.\/fonts\/([^"')\s]+)["']?\s*\)/g;
const CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
const LIMIT = WRITE_LIMIT;
// ---------------------------------------------------------------------------
// §A Top-level orchestration
// ---------------------------------------------------------------------------
export async function writePdf(
pages,
staticFiles,
site,
destRoot,
{ tolerateMissingImages = false, highlightCss = null, check = false } = {},
) {
if (!destRoot) {
throw new Error("writePdf requires a destRoot");
}
const pdfRoot = destRoot + PDF_SUFFIX;
resolveBookPage(pages); // existence check; throws if missing or duplicated
const { bookHtml, imagePaths } = deriveBookOutputs(pages, site, staticFiles);
const staticByDestRel = new Map(staticFiles.map((s) => [s.destRel.replaceAll("\\", "/"), s]));
const counters = { bookBytes: 0, html: 0, css: 0, fonts: 0, images: 0, missing: 0 };
const missingPaths = [];
const requiredFonts = await printCssFonts(staticByDestRel);
await Promise.all([
writePdfBook(bookHtml, pdfRoot, counters),
copyPdfCss(staticByDestRel, highlightCss, pdfRoot, counters),
copyPdfFonts(requiredFonts, staticByDestRel, pdfRoot, counters),
copyPdfImages(imagePaths, staticByDestRel, pdfRoot, counters, missingPaths),
]);
reportMissingImages(missingPaths, tolerateMissingImages, counters);
// A page with no _book.yml entry, or an entry with no page: lines for
// tbdocs to print under the pdf summary. Warnings, never a failure --
// the book this build wrote is complete for the manifest it was given.
counters.coverage = formatBookCoverage(bookCoverage(site.bookData, pages));
// --check: hand the assembled book and the tree's exact contents to
// the link check rather than making it read 6.5 MB back off disk.
// `missingPaths` are the images that were NOT copied, so they must
// not appear in the index -- a reference to one is a broken link and
// the check should say so.
if (check) {
const missing = new Set(missingPaths);
counters.checkBook = {
html: bookHtml,
rels: ["book.html", ...REQUIRED_CSS, ...requiredFonts, ...imagePaths.filter((r) => !missing.has(r))],
};
}
return counters;
}
// PLAN-8 §4 deps assembly: pure-compute helper. Returns the assembled
// book.html string + the list of relative image paths it references.
//
// PLAN-9 §5.9: image-path collection is folded into the assembly
// itself (book.mjs's emitChapter populates a Set as it goes), so there
// is no separate pass over book.html looking for images.
function deriveBookOutputs(pages, site, staticFiles) {
return assembleBook(site, pages, staticFiles);
}
// PLAN-8 §5.1: locate the one `layout: book-combined` page. Throws on
// zero or multiple matches. Held for assertion only -- the actual
// assembly walks `site.bookData` directly.
function resolveBookPage(pages) {
const matches = pages.filter((p) => p.frontmatter?.layout === "book-combined");
if (matches.length === 0) {
throw new Error(
"Phase 8: no page with `layout: book-combined` found. " +
"Expected docs/book.html with this frontmatter; check the source tree.",
);
}
if (matches.length > 1) {
const list = matches.map((p) => p.srcRel).join(", ");
throw new Error(
`Phase 8: multiple pages with \`layout: book-combined\` found: ${list}. ` + "Only one is supported.",
);
}
return matches[0];
}
// ---------------------------------------------------------------------------
// §D Copy pass
// ---------------------------------------------------------------------------
// PLAN-8 §5.5: write the assembled book.html.
async function writePdfBook(bookHtml, pdfRoot, counters) {
const dest = path.join(pdfRoot, "book.html");
await writeFileMkdirp(dest, bookHtml);
counters.html = 1;
counters.bookBytes = Buffer.byteLength(bookHtml, "utf8");
return counters.bookBytes;
}
// Copy the two required CSS files into <pdfRoot>/assets/css/.
// tb-highlight.css is written from the in-memory highlightCss string
// (generated by highlight-theme.mjs during markdownInit); print.css is
// copied from its source path via the staticFiles inventory. Neither
// requires _site/ to exist, so writePdf can run before the write task.
async function copyPdfCss(staticByDestRel, highlightCss, pdfRoot, counters) {
const warnings = [];
await runLimited(REQUIRED_CSS, LIMIT, async (rel) => {
const dest = path.join(pdfRoot, rel);
await mkdirRec(path.dirname(dest));
const key = rel.replaceAll("\\", "/");
if (key === "assets/css/tb-highlight.css" && highlightCss) {
await safeWrite(dest, () => fs.writeFile(dest, highlightCss, "utf8"));
} else {
const sf = staticByDestRel.get(key);
if (!sf) {
warnings.push(`missing required asset ${rel}; pagedjs render may break`);
return;
}
await safeWrite(dest, () => fs.copyFile(sf.srcPath, dest));
}
counters.css++;
});
for (const w of warnings) console.warn(`pdf: ${w}`);
}
// The tree-relative path of every face print.css points at, in its order.
// Comments are dropped first: print.css's own header names the url() form.
// A missing print.css gives none: copyPdfCss already warns about it.
async function printCssFonts(staticByDestRel) {
const sf = staticByDestRel.get(PRINT_CSS);
if (!sf) return [];
const css = (await fs.readFile(sf.srcPath, "utf8")).replace(CSS_COMMENT, "");
return [...new Set(Array.from(css.matchAll(FONT_URL), (m) => `assets/fonts/${m[1]}`))];
}
// Copy the webfaces print.css declares into <pdfRoot>/assets/fonts/.
//
// This throws where copyPdfCss warns and copyPdfImages collects, because a
// missing face is not a degradation the output survives: the book would set
// that text in whatever the rendering machine happens to own, and embed it,
// which is precisely the machine-dependent artifact self-hosting exists to
// prevent. Downstream would only catch some of it -- the forked paged.js
// rejects a face whose fetch errored, but a declared face the layout never
// exercises is never fetched at all and passes silently. Failing here names
// the path instead.
async function copyPdfFonts(requiredFonts, staticByDestRel, pdfRoot, counters) {
await runLimited(requiredFonts, LIMIT, async (rel) => {
const sf = staticByDestRel.get(rel);
if (!sf) {
throw new Error(
`pdf: required font ${rel} is not in the source tree. Run ` +
`\`python scripts/build_fonts.py\` and commit docs/assets/fonts/.`,
);
}
const dest = path.join(pdfRoot, rel);
await mkdirRec(path.dirname(dest));
await safeWrite(dest, () => fs.copyFile(sf.srcPath, dest));
counters.fonts++;
});
}
// PLAN-8 §5.7: copy every image referenced from book.html to its
// mirrored location under <pdfRoot>/. Missing source paths land in
// missingPaths for the strict-mode reporter.
async function copyPdfImages(imagePaths, staticByDestRel, pdfRoot, counters, missingPaths) {
await runLimited(imagePaths, LIMIT, async (rel) => {
const key = rel.replaceAll("\\", "/");
const staticFile = staticByDestRel.get(key);
if (!staticFile) {
missingPaths.push(rel);
return;
}
const dest = path.join(pdfRoot, rel);
await mkdirRec(path.dirname(dest));
await safeWrite(dest, () => fs.copyFile(staticFile.srcPath, dest));
counters.images++;
});
}
// ---------------------------------------------------------------------------
// §E Missing-image reporting (port of pdfify.rb's strict mode)
// ---------------------------------------------------------------------------
// PLAN-8 §5.8: per-path error log, then throw if !tolerateMissingImages.
// Mirrors pdfify.rb's strict mode -- `jekyll build` aborts on a non-zero
// missing count, `jekyll serve` warns only. `--tolerate-missing-images`
// (PLAN-12 §7.D5) flips the throw to a warning for iterative work.
function reportMissingImages(missingPaths, tolerateMissingImages, counters) {
counters.missing = missingPaths.length;
for (const rel of missingPaths) {
console.error(`pdf: missing image ${rel} (referenced from book.html, not present under source tree)`);
}
if (missingPaths.length === 0) return;
if (tolerateMissingImages) {
console.warn(
`pdf: ${missingPaths.length} image reference(s) missing; PDF render will show broken-image placeholders`,
);
return;
}
throw new Error(
`pdf: ${missingPaths.length} image reference(s) in book.html missing under source tree -- see error log above`,
);
}