gutterpress 0.10.1 → 0.10.2-alpha.1

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 (32) hide show
  1. package/dist/api/index.js +2 -2
  2. package/dist/{audit-p4e7v2m7.js → audit-e3xvt6vj.js} +4 -4
  3. package/dist/{build-h0x2w036.js → build-epz7zqcc.js} +4 -4
  4. package/dist/{cli-eewynen9.js → cli-6qhw9v08.js} +85 -4
  5. package/dist/{cli-n79ghfhs.js → cli-mnekrb8s.js} +1 -1
  6. package/dist/{cli-4n1p25df.js → cli-nrv3gzz9.js} +1 -1
  7. package/dist/{cli-bq7vkea9.js → cli-wrt39m23.js} +34 -17
  8. package/dist/cli.js +14 -14
  9. package/dist/{doctor-y2w1kcqf.js → doctor-236cwssq.js} +2 -2
  10. package/dist/engine/compiler/build.d.ts +1 -1
  11. package/dist/engine/shared/margin-box-support.d.ts +18 -1
  12. package/dist/{engine-57vw8zpd.js → engine-9m8k1mfz.js} +1 -1
  13. package/dist/{engine-8kdwy0jz.js → engine-e1ycm5p9.js} +2 -2
  14. package/dist/{gutterpress-viewer-tma1qwz2.js → gutterpress-viewer-mfeccbys.js} +12 -1
  15. package/dist/{index-j6b6cees.js → index-ahzftp35.js} +33 -16
  16. package/dist/{index-p0ebr7dh.js → index-n252zv7d.js} +75 -4
  17. package/dist/{index-7hrvksrb.js → index-y7jbczsb.js} +13 -2
  18. package/dist/index.js +3 -3
  19. package/dist/lib/asset-inline.d.ts +7 -5
  20. package/dist/lib/markdown/assemble.d.ts +37 -0
  21. package/dist/lib/static-serve.d.ts +1 -2
  22. package/dist/{lint-7wss5p35.js → lint-e36t5jz3.js} +4 -4
  23. package/dist/{new-6gz60bbx.js → new-8jhvnqzp.js} +4 -4
  24. package/dist/{plugin-7awkmgq0.js → plugin-2390rtn1.js} +4 -4
  25. package/dist/{preflight-3tb7stqb.js → preflight-bvtf28e4.js} +4 -4
  26. package/dist/preview/server-context.d.ts +6 -6
  27. package/dist/{preview-7a35z1v1.js → preview-37g504pr.js} +4 -4
  28. package/dist/{publish-dpx2cebg.js → publish-v1gc8zm4.js} +4 -4
  29. package/dist/render.js +3 -1
  30. package/dist/test-helpers/testkit.d.ts +31 -0
  31. package/dist/{validate-hbe05b26.js → validate-5yzb2qzq.js} +4 -4
  32. package/package.json +1 -1
package/dist/api/index.js CHANGED
@@ -95,12 +95,12 @@ import {
95
95
  syncProject,
96
96
  testRemoteAccess,
97
97
  validateProjectPlugins
98
- } from "../index-j6b6cees.js";
98
+ } from "../index-ahzftp35.js";
99
99
  import {
100
100
  BuildError,
101
101
  DEFAULT_PRINT_OPTS,
102
102
  readyProbeExpr
103
- } from "../index-7hrvksrb.js";
103
+ } from "../index-y7jbczsb.js";
104
104
  import {
105
105
  AUTO_SNAPSHOT_MESSAGE,
106
106
  HISTORY_PAGE_LIMIT,
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  executeAndReport
3
- } from "./cli-bq7vkea9.js";
3
+ } from "./cli-wrt39m23.js";
4
4
  import {
5
5
  log
6
- } from "./cli-n79ghfhs.js";
6
+ } from "./cli-mnekrb8s.js";
7
7
  import {
8
8
  UsageError,
9
9
  rejectExtraPositionals,
10
10
  rejectUnknownFlags
11
- } from "./cli-4n1p25df.js";
12
- import"./cli-eewynen9.js";
11
+ } from "./cli-nrv3gzz9.js";
12
+ import"./cli-6qhw9v08.js";
13
13
  import"./cli-c41yr7he.js";
14
14
  import {
15
15
  EXIT_CODES
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  runBuild,
3
3
  splitOutPath
4
- } from "./cli-bq7vkea9.js";
4
+ } from "./cli-wrt39m23.js";
5
5
  import {
6
6
  log
7
- } from "./cli-n79ghfhs.js";
7
+ } from "./cli-mnekrb8s.js";
8
8
  import {
9
9
  UsageError,
10
10
  parseEngine,
@@ -12,8 +12,8 @@ import {
12
12
  parsePdfxFlavor,
13
13
  rejectExtraPositionals,
14
14
  rejectUnknownFlags
15
- } from "./cli-4n1p25df.js";
16
- import"./cli-eewynen9.js";
15
+ } from "./cli-nrv3gzz9.js";
16
+ import"./cli-6qhw9v08.js";
17
17
  import"./cli-c41yr7he.js";
18
18
  import {
19
19
  BuildError
@@ -936,7 +936,18 @@ var MARGIN_BOX_IGNORED_PROPERTIES = new Set([
936
936
  "rotate",
937
937
  "translate",
938
938
  "scale",
939
- "box-shadow"
939
+ "perspective",
940
+ "box-shadow",
941
+ "opacity",
942
+ "outline",
943
+ "outline-color",
944
+ "outline-style",
945
+ "outline-width",
946
+ "outline-offset",
947
+ "filter",
948
+ "backdrop-filter",
949
+ "mix-blend-mode",
950
+ "clip-path"
940
951
  ]);
941
952
  function isIgnoredMarginBoxProperty(property) {
942
953
  return MARGIN_BOX_IGNORED_PROPERTIES.has(property.toLowerCase());
@@ -1434,7 +1445,7 @@ var preview_shell_default = "./preview-shell-kyjq59d9.js";
1434
1445
  var CGATS21_CRPC1_default = "./CGATS21_CRPC1-g0e3k7kr.icc";
1435
1446
 
1436
1447
  // src/assets/engine/gutterpress-viewer.js
1437
- var gutterpress_viewer_default = "./gutterpress-viewer-tma1qwz2.js";
1448
+ var gutterpress_viewer_default = "./gutterpress-viewer-mfeccbys.js";
1438
1449
 
1439
1450
  // src/assets/engine/gutterpress-agent.js
1440
1451
  var gutterpress_agent_default = "./gutterpress-agent-cazqstr1.js";
@@ -1885,6 +1896,7 @@ async function build(opts) {
1885
1896
  deviceScaleFactor: 1,
1886
1897
  mobile: false
1887
1898
  };
1899
+ await page.send("Emulation.setScrollbarsHidden", { hidden: true });
1888
1900
  await page.send("Emulation.setDeviceMetricsOverride", sheetViewport);
1889
1901
  await page.send("Emulation.setEmulatedMedia", { media: "print" });
1890
1902
  log(`print media emulated for audits and measurement`);
@@ -2255,13 +2267,77 @@ ${mapCss}`;
2255
2267
  log(`audit: ${audit.length} print-quality warning(s)`);
2256
2268
  }
2257
2269
  {
2258
- const { leaks, multicol, layerTraps } = JSON.parse(await page.evaluate(`(() => {
2270
+ const { leaks, multicol, layerTraps, pageBackgrounds } = JSON.parse(await page.evaluate(`(() => {
2259
2271
  const desc = ${DESC_JS};
2260
2272
  const leaks = [];
2261
2273
  const multicol = [];
2262
2274
  const layerTraps = [];
2263
2275
  const seenLayerTraps = new Set();
2264
2276
 
2277
+ // Images referenced ONLY from inside an @page rule. Chromium fetches
2278
+ // them and then paints nothing — see the pass comment above.
2279
+ const pageBackgrounds = (() => {
2280
+ const absolute = (u) => {
2281
+ try { return new URL(u, document.baseURI).href } catch { return u }
2282
+ };
2283
+ const urlsIn = (text) => {
2284
+ const out = [];
2285
+ const re = /url\\(\\s*(['"]?)([^'")]*)\\1\\s*\\)/g;
2286
+ let m;
2287
+ while ((m = re.exec(text))) if (m[2]) out.push(m[2]);
2288
+ return out;
2289
+ };
2290
+ const owned = new Map();
2291
+ // Resolved by something OTHER than an @page rule — a CSS rule
2292
+ // outside @page, or an element's inline style. Unconditionally
2293
+ // protective: the image is a StyleImage the load already resolved.
2294
+ const cssReferenced = new Set();
2295
+ // Named by a <link rel="preload">. Protective, unless an element
2296
+ // src names the same URL (see srcNamed).
2297
+ const preloaded = new Set();
2298
+ // Named by an element's src. NEVER protective — measured, this is
2299
+ // the one reference type that BREAKS the page box, and it breaks
2300
+ // it whether or not a preload is present (12/12, either document
2301
+ // order). An element reference is evidence of the failure, not of
2302
+ // safety.
2303
+ const srcNamed = new Set();
2304
+ for (const el of document.querySelectorAll("[src]"))
2305
+ srcNamed.add(absolute(el.getAttribute("src")));
2306
+ for (const el of document.querySelectorAll("link[rel~=preload][href]"))
2307
+ preloaded.add(absolute(el.getAttribute("href")));
2308
+ for (const el of document.querySelectorAll("[style]"))
2309
+ for (const u of urlsIn(el.getAttribute("style"))) cssReferenced.add(absolute(u));
2310
+ const walk = (rules, owner) => {
2311
+ for (const rule of rules) {
2312
+ let own = owner;
2313
+ if (rule.constructor.name === "CSSPageRule")
2314
+ own = "@page" + (rule.selectorText ? " " + rule.selectorText : "");
2315
+ else if (owner && rule.name) own = owner + " { @" + rule.name + " }";
2316
+ for (const u of urlsIn(rule.style ? rule.style.cssText : "")) {
2317
+ if (!own) cssReferenced.add(absolute(u));
2318
+ else if (!owned.has(absolute(u))) owned.set(absolute(u), { url: u, where: own });
2319
+ }
2320
+ if (rule.cssRules) walk(rule.cssRules, own);
2321
+ }
2322
+ };
2323
+ for (const sheet of document.styleSheets) {
2324
+ let rules;
2325
+ try { rules = sheet.cssRules } catch { continue }
2326
+ walk(rules, null);
2327
+ }
2328
+ // PROTECTED iff another CSS rule resolves it, OR a preload names
2329
+ // it and no element src names it too. "Something else mentions
2330
+ // this URL" is NOT the test — that inference is inverted for
2331
+ // [src], the very reference type that breaks the page box.
2332
+ return [...owned]
2333
+ .filter(([abs, hit]) =>
2334
+ !/^data:/i.test(hit.url) &&
2335
+ !cssReferenced.has(abs) &&
2336
+ !(preloaded.has(abs) && !srcNamed.has(abs)))
2337
+ .map(([, hit]) => hit)
2338
+ .slice(0, 20);
2339
+ })();
2340
+
2265
2341
  const stackingReasons = (el, cs) => {
2266
2342
  const reasons = [];
2267
2343
  const positioned = cs.position !== "static";
@@ -2382,7 +2458,7 @@ ${mapCss}`;
2382
2458
  }
2383
2459
  }
2384
2460
  }
2385
- return JSON.stringify({ leaks, multicol, layerTraps });
2461
+ return JSON.stringify({ leaks, multicol, layerTraps, pageBackgrounds });
2386
2462
  })()`));
2387
2463
  for (const d of leaks)
2388
2464
  diagnose("engine.abspos.leak", `${d} uses position: absolute with nothing positioned around it, so it is placed against the whole document rather than the page it sits on in your markdown — it can print on a completely different page.`);
@@ -2392,6 +2468,10 @@ ${mapCss}`;
2392
2468
  diagnose("engine.layer.trapped", `${trap.behind} cannot paint behind the page as intended because ancestor ${trap.ancestor} ${trap.detail}. Remove or narrowly scope that containment style around the .gp-behind element.`);
2393
2469
  if (layerTraps.length)
2394
2470
  log(`audit: ${layerTraps.length} trapped layer warning(s)`);
2471
+ for (const hit of pageBackgrounds)
2472
+ diagnose("engine.page-background.unreferenced", `"${hit.url}" is only referenced from "${hit.where}", and Chromium will not print an image referenced nowhere else — the page prints with its background colour alone, with no error. Gutterpress stages and preloads every image your project stylesheets reference, so this one is outside that: it is remote (a url(https://...), which is never staged), or it comes from CSS that does not pass through your stylesheets, or an element in the document uses the same URL as its src, which drops it on its own. Use a local image, referenced from one of your project stylesheets, and not also used as an <img>.`);
2473
+ if (pageBackgrounds.length)
2474
+ log(`audit: ${pageBackgrounds.length} unreferenced @page background image(s)`);
2395
2475
  for (const d of multicol)
2396
2476
  diagnose("engine.multicol.dead-column", `${d} runs over more than one page in columns, and only the last page's columns get balanced — earlier pages are left with an empty column. Add column-fill: auto to ${d}.`);
2397
2477
  if (multicol.length)
@@ -2544,6 +2624,7 @@ async function predictPageMap(browser, url, agentScript, viewerScript, args, she
2544
2624
  let page;
2545
2625
  try {
2546
2626
  page = await browser.newPage();
2627
+ await page.send("Emulation.setScrollbarsHidden", { hidden: true });
2547
2628
  await page.send("Emulation.setDeviceMetricsOverride", sheetViewport);
2548
2629
  await page.navigate(url);
2549
2630
  await page.evaluate(agentScript);
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  package_default
3
- } from "./cli-4n1p25df.js";
3
+ } from "./cli-nrv3gzz9.js";
4
4
  import {
5
5
  execCapture,
6
6
  findTool,
@@ -4,7 +4,7 @@ import {
4
4
  // package.json
5
5
  var package_default = {
6
6
  name: "gutterpress",
7
- version: "0.10.1",
7
+ version: "0.10.2-alpha.1",
8
8
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
9
9
  author: "itlackey",
10
10
  license: "MPL-2.0",
@@ -13,12 +13,12 @@ import {
13
13
  stampCreator,
14
14
  stripAnnotations,
15
15
  warn
16
- } from "./cli-n79ghfhs.js";
16
+ } from "./cli-mnekrb8s.js";
17
17
  import {
18
18
  DEBOUNCE,
19
19
  UsageError,
20
20
  resolvePort
21
- } from "./cli-4n1p25df.js";
21
+ } from "./cli-nrv3gzz9.js";
22
22
  import {
23
23
  MARGIN_BOX_IGNORED_PROPERTIES,
24
24
  RENDER_TIMEOUT_MS,
@@ -27,7 +27,7 @@ import {
27
27
  getAssetPath,
28
28
  getBrowser,
29
29
  prewarmBrowser
30
- } from "./cli-eewynen9.js";
30
+ } from "./cli-6qhw9v08.js";
31
31
  import {
32
32
  INSTALL_HINTS,
33
33
  execCapture,
@@ -1126,6 +1126,18 @@ var marginBoxAtRuleNames = new Set([
1126
1126
  "right-bottom"
1127
1127
  ]);
1128
1128
  var marginBoxIgnoredProperties = MARGIN_BOX_IGNORED_PROPERTIES;
1129
+ function isInPageBox(decl) {
1130
+ const page = decl.parent;
1131
+ return !!page && page.type === "atrule" && page.name.toLowerCase() === "page";
1132
+ }
1133
+ function isDroppedPageGradient(decl) {
1134
+ const prop = decl.prop.toLowerCase();
1135
+ if (prop !== "background" && prop !== "background-image")
1136
+ return false;
1137
+ if (!/(^|[\s,(])[\w-]*gradient\(/i.test(decl.value))
1138
+ return false;
1139
+ return isInPageBox(decl);
1140
+ }
1129
1141
  function isInPageMarginBox(decl) {
1130
1142
  const box = decl.parent;
1131
1143
  if (!box || box.type !== "atrule")
@@ -1226,7 +1238,14 @@ function checkCss(css, from) {
1226
1238
  root.walkAtRules((at) => reportRemoteUrls(at.params || "", at));
1227
1239
  root.walkDecls((decl) => {
1228
1240
  const prop = decl.prop.toLowerCase();
1229
- if (prop === "filter") {
1241
+ if (marginBoxIgnoredProperties.has(prop) && isInPageMarginBox(decl)) {
1242
+ warnings.push({
1243
+ rule: ruleRiskyProps,
1244
+ severity: "warning",
1245
+ message: `Property "${decl.prop}" is not supported in Chromium @page margin boxes and is silently ignored — the chrome renders square/unshadowed. Anything that would paint outside the box (box-shadow, outline, filter) or make it a stacking context (transform, opacity, mix-blend-mode) is dropped; text-shadow, border, border-radius, padding and background gradients all work inside the box.`,
1246
+ ...nodeLoc(decl)
1247
+ });
1248
+ } else if (prop === "filter") {
1230
1249
  warnings.push({
1231
1250
  rule: ruleRiskyProps,
1232
1251
  severity: "warning",
@@ -1240,11 +1259,11 @@ function checkCss(css, from) {
1240
1259
  message: `Property is high-risk for print/PDF (can force rasterization): ${decl.prop}`,
1241
1260
  ...nodeLoc(decl)
1242
1261
  });
1243
- } else if (marginBoxIgnoredProperties.has(prop) && isInPageMarginBox(decl)) {
1262
+ } else if (isDroppedPageGradient(decl)) {
1244
1263
  warnings.push({
1245
1264
  rule: ruleRiskyProps,
1246
1265
  severity: "warning",
1247
- message: `Property "${decl.prop}" is not supported in Chromium @page margin boxes and is silently ignored the chrome renders square/unshadowed.`,
1266
+ message: `"${decl.prop}: ${decl.value}" on @page paints nothing in print — Chromium drops a gradient in the @page box's own background, with no error and a valid-looking PDF. A solid colour there paints the whole sheet; the same gradient also works on "html" (it covers the content area, not the sheet) or in a margin box.`,
1248
1267
  ...nodeLoc(decl)
1249
1268
  });
1250
1269
  } else if (isUnpairedFitContentWidth(decl)) {
@@ -2776,7 +2795,6 @@ import { readFile as readFile9 } from "node:fs/promises";
2776
2795
  import path3 from "node:path";
2777
2796
  import postcss2 from "postcss";
2778
2797
  var FONT_EXTS = new Set([".woff2", ".woff", ".ttf", ".otf"]);
2779
- var IMAGE_INLINE_MAX_BYTES = 512 * 1024;
2780
2798
  var HASHED_ASSET_DIR = "assets";
2781
2799
  var MIME_BY_EXT = {
2782
2800
  ".woff2": "font/woff2",
@@ -2896,7 +2914,7 @@ function importTarget(atRule) {
2896
2914
  return null;
2897
2915
  return target;
2898
2916
  }
2899
- async function inlineOne(cssPath, projectDir, copies, warnings, seen) {
2917
+ async function inlineOne(cssPath, copies, warnings, seen) {
2900
2918
  const abs = path3.resolve(cssPath);
2901
2919
  if (seen.has(abs))
2902
2920
  return "";
@@ -2931,11 +2949,7 @@ async function inlineOne(cssPath, projectDir, copies, warnings, seen) {
2931
2949
  return dataUri(bytes2, ext);
2932
2950
  }
2933
2951
  const bytes = await readOrThrow(absAsset, "asset", abs);
2934
- if (bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
2935
- return dataUri(bytes, ext);
2936
- }
2937
- const projectRel = path3.relative(projectDir, absAsset);
2938
- const dest = projectRel && !escapesProjectRoot(projectDir, absAsset) ? toPosix(projectRel) : `${HASHED_ASSET_DIR}/${contentHash(bytes)}${ext}`;
2952
+ const dest = `${HASHED_ASSET_DIR}/${contentHash(bytes)}${ext}`;
2939
2953
  copies.set(dest, { from: absAsset, to: dest });
2940
2954
  return dest;
2941
2955
  });
@@ -2948,7 +2962,7 @@ async function inlineOne(cssPath, projectDir, copies, warnings, seen) {
2948
2962
  });
2949
2963
  for (const { node, target } of imports) {
2950
2964
  const importedAbs = path3.resolve(cssDir, stripUrlSuffix(decodeRef(target)));
2951
- const inlined = await inlineOne(importedAbs, projectDir, copies, warnings, seen);
2965
+ const inlined = await inlineOne(importedAbs, copies, warnings, seen);
2952
2966
  node.replaceWith(wrapImportConditions(node, postcss2.parse(inlined, { from: importedAbs })));
2953
2967
  }
2954
2968
  return root.toString();
@@ -2960,7 +2974,7 @@ async function inlineStyles(projectDir, stylePaths) {
2960
2974
  const parts = [];
2961
2975
  for (const rel of stylePaths) {
2962
2976
  const abs = path3.resolve(projectDir, rel);
2963
- const css = await inlineOne(abs, projectDir, copies, warnings, seen);
2977
+ const css = await inlineOne(abs, copies, warnings, seen);
2964
2978
  if (css.trim().length > 0) {
2965
2979
  parts.push(`/* ${toPosix(path3.relative(projectDir, abs))} */
2966
2980
  ${css.trim()}`);
@@ -6287,12 +6301,14 @@ ${projectCss.trim()}` : null
6287
6301
  ].filter(Boolean).join(`
6288
6302
 
6289
6303
  `);
6304
+ const preloadTags = (opts.preloadImages ?? []).map((href) => `
6305
+ <link rel="preload" as="image" href="${href}">`).join("");
6290
6306
  return `<!DOCTYPE html>
6291
6307
  <html lang="en">
6292
6308
  <head>
6293
6309
  <meta charset="UTF-8">
6294
6310
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6295
- <title>${title}</title>
6311
+ <title>${title}</title>${preloadTags}
6296
6312
  <style data-project-css>
6297
6313
  ${inlineCss}
6298
6314
  </style>
@@ -6324,6 +6340,7 @@ async function renderChapters(inputDir, opts = {}) {
6324
6340
  files,
6325
6341
  readText: (relPath) => readFile18(join2(inputDir, canonicalChapterId(relPath)), "utf-8"),
6326
6342
  projectCss: inlined.css,
6343
+ preloadImages: inlined.copies.map((c) => c.to),
6327
6344
  title: opts.title,
6328
6345
  plugins: opts.plugins,
6329
6346
  pluginCss: opts.pluginCss,
@@ -7521,7 +7538,7 @@ class PdfOutput {
7521
7538
  const rawPdf = pdfxMode ? path8.join(stage, "raw.pdf") : path8.resolve(pdfFile);
7522
7539
  await fsp2.mkdir(path8.dirname(path8.resolve(pdfFile)), { recursive: true });
7523
7540
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
7524
- const { buildNativePdf } = await import("./engine-57vw8zpd.js");
7541
+ const { buildNativePdf } = await import("./engine-9m8k1mfz.js");
7525
7542
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
7526
7543
  title: config.title,
7527
7544
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  UsageError,
7
7
  package_default,
8
8
  rejectUnknownFlags
9
- } from "./cli-4n1p25df.js";
9
+ } from "./cli-nrv3gzz9.js";
10
10
  import {
11
11
  EXIT_CODES
12
12
  } from "./cli-46ycxe6r.js";
@@ -18,16 +18,16 @@ import {
18
18
  import { defineCommand, parseArgs, runMain } from "citty";
19
19
  import { statSync } from "node:fs";
20
20
  var SUBCOMMANDS = {
21
- new: () => import("./new-6gz60bbx.js").then((m) => m.default),
22
- preview: () => import("./preview-7a35z1v1.js").then((m) => m.default),
23
- build: () => import("./build-h0x2w036.js").then((m) => m.default),
24
- publish: () => import("./publish-dpx2cebg.js").then((m) => m.default),
25
- lint: () => import("./lint-7wss5p35.js").then((m) => m.default),
26
- validate: () => import("./validate-hbe05b26.js").then((m) => m.default),
27
- audit: () => import("./audit-p4e7v2m7.js").then((m) => m.default),
28
- preflight: () => import("./preflight-3tb7stqb.js").then((m) => m.default),
29
- doctor: () => import("./doctor-y2w1kcqf.js").then((m) => m.default),
30
- plugin: () => import("./plugin-7awkmgq0.js").then((m) => m.default)
21
+ new: () => import("./new-8jhvnqzp.js").then((m) => m.default),
22
+ preview: () => import("./preview-37g504pr.js").then((m) => m.default),
23
+ build: () => import("./build-epz7zqcc.js").then((m) => m.default),
24
+ publish: () => import("./publish-v1gc8zm4.js").then((m) => m.default),
25
+ lint: () => import("./lint-e36t5jz3.js").then((m) => m.default),
26
+ validate: () => import("./validate-5yzb2qzq.js").then((m) => m.default),
27
+ audit: () => import("./audit-e3xvt6vj.js").then((m) => m.default),
28
+ preflight: () => import("./preflight-bvtf28e4.js").then((m) => m.default),
29
+ doctor: () => import("./doctor-236cwssq.js").then((m) => m.default),
30
+ plugin: () => import("./plugin-2390rtn1.js").then((m) => m.default)
31
31
  };
32
32
  var VERSION = package_default.version;
33
33
  var main = defineCommand({
@@ -52,7 +52,7 @@ async function preflightRequiredInvocations(rawArgs) {
52
52
  const [command, ...commandArgs] = rawArgs;
53
53
  try {
54
54
  if (command === "new") {
55
- const { newArgs } = await import("./new-6gz60bbx.js");
55
+ const { newArgs } = await import("./new-8jhvnqzp.js");
56
56
  rejectUnknownFlags(commandArgs, newArgs, "new");
57
57
  const parsed2 = parseArgs(commandArgs, {
58
58
  ...newArgs,
@@ -64,7 +64,7 @@ async function preflightRequiredInvocations(rawArgs) {
64
64
  return;
65
65
  }
66
66
  if (command === "preflight") {
67
- const { preflightArgs } = await import("./preflight-3tb7stqb.js");
67
+ const { preflightArgs } = await import("./preflight-bvtf28e4.js");
68
68
  rejectUnknownFlags(commandArgs, preflightArgs, "preflight");
69
69
  const parsed2 = parseArgs(commandArgs, {
70
70
  ...preflightArgs,
@@ -88,7 +88,7 @@ async function preflightRequiredInvocations(rawArgs) {
88
88
  if (subcommand !== "add") {
89
89
  throw new UsageError(`gutterpress plugin: unknown command "${subcommand}"`);
90
90
  }
91
- const { pluginAddArgs } = await import("./plugin-7awkmgq0.js");
91
+ const { pluginAddArgs } = await import("./plugin-2390rtn1.js");
92
92
  rejectUnknownFlags(subcommandArgs, pluginAddArgs, "plugin add");
93
93
  const parsed = parseArgs(subcommandArgs, {
94
94
  ...pluginAddArgs,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  getSystemDiagnostics,
3
3
  log
4
- } from "./cli-n79ghfhs.js";
4
+ } from "./cli-mnekrb8s.js";
5
5
  import {
6
6
  UsageError,
7
7
  rejectExtraPositionals,
8
8
  rejectUnknownFlags
9
- } from "./cli-4n1p25df.js";
9
+ } from "./cli-nrv3gzz9.js";
10
10
  import"./cli-c41yr7he.js";
11
11
  import"./cli-46ycxe6r.js";
12
12
  import"./cli-37x76zdn.js";
@@ -7,7 +7,7 @@ import { type PostprocessResult } from "./postprocess.ts";
7
7
  * set is closed here so a surface's label table can be asserted complete
8
8
  * against it rather than drifting silently as checks are added.
9
9
  */
10
- export type BuildDiagnosticCode = "engine.width.overflow" | "engine.width.intrinsic" | "engine.xref.broken" | "engine.abspos.leak" | "engine.layer.trapped" | "engine.multicol.dead-column" | "engine.content.overheight" | "engine.image.low-dpi" | "engine.flush.margin-box";
10
+ export type BuildDiagnosticCode = "engine.width.overflow" | "engine.width.intrinsic" | "engine.xref.broken" | "engine.abspos.leak" | "engine.layer.trapped" | "engine.multicol.dead-column" | "engine.content.overheight" | "engine.image.low-dpi" | "engine.flush.margin-box" | "engine.page-background.unreferenced";
11
11
  export declare const BUILD_DIAGNOSTIC_CODES: readonly BuildDiagnosticCode[];
12
12
  export interface BuildDiagnostic {
13
13
  code: BuildDiagnosticCode;
@@ -2,7 +2,24 @@
2
2
  * Declarations Chromium accepts syntactically in `@page` margin boxes but
3
3
  * silently drops in native print. These were measured with deliberately
4
4
  * visible values (`rotate(-12deg)`, `6px 6px 0 #c00`) and are documented in
5
- * docs/engine-history/ENGINE.md §8.
5
+ * docs/engine-history/ENGINE.md §8 and docs/known-limitations.md §2.
6
+ *
7
+ * The line is what the property PAINTS, not the property family: everything
8
+ * that establishes a stacking context or paints outside the border box is
9
+ * dropped, everything that paints inside it is honoured. `text-shadow` beside
10
+ * `box-shadow` is the clearest pair. Re-measured on Chrome 151.0.7922.75,
11
+ * 96dpi raster, mean absolute pixel difference against the same box without
12
+ * the declaration (control: removing the box entirely, 2.6485):
13
+ *
14
+ * dropped, all 0.0000 box-shadow · transform (rotate/scale/translate) ·
15
+ * rotate/scale/translate · opacity · outline (and its
16
+ * longhands) · filter · mix-blend-mode ·
17
+ * backdrop-filter · clip-path · perspective
18
+ * honoured text-shadow 0.1375 · border-radius 0.3397 ·
19
+ * background gradient 11.2260 · writing-mode 0.3200 ·
20
+ * padding-left 0.2927 · font-size 0.7864 ·
21
+ * color 0.1063 · letter-spacing 0.2781 ·
22
+ * text-transform 0.1804 · visibility 2.6485
6
23
  *
7
24
  * Keep the viewer and print-safety linter on this one contract: preview must
8
25
  * omit the same effects native PDF omits, while every other declaration is
@@ -4,7 +4,7 @@ import {
4
4
  build,
5
5
  connectChromium,
6
6
  getBrowser
7
- } from "./cli-eewynen9.js";
7
+ } from "./cli-6qhw9v08.js";
8
8
  import"./cli-c41yr7he.js";
9
9
  import {
10
10
  BuildError
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  build
3
- } from "./index-p0ebr7dh.js";
3
+ } from "./index-n252zv7d.js";
4
4
  import {
5
5
  BuildError,
6
6
  RENDER_TIMEOUT_MS,
7
7
  assertMilestone,
8
8
  connectChromium,
9
9
  getBrowser
10
- } from "./index-7hrvksrb.js";
10
+ } from "./index-y7jbczsb.js";
11
11
  import"./index-37x76zdn.js";
12
12
 
13
13
  // src/lib/engine.ts
@@ -2012,7 +2012,18 @@
2012
2012
  "rotate",
2013
2013
  "translate",
2014
2014
  "scale",
2015
- "box-shadow"
2015
+ "perspective",
2016
+ "box-shadow",
2017
+ "opacity",
2018
+ "outline",
2019
+ "outline-color",
2020
+ "outline-style",
2021
+ "outline-width",
2022
+ "outline-offset",
2023
+ "filter",
2024
+ "backdrop-filter",
2025
+ "mix-blend-mode",
2026
+ "clip-path"
2016
2027
  ]);
2017
2028
  function isIgnoredMarginBoxProperty(property) {
2018
2029
  return MARGIN_BOX_IGNORED_PROPERTIES.has(property.toLowerCase());
@@ -17,7 +17,7 @@ import {
17
17
  resolveChromiumExecutable,
18
18
  run,
19
19
  spawnCapture
20
- } from "./index-7hrvksrb.js";
20
+ } from "./index-y7jbczsb.js";
21
21
  import {
22
22
  gitFs,
23
23
  gitScopeFor,
@@ -2002,12 +2002,14 @@ ${projectCss.trim()}` : null
2002
2002
  ].filter(Boolean).join(`
2003
2003
 
2004
2004
  `);
2005
+ const preloadTags = (opts.preloadImages ?? []).map((href) => `
2006
+ <link rel="preload" as="image" href="${href}">`).join("");
2005
2007
  return `<!DOCTYPE html>
2006
2008
  <html lang="en">
2007
2009
  <head>
2008
2010
  <meta charset="UTF-8">
2009
2011
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2010
- <title>${title}</title>
2012
+ <title>${title}</title>${preloadTags}
2011
2013
  <style data-project-css>
2012
2014
  ${inlineCss}
2013
2015
  </style>
@@ -2127,7 +2129,6 @@ import { readFile as readFile2 } from "node:fs/promises";
2127
2129
  import path2 from "node:path";
2128
2130
  import postcss from "postcss";
2129
2131
  var FONT_EXTS = new Set([".woff2", ".woff", ".ttf", ".otf"]);
2130
- var IMAGE_INLINE_MAX_BYTES = 512 * 1024;
2131
2132
  var HASHED_ASSET_DIR = "assets";
2132
2133
  var MIME_BY_EXT = {
2133
2134
  ".woff2": "font/woff2",
@@ -2247,7 +2248,7 @@ function importTarget(atRule) {
2247
2248
  return null;
2248
2249
  return target;
2249
2250
  }
2250
- async function inlineOne(cssPath, projectDir, copies, warnings, seen) {
2251
+ async function inlineOne(cssPath, copies, warnings, seen) {
2251
2252
  const abs = path2.resolve(cssPath);
2252
2253
  if (seen.has(abs))
2253
2254
  return "";
@@ -2282,11 +2283,7 @@ async function inlineOne(cssPath, projectDir, copies, warnings, seen) {
2282
2283
  return dataUri(bytes2, ext);
2283
2284
  }
2284
2285
  const bytes = await readOrThrow(absAsset, "asset", abs);
2285
- if (bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
2286
- return dataUri(bytes, ext);
2287
- }
2288
- const projectRel = path2.relative(projectDir, absAsset);
2289
- const dest = projectRel && !escapesProjectRoot(projectDir, absAsset) ? toPosix(projectRel) : `${HASHED_ASSET_DIR}/${contentHash(bytes)}${ext}`;
2286
+ const dest = `${HASHED_ASSET_DIR}/${contentHash(bytes)}${ext}`;
2290
2287
  copies.set(dest, { from: absAsset, to: dest });
2291
2288
  return dest;
2292
2289
  });
@@ -2299,7 +2296,7 @@ async function inlineOne(cssPath, projectDir, copies, warnings, seen) {
2299
2296
  });
2300
2297
  for (const { node, target } of imports) {
2301
2298
  const importedAbs = path2.resolve(cssDir, stripUrlSuffix(decodeRef(target)));
2302
- const inlined = await inlineOne(importedAbs, projectDir, copies, warnings, seen);
2299
+ const inlined = await inlineOne(importedAbs, copies, warnings, seen);
2303
2300
  node.replaceWith(wrapImportConditions(node, postcss.parse(inlined, { from: importedAbs })));
2304
2301
  }
2305
2302
  return root.toString();
@@ -2311,7 +2308,7 @@ async function inlineStyles(projectDir, stylePaths) {
2311
2308
  const parts = [];
2312
2309
  for (const rel of stylePaths) {
2313
2310
  const abs = path2.resolve(projectDir, rel);
2314
- const css = await inlineOne(abs, projectDir, copies, warnings, seen);
2311
+ const css = await inlineOne(abs, copies, warnings, seen);
2315
2312
  if (css.trim().length > 0) {
2316
2313
  parts.push(`/* ${toPosix(path2.relative(projectDir, abs))} */
2317
2314
  ${css.trim()}`);
@@ -2451,6 +2448,7 @@ async function renderChapters(inputDir, opts = {}) {
2451
2448
  files,
2452
2449
  readText: (relPath) => readFile3(join(inputDir, canonicalChapterId(relPath)), "utf-8"),
2453
2450
  projectCss: inlined.css,
2451
+ preloadImages: inlined.copies.map((c) => c.to),
2454
2452
  title: opts.title,
2455
2453
  plugins: opts.plugins,
2456
2454
  pluginCss: opts.pluginCss,
@@ -3980,7 +3978,7 @@ import git from "isomorphic-git";
3980
3978
  // package.json
3981
3979
  var package_default = {
3982
3980
  name: "gutterpress",
3983
- version: "0.10.1",
3981
+ version: "0.10.2-alpha.1",
3984
3982
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
3985
3983
  author: "itlackey",
3986
3984
  license: "MPL-2.0",
@@ -4302,6 +4300,18 @@ var marginBoxAtRuleNames = new Set([
4302
4300
  "right-bottom"
4303
4301
  ]);
4304
4302
  var marginBoxIgnoredProperties = MARGIN_BOX_IGNORED_PROPERTIES;
4303
+ function isInPageBox(decl) {
4304
+ const page = decl.parent;
4305
+ return !!page && page.type === "atrule" && page.name.toLowerCase() === "page";
4306
+ }
4307
+ function isDroppedPageGradient(decl) {
4308
+ const prop = decl.prop.toLowerCase();
4309
+ if (prop !== "background" && prop !== "background-image")
4310
+ return false;
4311
+ if (!/(^|[\s,(])[\w-]*gradient\(/i.test(decl.value))
4312
+ return false;
4313
+ return isInPageBox(decl);
4314
+ }
4305
4315
  function isInPageMarginBox(decl) {
4306
4316
  const box = decl.parent;
4307
4317
  if (!box || box.type !== "atrule")
@@ -4402,7 +4412,14 @@ function checkCss(css, from) {
4402
4412
  root.walkAtRules((at) => reportRemoteUrls(at.params || "", at));
4403
4413
  root.walkDecls((decl) => {
4404
4414
  const prop = decl.prop.toLowerCase();
4405
- if (prop === "filter") {
4415
+ if (marginBoxIgnoredProperties.has(prop) && isInPageMarginBox(decl)) {
4416
+ warnings.push({
4417
+ rule: ruleRiskyProps,
4418
+ severity: "warning",
4419
+ message: `Property "${decl.prop}" is not supported in Chromium @page margin boxes and is silently ignored — the chrome renders square/unshadowed. Anything that would paint outside the box (box-shadow, outline, filter) or make it a stacking context (transform, opacity, mix-blend-mode) is dropped; text-shadow, border, border-radius, padding and background gradients all work inside the box.`,
4420
+ ...nodeLoc(decl)
4421
+ });
4422
+ } else if (prop === "filter") {
4406
4423
  warnings.push({
4407
4424
  rule: ruleRiskyProps,
4408
4425
  severity: "warning",
@@ -4416,11 +4433,11 @@ function checkCss(css, from) {
4416
4433
  message: `Property is high-risk for print/PDF (can force rasterization): ${decl.prop}`,
4417
4434
  ...nodeLoc(decl)
4418
4435
  });
4419
- } else if (marginBoxIgnoredProperties.has(prop) && isInPageMarginBox(decl)) {
4436
+ } else if (isDroppedPageGradient(decl)) {
4420
4437
  warnings.push({
4421
4438
  rule: ruleRiskyProps,
4422
4439
  severity: "warning",
4423
- message: `Property "${decl.prop}" is not supported in Chromium @page margin boxes and is silently ignored the chrome renders square/unshadowed.`,
4440
+ message: `"${decl.prop}: ${decl.value}" on @page paints nothing in print — Chromium drops a gradient in the @page box's own background, with no error and a valid-looking PDF. A solid colour there paints the whole sheet; the same gradient also works on "html" (it covers the content area, not the sheet) or in a margin box.`,
4424
4441
  ...nodeLoc(decl)
4425
4442
  });
4426
4443
  } else if (isUnpairedFitContentWidth(decl)) {
@@ -8016,7 +8033,7 @@ class PdfOutput {
8016
8033
  const rawPdf = pdfxMode ? path8.join(stage, "raw.pdf") : path8.resolve(pdfFile);
8017
8034
  await fsp2.mkdir(path8.dirname(path8.resolve(pdfFile)), { recursive: true });
8018
8035
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
8019
- const { buildNativePdf } = await import("./engine-8kdwy0jz.js");
8036
+ const { buildNativePdf } = await import("./engine-e1ycm5p9.js");
8020
8037
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
8021
8038
  title: config.title,
8022
8039
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
@@ -4,7 +4,7 @@ import {
4
4
  launchChromium,
5
5
  marginBoxAlign,
6
6
  marginBoxRectPt
7
- } from "./index-7hrvksrb.js";
7
+ } from "./index-y7jbczsb.js";
8
8
 
9
9
  // src/engine/compiler/build.ts
10
10
  import { readFile } from "node:fs/promises";
@@ -1383,7 +1383,8 @@ var BUILD_DIAGNOSTIC_CODES = [
1383
1383
  "engine.multicol.dead-column",
1384
1384
  "engine.content.overheight",
1385
1385
  "engine.image.low-dpi",
1386
- "engine.flush.margin-box"
1386
+ "engine.flush.margin-box",
1387
+ "engine.page-background.unreferenced"
1387
1388
  ];
1388
1389
  async function build(opts) {
1389
1390
  const log = opts.onProgress ?? (() => {});
@@ -1410,6 +1411,7 @@ async function build(opts) {
1410
1411
  deviceScaleFactor: 1,
1411
1412
  mobile: false
1412
1413
  };
1414
+ await page.send("Emulation.setScrollbarsHidden", { hidden: true });
1413
1415
  await page.send("Emulation.setDeviceMetricsOverride", sheetViewport);
1414
1416
  await page.send("Emulation.setEmulatedMedia", { media: "print" });
1415
1417
  log(`print media emulated for audits and measurement`);
@@ -1780,13 +1782,77 @@ ${mapCss}`;
1780
1782
  log(`audit: ${audit.length} print-quality warning(s)`);
1781
1783
  }
1782
1784
  {
1783
- const { leaks, multicol, layerTraps } = JSON.parse(await page.evaluate(`(() => {
1785
+ const { leaks, multicol, layerTraps, pageBackgrounds } = JSON.parse(await page.evaluate(`(() => {
1784
1786
  const desc = ${DESC_JS};
1785
1787
  const leaks = [];
1786
1788
  const multicol = [];
1787
1789
  const layerTraps = [];
1788
1790
  const seenLayerTraps = new Set();
1789
1791
 
1792
+ // Images referenced ONLY from inside an @page rule. Chromium fetches
1793
+ // them and then paints nothing — see the pass comment above.
1794
+ const pageBackgrounds = (() => {
1795
+ const absolute = (u) => {
1796
+ try { return new URL(u, document.baseURI).href } catch { return u }
1797
+ };
1798
+ const urlsIn = (text) => {
1799
+ const out = [];
1800
+ const re = /url\\(\\s*(['"]?)([^'")]*)\\1\\s*\\)/g;
1801
+ let m;
1802
+ while ((m = re.exec(text))) if (m[2]) out.push(m[2]);
1803
+ return out;
1804
+ };
1805
+ const owned = new Map();
1806
+ // Resolved by something OTHER than an @page rule — a CSS rule
1807
+ // outside @page, or an element's inline style. Unconditionally
1808
+ // protective: the image is a StyleImage the load already resolved.
1809
+ const cssReferenced = new Set();
1810
+ // Named by a <link rel="preload">. Protective, unless an element
1811
+ // src names the same URL (see srcNamed).
1812
+ const preloaded = new Set();
1813
+ // Named by an element's src. NEVER protective — measured, this is
1814
+ // the one reference type that BREAKS the page box, and it breaks
1815
+ // it whether or not a preload is present (12/12, either document
1816
+ // order). An element reference is evidence of the failure, not of
1817
+ // safety.
1818
+ const srcNamed = new Set();
1819
+ for (const el of document.querySelectorAll("[src]"))
1820
+ srcNamed.add(absolute(el.getAttribute("src")));
1821
+ for (const el of document.querySelectorAll("link[rel~=preload][href]"))
1822
+ preloaded.add(absolute(el.getAttribute("href")));
1823
+ for (const el of document.querySelectorAll("[style]"))
1824
+ for (const u of urlsIn(el.getAttribute("style"))) cssReferenced.add(absolute(u));
1825
+ const walk = (rules, owner) => {
1826
+ for (const rule of rules) {
1827
+ let own = owner;
1828
+ if (rule.constructor.name === "CSSPageRule")
1829
+ own = "@page" + (rule.selectorText ? " " + rule.selectorText : "");
1830
+ else if (owner && rule.name) own = owner + " { @" + rule.name + " }";
1831
+ for (const u of urlsIn(rule.style ? rule.style.cssText : "")) {
1832
+ if (!own) cssReferenced.add(absolute(u));
1833
+ else if (!owned.has(absolute(u))) owned.set(absolute(u), { url: u, where: own });
1834
+ }
1835
+ if (rule.cssRules) walk(rule.cssRules, own);
1836
+ }
1837
+ };
1838
+ for (const sheet of document.styleSheets) {
1839
+ let rules;
1840
+ try { rules = sheet.cssRules } catch { continue }
1841
+ walk(rules, null);
1842
+ }
1843
+ // PROTECTED iff another CSS rule resolves it, OR a preload names
1844
+ // it and no element src names it too. "Something else mentions
1845
+ // this URL" is NOT the test — that inference is inverted for
1846
+ // [src], the very reference type that breaks the page box.
1847
+ return [...owned]
1848
+ .filter(([abs, hit]) =>
1849
+ !/^data:/i.test(hit.url) &&
1850
+ !cssReferenced.has(abs) &&
1851
+ !(preloaded.has(abs) && !srcNamed.has(abs)))
1852
+ .map(([, hit]) => hit)
1853
+ .slice(0, 20);
1854
+ })();
1855
+
1790
1856
  const stackingReasons = (el, cs) => {
1791
1857
  const reasons = [];
1792
1858
  const positioned = cs.position !== "static";
@@ -1907,7 +1973,7 @@ ${mapCss}`;
1907
1973
  }
1908
1974
  }
1909
1975
  }
1910
- return JSON.stringify({ leaks, multicol, layerTraps });
1976
+ return JSON.stringify({ leaks, multicol, layerTraps, pageBackgrounds });
1911
1977
  })()`));
1912
1978
  for (const d of leaks)
1913
1979
  diagnose("engine.abspos.leak", `${d} uses position: absolute with nothing positioned around it, so it is placed against the whole document rather than the page it sits on in your markdown — it can print on a completely different page.`);
@@ -1917,6 +1983,10 @@ ${mapCss}`;
1917
1983
  diagnose("engine.layer.trapped", `${trap.behind} cannot paint behind the page as intended because ancestor ${trap.ancestor} ${trap.detail}. Remove or narrowly scope that containment style around the .gp-behind element.`);
1918
1984
  if (layerTraps.length)
1919
1985
  log(`audit: ${layerTraps.length} trapped layer warning(s)`);
1986
+ for (const hit of pageBackgrounds)
1987
+ diagnose("engine.page-background.unreferenced", `"${hit.url}" is only referenced from "${hit.where}", and Chromium will not print an image referenced nowhere else — the page prints with its background colour alone, with no error. Gutterpress stages and preloads every image your project stylesheets reference, so this one is outside that: it is remote (a url(https://...), which is never staged), or it comes from CSS that does not pass through your stylesheets, or an element in the document uses the same URL as its src, which drops it on its own. Use a local image, referenced from one of your project stylesheets, and not also used as an <img>.`);
1988
+ if (pageBackgrounds.length)
1989
+ log(`audit: ${pageBackgrounds.length} unreferenced @page background image(s)`);
1920
1990
  for (const d of multicol)
1921
1991
  diagnose("engine.multicol.dead-column", `${d} runs over more than one page in columns, and only the last page's columns get balanced — earlier pages are left with an empty column. Add column-fill: auto to ${d}.`);
1922
1992
  if (multicol.length)
@@ -2069,6 +2139,7 @@ async function predictPageMap(browser, url, agentScript, viewerScript, args, she
2069
2139
  let page;
2070
2140
  try {
2071
2141
  page = await browser.newPage();
2142
+ await page.send("Emulation.setScrollbarsHidden", { hidden: true });
2072
2143
  await page.send("Emulation.setDeviceMetricsOverride", sheetViewport);
2073
2144
  await page.navigate(url);
2074
2145
  await page.evaluate(agentScript);
@@ -608,7 +608,7 @@ var preview_shell_default = "./preview-shell-kyjq59d9.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-tma1qwz2.js";
611
+ var gutterpress_viewer_default = "./gutterpress-viewer-mfeccbys.js";
612
612
 
613
613
  // src/assets/engine/gutterpress-agent.js
614
614
  var gutterpress_agent_default = "./gutterpress-agent-cazqstr1.js";
@@ -711,7 +711,18 @@ var MARGIN_BOX_IGNORED_PROPERTIES = new Set([
711
711
  "rotate",
712
712
  "translate",
713
713
  "scale",
714
- "box-shadow"
714
+ "perspective",
715
+ "box-shadow",
716
+ "opacity",
717
+ "outline",
718
+ "outline-color",
719
+ "outline-style",
720
+ "outline-width",
721
+ "outline-offset",
722
+ "filter",
723
+ "backdrop-filter",
724
+ "mix-blend-mode",
725
+ "clip-path"
715
726
  ]);
716
727
  function isIgnoredMarginBoxProperty(property) {
717
728
  return MARGIN_BOX_IGNORED_PROPERTIES.has(property.toLowerCase());
package/dist/index.js CHANGED
@@ -113,15 +113,15 @@ import {
113
113
  syncProject,
114
114
  testRemoteAccess,
115
115
  validateProjectPlugins
116
- } from "./index-j6b6cees.js";
116
+ } from "./index-ahzftp35.js";
117
117
  import {
118
118
  BUILD_DIAGNOSTIC_CODES
119
- } from "./index-p0ebr7dh.js";
119
+ } from "./index-n252zv7d.js";
120
120
  import {
121
121
  BuildError,
122
122
  DEFAULT_PRINT_OPTS,
123
123
  readyProbeExpr
124
- } from "./index-7hrvksrb.js";
124
+ } from "./index-y7jbczsb.js";
125
125
  import {
126
126
  AUTO_SNAPSHOT_MESSAGE,
127
127
  HISTORY_PAGE_LIMIT,
@@ -6,7 +6,8 @@
6
6
  * local `@import` closure) is inlined, so a stylesheet's location is irrelevant
7
7
  * to the output and themes/shared design systems need no copying. Fonts become
8
8
  * `data:` URIs — which is what guarantees the byte-identical face reaches
9
- * Chromium, and therefore the PDF. Small images inline; large ones are copied.
9
+ * Chromium, and therefore the PDF. Images are files: every CSS image is copied
10
+ * under a content-addressed name, whatever it weighs.
10
11
  *
11
12
  * A missing stylesheet or font is a build error here, at read time, rather than
12
13
  * a 404 during pagination that Paged.js parsed as CSS or silently replaced with
@@ -14,8 +15,6 @@
14
15
  *
15
16
  * Bundle-safe (CLAUDE.md §1/§3): postcss only.
16
17
  */
17
- /** Inline images up to this size; copy larger ones (full-bleed page art). */
18
- export declare const IMAGE_INLINE_MAX_BYTES: number;
19
18
  /** One file the build must copy into the output directory. */
20
19
  export interface AssetCopy {
21
20
  /** Absolute path of the source file. */
@@ -92,8 +91,11 @@ export declare function inlineStyles(projectDir: string, stylePaths: string[]):
92
91
  */
93
92
  export declare function collectStyleDependencies(projectDir: string, stylePaths: string[]): Promise<string[]>;
94
93
  /**
95
- * Turn the renderer's recorded image references into a copy plan. Images keep
96
- * their authored relative path, so the author's folder layout is what ships.
94
+ * Turn the renderer's recorded image references into a copy plan. PROSE images
95
+ * keep their authored relative path, so the author's folder layout is what
96
+ * ships for them. CSS images do NOT — `inlineOne` content-addresses every one,
97
+ * deliberately, so a CSS URL and a prose URL can never coincide (see the
98
+ * comment there). A file used both ways therefore ships twice under two names.
97
99
  * A `../` reference is rejected rather than silently relocated — it has no
98
100
  * representable path under the output root.
99
101
  */
@@ -39,6 +39,43 @@ export interface AssembleBookHtmlOptions {
39
39
  * relocate or lose during staging.
40
40
  */
41
41
  projectCss?: string;
42
+ /**
43
+ * SHIM — spec gap #152. Output-relative hrefs of the images the project's
44
+ * stylesheets staged (`inlineStyles`'s copy plan, verbatim), each emitted as
45
+ * one `<link rel="preload" as="image">`.
46
+ *
47
+ * Chromium reaches an `@page`-only `url()` lazily, during the print, and the
48
+ * print path CDP drives never waits for a pending resource — so the sheet
49
+ * comes back with its background colour alone, no error, a valid PDF of
50
+ * blank paper (docs/known-limitations.md §3; mechanism in
51
+ * PR #187's `docs/analysis/why-page-background-drops.md`).
52
+ *
53
+ * What the preload buys is that the fetch STARTS during document load
54
+ * instead of during the print. That is not a timing guarantee: a response
55
+ * slow enough still loses (measured — held 1500 ms server-side, the preload
56
+ * row drops too). On the PDF path the asset is a local file staged beside
57
+ * `book.html`, so there is no server to be slow; a published `--format html`
58
+ * bundle read over a slow network can still lose the race.
59
+ *
60
+ * A second ELEMENT reference is not an alternative. Any `[src]` naming the
61
+ * URL drops the page box (measured 12/12, with or without a preload, in
62
+ * either document order) — which is why `asset-inline.ts` content-addresses
63
+ * every CSS image so no element can name one.
64
+ *
65
+ * WHAT PROVES IT IS STILL NEEDED: the expiry canary,
66
+ * `engine/compiler/page-background-chromium-bug.canary.test.ts`. The day it
67
+ * goes red, Chromium has fixed the bug — delete this option, the `.map()`
68
+ * that feeds it in `markdown/index.ts`, and the canary.
69
+ *
70
+ * The copy plan is the source, NOT a scan of the assembled CSS: `pluginCss`
71
+ * never passes through `inlineStyles`, so a `url()` inside it is never
72
+ * staged and a scan would emit a `<link>` to a file that does not exist.
73
+ * The plan is already deduped (keyed by destination), already excludes fonts
74
+ * (inlined) and remote urls (left alone), and already covers the
75
+ * `--paper: url()` + `var(--paper)` shape, because `walkDecls` sees custom
76
+ * properties like any other declaration.
77
+ */
78
+ preloadImages?: string[];
42
79
  title?: string;
43
80
  plugins?: LoadedPlugin[];
44
81
  pluginCss?: string;
@@ -14,8 +14,7 @@ import http from "node:http";
14
14
  * answering a separate question, "what Content-Type does a data: URI need
15
15
  * when embedding this file inline," vs. this table's "what Content-Type does
16
16
  * an HTTP response need" — had drifted again, missing `.webp`/`.avif` here.
17
- * A large (>512KB, over the inliner's threshold) WebP/AVIF image was copied
18
- * as a real file and then served as `application/octet-stream` by both
17
+ * A copied WebP/AVIF image was served as `application/octet-stream` by both
19
18
  * servers above. Added below; if a third image format shows up, add it to
20
19
  * BOTH tables in the same commit (asset-inline.ts is out of scope for this
21
20
  * PR — see docs/reviews/duplication-audit-2026-07-28.md).
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  MANIFEST_FILENAMES,
3
3
  runLint
4
- } from "./cli-bq7vkea9.js";
4
+ } from "./cli-wrt39m23.js";
5
5
  import {
6
6
  log
7
- } from "./cli-n79ghfhs.js";
7
+ } from "./cli-mnekrb8s.js";
8
8
  import {
9
9
  UsageError,
10
10
  rejectExtraPositionals,
11
11
  rejectUnknownFlags
12
- } from "./cli-4n1p25df.js";
13
- import"./cli-eewynen9.js";
12
+ } from "./cli-nrv3gzz9.js";
13
+ import"./cli-6qhw9v08.js";
14
14
  import"./cli-c41yr7he.js";
15
15
  import {
16
16
  EXIT_CODES
@@ -5,16 +5,16 @@ import {
5
5
  TARGETS,
6
6
  TARGET_IDS,
7
7
  scaffoldProject
8
- } from "./cli-bq7vkea9.js";
8
+ } from "./cli-wrt39m23.js";
9
9
  import {
10
10
  resolveGhostscript
11
- } from "./cli-n79ghfhs.js";
11
+ } from "./cli-mnekrb8s.js";
12
12
  import {
13
13
  UsageError,
14
14
  rejectExtraPositionals,
15
15
  rejectUnknownFlags
16
- } from "./cli-4n1p25df.js";
17
- import"./cli-eewynen9.js";
16
+ } from "./cli-nrv3gzz9.js";
17
+ import"./cli-6qhw9v08.js";
18
18
  import {
19
19
  isToolAvailable
20
20
  } from "./cli-c41yr7he.js";
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  addNpmPlugin,
3
3
  parseNpmPluginSpec
4
- } from "./cli-bq7vkea9.js";
5
- import"./cli-n79ghfhs.js";
4
+ } from "./cli-wrt39m23.js";
5
+ import"./cli-mnekrb8s.js";
6
6
  import {
7
7
  UsageError,
8
8
  rejectExtraPositionals,
9
9
  rejectUnknownFlags
10
- } from "./cli-4n1p25df.js";
11
- import"./cli-eewynen9.js";
10
+ } from "./cli-nrv3gzz9.js";
11
+ import"./cli-6qhw9v08.js";
12
12
  import"./cli-c41yr7he.js";
13
13
  import {
14
14
  EXIT_CODES
@@ -2,16 +2,16 @@ import {
2
2
  executeValidation,
3
3
  publishTargetFor,
4
4
  reportMissingTools
5
- } from "./cli-bq7vkea9.js";
5
+ } from "./cli-wrt39m23.js";
6
6
  import {
7
7
  log
8
- } from "./cli-n79ghfhs.js";
8
+ } from "./cli-mnekrb8s.js";
9
9
  import {
10
10
  UsageError,
11
11
  rejectExtraPositionals,
12
12
  rejectUnknownFlags
13
- } from "./cli-4n1p25df.js";
14
- import"./cli-eewynen9.js";
13
+ } from "./cli-nrv3gzz9.js";
14
+ import"./cli-6qhw9v08.js";
15
15
  import"./cli-c41yr7he.js";
16
16
  import {
17
17
  EXIT_CODES
@@ -35,12 +35,12 @@ export interface ServerState {
35
35
  * Files the inlined CSS references but could not embed, as
36
36
  * `book.html`-relative URL path → absolute source path.
37
37
  *
38
- * `asset-inline.ts` embeds fonts and images up to
39
- * `IMAGE_INLINE_MAX_BYTES`; anything larger keeps its project-relative path
40
- * (if it lives in the book) or becomes `assets/<contentHash><ext>` (if it
41
- * does not e.g. art referenced from a repo-root shared stylesheet, the
42
- * normative multi-book layout). Either way the inliner returns a COPY PLAN,
43
- * which the build executes into its output dir.
38
+ * `asset-inline.ts` embeds fonts; every image becomes
39
+ * `assets/<contentHash><ext>`, wherever it lives in the book, or in a
40
+ * repo-root shared stylesheet's art folder (the normative multi-book
41
+ * layout). The inliner returns a COPY PLAN, which the build executes into
42
+ * its output dir. Content-addressing is what keeps a CSS image's URL
43
+ * distinct from a prose image's; see `inlineOne`.
44
44
  *
45
45
  * The preview serves the project in place and has no output dir, so a
46
46
  * rewritten `assets/<hash>` URL had nothing behind it and shared art
@@ -6,10 +6,10 @@ import {
6
6
  runBuild,
7
7
  splitOutPath,
8
8
  startPreviewServer
9
- } from "./cli-bq7vkea9.js";
9
+ } from "./cli-wrt39m23.js";
10
10
  import {
11
11
  log
12
- } from "./cli-n79ghfhs.js";
12
+ } from "./cli-mnekrb8s.js";
13
13
  import {
14
14
  UsageError,
15
15
  parseEngine,
@@ -18,8 +18,8 @@ import {
18
18
  rejectExtraPositionals,
19
19
  rejectUnknownFlags,
20
20
  resolvePort
21
- } from "./cli-4n1p25df.js";
22
- import"./cli-eewynen9.js";
21
+ } from "./cli-nrv3gzz9.js";
22
+ import"./cli-6qhw9v08.js";
23
23
  import"./cli-c41yr7he.js";
24
24
  import {
25
25
  BuildError
@@ -7,17 +7,17 @@ import {
7
7
  publishCredentialKey,
8
8
  publishProviderFor,
9
9
  runPublish
10
- } from "./cli-bq7vkea9.js";
10
+ } from "./cli-wrt39m23.js";
11
11
  import {
12
12
  FileTokenStore,
13
13
  log
14
- } from "./cli-n79ghfhs.js";
14
+ } from "./cli-mnekrb8s.js";
15
15
  import {
16
16
  UsageError,
17
17
  rejectExtraPositionals,
18
18
  rejectUnknownFlags
19
- } from "./cli-4n1p25df.js";
20
- import"./cli-eewynen9.js";
19
+ } from "./cli-nrv3gzz9.js";
20
+ import"./cli-6qhw9v08.js";
21
21
  import"./cli-c41yr7he.js";
22
22
  import {
23
23
  EXIT_CODES
package/dist/render.js CHANGED
@@ -1389,12 +1389,14 @@ ${projectCss.trim()}` : null
1389
1389
  ].filter(Boolean).join(`
1390
1390
 
1391
1391
  `);
1392
+ const preloadTags = (opts.preloadImages ?? []).map((href) => `
1393
+ <link rel="preload" as="image" href="${href}">`).join("");
1392
1394
  return `<!DOCTYPE html>
1393
1395
  <html lang="en">
1394
1396
  <head>
1395
1397
  <meta charset="UTF-8">
1396
1398
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1397
- <title>${title}</title>
1399
+ <title>${title}</title>${preloadTags}
1398
1400
  <style data-project-css>
1399
1401
  ${inlineCss}
1400
1402
  </style>
@@ -89,3 +89,34 @@ export declare class ProcessExitSignal extends Error {
89
89
  * `build-runner.browser-lifecycle.test.ts`.
90
90
  */
91
91
  export declare function stubProcessExit(): ReturnType<typeof spyOn<typeof process, "exit">>;
92
+ /**
93
+ * "Did this declaration paint anything?" is not answerable from a PDF's
94
+ * structure — Chromium fetches an unreferenced `@page` background image and
95
+ * then paints nothing, with no error and no missing object
96
+ * (docs/known-limitations.md §3, #152). The only honest answer is pixels, so
97
+ * these helpers rasterize page 1 and diff it against a control.
98
+ *
99
+ * Ghostscript rather than poppler's `pdftoppm`: `gs` is what CI installs, and
100
+ * one rasterizer keeps the two page-background suites measuring the same way.
101
+ */
102
+ export interface GrayRaster {
103
+ width: number;
104
+ height: number;
105
+ data: Uint8Array;
106
+ }
107
+ /** Ghostscript's path, or `null`. Callers self-skip, as the PDF/X suites do. */
108
+ export declare function resolveRasterizer(): Promise<string | null>;
109
+ /** Page 1 of `pdfPath` as an 8-bit grayscale raster at 100 dpi. */
110
+ export declare function rasterizePdfPage(gsBin: string, pdfPath: string, outDir: string, tag: string): GrayRaster;
111
+ /**
112
+ * Mean absolute per-pixel difference. `0` means the two prints are identical —
113
+ * i.e. the declaration under test changed nothing at all.
114
+ */
115
+ export declare function meanAbsDiff(a: GrayRaster, b: GrayRaster): number;
116
+ /**
117
+ * A minimal RGB PNG encoder, so page-background fixtures can generate the
118
+ * exact image they need — a solid tile that is unmistakable when it paints, or
119
+ * an incompressible one that is genuinely large — instead of carrying opaque
120
+ * base64 blobs whose size and contrast nobody can check by reading them.
121
+ */
122
+ export declare function pngRgb(width: number, height: number, pixel: (x: number, y: number) => [number, number, number]): Buffer;
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  executeAndReport
3
- } from "./cli-bq7vkea9.js";
3
+ } from "./cli-wrt39m23.js";
4
4
  import {
5
5
  log
6
- } from "./cli-n79ghfhs.js";
6
+ } from "./cli-mnekrb8s.js";
7
7
  import {
8
8
  UsageError,
9
9
  rejectExtraPositionals,
10
10
  rejectUnknownFlags
11
- } from "./cli-4n1p25df.js";
12
- import"./cli-eewynen9.js";
11
+ } from "./cli-nrv3gzz9.js";
12
+ import"./cli-6qhw9v08.js";
13
13
  import"./cli-c41yr7he.js";
14
14
  import {
15
15
  EXIT_CODES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gutterpress",
3
- "version": "0.10.1",
3
+ "version": "0.10.2-alpha.1",
4
4
  "description": "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
5
5
  "author": "itlackey",
6
6
  "license": "MPL-2.0",