@segnavia/render 0.4.0 → 0.5.0

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.
@@ -0,0 +1,101 @@
1
+ import { RenderRefusedError } from "./errors.js";
2
+ import { PDFArray, PDFDict, PDFHexString, PDFName, PDFNumber, PDFRef, PDFString } from "pdf-lib";
3
+ //#region src/book-destinations.ts
4
+ const key = (value) => PDFName.of(value);
5
+ const resolve = (document, value) => value instanceof PDFRef ? document.context.lookup(value) : value;
6
+ function decode(value) {
7
+ if (value instanceof PDFString || value instanceof PDFHexString) return value.decodeText();
8
+ if (value instanceof PDFName) return value.toString().slice(1);
9
+ }
10
+ function destination(document, value) {
11
+ let found = resolve(document, value);
12
+ if (found instanceof PDFDict) found = resolve(document, found.get(key("D")));
13
+ if (!(found instanceof PDFArray)) return void 0;
14
+ const reference = found.get(0);
15
+ if (!(reference instanceof PDFRef)) return void 0;
16
+ const page = document.getPages().findIndex((candidate) => candidate.ref.toString() === reference.toString());
17
+ if (page < 0) return void 0;
18
+ const x = resolve(document, found.get(2));
19
+ const y = resolve(document, found.get(3));
20
+ return {
21
+ page: page + 1,
22
+ x: x instanceof PDFNumber ? x.asNumber() : 0,
23
+ y: y instanceof PDFNumber ? y.asNumber() : document.getPage(page).getHeight()
24
+ };
25
+ }
26
+ function nameTree(document, value, output) {
27
+ const dictionary = resolve(document, value);
28
+ if (!(dictionary instanceof PDFDict)) return;
29
+ const names = resolve(document, dictionary.get(key("Names")));
30
+ if (names instanceof PDFArray) for (let index = 0; index + 1 < names.size(); index += 2) {
31
+ const name = decode(resolve(document, names.get(index)));
32
+ const target = destination(document, names.get(index + 1));
33
+ if (name && target) output.set(name, target);
34
+ }
35
+ const kids = resolve(document, dictionary.get(key("Kids")));
36
+ if (kids instanceof PDFArray) for (let index = 0; index < kids.size(); index += 1) nameTree(document, kids.get(index), output);
37
+ }
38
+ function namedDestinations(document) {
39
+ const output = /* @__PURE__ */ new Map();
40
+ const names = resolve(document, document.catalog.get(key("Names")));
41
+ if (names instanceof PDFDict) nameTree(document, names.get(key("Dests")), output);
42
+ const direct = resolve(document, document.catalog.get(key("Dests")));
43
+ if (direct instanceof PDFDict) for (const [name, value] of direct.entries()) {
44
+ const target = destination(document, value);
45
+ if (target) output.set(name.toString().slice(1), target);
46
+ }
47
+ return output;
48
+ }
49
+ function hiddenAnnotation(document, annotation) {
50
+ const rectangle = resolve(document, annotation.get(key("Rect")));
51
+ if (!(rectangle instanceof PDFArray) || rectangle.size() !== 4) return false;
52
+ const values = Array.from({ length: 4 }, (_, index) => resolve(document, rectangle.get(index)));
53
+ if (!values.every((value) => value instanceof PDFNumber)) return false;
54
+ const [left = 0, bottom = 0, right = 0, top = 0] = values.map((value) => value.asNumber());
55
+ return Math.abs(right - left) <= 1 && Math.abs(top - bottom) <= 1;
56
+ }
57
+ function explicit(document, target, offset) {
58
+ return document.context.obj([
59
+ document.getPage(offset + target.page - 1).ref,
60
+ "XYZ",
61
+ target.x,
62
+ target.y,
63
+ null
64
+ ]);
65
+ }
66
+ function customTarget(uri) {
67
+ if (!uri?.startsWith("segnavia:target/")) return void 0;
68
+ try {
69
+ return decodeURIComponent(uri.slice(16));
70
+ } catch {
71
+ return uri.slice(16);
72
+ }
73
+ }
74
+ function rewriteBookLinks(document, frontPages, front, body) {
75
+ for (const [pageIndex, page] of document.getPages().entries()) {
76
+ const annotations = resolve(document, page.node.get(key("Annots")));
77
+ if (!(annotations instanceof PDFArray)) continue;
78
+ const kept = Array.from({ length: annotations.size() }, (_, index) => annotations.get(index)).filter((object) => rewriteAnnotation(document, object, pageIndex, frontPages, front, body));
79
+ if (kept.length > 0) page.node.set(key("Annots"), document.context.obj(kept));
80
+ else page.node.delete(key("Annots"));
81
+ }
82
+ }
83
+ function rewriteAnnotation(document, object, pageIndex, frontPages, front, body) {
84
+ const annotation = resolve(document, object);
85
+ if (!(annotation instanceof PDFDict)) return true;
86
+ if (hiddenAnnotation(document, annotation)) return false;
87
+ const named = decode(resolve(document, annotation.get(key("Dest"))));
88
+ const action = resolve(document, annotation.get(key("A")));
89
+ const uri = action instanceof PDFDict ? decode(resolve(document, action.get(key("URI")))) : void 0;
90
+ const cross = customTarget(uri);
91
+ const target = cross ? front.get(cross) ?? body.get(cross) : named ? pageIndex < frontPages ? front.get(named) : body.get(named) : void 0;
92
+ if (cross && !target) throw new RenderRefusedError(`The segnavia link to ${cross} has no printed destination.`);
93
+ if (target) {
94
+ const offset = front.has(cross ?? "") || !cross && pageIndex < frontPages ? 0 : frontPages;
95
+ annotation.set(key("Dest"), explicit(document, target, offset));
96
+ annotation.delete(key("A"));
97
+ } else if (uri?.startsWith("segnavia:")) throw new RenderRefusedError(`The PDF still contains the segnavia link ${uri}.`);
98
+ return true;
99
+ }
100
+ //#endregion
101
+ export { namedDestinations, rewriteBookLinks };
@@ -0,0 +1,20 @@
1
+ import { resolveDirection, sheetMm } from "@segnavia/format";
2
+ //#region src/book-margins.ts
3
+ function escapeHtml(value) {
4
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
5
+ }
6
+ function marginCss(book) {
7
+ if (!book.page) return "";
8
+ const size = sheetMm(book.page);
9
+ const edge = book.page.margin;
10
+ return `@page{size:${size.width}mm ${size.height}mm;margin:0}:where(html,body){margin:0}:where(.segnavia-margin-page){position:relative;inline-size:${size.width}mm;block-size:${size.height}mm;break-after:page;direction:ltr}:where(.segnavia-running-head,.segnavia-folio){position:absolute;display:flex;align-items:center}:where(.segnavia-running-head){inset-block-start:0;block-size:${edge.top}mm}:where(.segnavia-folio){inset-block-end:0;block-size:${edge.bottom}mm}:where([data-side="left"] .segnavia-running-head,[data-side="left"] .segnavia-folio){inset-inline-start:${edge.outer}mm;inset-inline-end:${edge.inner}mm;justify-content:flex-start}:where([data-side="right"] .segnavia-running-head,[data-side="right"] .segnavia-folio){inset-inline-start:${edge.inner}mm;inset-inline-end:${edge.outer}mm;justify-content:flex-end}`;
11
+ }
12
+ function marginDocument(book, pages) {
13
+ return pages.map((page) => {
14
+ const head = page.runningHead === void 0 ? "" : `<div class="segnavia-running-head"><bdi dir="${resolveDirection(book.dir, book.lang, page.runningHead)}">${escapeHtml(page.runningHead)}</bdi></div>`;
15
+ const folio = page.folio === void 0 ? "" : `<div class="segnavia-folio"><bdi dir="${resolveDirection(book.dir, book.lang, page.folio)}">${escapeHtml(page.folio)}</bdi></div>`;
16
+ return `<div class="segnavia-margin-page" data-side="${page.side}">${head}${folio}</div>`;
17
+ }).join("");
18
+ }
19
+ //#endregion
20
+ export { marginCss, marginDocument };
@@ -0,0 +1,233 @@
1
+ import { marginDocument } from "./book-margins.js";
2
+ import { RenderRefusedError } from "./errors.js";
3
+ import { namedDestinations, rewriteBookLinks } from "./book-destinations.js";
4
+ import { Book, formatNumber, resolveDirection, sheetMm } from "@segnavia/format";
5
+ import { PDFDocument } from "pdf-lib";
6
+ import { bookDocument, bookPrintPart } from "@segnavia/html";
7
+ //#region src/book-pdf.ts
8
+ async function inspect(bytes) {
9
+ const document = await PDFDocument.load(bytes);
10
+ return {
11
+ bytes,
12
+ pages: document.getPageCount(),
13
+ destinations: namedDestinations(document)
14
+ };
15
+ }
16
+ function hashTargets(document) {
17
+ return [...new Set([...document.html.matchAll(/href="#([^"]+)"/g)].map((match) => match[1]))];
18
+ }
19
+ function requireTargets(part, expected) {
20
+ const missing = expected.filter((id) => !part.destinations.has(id));
21
+ if (missing.length > 0) throw new RenderRefusedError(`Chromium wrote ${expected.length - missing.length} of ${expected.length} named destinations for hidden targets; missing ${missing.join(", ")}.`);
22
+ }
23
+ const sectionsFor = (sections, part) => sections.filter((section) => bookPrintPart(section.kind) === part);
24
+ function planBlanks(sections, destinations) {
25
+ const ids = [];
26
+ const predicted = /* @__PURE__ */ new Map();
27
+ let blanks = 0;
28
+ for (const section of sectionsFor(sections, "body")) {
29
+ const original = destinations.get(section.id)?.page;
30
+ if (original === void 0) throw new RenderRefusedError(`The first body print has no destination for ${section.id}.`);
31
+ let page = original + blanks;
32
+ if (page % 2 === 0) {
33
+ ids.push(section.id);
34
+ blanks += 1;
35
+ page += 1;
36
+ }
37
+ predicted.set(section.id, page);
38
+ }
39
+ return {
40
+ ids,
41
+ predicted
42
+ };
43
+ }
44
+ function verifyStarts(label, sections, expected, actual) {
45
+ for (const section of sections) {
46
+ const predicted = expected.get(section.id);
47
+ const found = actual.get(section.id)?.page;
48
+ if (predicted !== found) throw new RenderRefusedError(`${section.id} was predicted to start on ${label} page ${predicted}, but started on page ${found}.`);
49
+ }
50
+ }
51
+ async function appendBlank(part) {
52
+ if (!part.bytes) return part;
53
+ const document = await PDFDocument.load(part.bytes);
54
+ const last = document.getPage(document.getPageCount() - 1);
55
+ document.addPage([last.getWidth(), last.getHeight()]);
56
+ return {
57
+ ...part,
58
+ bytes: await document.save(),
59
+ pages: part.pages + 1
60
+ };
61
+ }
62
+ function pageSide(book, physicalPage) {
63
+ const odd = resolveDirection(book.dir, book.lang, book.title) === "rtl" ? "left" : "right";
64
+ return physicalPage % 2 === 1 ? odd : odd === "left" ? "right" : "left";
65
+ }
66
+ function marginsFor(book, sections, destinations, pages, offset, blanks, system) {
67
+ const starts = sections.flatMap((section) => {
68
+ const page = destinations.get(section.id)?.page;
69
+ return page === void 0 ? [] : [{
70
+ page,
71
+ section
72
+ }];
73
+ }).sort((left, right) => left.page - right.page);
74
+ return Array.from({ length: pages }, (_, index) => {
75
+ const page = index + 1;
76
+ if (blanks.has(page)) return {
77
+ kind: "blank",
78
+ side: pageSide(book, offset + page)
79
+ };
80
+ const owner = starts.filter((start) => start.page <= page).at(-1);
81
+ return {
82
+ kind: owner?.section.kind ?? "blank",
83
+ side: pageSide(book, offset + page),
84
+ folio: formatNumber(page, system),
85
+ runningHead: owner?.page === page ? void 0 : owner?.section.runningHead
86
+ };
87
+ });
88
+ }
89
+ function destinationOwner(id, destination, sections, destinations) {
90
+ const owner = sections.flatMap((section) => {
91
+ const start = destinations.get(section.id);
92
+ return start && start.page <= destination.page ? [{
93
+ section,
94
+ start
95
+ }] : [];
96
+ }).sort((left, right) => left.start.page - right.start.page).at(-1)?.section;
97
+ return owner ? `${owner.title ?? owner.id} (${owner.id})` : id;
98
+ }
99
+ function frontPrintStatus(prints, ids, sections) {
100
+ if (prints.length < 2) return { status: "more" };
101
+ const expected = prints.at(-2);
102
+ const actual = prints.at(-1);
103
+ for (const id of ids) {
104
+ const before = expected.get(id);
105
+ const after = actual.get(id);
106
+ if (!before || !after || before.page === after.page) continue;
107
+ if (prints.length < 3) return { status: "more" };
108
+ return {
109
+ status: "refused",
110
+ message: `Front destination ${id} in ${destinationOwner(id, before, sections, expected)} moved from page ${before.page} to page ${after.page} after three prints.`
111
+ };
112
+ }
113
+ return { status: "stable" };
114
+ }
115
+ function finalNumbers(book, headings, front, body) {
116
+ return Object.fromEntries(headings.map((heading) => {
117
+ const frontPage = front.destinations.get(heading.id)?.page;
118
+ if (frontPage !== void 0) return [heading.id, formatNumber(frontPage, book.numbering.frontMatter)];
119
+ const bodyPage = body.destinations.get(heading.id)?.page;
120
+ if (bodyPage === void 0) throw new RenderRefusedError(`The heading ${heading.id} has no printed destination.`);
121
+ return [heading.id, formatNumber(bodyPage, book.numbering.body)];
122
+ }));
123
+ }
124
+ async function printBody(book, options, adapter) {
125
+ const firstDocument = bookDocument(book, {
126
+ part: "body",
127
+ targets: true
128
+ });
129
+ if (firstDocument.issues.length > 0) throw new RenderRefusedError(`The book cannot be printed: ${firstDocument.issues.join("; ")}.`);
130
+ let part = await inspect(await adapter.print(firstDocument.html, book, options));
131
+ requireTargets(part, hashTargets(firstDocument));
132
+ const plan = book.rectoOpenings ? planBlanks(firstDocument.sections, part.destinations) : {
133
+ ids: [],
134
+ predicted: /* @__PURE__ */ new Map()
135
+ };
136
+ if (book.rectoOpenings) {
137
+ const finalDocument = bookDocument(book, {
138
+ part: "body",
139
+ targets: true,
140
+ blankBefore: plan.ids
141
+ });
142
+ part = await inspect(await adapter.print(finalDocument.html, book, options));
143
+ requireTargets(part, hashTargets(finalDocument));
144
+ verifyStarts("body", sectionsFor(firstDocument.sections, "body"), plan.predicted, part.destinations);
145
+ }
146
+ return {
147
+ document: firstDocument,
148
+ part,
149
+ blankBefore: plan.ids
150
+ };
151
+ }
152
+ async function printFront(book, source, body, options, adapter) {
153
+ const sections = sectionsFor(source.sections, "front");
154
+ if (sections.length === 0) return {
155
+ part: {
156
+ pages: 0,
157
+ destinations: /* @__PURE__ */ new Map()
158
+ },
159
+ trailingBlank: false
160
+ };
161
+ const ids = [];
162
+ const prints = [];
163
+ let numbers;
164
+ let final;
165
+ for (let attempt = 0; attempt < 3; attempt += 1) {
166
+ const document = bookDocument(book, {
167
+ part: "front",
168
+ targets: true,
169
+ crossLinks: true,
170
+ pageNumbers: numbers
171
+ });
172
+ const printed = await inspect(await adapter.print(document.html, book, options));
173
+ if (ids.length === 0) ids.push(...hashTargets(document));
174
+ requireTargets(printed, ids);
175
+ prints.push(printed);
176
+ const status = frontPrintStatus(prints.map((part) => part.destinations), ids, sections);
177
+ if (status.status === "stable") {
178
+ final = printed;
179
+ break;
180
+ }
181
+ if (status.status === "refused") throw new RenderRefusedError(status.message);
182
+ numbers = finalNumbers(book, source.headings, printed, body);
183
+ }
184
+ if (!final) throw new RenderRefusedError("The front print did not converge after three prints.");
185
+ const trailingBlank = book.rectoOpenings && final.pages % 2 === 1;
186
+ return {
187
+ part: trailingBlank ? await appendBlank(final) : final,
188
+ trailingBlank
189
+ };
190
+ }
191
+ async function join(front, body, marginBytes) {
192
+ const output = await PDFDocument.create();
193
+ for (const part of [front, body]) {
194
+ if (!part.bytes) continue;
195
+ const source = await PDFDocument.load(part.bytes);
196
+ for (const page of await output.copyPages(source, source.getPageIndices())) output.addPage(page);
197
+ }
198
+ const layer = await PDFDocument.load(marginBytes);
199
+ if (layer.getPageCount() !== output.getPageCount()) throw new RenderRefusedError(`The margin layer has ${layer.getPageCount()} pages, but the book has ${output.getPageCount()}.`);
200
+ for (const [index, page] of layer.getPages().entries()) {
201
+ const [embedded] = await output.embedPdf(layer, [index]);
202
+ if (embedded) output.getPage(index).drawPage(embedded, {
203
+ x: 0,
204
+ y: 0,
205
+ width: page.getWidth(),
206
+ height: page.getHeight()
207
+ });
208
+ }
209
+ rewriteBookLinks(output, front.pages, front.destinations, body.destinations);
210
+ return output.save();
211
+ }
212
+ async function renderBookPdf(input, options, adapter) {
213
+ const book = Book.parse(input);
214
+ if (book.mode === "paginated") throw new RenderRefusedError("bookPdf prints continuous books; printing a paginated book is not built yet.");
215
+ if (!book.page) throw new RenderRefusedError("Printing a continuous book needs page; set page before calling bookPdf.");
216
+ const printedBody = await printBody(book, options, adapter);
217
+ const printedFront = await printFront(book, printedBody.document, printedBody.part, options, adapter);
218
+ const front = printedFront.part;
219
+ const bodyBlankPages = new Set(printedBody.blankBefore.map((id) => (printedBody.part.destinations.get(id)?.page ?? 1) - 1));
220
+ const frontBlankPages = new Set(printedFront.trailingBlank ? [front.pages] : []);
221
+ const marginPages = [...marginsFor(book, sectionsFor(printedBody.document.sections, "front"), front.destinations, front.pages, 0, frontBlankPages, book.numbering.frontMatter), ...marginsFor(book, sectionsFor(printedBody.document.sections, "body"), printedBody.part.destinations, printedBody.part.pages, front.pages, bodyBlankPages, book.numbering.body)];
222
+ const marginBytes = await adapter.print(marginDocument(book, marginPages), book, options, true);
223
+ return adapter.finish(await join(front, printedBody.part, marginBytes), options);
224
+ }
225
+ function bookPageCss(book, marginLayer = false) {
226
+ if (!book.page) return "";
227
+ const size = sheetMm(book.page);
228
+ if (marginLayer) return `@page{size:${size.width}mm ${size.height}mm;margin:0}`;
229
+ const edge = book.page.margin;
230
+ return `@page{size:${size.width}mm ${size.height}mm;margin-top:${edge.top}mm;margin-bottom:${edge.bottom}mm}@page :right{margin-left:${edge.inner}mm;margin-right:${edge.outer}mm}@page :left{margin-left:${edge.outer}mm;margin-right:${edge.inner}mm}`;
231
+ }
232
+ //#endregion
233
+ export { bookPageCss, frontPrintStatus, renderBookPdf };
@@ -0,0 +1,48 @@
1
+ import { marginCss } from "./book-margins.js";
2
+ import { RenderRefusedError } from "./errors.js";
3
+ import { bookPageCss, renderBookPdf } from "./book-pdf.js";
4
+ import { PX_PER_MM, resolveDirection, sheetMm } from "@segnavia/format";
5
+ import { frameDocument, newNonce } from "@segnavia/frame/bridge";
6
+ //#region src/book-render.ts
7
+ function renderBrowserBookPdf(input, options, dependencies) {
8
+ return renderBookPdf(input, options, {
9
+ print: (html, book, renderOptions, marginLayer = false) => {
10
+ if (!book.page) throw new RenderRefusedError("Printing a continuous book needs page; set page before calling bookPdf.");
11
+ const size = sheetMm(book.page);
12
+ const css = `${bookPageCss(book, marginLayer)}${marginLayer ? marginCss(book) : ""}${dependencies.fontFaces(renderOptions)}${renderOptions.css ?? ""}`;
13
+ return dependencies.render({
14
+ viewport: {
15
+ width: Math.round(size.width * PX_PER_MM),
16
+ height: Math.round(size.height * PX_PER_MM)
17
+ },
18
+ scripts: !marginLayer && book.scripts === "sandboxed",
19
+ html: frameDocument(html, {
20
+ scripts: marginLayer ? "refuse" : book.scripts,
21
+ nonce: newNonce(),
22
+ dir: marginLayer ? "ltr" : resolveDirection(book.dir, book.lang, book.title),
23
+ lang: book.lang,
24
+ css,
25
+ relayKeys: false,
26
+ reportHeight: false
27
+ }),
28
+ waitForReady: !marginLayer && book.scripts === "sandboxed" && renderOptions.waitForPageReady !== false,
29
+ boundMs: renderOptions.boundMs,
30
+ beforeOutput: renderOptions.beforeOutput
31
+ }, {
32
+ width: `${size.width}mm`,
33
+ height: `${size.height}mm`,
34
+ printBackground: true,
35
+ preferCSSPageSize: true,
36
+ margin: {
37
+ top: "0mm",
38
+ right: "0mm",
39
+ bottom: "0mm",
40
+ left: "0mm"
41
+ }
42
+ });
43
+ },
44
+ finish: dependencies.finish
45
+ });
46
+ }
47
+ //#endregion
48
+ export { renderBrowserBookPdf };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { EffectsReport } from "./effects/types.js";
2
2
  import { PressImage, PressReport } from "./press/types.js";
3
+ import { Font, ImageOptions, RenderLimits, RenderOptions } from "./options.js";
3
4
  import { RenderRefusedError, RendererUnavailableError } from "./errors.js";
4
- import { Deck, ImpositionInput, PageInput } from "@segnavia/format";
5
+ import { BookInput, Deck, ImpositionInput, PageInput } from "@segnavia/format";
5
6
  import { PrintOptions } from "@segnavia/frame/print-document";
6
7
  import { Page as Page$1 } from "playwright-core";
7
8
  //#region src/index.d.ts
@@ -11,41 +12,6 @@ export declare const CONTAINER_ARGS: string[];
11
12
  export declare class RenderTimeoutError extends Error {
12
13
  name: string;
13
14
  }
14
- export interface Font {
15
- family: string;
16
- data: Uint8Array;
17
- format: 'woff2' | 'woff' | 'truetype' | 'opentype';
18
- weight?: number | string;
19
- style?: 'normal' | 'italic';
20
- }
21
- export interface RenderLimits {
22
- maxSheets?: number;
23
- maxBytes?: number;
24
- }
25
- export interface RenderOptions {
26
- css?: string;
27
- fonts?: Font[];
28
- limits?: RenderLimits;
29
- boundMs?: number;
30
- waitForPageReady?: boolean;
31
- preferCssPageSize?: boolean;
32
- beforeOutput?: (tab: Page$1) => Promise<void>;
33
- effects?: false | {
34
- report?: (report: EffectsReport) => void;
35
- };
36
- press?: {
37
- /** The ICC profile of the press, as bytes. The library ships none. */
38
- profile: Uint8Array;
39
- /** Called with authored RGB images left unchanged and counts read back from the output. */
40
- report?: (report: PressReport) => void;
41
- };
42
- }
43
- export interface ImageOptions extends RenderOptions {
44
- format?: 'png' | 'jpeg' | 'webp';
45
- quality?: number[];
46
- scale?: number;
47
- width?: number;
48
- }
49
15
  export interface RendererOptions {
50
16
  executablePath: string;
51
17
  args?: string[];
@@ -53,6 +19,7 @@ export interface RendererOptions {
53
19
  jsHeapMb?: number;
54
20
  }
55
21
  export interface Renderer {
22
+ bookPdf(book: BookInput, options?: RenderOptions): Promise<Uint8Array>;
56
23
  pagePdf(page: PageInput, options?: RenderOptions): Promise<Uint8Array>;
57
24
  pagesPdf(pages: PageInput[], options?: RenderOptions & {
58
25
  imposition?: ImpositionInput;
@@ -66,4 +33,4 @@ export declare function fontFaces(fonts?: Font[]): string;
66
33
  export declare function captureImage(tab: Page$1, options: Pick<ImageOptions, 'format' | 'quality' | 'limits'>, fullPage: boolean): Promise<Uint8Array>;
67
34
  export declare function openRenderer(options: RendererOptions): Promise<Renderer>;
68
35
  //#endregion
69
- export { type EffectsReport, type PressImage, type PressReport, RenderRefusedError, RendererUnavailableError };
36
+ export { type EffectsReport, type Font, type ImageOptions, type PressImage, type PressReport, type RenderLimits, type RenderOptions, RenderRefusedError, RendererUnavailableError };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { RenderRefusedError, RendererUnavailableError } from "./errors.js";
2
+ import { renderBrowserBookPdf } from "./book-render.js";
2
3
  import { containFixedPageOverflow } from "./overflow-containment.js";
3
4
  import { renderDeckPdf } from "./deck-pdf.js";
5
+ import { assertNeutralTolerance } from "./press/color-policy.js";
4
6
  import { rewriteEffectsPdf } from "./effects/adapter.js";
5
7
  import { assertPrintOptions, printCss, printPage, printViewport } from "./page-paper.js";
6
8
  import { renderPagesPdf } from "./pages-pdf.js";
@@ -62,7 +64,9 @@ async function finishPdf(pdf, options) {
62
64
  options.effects?.report?.(rewritten.report);
63
65
  }
64
66
  if (options.press) {
65
- const converted = await rewritePressPdf(output, options.press.profile);
67
+ const neutralTolerance = options.press.neutralTolerance ?? 3;
68
+ assertNeutralTolerance(neutralTolerance);
69
+ const converted = await rewritePressPdf(output, options.press.profile, neutralTolerance);
66
70
  output = converted.bytes;
67
71
  options.press.report?.(converted.report);
68
72
  }
@@ -267,6 +271,13 @@ async function openRenderer(options) {
267
271
  }));
268
272
  }
269
273
  return {
274
+ bookPdf(input, opts = {}) {
275
+ return renderBrowserBookPdf(input, opts, {
276
+ render: (setup, pdf) => withTab(setup, (tab) => tab.pdf(pdf)),
277
+ fontFaces: (options) => fontFaces(options.fonts),
278
+ finish: finishPdf
279
+ });
280
+ },
270
281
  async pagePdf(input, opts = {}) {
271
282
  return finishPdf(await renderPagePdf(Page.parse(input), opts), opts);
272
283
  },
@@ -0,0 +1,39 @@
1
+ import { EffectsReport } from "./effects/types.js";
2
+ import { PressReport } from "./press/types.js";
3
+ import { Page } from "playwright-core";
4
+ //#region src/options.d.ts
5
+ export interface Font {
6
+ family: string;
7
+ data: Uint8Array;
8
+ format: 'woff2' | 'woff' | 'truetype' | 'opentype';
9
+ weight?: number | string;
10
+ style?: 'normal' | 'italic';
11
+ }
12
+ export interface RenderLimits {
13
+ maxSheets?: number;
14
+ maxBytes?: number;
15
+ }
16
+ export interface RenderOptions {
17
+ css?: string;
18
+ fonts?: Font[];
19
+ limits?: RenderLimits;
20
+ boundMs?: number;
21
+ waitForPageReady?: boolean;
22
+ preferCssPageSize?: boolean;
23
+ beforeOutput?: (tab: Page) => Promise<void>;
24
+ effects?: false | {
25
+ report?: (report: EffectsReport) => void;
26
+ };
27
+ press?: {
28
+ profile: Uint8Array;
29
+ neutralTolerance?: number;
30
+ report?: (report: PressReport) => void;
31
+ };
32
+ }
33
+ export interface ImageOptions extends RenderOptions {
34
+ format?: 'png' | 'jpeg' | 'webp';
35
+ quality?: number[];
36
+ scale?: number;
37
+ width?: number;
38
+ }
39
+ //#endregion
@@ -1,5 +1,5 @@
1
1
  import { RenderRefusedError } from "../errors.js";
2
- import { cachedPressColor, pressColor } from "./color-policy.js";
2
+ import { cachedPressColor, newlySnapsToNeutral, pressColor } from "./color-policy.js";
3
3
  import { groupAction, shadingAction } from "./policy.js";
4
4
  import { rewriteContent } from "./content.js";
5
5
  import { arrayNumbers, decodeContent, dictionaryOf, nameOf, numberOf, replaceArray, resolvedColorSpace } from "./pdf-objects.js";
@@ -77,7 +77,7 @@ function convertType1Shading(state, shading, pattern) {
77
77
  const domain = sampleDomainForUses(shadingDomainOf(shading), uses);
78
78
  const matrices = uses.length > 0 ? uses.map((use) => use.matrix) : [shadingDeviceMatrix(shading, pattern)];
79
79
  const size = shadingSampleGridForUses(domain, matrices);
80
- replaceType1Function(state.document, shading, pattern, (rgb) => pressColor(state.profile, rgb), state.budget, void 0, domain, size);
80
+ replaceType1Function(state.document, shading, pattern, state.sampleConvert, state.budget, void 0, domain, size);
81
81
  }
82
82
  function convertShading(state, shading, pattern) {
83
83
  if (state.seenShadings.has(shading)) return false;
@@ -195,31 +195,41 @@ function convertContent(state, object, stream, setDirect) {
195
195
  if (reference) state.document.context.assign(reference, replacement);
196
196
  else setDirect(replacement);
197
197
  }
198
- function newState(document, profile) {
198
+ function newState(document, profile, tolerance) {
199
+ const cached = cachedPressColor(profile, tolerance);
200
+ const totals = {
201
+ fills: 0,
202
+ strokes: 0,
203
+ gradientStops: 0,
204
+ groups: 0,
205
+ neutralColors: 0
206
+ };
199
207
  return {
200
208
  budget: {
201
209
  calculatorOperations: 0,
202
210
  decodedBytes: 0,
203
211
  shadingSamples: 0
204
212
  },
205
- convert: cachedPressColor(profile),
213
+ convert: (rgb) => {
214
+ if (newlySnapsToNeutral(rgb, tolerance)) totals.neutralColors++;
215
+ return cached(rgb);
216
+ },
206
217
  document,
207
218
  painted: /* @__PURE__ */ new WeakMap(),
208
219
  profile,
220
+ sampleConvert: (rgb) => {
221
+ if (newlySnapsToNeutral(rgb, tolerance)) totals.neutralColors++;
222
+ return pressColor(profile, rgb, tolerance);
223
+ },
209
224
  seenGroups: /* @__PURE__ */ new WeakSet(),
210
225
  seenShadings: /* @__PURE__ */ new WeakSet(),
211
226
  shadingPainted: /* @__PURE__ */ new WeakMap(),
212
227
  shadingPatterns: /* @__PURE__ */ new WeakMap(),
213
- totals: {
214
- fills: 0,
215
- strokes: 0,
216
- gradientStops: 0,
217
- groups: 0
218
- }
228
+ totals
219
229
  };
220
230
  }
221
- function rewritePressDocument(document, profile) {
222
- const state = newState(document, profile);
231
+ function rewritePressDocument(document, profile, tolerance = 3) {
232
+ const state = newState(document, profile, tolerance);
223
233
  const boundsBudget = { decodedBytes: 0 };
224
234
  walkPressGraph(document, {
225
235
  beginContentsSequence: () => {
@@ -254,17 +264,19 @@ function addOutputIntent(document, profile) {
254
264
  }));
255
265
  document.catalog.set(key("OutputIntents"), document.context.obj([intentRef]));
256
266
  }
257
- async function rewritePressPdf(bytes, profileBytes) {
267
+ async function rewritePressPdf(bytes, profileBytes, tolerance = 3) {
258
268
  try {
259
269
  const profile = readProfile(profileBytes);
260
270
  if (profile.colorSpace !== "CMYK") refuse(`press profile color space ${profile.colorSpace}`, "CMYK");
261
271
  const document = await PDFDocument.load(bytes, { updateMetadata: false });
262
- rewritePressDocument(document, profile);
272
+ const counts = rewritePressDocument(document, profile, tolerance);
263
273
  addOutputIntent(document, profileBytes);
264
274
  const output = await document.save({ useObjectStreams: false });
275
+ const report = await inspectPressPdf(output);
276
+ report.neutralColors = counts.neutralColors;
265
277
  return {
266
278
  bytes: output,
267
- report: await inspectPressPdf(output)
279
+ report
268
280
  };
269
281
  } catch (error) {
270
282
  if (error instanceof RenderRefusedError) throw error;
@@ -1,13 +1,23 @@
1
+ import { RenderRefusedError } from "../errors.js";
1
2
  import { toPress } from "../color/engine.js";
2
3
  //#region src/press/color-policy.ts
3
4
  const NEUTRAL_TOLERANCE = .001;
4
- function pressColor(profile, rgb) {
5
+ function assertNeutralTolerance(value) {
6
+ if (!Number.isFinite(value) || value < 0 || value > 255) throw new RenderRefusedError(`The press neutral tolerance is ${String(value)}; supported: a finite number from 0 to 255.`);
7
+ }
8
+ function snapsToNeutral(rgb, tolerance) {
9
+ return Math.max(...rgb) - Math.min(...rgb) <= Math.max(NEUTRAL_TOLERANCE, tolerance / 255) + Number.EPSILON;
10
+ }
11
+ function newlySnapsToNeutral(rgb, tolerance) {
12
+ return snapsToNeutral(rgb, tolerance) && !snapsToNeutral(rgb, 0);
13
+ }
14
+ function pressColor(profile, rgb, tolerance = 3) {
5
15
  const [red, green, blue] = rgb;
6
- if (Math.max(red, green, blue) - Math.min(red, green, blue) <= NEUTRAL_TOLERANCE) return [
16
+ if (snapsToNeutral(rgb, tolerance)) return [
7
17
  0,
8
18
  0,
9
19
  0,
10
- 1 - red
20
+ 1 - (red === green && green === blue ? red : (red + green + blue) / 3)
11
21
  ];
12
22
  return toPress(profile, rgb);
13
23
  }
@@ -16,16 +26,16 @@ function formatPdfNumber(value) {
16
26
  if (Object.is(rounded, -0)) return "0";
17
27
  return rounded.toFixed(6).replace(/(?:\.0+|(?<=\.[0-9]*?)0+)$/, "");
18
28
  }
19
- function cachedPressColor(profile) {
29
+ function cachedPressColor(profile, tolerance = 3) {
20
30
  const cache = /* @__PURE__ */ new Map();
21
31
  return (rgb) => {
22
- const key = rgb.join(",");
32
+ const key = `${tolerance}:${rgb.join(",")}`;
23
33
  const cached = cache.get(key);
24
34
  if (cached) return cached;
25
- const converted = pressColor(profile, rgb);
35
+ const converted = pressColor(profile, rgb, tolerance);
26
36
  cache.set(key, converted);
27
37
  return converted;
28
38
  };
29
39
  }
30
40
  //#endregion
31
- export { cachedPressColor, formatPdfNumber, pressColor };
41
+ export { assertNeutralTolerance, cachedPressColor, formatPdfNumber, newlySnapsToNeutral, pressColor, snapsToNeutral };
@@ -68,7 +68,8 @@ async function inspectPressPdf(bytes) {
68
68
  strokes: 0,
69
69
  gradientStops: 0,
70
70
  sampledShadings: 0,
71
- groups: 0
71
+ groups: 0,
72
+ neutralColors: 0
72
73
  },
73
74
  shadings: /* @__PURE__ */ new WeakSet()
74
75
  };
@@ -14,5 +14,7 @@ export interface PressReport {
14
14
  /** Function-based shadings rewritten as DeviceCMYK sampled functions. */
15
15
  sampledShadings: number;
16
16
  groups: number;
17
+ /** Color conversions snapped to black ink by the configured tolerance but not by the exact-neutral rule. */
18
+ neutralColors: number;
17
19
  }
18
20
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/render",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,8 +26,9 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.4.0",
30
- "@segnavia/frame": "0.4.0",
29
+ "@segnavia/format": "0.5.0",
30
+ "@segnavia/frame": "0.5.0",
31
+ "@segnavia/html": "0.5.0",
31
32
  "playwright-core": "1.55.1",
32
33
  "pdf-lib": "^1.17.1"
33
34
  }