Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ci/measure/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,15 @@ async function join(job: JoinJob, report: (m: JoinMessage) => void): Promise<voi
result['outlineMissing'] = missing;
});

// Each page's number and the PDF's pathway, in an embedded subset of Roboto.
// Each page's number and the PDF's pathway, in an embedded subset of Roboto. From its
// WOFF file, as src/print/stamp.ts embeds it: fontkit's subset of the WOFF2 file has no
// outlines (T027, #147's review), so the runs before this embedded glyphs never drawn.
await stage('stamp', async () => {
doc.registerFontkit(fontkit);
const file = path.join(
path.dirname(new URL(import.meta.resolve('@fontsource/roboto/package.json')).pathname),
'files',
'roboto-latin-400-normal.woff2'
'roboto-latin-400-normal.woff'
);
const font = await doc.embedFont(readFileSync(file), { subset: true });
const hex = themeVariables().get('--mat-sys-on-surface-variant') ?? '';
Expand Down
9 changes: 7 additions & 2 deletions specs/001-pathway-pdfs/tasks.md

Large diffs are not rendered by default.

154 changes: 148 additions & 6 deletions src/print/contents.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
// The contents list's page numbers (spec 001 T019, research R4): the first print carries a
// placeholder in a box of fixed width for each entry; where each target landed is read back
// from that print, and the numbers go into the same boxes, so nothing else moves and the
// second print's pages are the first's. Chromium writes a named destination for each
// anchor a link points at, and every entry links to its target, so each target's page is
// its destination's.
// The contents list's page numbers (spec 001 T019, T028, research R4): the first print
// carries a placeholder in a box of fixed width for each entry; where each target landed is
// read back, and the numbers go into the same boxes, so nothing else moves and the second
// print's pages are the first's.
//
// A single event's PDF (T019) is printed whole: Chromium writes a named destination for
// each anchor a link points at, and every entry links to its target, so each target's page
// is its destination's.
//
// A book's PDF (T028) is printed in parts and joined, its links placeholders: each target's
// page is read from its probe in the joined PDF, as T026's links are, the front part alone
// is printed again with the numbers, and its pages are swapped for the first print's in the
// joined PDF, before its links are rewritten and its pages stamped, so the new front pages
// are linked and stamped as the rest are. The front part must print the same pages, its
// own headings on the same pages, or every later page and number would move: a reprint
// that does not is refused. The reprint's layout report is not kept: the first print's is
// the PDF's, so nothing in the front part is counted twice.
import {
PDFArray,
PDFDict,
PDFDocument,
PDFName,
PDFRef,
PDFStream,
type PDFContext,
type PDFObject,
} from '@cantoo/pdf-lib';
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';

import { layoutBookFront, type LayoutOptions } from '../layout/document.ts';
import { probeTargets, type Target } from './links.ts';
import type { Joined, PartPrinter } from './parts.ts';

/** An entry's placeholder, as the layout writes it (src/layout/front.ts). */
const PLACEHOLDER = /<span class="page" data-target="([^"]*)">[^<]*<\/span>/g;

Expand Down Expand Up @@ -58,3 +83,120 @@ export function fillContents(html: string, pages: ReadonlyMap<string, number>):
return whole.replace(/>[^<]*<\/span>$/, `>${String(page)}</span>`);
});
}

/**
* How a second print's pages differ from the first's, or null: another page count, or a
* target on another page. Either means the numbers printed are not the pages.
*/
export function movedPages(
first: Landed,
second: Landed,
targets: readonly string[]
): string | null {
const moved = targets.filter((t) => second.pages.get(t) !== first.pages.get(t));
if (second.pageCount === first.pageCount && moved.length === 0) return null;
return `${String(first.pageCount)} pages, then ${String(second.pageCount)}; moved: ${moved.join(', ') || 'none'}`;
}

const PARENT = PDFName.of('Parent');

/**
* Every object reachable by reference from those given; through `/Parent` only when asked,
* as a page's leads to the whole page tree.
*/
function reachable(context: PDFContext, from: readonly PDFObject[], parents: boolean): Set<PDFRef> {
const seen = new Set<PDFRef>();
const stack = [...from];
for (let object = stack.pop(); object !== undefined; object = stack.pop()) {
if (object instanceof PDFRef) {
if (seen.has(object)) continue;
seen.add(object);
const found = context.lookup(object);
if (found !== undefined) stack.push(found);
} else if (object instanceof PDFDict) {
for (const [key, value] of object.entries()) {
if (parents || key !== PARENT) stack.push(value);
}
} else if (object instanceof PDFArray) {
for (const value of object.asArray()) stack.push(value);
} else if (object instanceof PDFStream) {
stack.push(object.dict);
}
}
return seen;
}

/** Each probed anchor's page, counted from 1, for those on the first `count` pages. */
function pagesOf(targets: ReadonlyMap<string, Target>, count: number): Map<string, number> {
const pages = new Map<string, number>();
for (const [anchor, target] of targets) {
if (target.index < count) pages.set(anchor, target.index + 1);
}
return pages;
}

/**
* A joined book PDF's contents numbered (T028): each entry's target's page read from its
* probe, the front part printed again with the numbers, and its pages put in place of the
* first print's, in place, the first print's objects deleted with them. Runs before
* `rewriteLinks`, which removes the probes, and so before `stampPages`. Throws, changing
* nothing, when the first part is not the front part or the parts' pages are not the PDF's,
* an entry's target has no probe, or the reprint's pages differ from the first print's (its
* page count, or a front heading on another page), which would move every later page.
*/
export async function reprintFront(
joined: Pick<Joined, 'doc' | 'pdf' | 'parts'>,
printer: PartPrinter,
layout: LayoutOptions
): Promise<void> {
const { doc, pdf, parts } = joined;
const front = parts[0];
if (!front || front.places.length > 0) {
throw new Error("The joined PDF's first part is not its front part");
}
const counted = parts.reduce((n, part) => n + part.pages, 0);
if (counted !== pdf.getPageCount()) {
throw new Error(
`The parts hold ${String(counted)} pages, the joined PDF ${String(pdf.getPageCount())}`
);
}
const html = layoutBookFront(doc, layout).html;
const targets = contentsTargets(html);
if (targets.length === 0) return;
const probes = probeTargets(pdf);
const unprobed = targets.filter((target) => !probes.has(target));
if (unprobed.length > 0) {
throw new Error(
`The contents' entries with no probe in the joined PDF (run before rewriteLinks): ${unprobed.join(', ')}`
);
}
const reprinted = await PDFDocument.load(
await printer.part(fillContents(html, pagesOf(probes, counted)))
);
// the front part's own headings, where they landed the first time and the second
const first = { pages: pagesOf(probes, front.pages), pageCount: front.pages };
const pageCount = reprinted.getPageCount();
const second = { pages: pagesOf(probeTargets(reprinted), pageCount), pageCount };
const anchors = [...new Set([...first.pages.keys(), ...second.pages.keys()])];
const problem = movedPages(first, second, anchors);
if (problem) throw new Error(`The front part's numbers moved its pages: ${problem}`);
// the first print's page dictionaries: pdf-lib's removePage deletes a page's own object,
// not what it refers to (its contents, resources, annotations), which saving would write
const old = pdf
.getPages()
.slice(0, front.pages)
.map((page) => page.node);
const copied = await pdf.copyPages(reprinted, reprinted.getPageIndices());
copied.forEach((page, i) => {
pdf.removePage(i);
pdf.insertPage(i, page);
});
// what only the first print's pages held is deleted, never what the PDF still uses
const { context } = pdf;
const { Root, Info, Encrypt } = context.trailerInfo;
const roots = [Root, Info, Encrypt].filter((o) => o !== undefined);
const live = reachable(context, roots, true);
for (const ref of reachable(context, old, false)) {
if (!live.has(ref)) context.delete(ref);
}
}
26 changes: 26 additions & 0 deletions src/print/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,32 @@ export function destination(pdf: PDFDocument, target: Target): PDFArray {
return dest;
}

/**
* Where each anchor's probe is in a PDF, read without changing anything: T028 numbers the
* contents from them before `rewriteLinks` runs. Throws naming every anchor probed twice.
*/
export function probeTargets(pdf: PDFDocument): Map<string, Target> {
const targets = new Map<string, Target>();
const twice = new Set<string>();
pdf.getPages().forEach((page, index) => {
const annots = page.node.Annots();
for (let i = 0; i < (annots?.size() ?? 0); i++) {
const annot = pdf.context.lookup(annots?.get(i));
const uri = annot instanceof PDFDict ? uriOf(annot) : null;
if (!(annot instanceof PDFDict) || !uri?.startsWith(PROBE)) continue;
const anchor = decodeURIComponent(uri.slice(PROBE.length));
if (targets.has(anchor)) twice.add(anchor);
targets.set(anchor, { page: page.ref, index, y: topOf(annot) });
}
});
if (twice.size > 0) {
throw new Error(
`The PDF's probes: ${[...twice].map((a) => `${a} is probed twice`).join('; ')}`
);
}
return targets;
}

/**
* Each placeholder link in a joined PDF made a destination at its anchor's probe, and the
* probes removed. Returns each anchor's target, for the outline. Throws, changing nothing,
Expand Down
16 changes: 1 addition & 15 deletions src/print/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,7 @@ import { pathToFileURL } from 'node:url';

import { chromium, type Browser } from 'playwright-core';

import { contentsTargets, fillContents, landed, type Landed } from './contents.ts';

/**
* How a second print's pages differ from the first's, or null: another page count, or a
* target on another page. Either means the numbers printed are not the pages.
*/
export function movedPages(
first: Landed,
second: Landed,
targets: readonly string[]
): string | null {
const moved = targets.filter((t) => second.pages.get(t) !== first.pages.get(t));
if (second.pageCount === first.pageCount && moved.length === 0) return null;
return `${String(first.pageCount)} pages, then ${String(second.pageCount)}; moved: ${moved.join(', ') || 'none'}`;
}
import { contentsTargets, fillContents, landed, movedPages } from './contents.ts';

export class Printer {
readonly #browser: Browser;
Expand Down
76 changes: 55 additions & 21 deletions src/print/stamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
// where the box puts it. The cover has neither. A top-level pathway's name is stamped,
// not laid out, so FR-017's substitutions are made here; they are not counted in the run
// report, which counts the name where its section prints it. White space is collapsed and
// characters never drawn are left out, as a margin box does, so a stray tab or variation
// selector that prints in a section does not refuse the PDF here.
// characters never drawn are left out, close to a margin box, so a stray tab or variation
// selector that prints in a section does not refuse the PDF here (`marginText`).
//
// Each face is embedded from its WOFF file, not the WOFF2 the pages load: fontkit's
// subsetter copies a WOFF2 file's `glyf` bytes as they are, transformed, and the subset's
Expand All @@ -23,7 +23,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { ColorTypes, type PDFDocument, type PDFFont, type RGB } from '@cantoo/pdf-lib';
import * as fontkit from 'fontkit';

import { NEVER_DRAWN, substituted } from '../layout/characters.ts';
import { substituted } from '../layout/characters.ts';
import { MARGIN_FAMILIES, pageStyles } from '../layout/document.ts';
import { PAGE } from '../layout/figure.ts';
import { codePointName, fontFaces, type FontFace } from '../layout/fonts.ts';
Expand All @@ -42,15 +42,36 @@ export interface FrontTarget {
}

/**
* A text as a margin box draws it: each run of white space one space, characters never
* drawn (controls, variation selectors, joiners; FR-017's) left out, and trimmed.
* What a margin box leaves out: FR-017's characters never drawn (`NEVER_DRAWN`) but its
* white space, which is collapsed or kept (below), so controls and default-ignorable
* characters (variation selectors, joiners, a soft hyphen).
*/
const LEFT_OUT = /[\p{Cc}\p{Default_Ignorable_Code_Point}]/u;

/**
* A text close to how a margin box draws it: each run of CSS's collapsible white space
* (space, tab, line feed, carriage return; not JavaScript's `\s`) one space, `LEFT_OUT`'s
* characters left out (a form feed among them, a control CSS does not collapse), and a
* space at either end trimmed. Any other space is kept, as Chromium keeps it: as itself
* where a stamp face draws it (a no-break or thin space), and otherwise as U+0020, which
* Chromium's box draws in its place where it has no face for it (an ideographic space, a
* line separator, an Ogham space mark), not refused as a character no face draws.
*
* Gaps against Chromium, known: Hangul fillers (U+3164, U+115F) are default-ignorable and
* left out here, where Chromium may draw them as blanks; a kept space is as wide as
* U+0020, not its own width; and nothing here is shaped (above).
*/
export function marginText(text: string): string {
let out = '';
for (const char of text.replace(/\s+/g, ' ')) {
if (char === ' ' || !NEVER_DRAWN.test(char)) out += char;
for (const char of text.replace(/[ \t\n\r]+/g, ' ')) {
if (char === ' ' || !LEFT_OUT.test(char)) out += char;
}
out = out.replace(/ {2,}/g, ' ').replace(/^ | $/g, '');
let drawn = '';
for (const char of out) {
drawn += /\s/u.test(char) && !faceFor(char.codePointAt(0) ?? 0) ? ' ' : char;
}
return out.replace(/ {2,}/g, ' ').trim();
return drawn;
}

/**
Expand Down Expand Up @@ -82,6 +103,9 @@ export function pageStamps(
throw new Error(`Front parts outside the front part's pages 2–${String(frontPages)}: ${named}`);
}
const header = marginText(substituted(name));
if (/^\s*$/u.test(header)) {
throw new Error(`The pathway's name ${JSON.stringify(name)} leaves no header to stamp`);
}
const stamps: (PageStamp | null)[] = [];
for (let i = 0; i < pages; i++) {
const number = String(i + 1);
Expand Down Expand Up @@ -158,10 +182,12 @@ export function stampFile(face: FontFace): string {
return path;
}

function load(face: FontFace): Loaded {
let file = loaded.get(face.path);
/** A face's file read, once a path; its path is found each time, so a missing file is refused. */
function load(face: FontFace, fileOf: (face: FontFace) => string): Loaded {
const path = fileOf(face);
let file = loaded.get(path);
if (!file) {
const bytes = new Uint8Array(readFileSync(stampFile(face)));
const bytes = new Uint8Array(readFileSync(path));
const font = fontkit.create(Buffer.from(bytes));
if (!('postscriptName' in font)) throw new Error(`${face.path} is a collection, not a font`);
file = {
Expand All @@ -172,7 +198,7 @@ function load(face: FontFace): Loaded {
lineGap: font.lineGap,
unitsPerEm: font.unitsPerEm,
};
loaded.set(face.path, file);
loaded.set(path, file);
}
return file;
}
Expand Down Expand Up @@ -217,12 +243,16 @@ const stamped = new WeakSet<PDFDocument>();
* front part is the PDF's first part; `targets` are where its headings landed, as
* `rewriteLinks` gives them, so it runs after that. Throws, stamping nothing, when the
* PDF is stamped already, the parts' pages are not the PDF's, a front part's heading is
* not in the front part, or a character of a stamp is one no font draws.
* not in the front part, the name leaves no header, a character of a stamp is one no font
* draws, or a face's file cannot be read; a PDF refused is not held as stamped.
*
* @param fileOf each face's file (`stampFile`); the tests give one that is missing
*/
export async function stampPages(
joined: Pick<Joined, 'doc' | 'pdf' | 'parts'>,
targets: ReadonlyMap<string, FrontTarget>,
look: Look = stampLook()
look: Look = stampLook(),
fileOf: (face: FontFace) => string = stampFile
): Promise<void> {
const { doc, pdf, parts } = joined;
if (stamped.has(pdf)) throw new Error('The joined PDF is stamped already');
Expand Down Expand Up @@ -253,26 +283,30 @@ export async function stampPages(
used.set(run.face, chars);
}
}
// every face's file read, and the space's face found, before the PDF is held: a refusal
// leaves it unmarked
const files = new Map([...used.keys()].map((face) => [face, load(face, fileOf)]));
// a margin box's line is the first face's `normal` line (the one drawing a space,
// Roboto's), centred in the box: the margin's height at the top or bottom of the page
const space = faceFor(0x20);
if (!space) throw new Error('No face draws a space');
const metrics = load(space, fileOf);
// held before the first await, so two stampings of the PDF at once cannot both pass
stamped.add(pdf);
pdf.registerFontkit(fontkit);
const fonts = new Map<FontFace, PDFFont>();
for (const [face, chars] of used) {
const file = load(face);
const file = files.get(face) ?? load(face, fileOf);
const customName = `${subsetTag(face, chars)}+${file.postscriptName}`;
fonts.set(face, await pdf.embedFont(file.bytes, { subset: true, customName }));
}
// a margin box's line is the first face's `normal` line (the one drawing a space,
// Roboto's), centred in the box: the margin's height at the top or bottom of the page
const space = faceFor(0x20);
if (!space) throw new Error('No face draws a space');
const metrics = load(space);
const em = look.size / metrics.unitsPerEm;
const line = (metrics.ascent - metrics.descent + metrics.lineGap) * em;
// the baseline's distance from the page's top edge in the top box, and from its bottom
// edge in the bottom box
const top = (PAGE.margin - line) / 2 + metrics.ascent * em;
const bottom = PAGE.margin - top;
const { size, colour: color } = look;
stamped.add(pdf);
pages.forEach((page, i) => {
const stamp = laid[i];
if (!stamp) return;
Expand Down
Loading
Loading