@segnavia/render 0.6.0 → 0.7.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.
package/dist/book-pdf.js CHANGED
@@ -5,10 +5,11 @@ import { Book, formatNumber, resolveDirection, sheetMm } from "@segnavia/format"
5
5
  import { PDFDocument } from "pdf-lib";
6
6
  import { bookDocument, bookPrintPart } from "@segnavia/html";
7
7
  //#region src/book-pdf.ts
8
- async function inspect(bytes) {
9
- const document = await PDFDocument.load(bytes);
8
+ async function inspect(output) {
9
+ const document = await PDFDocument.load(output.bytes);
10
10
  return {
11
- bytes,
11
+ bytes: output.bytes,
12
+ overflow: output.overflow,
12
13
  pages: document.getPageCount(),
13
14
  destinations: namedDestinations(document)
14
15
  };
@@ -127,7 +128,7 @@ async function printBody(book, options, adapter) {
127
128
  targets: true
128
129
  });
129
130
  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
+ let part = await inspect(await adapter.print(firstDocument.html, book, options, "body"));
131
132
  requireTargets(part, hashTargets(firstDocument));
132
133
  const plan = book.rectoOpenings ? planBlanks(firstDocument.sections, part.destinations) : {
133
134
  ids: [],
@@ -139,7 +140,7 @@ async function printBody(book, options, adapter) {
139
140
  targets: true,
140
141
  blankBefore: plan.ids
141
142
  });
142
- part = await inspect(await adapter.print(finalDocument.html, book, options));
143
+ part = await inspect(await adapter.print(finalDocument.html, book, options, "body"));
143
144
  requireTargets(part, hashTargets(finalDocument));
144
145
  verifyStarts("body", sectionsFor(firstDocument.sections, "body"), plan.predicted, part.destinations);
145
146
  }
@@ -169,7 +170,7 @@ async function printFront(book, source, body, options, adapter) {
169
170
  crossLinks: true,
170
171
  pageNumbers: numbers
171
172
  });
172
- const printed = await inspect(await adapter.print(document.html, book, options));
173
+ const printed = await inspect(await adapter.print(document.html, book, options, "front"));
173
174
  if (ids.length === 0) ids.push(...hashTargets(document));
174
175
  requireTargets(printed, ids);
175
176
  prints.push(printed);
@@ -219,8 +220,14 @@ async function renderBookPdf(input, options, adapter) {
219
220
  const bodyBlankPages = new Set(printedBody.blankBefore.map((id) => (printedBody.part.destinations.get(id)?.page ?? 1) - 1));
220
221
  const frontBlankPages = new Set(printedFront.trailingBlank ? [front.pages] : []);
221
222
  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);
223
+ const margin = await adapter.print(marginDocument(book, marginPages), book, options, "margin");
224
+ const output = await adapter.finish(await join(front, printedBody.part, margin.bytes), options);
225
+ options.overflow?.report?.({ entries: [
226
+ front.overflow,
227
+ printedBody.part.overflow,
228
+ margin.overflow
229
+ ].filter((entry) => entry !== void 0) });
230
+ return output;
224
231
  }
225
232
  function bookPageCss(book, marginLayer = false) {
226
233
  if (!book.page) return "";
@@ -6,9 +6,10 @@ import { frameDocument, newNonce } from "@segnavia/frame/bridge";
6
6
  //#region src/book-render.ts
7
7
  function renderBrowserBookPdf(input, options, dependencies) {
8
8
  return renderBookPdf(input, options, {
9
- print: (html, book, renderOptions, marginLayer = false) => {
9
+ print: (html, book, renderOptions, part) => {
10
10
  if (!book.page) throw new RenderRefusedError("Printing a continuous book needs page; set page before calling bookPdf.");
11
11
  const size = sheetMm(book.page);
12
+ const marginLayer = part === "margin";
12
13
  const css = `${bookPageCss(book, marginLayer)}${marginLayer ? marginCss(book) : ""}${dependencies.fontFaces(renderOptions)}${renderOptions.css ?? ""}`;
13
14
  return dependencies.render({
14
15
  viewport: {
@@ -39,7 +40,7 @@ function renderBrowserBookPdf(input, options, dependencies) {
39
40
  bottom: "0mm",
40
41
  left: "0mm"
41
42
  }
42
- });
43
+ }, Math.round(size.width * PX_PER_MM), part);
43
44
  },
44
45
  finish: dependencies.finish
45
46
  });
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EffectsReport } from "./effects/types.js";
2
2
  import { PressDestination, PressImage, PressReport } from "./press/types.js";
3
- import { Font, ImageOptions, RenderLimits, RenderOptions } from "./options.js";
3
+ import { Font, ImageOptions, OverflowContribution, OverflowEntry, OverflowPart, OverflowReport, RenderLimits, RenderOptions } from "./options.js";
4
4
  import { RenderRefusedError, RendererUnavailableError } from "./errors.js";
5
5
  import { BookInput, Deck, ImpositionInput, PageInput } from "@segnavia/format";
6
6
  import { PrintOptions } from "@segnavia/frame/print-document";
@@ -33,4 +33,4 @@ export declare function fontFaces(fonts?: Font[]): string;
33
33
  export declare function captureImage(tab: Page$1, options: Pick<ImageOptions, 'format' | 'quality' | 'limits'>, fullPage: boolean): Promise<Uint8Array>;
34
34
  export declare function openRenderer(options: RendererOptions): Promise<Renderer>;
35
35
  //#endregion
36
- export { type EffectsReport, type Font, type ImageOptions, type PressDestination, type PressImage, type PressReport, type RenderLimits, type RenderOptions, RenderRefusedError, RendererUnavailableError };
36
+ export { type EffectsReport, type Font, type ImageOptions, type OverflowContribution, type OverflowEntry, type OverflowPart, type OverflowReport, type PressDestination, type PressImage, type PressReport, type RenderLimits, type RenderOptions, RenderRefusedError, RendererUnavailableError };
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { containFixedPageOverflow } from "./overflow-containment.js";
5
5
  import { renderDeckPdf } from "./deck-pdf.js";
6
6
  import { assertNeutralTolerance } from "./press/color-policy.js";
7
7
  import { rewriteEffectsPdf } from "./effects/adapter.js";
8
+ import { measureOverflow, printWithOverflow } from "./overflow.js";
8
9
  import { assertPrintOptions, printCss, printPage, printViewport } from "./page-paper.js";
9
10
  import { renderPagesPdf } from "./pages-pdf.js";
10
11
  import { rewritePressPdf } from "./press/adapter.js";
@@ -230,10 +231,10 @@ async function openRenderer(options) {
230
231
  };
231
232
  }
232
233
  const image = captureImage;
233
- async function renderPagePdf(page, opts) {
234
+ async function renderPagePdf(page, opts, inputIndex) {
234
235
  if (page.print) assertPrintOptions(page.print, opts.preferCssPageSize);
235
236
  const viewport = pageViewport(page, void 0, true);
236
- return await withTab({
237
+ return withTab({
237
238
  viewport,
238
239
  scripts: page.scripts === "sandboxed",
239
240
  html: pageDocument(page, opts, true),
@@ -241,15 +242,26 @@ async function openRenderer(options) {
241
242
  boundMs: opts.boundMs,
242
243
  beforeOutput: opts.beforeOutput
243
244
  }, async (tab) => {
244
- if (page.print) return printPage(tab, page.print, opts.preferCssPageSize);
245
+ if (page.print) return printPage(tab, page.print, opts.preferCssPageSize, inputIndex);
245
246
  const fixedHeight = typeof page.size === "object" && page.size.height !== "auto" ? page.size.height : null;
246
247
  const height = fixedHeight ?? await contentHeight(tab);
247
- return tab.pdf({
248
+ if (fixedHeight !== null) {
249
+ const overflow = await measureOverflow(tab, viewport.width, "page", inputIndex);
250
+ return {
251
+ bytes: await tab.pdf({
252
+ width: `${viewport.width}px`,
253
+ height: `${height}px`,
254
+ printBackground: true
255
+ }),
256
+ overflow
257
+ };
258
+ }
259
+ return printWithOverflow(tab, viewport.width, "page", {
248
260
  width: `${viewport.width}px`,
249
261
  height: `${height}px`,
250
262
  printBackground: true,
251
- pageRanges: fixedHeight ? void 0 : "1"
252
- });
263
+ pageRanges: "1"
264
+ }, inputIndex);
253
265
  });
254
266
  }
255
267
  async function renderPiecePdf(page, geometry, css, opts) {
@@ -286,13 +298,16 @@ async function openRenderer(options) {
286
298
  return {
287
299
  bookPdf(input, opts = {}) {
288
300
  return renderBrowserBookPdf(input, opts, {
289
- render: (setup, pdf) => withTab(setup, (tab) => tab.pdf(pdf)),
301
+ render: (setup, pdf, pageWidth, part) => withTab(setup, (tab) => printWithOverflow(tab, pageWidth, part, pdf)),
290
302
  fontFaces: (options) => fontFaces(options.fonts),
291
303
  finish: finishWithBrowser
292
304
  });
293
305
  },
294
306
  async pagePdf(input, opts = {}) {
295
- return finishWithBrowser(await renderPagePdf(Page.parse(input), opts), opts);
307
+ const printed = await renderPagePdf(Page.parse(input), opts);
308
+ const output = await finishWithBrowser(printed.bytes, opts);
309
+ if (printed.overflow) opts.overflow?.report?.({ entries: [printed.overflow] });
310
+ return output;
296
311
  },
297
312
  async pageImage(input, opts = {}) {
298
313
  const page = Page.parse(input);
@@ -313,7 +328,7 @@ async function openRenderer(options) {
313
328
  },
314
329
  pagesPdf(inputs, opts = {}) {
315
330
  return renderPagesPdf(inputs, opts, {
316
- pagePdf: renderPagePdf,
331
+ pagePdf: (page, options, inputIndex) => renderPagePdf(page, options, inputIndex),
317
332
  piecePdf: renderPiecePdf,
318
333
  fontFaces: (options) => fontFaces(options.fonts),
319
334
  finish: finishWithBrowser
package/dist/options.d.ts CHANGED
@@ -2,6 +2,37 @@ import { EffectsReport } from "./effects/types.js";
2
2
  import { PressReport } from "./press/types.js";
3
3
  import { Page } from "playwright-core";
4
4
  //#region src/options.d.ts
5
+ export type OverflowPart = 'page' | 'front' | 'body' | 'margin';
6
+ export interface OverflowContribution {
7
+ /** A short id or tag-and-class path locating the element. */
8
+ locator: string;
9
+ /** Border-box pixels clipped beyond the inline start of the page. */
10
+ clippedStart: number;
11
+ /** Border-box pixels clipped beyond the inline end of the page. */
12
+ clippedEnd: number;
13
+ }
14
+ export interface OverflowEntry {
15
+ /** Output part printed by this entry. */
16
+ part: OverflowPart;
17
+ /** Input position when the public render is pagesPdf. */
18
+ inputIndex?: number;
19
+ /** Printable inline width in CSS pixels. */
20
+ pageWidth: number;
21
+ /** Greatest document scroll width in CSS pixels. */
22
+ extent: number;
23
+ /** Pixels clipped beyond the inline start of the page. */
24
+ clippedStart: number;
25
+ /** Pixels clipped beyond the inline end of the page. */
26
+ clippedEnd: number;
27
+ /** PDF scale used to preserve the print's true size. */
28
+ scale: number;
29
+ /** Up to twenty border boxes that extend beyond the page. */
30
+ elements: OverflowContribution[];
31
+ }
32
+ export interface OverflowReport {
33
+ /** Kept prints in output order. */
34
+ entries: OverflowEntry[];
35
+ }
5
36
  export interface Font {
6
37
  family: string;
7
38
  data: Uint8Array;
@@ -24,6 +55,9 @@ export interface RenderOptions {
24
55
  effects?: false | {
25
56
  report?: (report: EffectsReport) => void;
26
57
  };
58
+ overflow?: {
59
+ report?: (report: OverflowReport) => void;
60
+ };
27
61
  press?: {
28
62
  profile: Uint8Array;
29
63
  neutralTolerance?: number;
@@ -0,0 +1,123 @@
1
+ //#region src/overflow.ts
2
+ /**
3
+ * Chromium's measured maximum print shrink; the 3x overflow demo check fails
4
+ * if it changes. Chromium lays a scaled print out at the narrower width P / s,
5
+ * then shrinks it by at most this factor. The solved scale searches layout
6
+ * widths for the point where rightward content reaches P, and uses this exact
7
+ * cap when it is already reached at the narrowest layout. Rightward generated
8
+ * content in an RTL root is a known limit: neither scroll extent nor element
9
+ * boxes expose it.
10
+ */
11
+ const CHROMIUM_MAX_SHRINK = 1.5;
12
+ /** Decides the next layout to measure, or the print scale, from measured widths. */
13
+ function solveScale(pageWidth, measurements) {
14
+ const measured = new Map(measurements.map((measurement) => [measurement.layoutWidth, measurement.contentWidth]));
15
+ const atPage = measured.get(pageWidth);
16
+ if (atPage === void 0) return { measure: pageWidth };
17
+ if (atPage <= pageWidth) return { scale: 1 };
18
+ const narrowest = Math.ceil(pageWidth / CHROMIUM_MAX_SHRINK);
19
+ const atNarrowest = measured.get(narrowest);
20
+ if (atNarrowest === void 0) return { measure: narrowest };
21
+ if (atNarrowest >= pageWidth) return { scale: CHROMIUM_MAX_SHRINK };
22
+ let low = narrowest;
23
+ let high = pageWidth;
24
+ let lastSatisfied = pageWidth;
25
+ while (high - low > 1) {
26
+ const middle = Math.floor((low + high) / 2);
27
+ const width = measured.get(middle);
28
+ if (width === void 0) return { measure: middle };
29
+ if (width >= pageWidth) {
30
+ high = middle;
31
+ lastSatisfied = middle;
32
+ } else low = middle;
33
+ }
34
+ return { scale: pageWidth / (measured.get(high) !== void 0 && measured.get(high) >= pageWidth ? high : lastSatisfied) };
35
+ }
36
+ async function measuredOverflow(tab, pageWidth) {
37
+ return tab.evaluate((width) => {
38
+ const root = document.documentElement;
39
+ const extent = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
40
+ const endOverflow = Math.max(0, extent - root.clientWidth);
41
+ const rtl = getComputedStyle(root).direction === "rtl";
42
+ const contributions = [];
43
+ let clippedStart = 0;
44
+ let rightOverflow = rtl ? 0 : endOverflow;
45
+ const locator = (element) => {
46
+ if (element.id) return `#${CSS.escape(element.id)}`;
47
+ const parts = [];
48
+ let current = element;
49
+ while (current && current !== root && parts.length < 3) {
50
+ const classes = [...current.classList].slice(0, 2).map((name) => `.${CSS.escape(name)}`).join("");
51
+ parts.unshift(`${current.localName}${classes}`);
52
+ current = current.parentElement;
53
+ }
54
+ return parts.join(" > ");
55
+ };
56
+ for (const element of document.querySelectorAll("*")) {
57
+ const box = element.getBoundingClientRect();
58
+ const elementStart = Math.max(0, rtl ? box.right - width : -box.left);
59
+ const elementEnd = Math.max(0, rtl ? -box.left : box.right - width);
60
+ clippedStart = Math.max(clippedStart, elementStart);
61
+ rightOverflow = Math.max(rightOverflow, box.right - width);
62
+ if ((elementStart > 0 || elementEnd > 0) && contributions.length < 20) contributions.push({
63
+ locator: locator(element),
64
+ clippedStart: elementStart,
65
+ clippedEnd: elementEnd
66
+ });
67
+ }
68
+ return {
69
+ overflow: {
70
+ pageWidth: width,
71
+ extent,
72
+ clippedStart,
73
+ clippedEnd: endOverflow,
74
+ elements: contributions
75
+ },
76
+ rightOverflow
77
+ };
78
+ }, pageWidth);
79
+ }
80
+ function overflowEntry(measured, scale, part, inputIndex) {
81
+ return {
82
+ part,
83
+ ...inputIndex === void 0 ? {} : { inputIndex },
84
+ ...measured.overflow,
85
+ scale
86
+ };
87
+ }
88
+ async function measureOverflow(tab, pageWidth, part, inputIndex) {
89
+ await tab.emulateMedia({ media: "print" });
90
+ return overflowEntry(await measuredOverflow(tab, pageWidth), 1, part, inputIndex);
91
+ }
92
+ async function printWithOverflow(tab, pageWidth, part, pdf, inputIndex) {
93
+ await tab.emulateMedia({ media: "print" });
94
+ const viewport = tab.viewportSize();
95
+ if (!viewport) throw new Error("The print tab has no viewport.");
96
+ const measurements = [];
97
+ let original;
98
+ let decision = solveScale(pageWidth, measurements);
99
+ while ("measure" in decision) {
100
+ await tab.setViewportSize({
101
+ width: decision.measure,
102
+ height: viewport.height
103
+ });
104
+ const measured = await measuredOverflow(tab, decision.measure);
105
+ if (decision.measure === pageWidth) original = measured;
106
+ measurements.push({
107
+ layoutWidth: decision.measure,
108
+ contentWidth: decision.measure + measured.rightOverflow
109
+ });
110
+ decision = solveScale(pageWidth, measurements);
111
+ }
112
+ await tab.setViewportSize(viewport);
113
+ if (!original) throw new Error("The page-width overflow measurement was not made.");
114
+ return {
115
+ bytes: await tab.pdf({
116
+ ...pdf,
117
+ scale: decision.scale
118
+ }),
119
+ overflow: overflowEntry(original, decision.scale, part, inputIndex)
120
+ };
121
+ }
122
+ //#endregion
123
+ export { CHROMIUM_MAX_SHRINK, measureOverflow, printWithOverflow, solveScale };
@@ -1,5 +1,6 @@
1
1
  import { RenderRefusedError } from "./errors.js";
2
2
  import { pdfBox, pdfPoint, pt } from "./pdf-geometry.js";
3
+ import { printWithOverflow } from "./overflow.js";
3
4
  import { PX_PER_MM, printBoxes, printPadding, printProperties, sheetMm } from "@segnavia/format";
4
5
  import { PDFDocument, PDFName, rgb } from "pdf-lib";
5
6
  //#region src/page-paper.ts
@@ -56,11 +57,12 @@ async function addPressBoxes(pdf, print) {
56
57
  });
57
58
  return document.save();
58
59
  }
59
- async function printPage(tab, print, preferCssPageSize) {
60
+ async function printPage(tab, print, preferCssPageSize, inputIndex) {
60
61
  if (!usesPressLayout(print)) {
61
62
  const mm = sheetMm(print);
62
63
  const margin = print.margin;
63
- return tab.pdf({
64
+ const pageWidth = Math.round(mm.width * PX_PER_MM);
65
+ return printWithOverflow(tab, pageWidth, "page", {
64
66
  width: `${mm.width}mm`,
65
67
  height: `${mm.height}mm`,
66
68
  printBackground: true,
@@ -71,10 +73,10 @@ async function printPage(tab, print, preferCssPageSize) {
71
73
  bottom: `${margin.bottom}mm`,
72
74
  left: `${margin.left}mm`
73
75
  } : void 0
74
- });
76
+ }, inputIndex);
75
77
  }
76
78
  const boxes = printBoxes(print);
77
- const pdf = await tab.pdf({
79
+ const printed = await printWithOverflow(tab, Math.round(boxes.bleedBox.width * PX_PER_MM), "page", {
78
80
  width: `${boxes.bleedBox.width}mm`,
79
81
  height: `${boxes.bleedBox.height}mm`,
80
82
  printBackground: true,
@@ -85,10 +87,13 @@ async function printPage(tab, print, preferCssPageSize) {
85
87
  bottom: "0mm",
86
88
  left: "0mm"
87
89
  }
88
- });
89
- const papers = (await PDFDocument.load(pdf)).getPageCount();
90
+ }, inputIndex);
91
+ const papers = (await PDFDocument.load(printed.bytes)).getPageCount();
90
92
  if (papers > 1) throw new RenderRefusedError(`A page with bleed or marks is one printed piece, but the content needs ${papers} papers. Shorten the content, or remove the bleed and marks.`);
91
- return addPressBoxes(pdf, print);
93
+ return {
94
+ bytes: await addPressBoxes(printed.bytes, print),
95
+ overflow: printed.overflow
96
+ };
92
97
  }
93
98
  //#endregion
94
99
  export { assertPrintOptions, printCss, printPage, printViewport };
package/dist/pages-pdf.js CHANGED
@@ -51,13 +51,16 @@ async function renderPagesPdf(inputs, opts, services) {
51
51
  if (inputs.length === 0) throw new RenderRefusedError("There is nothing to render. Pass at least one page to clear this refusal.");
52
52
  const pages = inputs.map((input) => Page.parse(input));
53
53
  if (!opts.imposition) {
54
- const rendered = await Promise.all(pages.map((page) => services.pagePdf(page, {
54
+ const rendered = await Promise.all(pages.map((page, inputIndex) => services.pagePdf(page, {
55
55
  ...opts,
56
56
  effects: false,
57
57
  limits: void 0,
58
- press: void 0
59
- })));
60
- return services.finish(await joinPdfs(rendered), opts);
58
+ press: void 0,
59
+ overflow: void 0
60
+ }, inputIndex)));
61
+ const output = await services.finish(await joinPdfs(rendered.map((part) => part.bytes)), opts);
62
+ opts.overflow?.report?.({ entries: rendered.flatMap((part) => part.overflow ? [part.overflow] : []) });
63
+ return output;
61
64
  }
62
65
  const imposition = Imposition.parse(opts.imposition);
63
66
  if (opts.preferCssPageSize) throw new RenderRefusedError("Pages in an imposition take their size from its paper. Remove preferCssPageSize, or remove the imposition, to clear this refusal.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/render",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,9 +26,9 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.6.0",
30
- "@segnavia/frame": "0.6.0",
31
- "@segnavia/html": "0.6.0",
29
+ "@segnavia/format": "0.7.0",
30
+ "@segnavia/frame": "0.7.0",
31
+ "@segnavia/html": "0.7.0",
32
32
  "playwright-core": "1.55.1",
33
33
  "pdf-lib": "^1.17.1"
34
34
  }