gutterpress 0.10.0-alpha.4 → 0.10.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.
Files changed (38) hide show
  1. package/README.md +2 -0
  2. package/dist/api/index.js +2 -2
  3. package/dist/{audit-k1vnfwvc.js → audit-cvarpa72.js} +4 -4
  4. package/dist/{build-hqmwgvdw.js → build-6r502chw.js} +6 -4
  5. package/dist/{cli-0r0tq16s.js → cli-149edp6b.js} +1 -1
  6. package/dist/{cli-ra0ed2xt.js → cli-cqtggsng.js} +1 -1
  7. package/dist/{cli-n25qycwz.js → cli-eq5naw4m.js} +222 -141
  8. package/dist/{cli-revgt4pr.js → cli-yzf38679.js} +1 -0
  9. package/dist/{cli-hp9r2pzt.js → cli-zfcryxg8.js} +327 -24
  10. package/dist/cli.js +16 -16
  11. package/dist/{doctor-dxms7ehm.js → doctor-akvxbtjb.js} +2 -2
  12. package/dist/engine/compiler/build.d.ts +1 -1
  13. package/dist/engine/shared/flush.d.ts +59 -0
  14. package/dist/engine/shared/margin-box-support.d.ts +24 -0
  15. package/dist/{engine-z4p9sr4h.js → engine-b159tbns.js} +2 -2
  16. package/dist/{engine-wa7y9av9.js → engine-ft4cr3ep.js} +1 -1
  17. package/dist/{gutterpress-agent-1ctgfz92.js → gutterpress-agent-cazqstr1.js} +49 -1
  18. package/dist/{gutterpress-viewer-cem7dmr5.js → gutterpress-viewer-te8g5grx.js} +191 -71
  19. package/dist/{index-05y3dnxq.js → index-ge7q9xj3.js} +330 -249
  20. package/dist/{index-xxg4zfrg.js → index-wq3r5pj7.js} +287 -23
  21. package/dist/{index-9tyq9kks.js → index-ycpvr0am.js} +47 -4
  22. package/dist/index.js +3 -3
  23. package/dist/lib/build-runner.d.ts +7 -0
  24. package/dist/lib/build-staging.d.ts +51 -0
  25. package/dist/lib/engine.d.ts +9 -0
  26. package/dist/lib/markdown/gutterpress-css.d.ts +9 -1
  27. package/dist/lib/markdown/markers.d.ts +51 -1
  28. package/dist/{lint-xjwm5ep8.js → lint-p2sw53d9.js} +4 -4
  29. package/dist/{new-kwdwpf0j.js → new-hvq0x91q.js} +4 -4
  30. package/dist/{plugin-rg4tnn96.js → plugin-pssmk0dx.js} +4 -4
  31. package/dist/{preflight-3127y25z.js → preflight-4j00yd0g.js} +4 -4
  32. package/dist/{preview-ncgfhqmw.js → preview-d4s6gk6p.js} +6 -5
  33. package/dist/{preview-interface-435cczt5.js → preview-interface-0ssk8bmm.js} +153 -6
  34. package/dist/{publish-pr0rwh6p.js → publish-tkc26en7.js} +4 -4
  35. package/dist/render.js +83 -7
  36. package/dist/{repair-8270smfw.js → repair-2va4w12t.js} +4 -4
  37. package/dist/{validate-54e17rae.js → validate-w9vfefrw.js} +4 -4
  38. package/package.json +1 -1
@@ -1,7 +1,10 @@
1
1
  import {
2
2
  getAssetPath,
3
- launchChromium
4
- } from "./index-9tyq9kks.js";
3
+ isIgnoredMarginBoxProperty,
4
+ launchChromium,
5
+ marginBoxAlign,
6
+ marginBoxRectPt
7
+ } from "./index-ycpvr0am.js";
5
8
 
6
9
  // src/engine/compiler/build.ts
7
10
  import { readFile } from "node:fs/promises";
@@ -568,6 +571,45 @@ function specificity(r) {
568
571
  return (r.name ? 2 : 0) + r.pseudos.length;
569
572
  }
570
573
 
574
+ // src/engine/shared/flush.ts
575
+ var FLUSH_EDGES = ["top", "right", "bottom", "left"];
576
+ function flushKey(edges) {
577
+ return FLUSH_EDGES.filter((e) => edges.includes(e)).map((e) => e[0]).join("");
578
+ }
579
+ function flushPageName(authorPage, edges) {
580
+ const safe = authorPage ? authorPage.replace(/[^A-Za-z0-9_-]/g, "_") : "";
581
+ return `gp--flush${safe ? `-${safe}` : ""}-${flushKey(edges)}`;
582
+ }
583
+ function flushMargins(margin, edges) {
584
+ return {
585
+ top: edges.includes("top") ? 0 : margin.top,
586
+ right: edges.includes("right") ? 0 : margin.right,
587
+ bottom: edges.includes("bottom") ? 0 : margin.bottom,
588
+ left: edges.includes("left") ? 0 : margin.left
589
+ };
590
+ }
591
+ function marginBoxesOnEdges(edges) {
592
+ const owners = {
593
+ "top-left-corner": ["top", "left"],
594
+ "top-left": ["top"],
595
+ "top-center": ["top"],
596
+ "top-right": ["top"],
597
+ "top-right-corner": ["top", "right"],
598
+ "bottom-left-corner": ["bottom", "left"],
599
+ "bottom-left": ["bottom"],
600
+ "bottom-center": ["bottom"],
601
+ "bottom-right": ["bottom"],
602
+ "bottom-right-corner": ["bottom", "right"],
603
+ "left-top": ["left"],
604
+ "left-middle": ["left"],
605
+ "left-bottom": ["left"],
606
+ "right-top": ["right"],
607
+ "right-middle": ["right"],
608
+ "right-bottom": ["right"]
609
+ };
610
+ return Object.entries(owners).filter(([, own]) => own.some((e) => edges.includes(e))).map(([name]) => name);
611
+ }
612
+
571
613
  // src/engine/shared/synthesis.ts
572
614
  var RECTO_VERSO_VALUES = /^(right|recto|left|verso)$/;
573
615
  function isRectoVersoBreak(decl) {
@@ -1284,7 +1326,8 @@ var BUILD_DIAGNOSTIC_CODES = [
1284
1326
  "engine.layer.trapped",
1285
1327
  "engine.multicol.dead-column",
1286
1328
  "engine.content.overheight",
1287
- "engine.image.low-dpi"
1329
+ "engine.image.low-dpi",
1330
+ "engine.flush.margin-box"
1288
1331
  ];
1289
1332
  async function build(opts) {
1290
1333
  const log = opts.onProgress ?? (() => {});
@@ -1303,8 +1346,7 @@ async function build(opts) {
1303
1346
  await page.evaluate(AGENT);
1304
1347
  await page.waitForReady();
1305
1348
  const cssText = await page.evaluate(`window.__gp.collectCss()`);
1306
- const model = extract(cssText);
1307
- const { tier3Reasons } = classify(model);
1349
+ let model = extract(cssText);
1308
1350
  const baseGeom = resolvePage(model).geometry;
1309
1351
  const sheetViewport = {
1310
1352
  width: Math.max(1, Math.round(baseGeom.width * 96 / 72)),
@@ -1315,6 +1357,97 @@ async function build(opts) {
1315
1357
  await page.send("Emulation.setDeviceMetricsOverride", sheetViewport);
1316
1358
  await page.send("Emulation.setEmulatedMedia", { media: "print" });
1317
1359
  log(`print media emulated for audits and measurement`);
1360
+ const flushRoots = await page.evaluate(`window.__gp.flushRoots()`);
1361
+ const flushGroups = new Map;
1362
+ for (const root of flushRoots) {
1363
+ if (flushGroups.has(root.key))
1364
+ continue;
1365
+ const authorPage = root.page === "auto" ? undefined : root.page;
1366
+ flushGroups.set(root.key, {
1367
+ authorPage,
1368
+ edges: root.edges,
1369
+ genName: flushPageName(authorPage, root.edges),
1370
+ key: root.key
1371
+ });
1372
+ }
1373
+ if (flushGroups.size) {
1374
+ const lines = [];
1375
+ for (const group of flushGroups.values()) {
1376
+ const relocated = new Set(marginBoxesOnEdges(group.edges));
1377
+ const pseudoSets = [[]];
1378
+ if (group.authorPage) {
1379
+ for (const rule of model.pageRules) {
1380
+ if (rule.name !== group.authorPage)
1381
+ continue;
1382
+ const pseudo = rule.pseudos.length ? `:${rule.pseudos.join(":")}` : "";
1383
+ if (rule.pseudos.length && !pseudoSets.some((ps) => ps.length === rule.pseudos.length && rule.pseudos.every((x) => ps.includes(x))))
1384
+ pseudoSets.push(rule.pseudos);
1385
+ const body = [];
1386
+ for (const [prop, value] of Object.entries(rule.decls))
1387
+ body.push(` ${prop}: ${value};`);
1388
+ for (const [box, decls] of Object.entries(rule.marginBoxes)) {
1389
+ if (relocated.has(box.slice(1)))
1390
+ continue;
1391
+ body.push(` ${box} {`);
1392
+ for (const [prop, value] of Object.entries(decls))
1393
+ body.push(` ${prop}: ${value};`);
1394
+ body.push(` }`);
1395
+ }
1396
+ lines.push(`@page ${group.genName}${pseudo} {
1397
+ ${body.join(`
1398
+ `)}
1399
+ }`);
1400
+ }
1401
+ }
1402
+ for (const pseudos of pseudoSets) {
1403
+ const pseudo = pseudos.length ? `:${pseudos.join(":")}` : "";
1404
+ const body = [];
1405
+ for (const edge of group.edges)
1406
+ body.push(` margin-${edge}: 0;`);
1407
+ for (const box of relocated)
1408
+ body.push(` @${box} { content: none; }`);
1409
+ lines.push(`@page ${group.genName}${pseudo} {
1410
+ ${body.join(`
1411
+ `)}
1412
+ }`);
1413
+ }
1414
+ lines.push(`:where(.page, .spread)[data-gp-flush="${group.key}"][data-gp-flush][data-gp-flush][data-gp-flush] { page: ${group.genName}; }`);
1415
+ }
1416
+ const flushCss = lines.join(`
1417
+ `);
1418
+ await page.evaluate(`window.__gp.addCss("gp-flush-css", ${JSON.stringify(flushCss)})`);
1419
+ model = extract(`${cssText}
1420
+ ${flushCss}`);
1421
+ log(`.gp-flush: ${flushRoots.length} pinned root(s) -> ${flushGroups.size} generated page context(s)`);
1422
+ }
1423
+ const { tier3Reasons } = classify(model);
1424
+ const pseudoContexts = pagePseudoContexts(model);
1425
+ const contentHeightPt = (name) => Math.min(...pseudoContexts.map((pseudos) => {
1426
+ const geometry = resolvePage(model, { name, pseudos }).geometry;
1427
+ return geometry.height - geometry.margin.top - geometry.margin.bottom;
1428
+ }));
1429
+ const contentHeightPx = contentHeightPt() * 96 / 72;
1430
+ const namedContentHeightsPx = Object.fromEntries(model.pageNames.map((name) => [name, contentHeightPt(name) * 96 / 72]));
1431
+ const minContentHeightPx = Math.min(contentHeightPt(), ...model.pageNames.map((name) => contentHeightPt(name))) * 96 / 72;
1432
+ const pageVarsCss = [
1433
+ `:root { --gp-content-h: ${contentHeightPx}px; }`,
1434
+ ...model.pageAssignments.map((a) => `${a.selector} { --gp-content-h: ${namedContentHeightsPx[a.page] ?? contentHeightPx}px; }`)
1435
+ ].join(`
1436
+ `);
1437
+ await page.evaluate(`window.__gp.addCss("gp-page-vars", ${JSON.stringify(pageVarsCss)})`);
1438
+ log(`page geometry published (--gp-content-h: ${Math.round(contentHeightPx)}px)`);
1439
+ const relocationBoxes = (group, pseudos) => {
1440
+ const ctx = resolvePage(model, { name: group.authorPage, pseudos });
1441
+ return marginBoxesOnEdges(group.edges).map((name) => [name, ctx.marginBoxes[`@${name}`]]).filter((pair) => {
1442
+ const c = pair[1]?.content?.trim();
1443
+ return !!c && c !== "none" && c !== "normal";
1444
+ });
1445
+ };
1446
+ const furnitureRoots = flushRoots.filter((root) => {
1447
+ const group = flushGroups.get(root.key);
1448
+ return pseudoContexts.some((pseudos) => relocationBoxes(group, pseudos).length > 0);
1449
+ });
1450
+ const flushDiagnosed = new Set;
1318
1451
  const tier2 = synthesize({
1319
1452
  model,
1320
1453
  marks: opts.marks,
@@ -1334,11 +1467,15 @@ async function build(opts) {
1334
1467
  }).join(`
1335
1468
  `);
1336
1469
  if (widthOffenders.boxes.length) {
1337
- const msg = `content wider than the page content box triggers Chromium print ` + `shrink-to-fit (the WHOLE book scales down, silently):
1470
+ const scale = shrinkScale(widthOffenders.boxes, widthOffenders.limitPx);
1471
+ const headline = scale === null ? `content outside the page content box risks Chromium print ` + `shrink-to-fit (the WHOLE book scales down, silently)` : `content wider than the page content box: Chromium print shrink-to-fit ` + `scales the WHOLE document — every page, every measurement — to about ` + `${scale.toFixed(2)}x its declared size (12pt type prints at ` + `${(12 * scale).toFixed(1)}pt). The page size and page count do not ` + `change, so the shrink is invisible in the PDF`;
1472
+ const msg = `${headline}:
1338
1473
  ${describe(widthOffenders.boxes)}`;
1339
1474
  if (opts.allowShrink) {
1475
+ if (scale !== null)
1476
+ diagnose("engine.width.overflow", `${headline}.`);
1340
1477
  for (const o of widthOffenders.boxes)
1341
- diagnose("engine.width.overflow", `${o.desc} is wider than the page — Chromium shrinks the WHOLE book to fit it. ${o.left < -1 ? "Keep it inside the page content box." : "Give it an explicit width that fits."}`);
1478
+ diagnose("engine.width.overflow", `${o.desc} is ${Math.round(o.px)}px wide, past the ${Math.round(widthOffenders.limitPx)}px page content box. ${o.left < -1 ? "Keep it inside the page content box." : "Give it an explicit width that fits."}`);
1342
1479
  log(`WARNING: ${msg}`);
1343
1480
  } else {
1344
1481
  throw new Error(`${msg}
@@ -1353,7 +1490,7 @@ ${describe(widthOffenders.intrinsics)}`;
1353
1490
  log(`WARNING: ${msg}`);
1354
1491
  }
1355
1492
  const rectoDecls = model.breaks.filter(isRectoVersoBreak);
1356
- const needsMeasure = tier3Reasons.length > 0 || consumedStrings(model).size > 0 || rectoDecls.length > 0 || model.counterResets.length > 0;
1493
+ const needsMeasure = tier3Reasons.length > 0 || consumedStrings(model).size > 0 || rectoDecls.length > 0 || model.counterResets.length > 0 || furnitureRoots.length > 0;
1357
1494
  let tier = tier2.geometry.bleed > 0 || tier2.geometry.slug > 0 ? 2 : 1;
1358
1495
  let passes = 1;
1359
1496
  let pageMap = {};
@@ -1381,6 +1518,8 @@ ${describe(widthOffenders.intrinsics)}`;
1381
1518
  targets.add(s.id);
1382
1519
  for (const s of resetSites)
1383
1520
  targets.add(s.id);
1521
+ for (const r of furnitureRoots)
1522
+ targets.add(r.id);
1384
1523
  await page.evaluate(`window.__gp.instrument(${JSON.stringify([...targets])})`);
1385
1524
  const targetText = await page.evaluate(`window.__gp.targetTexts(${JSON.stringify([...targets])})`);
1386
1525
  const brokenXrefs = findBrokenXrefRefs(sites, targetText);
@@ -1465,6 +1604,73 @@ ${describe(widthOffenders.intrinsics)}`;
1465
1604
  /* Tier 3 */
1466
1605
  ${mapCss}`;
1467
1606
  }
1607
+ if (furnitureRoots.length) {
1608
+ const pageAt = (id) => map[id] ?? predictedForResult?.pageMap[id];
1609
+ const byName = new Map;
1610
+ for (const s of sources) {
1611
+ const p = pageAt(s.id);
1612
+ if (!p)
1613
+ continue;
1614
+ const list = byName.get(s.name) ?? [];
1615
+ list.push({ page: p, value: s.text });
1616
+ byName.set(s.name, list);
1617
+ }
1618
+ for (const entries of byName.values())
1619
+ entries.sort((a, b) => a.page - b.page);
1620
+ const PXPT = 96 / 72;
1621
+ const items = [];
1622
+ for (const root of furnitureRoots) {
1623
+ const physical = pageAt(root.id);
1624
+ if (!physical)
1625
+ continue;
1626
+ const group = flushGroups.get(root.key);
1627
+ const pseudos = [physical % 2 === 1 ? "right" : "left"];
1628
+ if (physical === 1)
1629
+ pseudos.push("first");
1630
+ const ctx = resolvePage(model, { name: group.authorPage, pseudos });
1631
+ const g = ctx.geometry;
1632
+ const eff = flushMargins(g.margin, group.edges);
1633
+ const boxes = [];
1634
+ for (const [name, decls] of relocationBoxes(group, pseudos)) {
1635
+ const text = evaluate(decls.content, {
1636
+ page: toFolioPage(physical, pageValues),
1637
+ pages: pageCount,
1638
+ strings: (n, w) => stringValueAt(byName.get(n) ?? [], physical, parseWhich(w)),
1639
+ targetPage: (u) => {
1640
+ const p = pageAt(u.replace(/^#/, ""));
1641
+ return p === undefined ? undefined : toFolioPage(p, pageValues);
1642
+ }
1643
+ });
1644
+ if (!text) {
1645
+ if (!flushDiagnosed.has(name)) {
1646
+ flushDiagnosed.add(name);
1647
+ diagnose("engine.flush.margin-box", `@${name} sits on an edge a .gp-flush pin frees, and its content could not be ` + `re-homed into the page (unsupported content value: ${decls.content}). It will ` + `not print on that page — simplify the box's content, or drop .gp-flush there.`);
1648
+ }
1649
+ continue;
1650
+ }
1651
+ const r = marginBoxRectPt(name, g);
1652
+ const outDecls = {};
1653
+ for (const [prop, value] of Object.entries(decls)) {
1654
+ if (prop.toLowerCase() === "content" || isIgnoredMarginBoxProperty(prop))
1655
+ continue;
1656
+ outDecls[prop] = value;
1657
+ }
1658
+ boxes.push({
1659
+ box: name,
1660
+ x: (r.x - eff.left) * PXPT,
1661
+ y: (r.y - eff.top) * PXPT,
1662
+ w: r.w * PXPT,
1663
+ h: r.h * PXPT,
1664
+ align: marginBoxAlign(name),
1665
+ text,
1666
+ decls: outDecls
1667
+ });
1668
+ }
1669
+ items.push({ id: root.id, boxes });
1670
+ }
1671
+ if (items.length)
1672
+ await page.evaluate(`window.__gp.setFlushFurniture(${JSON.stringify(items)})`);
1673
+ }
1468
1674
  };
1469
1675
  const predicted = await predictPageMap(browser, url, AGENT, VIEWER, {
1470
1676
  stringSets: model.stringSets.map((s) => ({ selector: s.selector, name: s.name, value: s.value })),
@@ -1503,14 +1709,6 @@ ${mapCss}`;
1503
1709
  log(`tier 3: NOT converged after ${passes} passes`);
1504
1710
  }
1505
1711
  }
1506
- const pseudoContexts = pagePseudoContexts(model);
1507
- const contentHeightPt = (name) => Math.min(...pseudoContexts.map((pseudos) => {
1508
- const geometry = resolvePage(model, { name, pseudos }).geometry;
1509
- return geometry.height - geometry.margin.top - geometry.margin.bottom;
1510
- }));
1511
- const contentHeightPx = contentHeightPt() * 96 / 72;
1512
- const namedContentHeightsPx = Object.fromEntries(model.pageNames.map((name) => [name, contentHeightPt(name) * 96 / 72]));
1513
- const minContentHeightPx = Math.min(contentHeightPt(), ...model.pageNames.map((name) => contentHeightPt(name))) * 96 / 72;
1514
1712
  {
1515
1713
  const audit = await page.evaluate(`window.__gp.auditContent(${JSON.stringify({
1516
1714
  default: contentHeightPx,
@@ -1564,6 +1762,26 @@ ${mapCss}`;
1564
1762
  return reasons;
1565
1763
  };
1566
1764
 
1765
+ // Does this box establish the containing block for an absolutely
1766
+ // positioned descendant? Overflow clipping binds an abspos
1767
+ // .gp-pin only from its containing block outward — MEASURED: a
1768
+ // pin whose box lay entirely outside a STATIC overflow:hidden
1769
+ // (and overflow:clip) wrapper printed complete and still behind
1770
+ // the page text; the wrapper's clip never touched it.
1771
+ const establishesAbsContainingBlock = (cs) => {
1772
+ if (cs.position !== "static") return true;
1773
+ for (const prop of [
1774
+ "transform", "translate", "rotate", "scale", "perspective",
1775
+ "filter", "backdropFilter",
1776
+ ]) {
1777
+ const value = cs[prop];
1778
+ if (value && value !== "none") return true;
1779
+ }
1780
+ if (/\\b(layout|paint|strict|content)\\b/.test(cs.contain)) return true;
1781
+ if (cs.containerType && cs.containerType !== "normal") return true;
1782
+ return /\\b(transform|translate|rotate|scale|perspective|filter)\\b/.test(cs.willChange);
1783
+ };
1784
+
1567
1785
  for (const el of document.querySelectorAll("*")) {
1568
1786
  const cs = getComputedStyle(el);
1569
1787
  if (leaks.length < 20) {
@@ -1604,20 +1822,58 @@ ${mapCss}`;
1604
1822
  ) {
1605
1823
  const boundary = el.closest(".page, .spread");
1606
1824
  if (boundary) {
1825
+ const elRect = el.getBoundingClientRect();
1826
+ // Clip binding: an in-flow .gp-behind is bound by every
1827
+ // ancestor's overflow, but an abspos one only from its
1828
+ // containing block outward — static wrappers in between
1829
+ // never clip it (measured; see the pass comment above).
1830
+ let clipBinds = cs.position !== "absolute" && cs.position !== "fixed";
1607
1831
  for (let ancestor = el.parentElement; ancestor; ancestor = ancestor.parentElement) {
1608
1832
  const ancestorStyle = getComputedStyle(ancestor);
1833
+ if (!clipBinds && establishesAbsContainingBlock(ancestorStyle)) clipBinds = true;
1609
1834
  const reasons = stackingReasons(ancestor, ancestorStyle);
1610
- const clips = ancestorStyle.overflowX !== "visible" ||
1611
- ancestorStyle.overflowY !== "visible";
1612
- if ((reasons.length || clips) && !seenLayerTraps.has(ancestor)) {
1835
+ // Clipping never reorders layers it can only CUT the
1836
+ // art (measured, pass comment above): warn only where the
1837
+ // border box crosses a binding ancestor's clip edge on an
1838
+ // axis whose overflow is not \`visible\`. The clip edge is
1839
+ // the padding box, grown by overflow-clip-margin where
1840
+ // that axis's value is \`clip\` (px values only; keyword
1841
+ // forms parse NaN -> 0, i.e. the ungrown padding box).
1842
+ // 1px epsilon: the measured cut lands exactly at the
1843
+ // edge, and sub-pixel layout rounding is not an overhang.
1844
+ const cuts = [];
1845
+ if (clipBinds && (ancestorStyle.overflowX !== "visible" || ancestorStyle.overflowY !== "visible")) {
1846
+ const r = ancestor.getBoundingClientRect();
1847
+ const clipMargin = parseFloat(ancestorStyle.overflowClipMargin) || 0;
1848
+ if (ancestorStyle.overflowX !== "visible") {
1849
+ const grow = ancestorStyle.overflowX === "clip" ? clipMargin : 0;
1850
+ const left = r.left + parseFloat(ancestorStyle.borderLeftWidth) - grow;
1851
+ const right = r.right - parseFloat(ancestorStyle.borderRightWidth) + grow;
1852
+ if (elRect.left < left - 1)
1853
+ cuts.push(Math.round(left - elRect.left) + "px past its left clip edge");
1854
+ if (elRect.right > right + 1)
1855
+ cuts.push(Math.round(elRect.right - right) + "px past its right clip edge");
1856
+ }
1857
+ if (ancestorStyle.overflowY !== "visible") {
1858
+ const grow = ancestorStyle.overflowY === "clip" ? clipMargin : 0;
1859
+ const top = r.top + parseFloat(ancestorStyle.borderTopWidth) - grow;
1860
+ const bottom = r.bottom - parseFloat(ancestorStyle.borderBottomWidth) + grow;
1861
+ if (elRect.top < top - 1)
1862
+ cuts.push(Math.round(top - elRect.top) + "px past its top clip edge");
1863
+ if (elRect.bottom > bottom + 1)
1864
+ cuts.push(Math.round(elRect.bottom - bottom) + "px past its bottom clip edge");
1865
+ }
1866
+ }
1867
+ if ((reasons.length || cuts.length) && !seenLayerTraps.has(ancestor)) {
1613
1868
  seenLayerTraps.add(ancestor);
1614
1869
  const effects = [];
1615
1870
  if (reasons.length)
1616
1871
  effects.push("creates a stacking context (" + reasons.join(", ") + ")");
1617
- if (clips)
1872
+ if (cuts.length)
1618
1873
  effects.push(
1619
- "clips descendants (overflow-x: " + ancestorStyle.overflowX +
1620
- ", overflow-y: " + ancestorStyle.overflowY + ")"
1874
+ "clips it (overflow-x: " + ancestorStyle.overflowX +
1875
+ ", overflow-y: " + ancestorStyle.overflowY +
1876
+ ") — the art extends " + cuts.join(" and ") + " and is cut off there"
1621
1877
  );
1622
1878
  layerTraps.push({
1623
1879
  behind: desc(el),
@@ -1685,10 +1941,17 @@ async function printPdf(page) {
1685
1941
  await page.waitForReady();
1686
1942
  return page.printToPDF();
1687
1943
  }
1944
+ var MAX_SHRINK = 1.5;
1945
+ function shrinkScale(boxes, limitPx) {
1946
+ const maxRight = Math.max(...boxes.map((o) => o.px + Math.min(0, o.left)));
1947
+ if (!(maxRight > limitPx))
1948
+ return null;
1949
+ return Math.max(1 / MAX_SHRINK, limitPx / maxRight);
1950
+ }
1688
1951
  async function findWidthOffenders(page, model, bleedSlugExtensionPt, restoreViewport) {
1689
1952
  const contexts = [
1690
1953
  resolvePage(model),
1691
- ...model.pageNames.filter((n) => !n.startsWith("gp-")).map((n) => resolvePage(model, { name: n }))
1954
+ ...model.pageNames.filter((n) => !n.startsWith("gp-") || n.startsWith("gp--flush-")).map((n) => resolvePage(model, { name: n }))
1692
1955
  ];
1693
1956
  const maxContentPt = Math.max(...contexts.map((c) => c.geometry.width - c.geometry.margin.left - c.geometry.margin.right)) + bleedSlugExtensionPt;
1694
1957
  const limitPx = maxContentPt * 96 / 72;
@@ -1775,6 +2038,7 @@ async function predictPageMap(browser, url, agentScript, viewerScript, args, she
1775
2038
  await page.waitForReady();
1776
2039
  await page.evaluate(`window.__GP_MANUAL__ = true;`);
1777
2040
  await page.evaluate(viewerScript);
2041
+ await page.evaluate(`window.__gp.flushRoots()`);
1778
2042
  await page.evaluate(`window.__gp.stringSources(${JSON.stringify(args.stringSets)})`);
1779
2043
  if (args.rectoDecls.length)
1780
2044
  await page.evaluate(`window.__gp.forcedBreakSites(${JSON.stringify(args.rectoDecls)})`);
@@ -596,7 +596,7 @@ var favicon_default = "./favicon-wkbm9cjn.ico";
596
596
  var manifest_schema_default = "./manifest.schema-zxgxnbg7.json";
597
597
 
598
598
  // src/assets/preview/scripts/preview-interface.js
599
- var preview_interface_default = "./preview-interface-435cczt5.js";
599
+ var preview_interface_default = "./preview-interface-0ssk8bmm.js";
600
600
 
601
601
  // src/assets/preview/scripts/preview-bridge.js
602
602
  var preview_bridge_default = "./preview-bridge-fz7vpk8m.js";
@@ -608,10 +608,10 @@ var preview_shell_default = "./preview-shell-c5mfa3q0.js";
608
608
  var CGATS21_CRPC1_default = "./CGATS21_CRPC1-g0e3k7kr.icc";
609
609
 
610
610
  // src/assets/engine/gutterpress-viewer.js
611
- var gutterpress_viewer_default = "./gutterpress-viewer-cem7dmr5.js";
611
+ var gutterpress_viewer_default = "./gutterpress-viewer-te8g5grx.js";
612
612
 
613
613
  // src/assets/engine/gutterpress-agent.js
614
- var gutterpress_agent_default = "./gutterpress-agent-1ctgfz92.js";
614
+ var gutterpress_agent_default = "./gutterpress-agent-cazqstr1.js";
615
615
 
616
616
  // src/assets/templates/book/manifest.yaml
617
617
  var manifest_default = "./manifest-n1gh3qw5.yaml";
@@ -705,4 +705,47 @@ async function getAssetPath(relPath) {
705
705
  return join3(root, relPath);
706
706
  }
707
707
 
708
- export { EXIT_CODES, BuildError, run, spawnCapture, execCapture, isToolAvailable, findTool, INSTALL_HINTS, fullInstallHint, resolveChromiumExecutable, requireChromiumExecutable, RENDER_TIMEOUT_MS, prewarmBrowser, getBrowser, closeBrowser, getAssetPath, REQUIRED_MILESTONE, assertMilestone, readyProbeExpr, DEFAULT_PRINT_OPTS, launchChromium, connectChromium };
708
+ // src/engine/shared/margin-box-support.ts
709
+ var MARGIN_BOX_IGNORED_PROPERTIES = new Set([
710
+ "transform",
711
+ "rotate",
712
+ "translate",
713
+ "scale",
714
+ "box-shadow"
715
+ ]);
716
+ function isIgnoredMarginBoxProperty(property) {
717
+ return MARGIN_BOX_IGNORED_PROPERTIES.has(property.toLowerCase());
718
+ }
719
+ function marginBoxRectPt(name, g) {
720
+ const { top, right, bottom, left } = g.margin;
721
+ const cw = g.width - left - right;
722
+ const ch = g.height - top - bottom;
723
+ const third = (n) => n / 3;
724
+ const T = {
725
+ "top-left-corner": [0, 0, left, top],
726
+ "top-left": [left, 0, third(cw), top],
727
+ "top-center": [left + third(cw), 0, third(cw), top],
728
+ "top-right": [left + 2 * third(cw), 0, third(cw), top],
729
+ "top-right-corner": [g.width - right, 0, right, top],
730
+ "bottom-left-corner": [0, g.height - bottom, left, bottom],
731
+ "bottom-left": [left, g.height - bottom, third(cw), bottom],
732
+ "bottom-center": [left + third(cw), g.height - bottom, third(cw), bottom],
733
+ "bottom-right": [left + 2 * third(cw), g.height - bottom, third(cw), bottom],
734
+ "bottom-right-corner": [g.width - right, g.height - bottom, right, bottom],
735
+ "left-top": [0, top, left, third(ch)],
736
+ "left-middle": [0, top + third(ch), left, third(ch)],
737
+ "left-bottom": [0, top + 2 * third(ch), left, third(ch)],
738
+ "right-top": [g.width - right, top, right, third(ch)],
739
+ "right-middle": [g.width - right, top + third(ch), right, third(ch)],
740
+ "right-bottom": [g.width - right, top + 2 * third(ch), right, third(ch)]
741
+ };
742
+ const [x, y, w, h] = T[name] ?? [0, 0, 0, 0];
743
+ return { x, y, w, h };
744
+ }
745
+ function marginBoxAlign(name) {
746
+ if (name.includes("center") || name.includes("middle"))
747
+ return "center";
748
+ return /right/.test(name) ? "end" : "start";
749
+ }
750
+
751
+ export { EXIT_CODES, BuildError, run, spawnCapture, execCapture, isToolAvailable, findTool, INSTALL_HINTS, fullInstallHint, resolveChromiumExecutable, requireChromiumExecutable, RENDER_TIMEOUT_MS, prewarmBrowser, getBrowser, closeBrowser, getAssetPath, MARGIN_BOX_IGNORED_PROPERTIES, isIgnoredMarginBoxProperty, marginBoxRectPt, marginBoxAlign, REQUIRED_MILESTONE, assertMilestone, readyProbeExpr, DEFAULT_PRINT_OPTS, launchChromium, connectChromium };
package/dist/index.js CHANGED
@@ -140,15 +140,15 @@ import {
140
140
  validateProjectPlugins,
141
141
  verifyRepoReadable,
142
142
  writeAppHeartbeat
143
- } from "./index-05y3dnxq.js";
143
+ } from "./index-ge7q9xj3.js";
144
144
  import {
145
145
  BUILD_DIAGNOSTIC_CODES
146
- } from "./index-xxg4zfrg.js";
146
+ } from "./index-wq3r5pj7.js";
147
147
  import {
148
148
  BuildError,
149
149
  DEFAULT_PRINT_OPTS,
150
150
  readyProbeExpr
151
- } from "./index-9tyq9kks.js";
151
+ } from "./index-ycpvr0am.js";
152
152
  import {
153
153
  AUTO_SNAPSHOT_MESSAGE,
154
154
  HISTORY_PAGE_LIMIT,
@@ -22,6 +22,13 @@ export interface BuildRunnerOptions {
22
22
  skipLint?: boolean;
23
23
  skipPreValidate?: boolean;
24
24
  skipPostValidate?: boolean;
25
+ /**
26
+ * Proceed past the engine's over-wide-content check (pdf/pdfx only), which
27
+ * otherwise hard-errors because Chromium silently scales the WHOLE book down
28
+ * to fit the offending box. Each offender is still reported as a warning +
29
+ * diagnostic — this buys an eyes-open build, not a clean one.
30
+ */
31
+ allowShrink?: boolean;
25
32
  /**
26
33
  * Keep the pooled headless browser alive after the build returns. A one-shot
27
34
  * CLI build leaves this false so the process can exit; a long-lived
@@ -1,3 +1,4 @@
1
+ import { type AssetCopy } from "./asset-inline";
1
2
  /**
2
3
  * `--format html`: ship the self-contained `book.html` (already fully
3
4
  * inlined — see `lib/asset-inline.ts`) alongside a copy of the native engine's
@@ -8,6 +9,56 @@
8
9
  * in the migration plan).
9
10
  */
10
11
  export declare function shipViewerHtml(htmlFile: string, outDir: string): Promise<void>;
12
+ /** The copy plan, handed to `onPlan` before a single byte is written. */
13
+ export interface StagingPlan {
14
+ /** Refs that name no in-project file at all (absolute, or outside the book). */
15
+ unresolved: string[];
16
+ /** How many files the staging is about to copy. */
17
+ copyCount: number;
18
+ }
19
+ /** What `stageBookAssets` could not stage, for the caller to report. */
20
+ export interface StagedAssets {
21
+ /** Output-relative paths whose source file does not exist; a placeholder shipped. */
22
+ missing: string[];
23
+ }
24
+ /**
25
+ * Turn a freshly rendered `book.html` plus its reported asset references into a
26
+ * COMPLETE, self-contained staged book in `outDir` — the exact document both
27
+ * renderers must paginate.
28
+ *
29
+ * THE one implementation: `renderBook` (every real build/export) and the
30
+ * preview/print parity gate both call this, so the gate can never measure a
31
+ * document the build would not have produced. It previously hand-rolled a bare
32
+ * `copyFile` loop, which (a) hard-crashed with a raw `ENOENT` on any book
33
+ * carrying a stale image path — i.e. the tool that enforces preview↔print
34
+ * parity could not run on the real books that need it — and (b) skipped
35
+ * `inlineShapeUrls`, so `.gp-shape` wrapping silently differed from the build.
36
+ *
37
+ * A missing image is NOT fatal: the same magenta placeholder the build ships
38
+ * (see missing-asset-placeholder.ts) is substituted and every reference
39
+ * rewritten to it, so the staged layout matches the build's. What could not be
40
+ * staged is returned, never swallowed — callers decide how loud to be.
41
+ *
42
+ * A ref that resolves to no in-project file at all is a different matter, and
43
+ * the two callers disagree about it: a real build refuses to ship (it throws
44
+ * from `onPlan`, before any bytes are copied), while the gate reports it and
45
+ * measures the book anyway. That is why the plan is handed out rather than
46
+ * judged here.
47
+ */
48
+ export declare function stageBookAssets(options: {
49
+ /** Project dir every image ref resolves against. */
50
+ renderDir: string;
51
+ /** Directory the staged book is being assembled in. */
52
+ outDir: string;
53
+ /** The rendered `book.html`, rewritten in place. */
54
+ htmlFile: string;
55
+ /** Image `src` values the render reported. */
56
+ imageRefs: Iterable<string>;
57
+ /** CSS images too large to inline, already planned by the render. */
58
+ cssAssets: AssetCopy[];
59
+ /** Called once with the copy plan; throw here to abort before copying. */
60
+ onPlan?: (plan: StagingPlan) => void;
61
+ }): Promise<StagedAssets>;
11
62
  /**
12
63
  * Create a unique scratch directory under the OS temp dir. Used only for
13
64
  * PDF/X intermediates (`raw.pdf`, Ghostscript work files) — never for staging
@@ -6,6 +6,15 @@ export interface NativePdfOptions {
6
6
  title?: string;
7
7
  author?: string;
8
8
  signature?: number;
9
+ /**
10
+ * Downgrade the engine's over-wide-content hard error to a warning +
11
+ * diagnostic. The engine's message tells the author to "pass allowShrink to
12
+ * build anyway"; without this the advice is unreachable from every product
13
+ * path (only a test and the parity gate could set it). The book still prints
14
+ * at Chromium's mystery shrink scale, which is why this is opt-in per build
15
+ * and never a config default.
16
+ */
17
+ allowShrink?: boolean;
9
18
  }
10
19
  /**
11
20
  * Render `htmlFile` to `outPdf` via the Gutterpress engine. No HTTP staging,
@@ -41,6 +41,14 @@
41
41
  * .gp-pin — pins within the nearest @page/@spread container;
42
42
  * centered on both axes unless combined with the edge
43
43
  * modifiers .gp-top/.gp-bottom/.gp-left/.gp-right.
44
+ * .gp-flush — with .gp-pin + an edge, the art sits on the PAPER's
45
+ * edge rather than on the text block's. No CSS rule here:
46
+ * the class is a marker both ENGINES implement (see
47
+ * engine/shared/flush.ts), because reaching the paper
48
+ * requires freeing that page's margin — per page — and
49
+ * relocating the furniture that lived in it, neither of
50
+ * which a stylesheet can do. Inert without .gp-pin + an
51
+ * edge word, and inert under plain markdown-it.
44
52
  * .gp-bleed — forces its own page (break-before) and spans it
45
53
  * edge-to-edge horizontally. This does NOT cancel the
46
54
  * top/bottom margins, extend past the trim into printer
@@ -122,4 +130,4 @@
122
130
  * carrying gp-left could start shifting; that is standards-tracking per
123
131
  * CLAUDE.md ("Chrome wins once it ships"), not a bug in the author's book.
124
132
  */
125
- export declare const GUTTERPRESS_CSS = "\n/* gp-* author image/block vocabulary. One vocabulary, gp-* only \u2014 the\n pre-vocabulary utility names (.center/.float-left/.float-right/\n .full-width/.full-bleed) were REMOVED when gp-* shipped; books rename\n the classes in their markdown (see the migration note). Source ORDER is\n the contract \u2014 see the doctrine comment above. */\n\n/* flow positions */\n.gp-left {\n float: left;\n margin: 0 var(--gp-gap, 1em) var(--gp-gap, 1em) 0;\n max-width: 50%;\n}\n.gp-right {\n float: right;\n margin: 0 0 var(--gp-gap, 1em) var(--gp-gap, 1em);\n max-width: 50%;\n}\n.gp-center {\n display: block;\n float: none;\n margin-left: auto;\n margin-right: auto;\n max-width: 100%;\n}\n.gp-full {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n@page gp-full-bleed { margin-left: 0; margin-right: 0; }\n.gp-bleed {\n display: block;\n float: none;\n break-before: page;\n page: gp-full-bleed;\n max-width: none;\n width: 100%;\n margin-left: 0;\n margin-right: 0;\n}\n\n/* sizes \u2014 AFTER the flow positions so max-width:100% lifts the floats' 50%\n cap at equal specificity */\n.gp-small { width: 25%; max-width: 100%; }\n.gp-medium { width: 50%; max-width: 100%; }\n.gp-large { width: 75%; max-width: 100%; }\n\n/* float clearance presets \u2014 consumed by var(--gp-gap) in the float rules\n above and by .gp-shape's shape-margin below; --gp-gap itself is\n author-settable CSS */\n.gp-tight { --gp-gap: 0.5em; }\n.gp-loose { --gp-gap: 2em; }\n\n/* column runs \u2014 plain CSS Multi-column, exposed as author vocabulary so\n \"put this in two columns\" does not require borrowing a styled container\n from the book's own component layer. That borrowing is what this exists\n to prevent: a book whose theme paints .section chrome by default gives\n every author who opens a section just to start a column run a panel they\n did not ask for, and the book then needs a reset rule to take it back.\n With a neutral primitive the author opts into columns and nothing else.\n\n Permanent vocabulary, not a shim: Chromium implements multicol natively\n and these rules are the standard properties verbatim, so there is no\n spec gap here to remove later. Deliberately minimal \u2014 column-fill is\n NOT set, because the correct value depends on whether the run fragments\n across pages (auto packs each page's columns; the CSS initial balance is\n right for a run that fits on one page) and only the author knows which.\n --gp-column-gap is author-settable. */\n.gp-columns-2 { columns: 2; column-gap: var(--gp-column-gap, 1.5em); }\n.gp-columns-3 { columns: 3; column-gap: var(--gp-column-gap, 1.5em); }\n\n/* shape wrap \u2014 text follows the image's alpha silhouette instead of its\n rectangular box. shape-outside only applies to floats, so this is inert\n without .gp-left/.gp-right (and under .gp-pin, which un-floats). The\n shape URL cannot be written in CSS (url() contexts can't read attr()),\n so the image renderer rule (images.ts) mirrors the src into an inline\n --gp-shape:url(...) custom property whenever it sees this class --\n authors only ever type the class. threshold 0.2 ignores near-transparent\n anti-aliasing halos; shape-margin shares the float-gap vocabulary. */\nimg.gp-shape {\n shape-outside: var(--gp-shape);\n shape-image-threshold: 0.2;\n shape-margin: var(--gp-gap, 1em);\n}\n\n/* pin \u2014 within the nearest positioned ancestor (.page/.spread, rule above).\n inset:0 and the explicit centers are load-bearing; see doctrine comment. */\n.gp-pin {\n position: absolute;\n inset: 0;\n align-self: center;\n justify-self: center;\n margin: 0;\n max-width: 100%;\n}\n\n/* pin edge modifiers \u2014 AFTER .gp-pin to beat its center defaults;\n justify-self is inert on in-flow floats, so gp-left/gp-right safely do\n double duty as flow float + pin edge */\n.gp-top { align-self: start; }\n.gp-bottom { align-self: end; }\n.gp-left { justify-self: start; }\n.gp-right { justify-self: end; }\n\n/* wrapper-margin neutralization (same pattern and rationale as the\n .gp-bleed paragraph-margin note in the doctrine comment; for pin, the\n emptied paragraph would otherwise leave a phantom margin gap in flow) */\n:where(p:has(> img.gp-bleed:only-child)) { margin: 0; }\n:where(p:has(> img.gp-pin:only-child)) { margin: 0; }\n\n/* depth \u2014 a named ladder for z-index, so books stop hand-tuning bare\n integers. A real book measured 21 z-index declarations using only four\n distinct values (-1, 0, 1, 2), each written literally at its use site.\n The custom properties are the author-settable surface (a book needing a\n deeper stack raises them once); the classes are the shorthand.\n\n NOT named \"layer\": CSS Paged Media 3 \u00A73.1 already defines \"page layers\"\n (page background, canvas, borders, contents, margin boxes) and those are\n parts of the PAGE BOX, not a z-ladder for content. Reusing the word for a\n different concept would collide with the spec vocabulary this project\n tracks. The pin EDGE modifiers already own .gp-top/.gp-bottom, so the\n ladder avoids those words too.\n\n .gp-behind is the one that earns its place: it puts a pinned image UNDER\n the page's text, which is otherwise impossible to express without a bare\n negative z-index. \"Above\" needs no class \u2014 an out-of-flow pin already\n paints above in-flow content.\n\n Two things silently defeat .gp-behind, neither visible at the use site:\n - a stacking context on the .page/.spread ancestor (z-index, isolation,\n opacity, filter, transform on it traps the negative layer inside).\n Core keeps .page/.spread at 'position: relative; z-index: auto'\n precisely so they are not stacking contexts.\n - a clipping ancestor (overflow other than visible), the same mechanism\n that clips a .gp-bleed plate back to the wrapper's width.\n The build-time engine.layer.trapped audit reports both against the live\n ancestor chain. printsafe/page-containment is only an early source hint for\n declarations written directly on .page/.spread. */\n:root {\n --gp-z-behind: -1;\n --gp-z-base: 0;\n --gp-z-raised: 1;\n --gp-z-front: 2;\n}\n.gp-behind { z-index: var(--gp-z-behind); }\n.gp-base { z-index: var(--gp-z-base); }\n.gp-raised { z-index: var(--gp-z-raised); }\n.gp-front { z-index: var(--gp-z-front); }\n";
133
+ export declare const GUTTERPRESS_CSS = "\n/* gp-* author image/block vocabulary. One vocabulary, gp-* only \u2014 the\n pre-vocabulary utility names (.center/.float-left/.float-right/\n .full-width/.full-bleed) were REMOVED when gp-* shipped; books rename\n the classes in their markdown (see the migration note). Source ORDER is\n the contract \u2014 see the doctrine comment above. */\n\n/* flow positions */\n.gp-left {\n float: left;\n margin: 0 var(--gp-gap, 1em) var(--gp-gap, 1em) 0;\n max-width: 50%;\n}\n.gp-right {\n float: right;\n margin: 0 0 var(--gp-gap, 1em) var(--gp-gap, 1em);\n max-width: 50%;\n}\n.gp-center {\n display: block;\n float: none;\n margin-left: auto;\n margin-right: auto;\n max-width: 100%;\n}\n.gp-full {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n@page gp-full-bleed { margin-left: 0; margin-right: 0; }\n.gp-bleed {\n display: block;\n float: none;\n break-before: page;\n page: gp-full-bleed;\n max-width: none;\n width: 100%;\n margin-left: 0;\n margin-right: 0;\n}\n\n/* sizes \u2014 AFTER the flow positions so max-width:100% lifts the floats' 50%\n cap at equal specificity */\n.gp-small { width: 25%; max-width: 100%; }\n.gp-medium { width: 50%; max-width: 100%; }\n.gp-large { width: 75%; max-width: 100%; }\n\n/* float clearance presets \u2014 consumed by var(--gp-gap) in the float rules\n above and by .gp-shape's shape-margin below; --gp-gap itself is\n author-settable CSS */\n.gp-tight { --gp-gap: 0.5em; }\n.gp-loose { --gp-gap: 2em; }\n\n/* column runs \u2014 plain CSS Multi-column, exposed as author vocabulary so\n \"put this in two columns\" does not require borrowing a styled container\n from the book's own component layer. That borrowing is what this exists\n to prevent: a book whose theme paints .section chrome by default gives\n every author who opens a section just to start a column run a panel they\n did not ask for, and the book then needs a reset rule to take it back.\n With a neutral primitive the author opts into columns and nothing else.\n\n Permanent vocabulary, not a shim: Chromium implements multicol natively\n and these rules are the standard properties verbatim, so there is no\n spec gap here to remove later. Deliberately minimal \u2014 column-fill is\n NOT set, because the correct value depends on whether the run fragments\n across pages (auto packs each page's columns; the CSS initial balance is\n right for a run that fits on one page) and only the author knows which.\n --gp-column-gap is author-settable. */\n.gp-columns-2 { columns: 2; column-gap: var(--gp-column-gap, 1.5em); }\n.gp-columns-3 { columns: 3; column-gap: var(--gp-column-gap, 1.5em); }\n\n/* grid runs \u2014 the SLOTTED counterpart to the column runs above. Grid places\n each child into the next cell, across then down (deterministic slots: card\n layouts, stat blocks, image-plus-caption pairs); columns FLOW one text run\n down then across. Same neutral-primitive rationale as .gp-columns-*, and\n permanent vocabulary for the same reason: standard CSS Grid verbatim, no\n spec gap to remove later. MEASURED (Chromium 151, gp-grid evidence pack):\n grid rows fragment across sheets with EXACT print/viewer parity \u2014 2- and\n 3-col, unequal item heights, mid-row cuts, multi-sheet overflow,\n break-inside:avoid, gap geometry \u2014 so a grid taller than the page is safe,\n no fit-one-page constraint. Two things to know, not fix:\n - on a min-height page root (MARKER_CSS), default align-content\n stretches rows apart to fill the page \u2014 identically in both engines.\n Authors wanting packed rows set align-content: start.\n - a @page-break / @column-break marker DIRECTLY inside a grid container\n becomes a grid item and corrupts placement (the one measured parity\n break); markers.js diagnoses it (break_inside_grid).\n --gp-grid-gap is author-settable. */\n.gp-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gp-grid-gap, 1.5em); }\n.gp-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gp-grid-gap, 1.5em); }\n\n/* shape wrap \u2014 text follows the image's alpha silhouette instead of its\n rectangular box. shape-outside only applies to floats, so this is inert\n without .gp-left/.gp-right (and under .gp-pin, which un-floats). The\n shape URL cannot be written in CSS (url() contexts can't read attr()),\n so the image renderer rule (images.ts) mirrors the src into an inline\n --gp-shape:url(...) custom property whenever it sees this class --\n authors only ever type the class. threshold 0.2 ignores near-transparent\n anti-aliasing halos; shape-margin shares the float-gap vocabulary. */\nimg.gp-shape {\n shape-outside: var(--gp-shape);\n shape-image-threshold: 0.2;\n shape-margin: var(--gp-gap, 1em);\n}\n\n/* pin \u2014 within the nearest positioned ancestor (.page/.spread, rule above).\n inset:0 and the explicit centers are load-bearing; see doctrine comment. */\n.gp-pin {\n position: absolute;\n inset: 0;\n align-self: center;\n justify-self: center;\n margin: 0;\n max-width: 100%;\n}\n\n/* pin edge modifiers \u2014 AFTER .gp-pin to beat its center defaults;\n justify-self is inert on in-flow floats, so gp-left/gp-right safely do\n double duty as flow float + pin edge */\n.gp-top { align-self: start; }\n.gp-bottom { align-self: end; }\n.gp-left { justify-self: start; }\n.gp-right { justify-self: end; }\n\n/* wrapper-margin neutralization (same pattern and rationale as the\n .gp-bleed paragraph-margin note in the doctrine comment; for pin, the\n emptied paragraph would otherwise leave a phantom margin gap in flow) */\n:where(p:has(> img.gp-bleed:only-child)) { margin: 0; }\n:where(p:has(> img.gp-pin:only-child)) { margin: 0; }\n\n/* depth \u2014 a named ladder for z-index, so books stop hand-tuning bare\n integers. A real book measured 21 z-index declarations using only four\n distinct values (-1, 0, 1, 2), each written literally at its use site.\n The custom properties are the author-settable surface (a book needing a\n deeper stack raises them once); the classes are the shorthand.\n\n NOT named \"layer\": CSS Paged Media 3 \u00A73.1 already defines \"page layers\"\n (page background, canvas, borders, contents, margin boxes) and those are\n parts of the PAGE BOX, not a z-ladder for content. Reusing the word for a\n different concept would collide with the spec vocabulary this project\n tracks. The pin EDGE modifiers already own .gp-top/.gp-bottom, so the\n ladder avoids those words too.\n\n .gp-behind is the one that earns its place: it puts a pinned image UNDER\n the page's text, which is otherwise impossible to express without a bare\n negative z-index. \"Above\" needs no class \u2014 an out-of-flow pin already\n paints above in-flow content.\n\n Two things silently defeat .gp-behind, neither visible at the use site:\n - a stacking context on the .page/.spread ancestor (z-index, isolation,\n opacity, filter, transform on it traps the negative layer inside).\n Core keeps .page/.spread at 'position: relative; z-index: auto'\n precisely so they are not stacking contexts.\n - a clipping ancestor (overflow other than visible) \u2014 but only where\n the art actually overhangs that ancestor's clip box on a clipped\n axis: the overhang is cut off, the same mechanism that clips a\n .gp-bleed plate back to the wrapper's width. Clipping never reorders\n layers \u2014 within-bounds art under a clipping .page prints whole and\n still behind (measured; see the build audit's comment in\n engine/compiler/build.ts), and a static wrapper's overflow never\n binds an abspos .gp-pin at all.\n The build-time engine.layer.trapped audit reports both against the live\n ancestor chain. printsafe/page-containment is only an early source hint for\n declarations written directly on .page/.spread. */\n:root {\n --gp-z-behind: -1;\n --gp-z-base: 0;\n --gp-z-raised: 1;\n --gp-z-front: 2;\n}\n.gp-behind { z-index: var(--gp-z-behind); }\n.gp-base { z-index: var(--gp-z-base); }\n.gp-raised { z-index: var(--gp-z-raised); }\n.gp-front { z-index: var(--gp-z-front); }\n";