gutterpress 0.10.9-beta.1 → 0.10.9

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.
@@ -4782,7 +4782,7 @@ import git from "isomorphic-git";
4782
4782
  // package.json
4783
4783
  var package_default = {
4784
4784
  name: "gutterpress",
4785
- version: "0.10.9-beta.1",
4785
+ version: "0.10.9",
4786
4786
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
4787
4787
  author: "itlackey",
4788
4788
  license: "MPL-2.0",
@@ -5145,26 +5145,54 @@ function targetsPageWrapper(selector) {
5145
5145
  return false;
5146
5146
  return !/\bgp-(bleed|pin)\b/.test(selector);
5147
5147
  }
5148
+ var cssWideResetKeywords = new Set(["initial", "unset", "revert", "revert-layer"]);
5149
+ var inertValues = {
5150
+ "z-index": new Set(["auto"]),
5151
+ opacity: new Set(["1", "100%"]),
5152
+ "mix-blend-mode": new Set(["normal"]),
5153
+ "background-blend-mode": new Set(["normal"]),
5154
+ filter: new Set(["none"]),
5155
+ "backdrop-filter": new Set(["none"]),
5156
+ transform: new Set(["none"]),
5157
+ rotate: new Set(["none"]),
5158
+ translate: new Set(["none"]),
5159
+ scale: new Set(["none"]),
5160
+ perspective: new Set(["none"]),
5161
+ "box-shadow": new Set(["none"]),
5162
+ outline: new Set(["none"]),
5163
+ "outline-style": new Set(["none"]),
5164
+ "will-change": new Set(["auto", "none"]),
5165
+ "clip-path": new Set(["none"]),
5166
+ transition: new Set(["none"]),
5167
+ animation: new Set(["none"]),
5168
+ "animation-name": new Set(["none"]),
5169
+ overflow: new Set(["visible"]),
5170
+ "overflow-x": new Set(["visible"]),
5171
+ "overflow-y": new Set(["visible"])
5172
+ };
5173
+ function isInertValue(prop, value) {
5174
+ const v = value.trim().toLowerCase();
5175
+ return cssWideResetKeywords.has(v) || (inertValues[prop]?.has(v) ?? false);
5176
+ }
5148
5177
  function createsStackingContext(decl) {
5149
5178
  const p = decl.prop.toLowerCase();
5150
5179
  const v = decl.value.trim().toLowerCase();
5180
+ if (isInertValue(p, v))
5181
+ return false;
5151
5182
  switch (p) {
5152
- case "z-index":
5153
- return v !== "auto" && v !== "initial" && v !== "unset";
5154
5183
  case "isolation":
5155
5184
  return v === "isolate";
5185
+ case "contain":
5186
+ return /\b(paint|layout|strict|content)\b/.test(v);
5187
+ case "z-index":
5156
5188
  case "opacity":
5157
- return v !== "1" && v !== "100%" && v !== "initial" && v !== "unset";
5158
5189
  case "mix-blend-mode":
5159
- return v !== "normal" && v !== "initial" && v !== "unset";
5160
5190
  case "filter":
5161
5191
  case "backdrop-filter":
5162
5192
  case "transform":
5163
5193
  case "perspective":
5164
5194
  case "will-change":
5165
- return v !== "none" && v !== "initial" && v !== "unset";
5166
- case "contain":
5167
- return /\b(paint|layout|strict|content)\b/.test(v);
5195
+ return true;
5168
5196
  default:
5169
5197
  return false;
5170
5198
  }
@@ -5174,7 +5202,7 @@ function clipsDescendants(decl) {
5174
5202
  if (p !== "overflow" && p !== "overflow-x" && p !== "overflow-y")
5175
5203
  return false;
5176
5204
  const v = decl.value.trim().toLowerCase();
5177
- return v !== "" && !/^(visible|initial|unset|revert)$/.test(v);
5205
+ return v !== "" && !isInertValue(p, v);
5178
5206
  }
5179
5207
  function nodeLoc(node) {
5180
5208
  return {
@@ -5215,6 +5243,8 @@ function checkCss(css, from) {
5215
5243
  root.walkAtRules((at) => reportRemoteUrls(at.params || "", at));
5216
5244
  root.walkDecls((decl) => {
5217
5245
  const prop = decl.prop.toLowerCase();
5246
+ if (isInertValue(prop, decl.value))
5247
+ return;
5218
5248
  if (marginBoxIgnoredProperties.has(prop) && isInPageMarginBox(decl)) {
5219
5249
  warnings.push({
5220
5250
  rule: ruleRiskyProps,
@@ -5321,7 +5351,8 @@ async function runLint(opts = {}) {
5321
5351
  linted++;
5322
5352
  const warnings = checkCss(css, file);
5323
5353
  const errors = warnings.filter((w) => w.severity === "error");
5324
- riskyCount += warnings.filter((w) => w.rule === ruleRiskyProps).length;
5354
+ const risky = warnings.filter((w) => w.rule === ruleRiskyProps);
5355
+ riskyCount += risky.length;
5325
5356
  if (errors.length > 0) {
5326
5357
  log.error(` ${file}`);
5327
5358
  for (const w of errors) {
@@ -5329,6 +5360,12 @@ async function runLint(opts = {}) {
5329
5360
  }
5330
5361
  errorCount += errors.length;
5331
5362
  }
5363
+ if (risky.length > 0) {
5364
+ log.warn(` ${file}`);
5365
+ for (const w of risky) {
5366
+ log.warn(` ${w.line}:${w.column} ${w.message} (${w.rule})`);
5367
+ }
5368
+ }
5332
5369
  }
5333
5370
  if (errorCount > 0) {
5334
5371
  log.error("CSS lint errors found");
@@ -5336,7 +5373,6 @@ async function runLint(opts = {}) {
5336
5373
  }
5337
5374
  if (riskyCount > 0) {
5338
5375
  log.warn(`${riskyCount} risky print properties found (may cause rasterization)`);
5339
- log.warn("The validator will check for actual rasterized pages after PDF generation.");
5340
5376
  } else {
5341
5377
  log.success("CSS lint passed");
5342
5378
  }
@@ -6969,10 +7005,10 @@ registerCheck(check19);
6969
7005
 
6970
7006
  // src/checks/source/local-refs.ts
6971
7007
  import { existsSync as existsSync9 } from "node:fs";
6972
- import { readFile as readFile16 } from "node:fs/promises";
6973
7008
  import { dirname as dirname3, resolve as resolve7 } from "node:path";
6974
7009
 
6975
7010
  // src/checks/source/local-ref-parser.ts
7011
+ import { readFile as readFile16 } from "node:fs/promises";
6976
7012
  function newlineOffsets(content) {
6977
7013
  const offsets = [];
6978
7014
  for (let i = 0;i < content.length; i++)
@@ -7017,7 +7053,7 @@ function createRenderedLocalRefCollector(customPlugins) {
7017
7053
  if (!kind)
7018
7054
  continue;
7019
7055
  const ref = child.attrGet(kind === "image" ? "src" : "href");
7020
- if (!ref)
7056
+ if (ref === null)
7021
7057
  continue;
7022
7058
  const offset = tokenOffsets.get(child);
7023
7059
  refs.push({
@@ -7030,6 +7066,26 @@ function createRenderedLocalRefCollector(customPlugins) {
7030
7066
  return refs;
7031
7067
  };
7032
7068
  }
7069
+ async function* renderedLocalRefs(ctx, checkId, results) {
7070
+ const files = (ctx.markdownFiles ?? []).slice().sort();
7071
+ if (files.length === 0)
7072
+ return;
7073
+ const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
7074
+ results.push(inspectionFailed(checkId, `Plugin "${ref}" could not be loaded, so local references it defines were not checked: ${error2.message}`));
7075
+ });
7076
+ const collectRenderedLocalRefs = createRenderedLocalRefCollector(plugins);
7077
+ for (const file of files) {
7078
+ let refs;
7079
+ try {
7080
+ refs = collectRenderedLocalRefs(await readFile16(file, "utf8"));
7081
+ } catch {
7082
+ results.push(inspectionFailed(checkId, `Could not read source file: ${file}`, { file }));
7083
+ continue;
7084
+ }
7085
+ for (const ref of refs)
7086
+ yield { ...ref, file };
7087
+ }
7088
+ }
7033
7089
 
7034
7090
  // src/checks/source/local-refs.ts
7035
7091
  var check20 = {
@@ -7039,45 +7095,29 @@ var check20 = {
7039
7095
  category: "source",
7040
7096
  phase: "pre-build",
7041
7097
  async run(ctx) {
7042
- const files = (ctx.markdownFiles ?? []).slice().sort();
7043
- if (files.length === 0)
7044
- return [];
7045
7098
  const results = [];
7046
- const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
7047
- results.push(inspectionFailed(check20.id, `Plugin "${ref}" could not be loaded, so local references it defines were not checked: ${error2.message}`));
7048
- });
7049
- const collectRenderedLocalRefs = createRenderedLocalRefCollector(plugins);
7050
- for (const file of files) {
7051
- try {
7052
- const content = await readFile16(file, "utf8");
7053
- for (const { ref, kind, line } of collectRenderedLocalRefs(content)) {
7054
- if (isNonFilesystemRef(ref))
7055
- continue;
7056
- const escape = kind === "image" ? proseImageRefError(ref, ctx.inputDir) : null;
7057
- if (escape) {
7058
- results.push(finding(check20.id, {
7059
- severity: "error",
7060
- message: escape,
7061
- file,
7062
- line
7063
- }));
7064
- continue;
7065
- }
7066
- if (localRefExists(ref, kind, file, ctx.inputDir))
7067
- continue;
7068
- results.push(finding(check20.id, {
7069
- severity: kind === "image" ? "warning" : "error",
7070
- code: kind === "image" ? "missing-image-placeholder" : undefined,
7071
- message: kind === "image" ? `Local image not found; build will substitute a magenta placeholder: ${ref}` : `Local reference not found: ${ref}`,
7072
- file,
7073
- line
7074
- }));
7075
- }
7076
- } catch {
7077
- results.push(inspectionFailed(check20.id, `Could not read source file: ${file}`, {
7078
- file
7099
+ for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check20.id, results)) {
7100
+ if (isNonFilesystemRef(ref))
7101
+ continue;
7102
+ const escape = kind === "image" ? proseImageRefError(ref, ctx.inputDir) : null;
7103
+ if (escape) {
7104
+ results.push(finding(check20.id, {
7105
+ severity: "error",
7106
+ message: escape,
7107
+ file,
7108
+ line
7079
7109
  }));
7110
+ continue;
7080
7111
  }
7112
+ if (localRefExists(ref, kind, file, ctx.inputDir))
7113
+ continue;
7114
+ results.push(finding(check20.id, {
7115
+ severity: kind === "image" ? "warning" : "error",
7116
+ code: kind === "image" ? "missing-image-placeholder" : undefined,
7117
+ message: kind === "image" ? `Local image not found; build will substitute a magenta placeholder: ${ref}` : `Local reference not found: ${ref}`,
7118
+ file,
7119
+ line
7120
+ }));
7081
7121
  }
7082
7122
  return results;
7083
7123
  }
@@ -7098,70 +7138,359 @@ function localRefExists(ref, kind, sourceFile, projectRoot) {
7098
7138
  }
7099
7139
  registerCheck(check20);
7100
7140
 
7101
- // src/checks/source/accessibility-alt-text.ts
7102
- import { readFile as readFile17 } from "node:fs/promises";
7103
- var check21 = {
7104
- id: "source.accessibility.alt-text",
7105
- name: "Image Alt Text",
7106
- description: "Checks markdown images include non-empty alt text",
7107
- category: "source",
7108
- phase: "pre-build",
7109
- async run(ctx) {
7110
- const files = (ctx.markdownFiles ?? []).slice().sort();
7111
- if (files.length === 0)
7112
- return [];
7113
- const results = [];
7114
- for (const file of files) {
7115
- try {
7116
- const content = await readFile17(file, "utf8");
7117
- const lines = content.split(`
7118
- `);
7119
- let inFence = false;
7120
- for (let i = 0;i < lines.length; i++) {
7121
- const line = lines[i] ?? "";
7122
- if (/^\s*(```|~~~)/.test(line)) {
7123
- inFence = !inFence;
7141
+ // src/lib/build-staging.ts
7142
+ import path7 from "node:path";
7143
+ import os from "node:os";
7144
+ import fsp from "node:fs/promises";
7145
+
7146
+ // src/lib/missing-asset-placeholder.ts
7147
+ import { createHash as createHash5 } from "node:crypto";
7148
+ import { deflateSync } from "node:zlib";
7149
+ function placeholderOutputPath(missingOutputPath) {
7150
+ const hash = createHash5("sha256").update(missingOutputPath).digest("hex").slice(0, 16);
7151
+ return `assets/gutterpress-missing/${hash}.png`;
7152
+ }
7153
+ function decodeHtmlAttribute(value) {
7154
+ return value.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
7155
+ }
7156
+ function rewriteMissingImageReferences(html, replacements) {
7157
+ if (replacements.size === 0)
7158
+ return html;
7159
+ const replacementFor = (raw) => replacements.get(raw) ?? replacements.get(decodeHtmlAttribute(raw));
7160
+ const cssUrl = /url\(\s*(?:"([^"]*)"|'([^']*)'|&quot;((?:(?!&quot;).)*)&quot;|([^'"()\s]*))\s*\)/giy;
7161
+ const rewriteCssUrls = (css) => {
7162
+ let out = "";
7163
+ let copiedThrough = 0;
7164
+ let index = 0;
7165
+ while (index < css.length) {
7166
+ if (css.startsWith("/*", index)) {
7167
+ const end = css.indexOf("*/", index + 2);
7168
+ index = end < 0 ? css.length : end + 2;
7169
+ continue;
7170
+ }
7171
+ const char = css[index];
7172
+ if (char === '"' || char === "'") {
7173
+ const quote = char;
7174
+ index++;
7175
+ while (index < css.length) {
7176
+ if (css[index] === "\\") {
7177
+ index += 2;
7124
7178
  continue;
7125
7179
  }
7126
- if (inFence)
7180
+ const current = css[index++];
7181
+ if (current === quote)
7182
+ break;
7183
+ }
7184
+ continue;
7185
+ }
7186
+ const previous = index > 0 ? css[index - 1] : "";
7187
+ if (!/[a-z0-9_-]/i.test(previous) && css.slice(index, index + 3).toLowerCase() === "url") {
7188
+ cssUrl.lastIndex = index;
7189
+ const match = cssUrl.exec(css);
7190
+ if (match) {
7191
+ const raw = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
7192
+ const replacement = replacementFor(raw);
7193
+ if (replacement) {
7194
+ out += css.slice(copiedThrough, index);
7195
+ out += match[3] !== undefined ? `url(&quot;${replacement}&quot;)` : `url("${replacement}")`;
7196
+ index = cssUrl.lastIndex;
7197
+ copiedThrough = index;
7127
7198
  continue;
7128
- for (const alt of extractImageAlts(line)) {
7129
- if (alt.trim())
7130
- continue;
7131
- results.push({
7132
- checkId: check21.id,
7133
- severity: "warning",
7134
- message: "Image is missing alt text",
7135
- file,
7136
- line: i + 1
7137
- });
7138
7199
  }
7139
7200
  }
7140
- } catch {}
7201
+ }
7202
+ index++;
7141
7203
  }
7142
- return results;
7204
+ return copiedThrough === 0 ? css : out + css.slice(copiedThrough);
7205
+ };
7206
+ const rewriteTag = (tag) => {
7207
+ let out = tag;
7208
+ if (/^<img\b/i.test(tag)) {
7209
+ out = out.replace(/(\s+src\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
7210
+ const raw = double ?? single ?? bare ?? "";
7211
+ const replacement = replacementFor(raw);
7212
+ if (!replacement)
7213
+ return whole;
7214
+ if (double !== undefined)
7215
+ return `${prefix}"${replacement}"`;
7216
+ if (single !== undefined)
7217
+ return `${prefix}'${replacement}'`;
7218
+ return `${prefix}${replacement}`;
7219
+ });
7220
+ }
7221
+ if (/^<(?:img|source)\b/i.test(tag)) {
7222
+ out = out.replace(/(\s+srcset\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
7223
+ const value = double ?? single ?? bare ?? "";
7224
+ let rewritten = "";
7225
+ let copiedThrough = 0;
7226
+ let changed = false;
7227
+ for (const candidate of parseSrcsetUrlCandidates(value)) {
7228
+ const replacement = replacementFor(candidate.url);
7229
+ if (!replacement)
7230
+ continue;
7231
+ rewritten += value.slice(copiedThrough, candidate.start) + replacement;
7232
+ copiedThrough = candidate.end;
7233
+ changed = true;
7234
+ }
7235
+ if (!changed)
7236
+ return whole;
7237
+ rewritten += value.slice(copiedThrough);
7238
+ if (double !== undefined)
7239
+ return `${prefix}"${rewritten}"`;
7240
+ if (single !== undefined)
7241
+ return `${prefix}'${rewritten}'`;
7242
+ return `${prefix}${rewritten}`;
7243
+ });
7244
+ }
7245
+ out = out.replace(/(\s+style\s*=\s*)(["'])(.*?)\2/gi, (whole, prefix, quote, value) => {
7246
+ const rewritten = rewriteCssUrls(value);
7247
+ return rewritten === value ? whole : `${prefix}${quote}${rewritten}${quote}`;
7248
+ });
7249
+ return out;
7250
+ };
7251
+ return rewriteActiveHtml(html, rewriteTag, (region, element) => {
7252
+ if (element !== "style")
7253
+ return region;
7254
+ const style = /^(<style\b[^>]*>)([\s\S]*)(<\/style\s*>)$/i.exec(region);
7255
+ return style ? `${rewriteTag(style[1])}${rewriteCssUrls(style[2])}${style[3]}` : region;
7256
+ });
7257
+ }
7258
+ function rewriteActiveHtml(html, rewriteTag, rewriteProtected = (region) => region) {
7259
+ const rewriteTags = (active) => active.replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g, rewriteTag);
7260
+ const protectedRegion = /<!--[\s\S]*?-->|<(script|style|pre|code|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
7261
+ let out = "";
7262
+ let last = 0;
7263
+ for (const match of html.matchAll(protectedRegion)) {
7264
+ out += rewriteTags(html.slice(last, match.index));
7265
+ out += rewriteProtected(match[0], match[1]?.toLowerCase());
7266
+ last = (match.index ?? 0) + match[0].length;
7143
7267
  }
7144
- };
7145
- function extractImageAlts(line) {
7146
- const alts = [];
7147
- const inlinePattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
7148
- const referencePattern = /!\[([^\]]*)\]\[[^\]]*\]/g;
7149
- for (const match of line.matchAll(inlinePattern)) {
7150
- alts.push(match[1] ?? "");
7268
+ return out + rewriteTags(html.slice(last));
7269
+ }
7270
+ function crc32(buf) {
7271
+ let c = ~0;
7272
+ for (let i = 0;i < buf.length; i++) {
7273
+ c ^= buf[i];
7274
+ for (let k = 0;k < 8; k++)
7275
+ c = c >>> 1 ^ 3988292384 & -(c & 1);
7151
7276
  }
7152
- for (const match of line.matchAll(referencePattern)) {
7153
- alts.push(match[1] ?? "");
7277
+ return ~c >>> 0;
7278
+ }
7279
+ function chunk(type, data) {
7280
+ const out = new Uint8Array(data.length + 12);
7281
+ const view = new DataView(out.buffer);
7282
+ view.setUint32(0, data.length);
7283
+ for (let i = 0;i < 4; i++)
7284
+ out[4 + i] = type.charCodeAt(i);
7285
+ out.set(data, 8);
7286
+ view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
7287
+ return out;
7288
+ }
7289
+ function placeholderPng(width = 640, height = 480, cell = 32) {
7290
+ const A = [217, 70, 239];
7291
+ const B = [26, 26, 26];
7292
+ const raw = new Uint8Array(height * (1 + width * 3));
7293
+ let p = 0;
7294
+ for (let y = 0;y < height; y++) {
7295
+ raw[p++] = 0;
7296
+ for (let x = 0;x < width; x++) {
7297
+ const c = ((x / cell | 0) + (y / cell | 0)) % 2 === 0 ? A : B;
7298
+ raw[p++] = c[0];
7299
+ raw[p++] = c[1];
7300
+ raw[p++] = c[2];
7301
+ }
7154
7302
  }
7155
- return alts;
7303
+ const ihdr = new Uint8Array(13);
7304
+ const hv = new DataView(ihdr.buffer);
7305
+ hv.setUint32(0, width);
7306
+ hv.setUint32(4, height);
7307
+ ihdr[8] = 8;
7308
+ ihdr[9] = 2;
7309
+ const parts = [
7310
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
7311
+ chunk("IHDR", ihdr),
7312
+ chunk("IDAT", new Uint8Array(deflateSync(raw))),
7313
+ chunk("IEND", new Uint8Array(0))
7314
+ ];
7315
+ const total = parts.reduce((n, x) => n + x.length, 0);
7316
+ const png = new Uint8Array(total);
7317
+ let o = 0;
7318
+ for (const part of parts) {
7319
+ png.set(part, o);
7320
+ o += part.length;
7321
+ }
7322
+ return png;
7156
7323
  }
7157
- registerCheck(check21);
7158
7324
 
7159
- // src/checks/source/accessibility-heading-order.ts
7160
- import { readFile as readFile18 } from "node:fs/promises";
7161
- var check22 = {
7162
- id: "source.accessibility.heading-order",
7163
- name: "Heading Order",
7164
- description: "Checks markdown heading levels do not jump by more than one",
7325
+ // src/lib/build-staging.ts
7326
+ async function shipViewerHtml(htmlFile, outDir) {
7327
+ await fsp.mkdir(path7.join(outDir, "engine"), { recursive: true });
7328
+ await fsp.copyFile(await getAssetPath("engine/gutterpress-viewer.js"), path7.join(outDir, "engine/gutterpress-viewer.js"));
7329
+ const tag = ` <script src="engine/gutterpress-viewer.js"></script>
7330
+ `;
7331
+ const html = await fsp.readFile(htmlFile, "utf-8");
7332
+ await fsp.writeFile(htmlFile, /<\/head>/i.test(html) ? html.replace(/<\/head>/i, tag + "</head>") : tag + html, "utf-8");
7333
+ }
7334
+ async function stageBookAssets(options) {
7335
+ const { renderDir, outDir, htmlFile, imageRefs, cssAssets, onPlan, dropRelativeLinks } = options;
7336
+ const { copies: imageCopies, errors, destinations } = await planImageCopies(renderDir, imageRefs);
7337
+ const copies = [...cssAssets, ...imageCopies];
7338
+ onPlan?.({ unresolved: errors, copyCount: copies.length });
7339
+ const missingPlaceholders = copies.length ? await copyReferencedAssets(copies, outDir) : new Map;
7340
+ const rendered = await fsp.readFile(htmlFile, "utf8");
7341
+ let staged = rendered;
7342
+ if (missingPlaceholders.size > 0) {
7343
+ const rewrites = new Map(missingPlaceholders);
7344
+ for (const [ref, dest] of destinations) {
7345
+ const placeholder = missingPlaceholders.get(dest);
7346
+ if (placeholder)
7347
+ rewrites.set(ref, placeholder);
7348
+ }
7349
+ staged = rewriteMissingImageReferences(staged, rewrites);
7350
+ }
7351
+ if (dropRelativeLinks)
7352
+ staged = dropRelativeLinkHrefs(staged);
7353
+ if (staged.includes("--gp-shape:"))
7354
+ staged = await inlineShapeUrls(staged, outDir);
7355
+ if (staged !== rendered)
7356
+ await fsp.writeFile(htmlFile, staged, "utf8");
7357
+ return { missing: [...missingPlaceholders.keys()].sort() };
7358
+ }
7359
+ async function copyReferencedAssets(copies, outDir) {
7360
+ const dirs = new Set(copies.map((c) => path7.dirname(path7.resolve(outDir, c.to))));
7361
+ await Promise.all([...dirs].map((d) => fsp.mkdir(d, { recursive: true })));
7362
+ const missing = new Map;
7363
+ await Promise.all(copies.map(async (c) => {
7364
+ const dest = path7.resolve(outDir, c.to);
7365
+ try {
7366
+ await fsp.copyFile(c.from, dest);
7367
+ } catch (err) {
7368
+ if (err?.code === "ENOENT") {
7369
+ const placeholder = placeholderOutputPath(c.to);
7370
+ const placeholderDest = path7.resolve(outDir, placeholder);
7371
+ await fsp.mkdir(path7.dirname(placeholderDest), { recursive: true });
7372
+ await fsp.writeFile(placeholderDest, placeholderPng());
7373
+ missing.set(c.to, placeholder);
7374
+ return;
7375
+ }
7376
+ throw new BuildError(`Could not copy asset ${c.from} → ${c.to}: ` + (err instanceof Error ? err.message : String(err)), 1);
7377
+ }
7378
+ }));
7379
+ return missing;
7380
+ }
7381
+ function isPrintResolvableHref(href) {
7382
+ const value = href.trim();
7383
+ if (value.startsWith("#"))
7384
+ return true;
7385
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
7386
+ return scheme !== undefined && scheme !== "file";
7387
+ }
7388
+ function dropRelativeLinkHrefs(html) {
7389
+ const hrefAttr = /\s+href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
7390
+ return rewriteActiveHtml(html, (tag) => {
7391
+ if (!/^<a(?=[\s/>])/i.test(tag))
7392
+ return tag;
7393
+ const m = hrefAttr.exec(tag);
7394
+ if (!m)
7395
+ return tag;
7396
+ const href = decodeHtmlAttribute(m[1] ?? m[2] ?? m[3] ?? "");
7397
+ return isPrintResolvableHref(href) ? tag : tag.replace(hrefAttr, "");
7398
+ });
7399
+ }
7400
+ async function createStageRoot() {
7401
+ return fsp.mkdtemp(path7.join(os.tmpdir(), "gutterpress-stage-"));
7402
+ }
7403
+
7404
+ // src/checks/source/dangling-links.ts
7405
+ var check21 = {
7406
+ id: "source.links.dangling",
7407
+ name: "Dangling Links",
7408
+ description: "Flags relative links the printed book cannot open (their href is dropped at print time)",
7409
+ category: "source",
7410
+ phase: "pre-build",
7411
+ async run(ctx) {
7412
+ const results = [];
7413
+ for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check21.id, results)) {
7414
+ if (kind !== "link" || isPrintResolvableHref(ref))
7415
+ continue;
7416
+ results.push(finding(check21.id, {
7417
+ severity: "warning",
7418
+ code: "dangling-link",
7419
+ data: { ref },
7420
+ message: `Link "${ref}" cannot be opened from the printed book: a PDF has no files ` + `beside it and no address of its own, so a relative link (a chapter file of ` + `this same book included) leads nowhere. The link text stays but its href is ` + `dropped at print time. Link to a heading with #anchor, use an absolute URL, ` + `or write it as plain text.`,
7421
+ file,
7422
+ line
7423
+ }));
7424
+ }
7425
+ return results;
7426
+ }
7427
+ };
7428
+ registerCheck(check21);
7429
+
7430
+ // src/checks/source/accessibility-alt-text.ts
7431
+ import { readFile as readFile17 } from "node:fs/promises";
7432
+ var check22 = {
7433
+ id: "source.accessibility.alt-text",
7434
+ name: "Image Alt Text",
7435
+ description: "Checks markdown images include non-empty alt text",
7436
+ category: "source",
7437
+ phase: "pre-build",
7438
+ async run(ctx) {
7439
+ const files = (ctx.markdownFiles ?? []).slice().sort();
7440
+ if (files.length === 0)
7441
+ return [];
7442
+ const results = [];
7443
+ for (const file of files) {
7444
+ try {
7445
+ const content = await readFile17(file, "utf8");
7446
+ const lines = content.split(`
7447
+ `);
7448
+ let inFence = false;
7449
+ for (let i = 0;i < lines.length; i++) {
7450
+ const line = lines[i] ?? "";
7451
+ if (/^\s*(```|~~~)/.test(line)) {
7452
+ inFence = !inFence;
7453
+ continue;
7454
+ }
7455
+ if (inFence)
7456
+ continue;
7457
+ for (const alt of extractImageAlts(line)) {
7458
+ if (alt.trim())
7459
+ continue;
7460
+ results.push({
7461
+ checkId: check22.id,
7462
+ severity: "warning",
7463
+ message: "Image is missing alt text",
7464
+ file,
7465
+ line: i + 1
7466
+ });
7467
+ }
7468
+ }
7469
+ } catch {}
7470
+ }
7471
+ return results;
7472
+ }
7473
+ };
7474
+ function extractImageAlts(line) {
7475
+ const alts = [];
7476
+ const inlinePattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
7477
+ const referencePattern = /!\[([^\]]*)\]\[[^\]]*\]/g;
7478
+ for (const match of line.matchAll(inlinePattern)) {
7479
+ alts.push(match[1] ?? "");
7480
+ }
7481
+ for (const match of line.matchAll(referencePattern)) {
7482
+ alts.push(match[1] ?? "");
7483
+ }
7484
+ return alts;
7485
+ }
7486
+ registerCheck(check22);
7487
+
7488
+ // src/checks/source/accessibility-heading-order.ts
7489
+ import { readFile as readFile18 } from "node:fs/promises";
7490
+ var check23 = {
7491
+ id: "source.accessibility.heading-order",
7492
+ name: "Heading Order",
7493
+ description: "Checks markdown heading levels do not jump by more than one",
7165
7494
  category: "source",
7166
7495
  phase: "pre-build",
7167
7496
  async run(ctx) {
@@ -7190,7 +7519,7 @@ var check22 = {
7190
7519
  const level = match[1].length;
7191
7520
  if (prevLevel != null && level > prevLevel + 1) {
7192
7521
  results.push({
7193
- checkId: check22.id,
7522
+ checkId: check23.id,
7194
7523
  severity: "warning",
7195
7524
  message: `Heading level jump from h${prevLevel} to h${level}`,
7196
7525
  file,
@@ -7204,11 +7533,11 @@ var check22 = {
7204
7533
  return results;
7205
7534
  }
7206
7535
  };
7207
- registerCheck(check22);
7536
+ registerCheck(check23);
7208
7537
 
7209
7538
  // src/checks/source/layout-markers.ts
7210
7539
  import { readFile as readFile19 } from "node:fs/promises";
7211
- var check23 = {
7540
+ var check24 = {
7212
7541
  id: "source.markdown.layout-markers",
7213
7542
  name: "Layout Markers",
7214
7543
  description: "Reports @page/@section/@chapter marker arguments Gutterpress could not understand, plus any unknown gp-* class",
@@ -7220,7 +7549,7 @@ var check23 = {
7220
7549
  return [];
7221
7550
  const results = [];
7222
7551
  const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
7223
- results.push(inspectionFailed(check23.id, `Plugin "${ref}" could not be loaded, so markers it defines were not checked: ${error2.message}`));
7552
+ results.push(inspectionFailed(check24.id, `Plugin "${ref}" could not be loaded, so markers it defines were not checked: ${error2.message}`));
7224
7553
  });
7225
7554
  const md = createMarkdownRenderer(plugins);
7226
7555
  for (const file of files) {
@@ -7229,7 +7558,7 @@ var check23 = {
7229
7558
  const env = {};
7230
7559
  md.render(content, env);
7231
7560
  for (const w of env.layoutWarnings ?? []) {
7232
- results.push(finding(check23.id, {
7561
+ results.push(finding(check24.id, {
7233
7562
  severity: "warning",
7234
7563
  message: w.message,
7235
7564
  file,
@@ -7238,17 +7567,17 @@ var check23 = {
7238
7567
  }));
7239
7568
  }
7240
7569
  } catch (error2) {
7241
- results.push(inspectionFailed(check23.id, `Could not check layout markers in ${file}: ${error2 instanceof Error ? error2.message : String(error2)}`, { file }));
7570
+ results.push(inspectionFailed(check24.id, `Could not check layout markers in ${file}: ${error2 instanceof Error ? error2.message : String(error2)}`, { file }));
7242
7571
  }
7243
7572
  }
7244
7573
  return results;
7245
7574
  }
7246
7575
  };
7247
- registerCheck(check23);
7576
+ registerCheck(check24);
7248
7577
 
7249
7578
  // src/checks/source/merge-markers.ts
7250
7579
  import { readFile as readFile20 } from "node:fs/promises";
7251
- import path7 from "node:path";
7580
+ import path8 from "node:path";
7252
7581
 
7253
7582
  // src/checks/asset/extensions.ts
7254
7583
  var RASTER_INSPECTABLE_EXTS = [
@@ -7277,7 +7606,7 @@ var CLOSE_SENTINEL = ">>>>>>> online version";
7277
7606
  function withoutOnlineTag(relPath) {
7278
7607
  return relPath.replace(/\.online(?=\.[^./]*$|$)/, "");
7279
7608
  }
7280
- var check24 = {
7609
+ var check25 = {
7281
7610
  id: "source.sync.merge-markers",
7282
7611
  name: "Combined Versions",
7283
7612
  description: "Finds passages and files still holding two versions (yours and the online copy) after a sync",
@@ -7291,7 +7620,7 @@ var check24 = {
7291
7620
  try {
7292
7621
  content = await readFile20(file, "utf8");
7293
7622
  } catch {
7294
- results.push(inspectionFailed(check24.id, `Could not read source file: ${file}`, { file }));
7623
+ results.push(inspectionFailed(check25.id, `Could not read source file: ${file}`, { file }));
7295
7624
  continue;
7296
7625
  }
7297
7626
  if (!content.includes(OPEN_SENTINEL) && !content.includes(CLOSE_SENTINEL))
@@ -7303,7 +7632,7 @@ var check24 = {
7303
7632
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
7304
7633
  if (line === OPEN_SENTINEL) {
7305
7634
  inBlock = true;
7306
- results.push(finding(check24.id, {
7635
+ results.push(finding(check25.id, {
7307
7636
  severity: "error",
7308
7637
  code: "two-versions-passage",
7309
7638
  message: "This passage has two versions (yours and the online copy) — keep what you want, then delete the marker lines.",
@@ -7312,7 +7641,7 @@ var check24 = {
7312
7641
  }));
7313
7642
  } else if (line === CLOSE_SENTINEL) {
7314
7643
  if (!inBlock) {
7315
- results.push(finding(check24.id, {
7644
+ results.push(finding(check25.id, {
7316
7645
  severity: "error",
7317
7646
  code: "leftover-version-marker",
7318
7647
  message: "This marker line is left over from combining two versions — delete it.",
@@ -7332,8 +7661,8 @@ var check24 = {
7332
7661
  ignore: [...ASSET_SCAN_IGNORE_GLOBS]
7333
7662
  });
7334
7663
  for (const sibling of siblings.sort()) {
7335
- const rel = path7.relative(ctx.inputDir, sibling).split(path7.sep).join("/");
7336
- results.push(finding(check24.id, {
7664
+ const rel = path8.relative(ctx.inputDir, sibling).split(path8.sep).join("/");
7665
+ results.push(finding(check25.id, {
7337
7666
  severity: "warning",
7338
7667
  code: "kept-both-versions",
7339
7668
  message: `Two versions of ${withoutOnlineTag(rel)} are in your project — keep the one you want, then delete the other.`,
@@ -7341,12 +7670,12 @@ var check24 = {
7341
7670
  }));
7342
7671
  }
7343
7672
  } catch (error2) {
7344
- results.push(inspectionFailed(check24.id, `Could not scan for kept-both files: ${error2 instanceof Error ? error2.message : String(error2)}`));
7673
+ results.push(inspectionFailed(check25.id, `Could not scan for kept-both files: ${error2 instanceof Error ? error2.message : String(error2)}`));
7345
7674
  }
7346
7675
  return results;
7347
7676
  }
7348
7677
  };
7349
- registerCheck(check24);
7678
+ registerCheck(check25);
7350
7679
 
7351
7680
  // src/checks/asset/image-file-size.ts
7352
7681
  import { stat as stat3 } from "node:fs/promises";
@@ -7355,24 +7684,24 @@ import { stat as stat3 } from "node:fs/promises";
7355
7684
  import { stat as stat2, readFile as readFile21 } from "node:fs/promises";
7356
7685
  var DEFAULT_DPI = 72;
7357
7686
  var cache = new Map;
7358
- async function inspectImage(path8) {
7687
+ async function inspectImage(path9) {
7359
7688
  let st;
7360
7689
  try {
7361
- st = await stat2(path8);
7690
+ st = await stat2(path9);
7362
7691
  } catch {
7363
7692
  return null;
7364
7693
  }
7365
- const hit = cache.get(path8);
7694
+ const hit = cache.get(path9);
7366
7695
  if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
7367
7696
  return hit.info;
7368
7697
  let info2 = null;
7369
7698
  try {
7370
- const buf = await readFile21(path8);
7699
+ const buf = await readFile21(path9);
7371
7700
  info2 = parseImage(buf);
7372
7701
  } catch {
7373
7702
  info2 = null;
7374
7703
  }
7375
- cache.set(path8, { mtimeMs: st.mtimeMs, size: st.size, info: info2 });
7704
+ cache.set(path9, { mtimeMs: st.mtimeMs, size: st.size, info: info2 });
7376
7705
  return info2;
7377
7706
  }
7378
7707
  function parseImage(b) {
@@ -7572,7 +7901,7 @@ async function collectImageFiles(dirs, exts, ignore = ASSET_SCAN_IGNORE_GLOBS) {
7572
7901
  }
7573
7902
 
7574
7903
  // src/checks/asset/image-file-size.ts
7575
- var check25 = {
7904
+ var check26 = {
7576
7905
  id: "asset.image.file-size",
7577
7906
  name: "Image File Size",
7578
7907
  description: "Checks that image files do not exceed the maximum size limit",
@@ -7593,14 +7922,14 @@ var check25 = {
7593
7922
  if (info2.size > maxSize) {
7594
7923
  const sizeMb = (info2.size / 1e6).toFixed(1);
7595
7924
  const maxMb = (maxSize / 1e6).toFixed(1);
7596
- results.push(finding(check25.id, {
7925
+ results.push(finding(check26.id, {
7597
7926
  severity: "warning",
7598
7927
  message: `Image file too large: ${sizeMb}MB (max ${maxMb}MB)`,
7599
7928
  file
7600
7929
  }));
7601
7930
  }
7602
7931
  } catch {
7603
- results.push(inspectionFailed(check25.id, `Could not stat image file: ${file}`, {
7932
+ results.push(inspectionFailed(check26.id, `Could not stat image file: ${file}`, {
7604
7933
  file
7605
7934
  }));
7606
7935
  }
@@ -7608,10 +7937,10 @@ var check25 = {
7608
7937
  return results;
7609
7938
  }
7610
7939
  };
7611
- registerCheck(check25);
7940
+ registerCheck(check26);
7612
7941
 
7613
7942
  // src/checks/asset/image-resolution.ts
7614
- var check26 = {
7943
+ var check27 = {
7615
7944
  id: "asset.image.resolution",
7616
7945
  name: "Image Resolution",
7617
7946
  description: "Checks source image DPI from embedded density metadata",
@@ -7633,7 +7962,7 @@ var check26 = {
7633
7962
  const { xDpi, yDpi } = info2;
7634
7963
  if (xDpi > 0 && yDpi > 0 && (xDpi < minDpi || yDpi < minDpi)) {
7635
7964
  results.push({
7636
- checkId: check26.id,
7965
+ checkId: check27.id,
7637
7966
  severity: "warning",
7638
7967
  message: `Image resolution too low: ${xDpi}x${yDpi} DPI (minimum ${minDpi} DPI)`,
7639
7968
  file
@@ -7643,7 +7972,7 @@ var check26 = {
7643
7972
  return results;
7644
7973
  }
7645
7974
  };
7646
- registerCheck(check26);
7975
+ registerCheck(check27);
7647
7976
 
7648
7977
  // src/checks/asset/image-color-space.ts
7649
7978
  var LABEL = {
@@ -7651,7 +7980,7 @@ var LABEL = {
7651
7980
  gray: "Gray",
7652
7981
  cmyk: "CMYK"
7653
7982
  };
7654
- var check27 = {
7983
+ var check28 = {
7655
7984
  id: "asset.image.color-space",
7656
7985
  name: "Image Color Space",
7657
7986
  description: "Validates image color spaces against allowed list",
@@ -7672,7 +8001,7 @@ var check27 = {
7672
8001
  const cs = info2?.colorSpace;
7673
8002
  if (cs && !allowedLower.has(cs)) {
7674
8003
  results.push({
7675
- checkId: check27.id,
8004
+ checkId: check28.id,
7676
8005
  severity: "warning",
7677
8006
  message: `Image uses ${LABEL[cs] ?? cs} color space (allowed: ${allowed.join(", ")})`,
7678
8007
  file
@@ -7682,10 +8011,10 @@ var check27 = {
7682
8011
  return results;
7683
8012
  }
7684
8013
  };
7685
- registerCheck(check27);
8014
+ registerCheck(check28);
7686
8015
 
7687
8016
  // src/checks/asset/image-alpha.ts
7688
- var check28 = {
8017
+ var check29 = {
7689
8018
  id: "asset.image.alpha-channel",
7690
8019
  name: "Image Alpha Channel",
7691
8020
  description: "Checks for alpha channels in PNG/TIFF images",
@@ -7703,7 +8032,7 @@ var check28 = {
7703
8032
  const info2 = await inspectImage(file);
7704
8033
  if (info2?.hasAlpha) {
7705
8034
  results.push({
7706
- checkId: check28.id,
8035
+ checkId: check29.id,
7707
8036
  severity: "warning",
7708
8037
  message: "Image contains alpha channel, which may cause print issues",
7709
8038
  file
@@ -7713,10 +8042,10 @@ var check28 = {
7713
8042
  return results;
7714
8043
  }
7715
8044
  };
7716
- registerCheck(check28);
8045
+ registerCheck(check29);
7717
8046
 
7718
8047
  // src/checks/asset/image-tac.ts
7719
- var check29 = {
8048
+ var check30 = {
7720
8049
  id: "asset.image.tac-raster",
7721
8050
  name: "Image TAC (Raster)",
7722
8051
  description: "Rasterizes and checks TAC per image using Ghostscript",
@@ -7735,7 +8064,7 @@ var check29 = {
7735
8064
  const ghostscript = await resolveGhostscript();
7736
8065
  if (!ghostscript) {
7737
8066
  return [
7738
- inspectionFailed(check29.id, "Could not inspect image ink coverage: Ghostscript executable not found")
8067
+ inspectionFailed(check30.id, "Could not inspect image ink coverage: Ghostscript executable not found")
7739
8068
  ];
7740
8069
  }
7741
8070
  for (const file of files) {
@@ -7753,7 +8082,7 @@ var check29 = {
7753
8082
  if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
7754
8083
  const tac = (nums[0] + nums[1] + nums[2] + nums[3]) * 100;
7755
8084
  if (tac > maxTac) {
7756
- results.push(finding(check29.id, {
8085
+ results.push(finding(check30.id, {
7757
8086
  severity: "warning",
7758
8087
  message: `Image TAC exceeds limit: ${tac.toFixed(1)}% (max ${maxTac}%)`,
7759
8088
  file,
@@ -7765,16 +8094,16 @@ var check29 = {
7765
8094
  }
7766
8095
  }
7767
8096
  } catch {
7768
- results.push(inspectionFailed(check29.id, `Could not inspect image ink coverage: ${file}`, { file }));
8097
+ results.push(inspectionFailed(check30.id, `Could not inspect image ink coverage: ${file}`, { file }));
7769
8098
  }
7770
8099
  }
7771
8100
  return results;
7772
8101
  }
7773
8102
  };
7774
- registerCheck(check29);
8103
+ registerCheck(check30);
7775
8104
 
7776
8105
  // src/checks/asset/approved-fonts.ts
7777
- var check30 = {
8106
+ var check31 = {
7778
8107
  id: "asset.font.approved-files",
7779
8108
  name: "Approved Font Files",
7780
8109
  description: "Checks font files against the approved file patterns",
@@ -7813,7 +8142,7 @@ var check30 = {
7813
8142
  for (const font of allFonts) {
7814
8143
  if (!approvedFonts.has(font)) {
7815
8144
  results.push({
7816
- checkId: check30.id,
8145
+ checkId: check31.id,
7817
8146
  severity: "warning",
7818
8147
  message: "Font file not in approved list",
7819
8148
  file: font
@@ -7823,7 +8152,7 @@ var check30 = {
7823
8152
  return results;
7824
8153
  }
7825
8154
  };
7826
- registerCheck(check30);
8155
+ registerCheck(check31);
7827
8156
 
7828
8157
  // src/checks/asset/font-license.ts
7829
8158
  import { existsSync as existsSync10 } from "node:fs";
@@ -7838,7 +8167,7 @@ var LICENSE_NAMES = [
7838
8167
  "OFL-1.1.txt",
7839
8168
  "COPYING"
7840
8169
  ];
7841
- var check31 = {
8170
+ var check32 = {
7842
8171
  id: "asset.font.license",
7843
8172
  name: "Font License",
7844
8173
  description: "Checks for license files in font directories",
@@ -7865,7 +8194,7 @@ var check31 = {
7865
8194
  const hasLicense = LICENSE_NAMES.some((name) => existsSync10(resolve8(fontDir, name)));
7866
8195
  if (!hasLicense) {
7867
8196
  results.push({
7868
- checkId: check31.id,
8197
+ checkId: check32.id,
7869
8198
  severity: "warning",
7870
8199
  message: `No font license file found in directory`,
7871
8200
  file: fontDir
@@ -7875,10 +8204,10 @@ var check31 = {
7875
8204
  return results;
7876
8205
  }
7877
8206
  };
7878
- registerCheck(check31);
8207
+ registerCheck(check32);
7879
8208
 
7880
8209
  // src/checks/heuristic/text-density.ts
7881
- var check32 = {
8210
+ var check33 = {
7882
8211
  id: "heuristic.whitespace.text-density",
7883
8212
  name: "Text Density",
7884
8213
  description: "Checks characters-per-page ratio",
@@ -7907,7 +8236,7 @@ var check32 = {
7907
8236
  });
7908
8237
  if (lowPages.length > 0) {
7909
8238
  results.push({
7910
- checkId: check32.id,
8239
+ checkId: check33.id,
7911
8240
  severity: "info",
7912
8241
  message: `Low text density on pages: ${lowPages.join(", ")} (below ${range.min} chars)`,
7913
8242
  file: ctx.pdfPath
@@ -7915,7 +8244,7 @@ var check32 = {
7915
8244
  }
7916
8245
  if (highPages.length > 0) {
7917
8246
  results.push({
7918
- checkId: check32.id,
8247
+ checkId: check33.id,
7919
8248
  severity: "info",
7920
8249
  message: `High text density on pages: ${highPages.join(", ")} (above ${range.max} chars)`,
7921
8250
  file: ctx.pdfPath
@@ -7924,11 +8253,11 @@ var check32 = {
7924
8253
  return results;
7925
8254
  }
7926
8255
  };
7927
- registerCheck(check32);
8256
+ registerCheck(check33);
7928
8257
 
7929
8258
  // src/checks/heuristic/section-density.ts
7930
8259
  import { readFile as readFile22 } from "node:fs/promises";
7931
- var check33 = {
8260
+ var check34 = {
7932
8261
  id: "heuristic.chunking.section-density",
7933
8262
  name: "Section Density",
7934
8263
  description: "Checks heading/paragraph/callout density from source Markdown",
@@ -7954,7 +8283,7 @@ var check33 = {
7954
8283
  const line = lines[i];
7955
8284
  if (/^#{1,6}\s/.test(line)) {
7956
8285
  if (paragraphCount > maxParas) {
7957
- results.push(finding(check33.id, {
8286
+ results.push(finding(check34.id, {
7958
8287
  severity: "info",
7959
8288
  message: `Section has ${paragraphCount} paragraphs (max recommended: ${maxParas})`,
7960
8289
  file,
@@ -7974,7 +8303,7 @@ var check33 = {
7974
8303
  }
7975
8304
  }
7976
8305
  if (paragraphCount > maxParas) {
7977
- results.push(finding(check33.id, {
8306
+ results.push(finding(check34.id, {
7978
8307
  severity: "info",
7979
8308
  message: `Section has ${paragraphCount} paragraphs (max recommended: ${maxParas})`,
7980
8309
  file,
@@ -7982,7 +8311,7 @@ var check33 = {
7982
8311
  }));
7983
8312
  }
7984
8313
  } catch {
7985
- results.push(inspectionFailed(check33.id, `Could not read source file: ${file}`, {
8314
+ results.push(inspectionFailed(check34.id, `Could not read source file: ${file}`, {
7986
8315
  file
7987
8316
  }));
7988
8317
  }
@@ -7990,10 +8319,10 @@ var check33 = {
7990
8319
  return results;
7991
8320
  }
7992
8321
  };
7993
- registerCheck(check33);
8322
+ registerCheck(check34);
7994
8323
 
7995
8324
  // src/checks/heuristic/layer-count.ts
7996
- var check34 = {
8325
+ var check35 = {
7997
8326
  id: "heuristic.decoration.layer-count",
7998
8327
  name: "Layer Count",
7999
8328
  description: "Counts image objects per page",
@@ -8018,7 +8347,7 @@ var check34 = {
8018
8347
  heavyPages.sort((a, b) => a - b);
8019
8348
  return [
8020
8349
  {
8021
- checkId: check34.id,
8350
+ checkId: check35.id,
8022
8351
  severity: "info",
8023
8352
  message: `Pages with many image layers (>${maxLayers}): ${heavyPages.join(", ")}`,
8024
8353
  file: ctx.pdfPath
@@ -8028,10 +8357,10 @@ var check34 = {
8028
8357
  return [];
8029
8358
  }
8030
8359
  };
8031
- registerCheck(check34);
8360
+ registerCheck(check35);
8032
8361
 
8033
8362
  // src/checks/heuristic/placement-variance.ts
8034
- var check35 = {
8363
+ var check36 = {
8035
8364
  id: "heuristic.layout.placement-variance",
8036
8365
  name: "Placement Variance",
8037
8366
  description: "Analyzes text baseline coordinates for layout consistency",
@@ -8049,7 +8378,7 @@ var check35 = {
8049
8378
  const uniqueX = new Set(positions.map((p) => Math.round(p.x)));
8050
8379
  return [
8051
8380
  {
8052
- checkId: check35.id,
8381
+ checkId: check36.id,
8053
8382
  severity: "info",
8054
8383
  message: `Layout analysis: ${uniqueX.size} unique horizontal text positions across ${positions.length} text blocks.`,
8055
8384
  file: ctx.pdfPath
@@ -8057,7 +8386,7 @@ var check35 = {
8057
8386
  ];
8058
8387
  }
8059
8388
  };
8060
- registerCheck(check35);
8389
+ registerCheck(check36);
8061
8390
 
8062
8391
  // src/checks/runner.ts
8063
8392
  function getCheckSeverityOverride(checkId, config) {
@@ -8087,25 +8416,25 @@ async function runChecks(ctx, opts = {}) {
8087
8416
  }
8088
8417
  const release = retainPdfCache();
8089
8418
  try {
8090
- for (const check36 of checks2) {
8419
+ for (const check37 of checks2) {
8091
8420
  try {
8092
- const results = await check36.run(ctx);
8093
- const severityOverride = getCheckSeverityOverride(check36.id, ctx.config);
8421
+ const results = await check37.run(ctx);
8422
+ const severityOverride = getCheckSeverityOverride(check37.id, ctx.config);
8094
8423
  if (severityOverride) {
8095
8424
  for (const r of results) {
8096
8425
  r.severity = severityOverride;
8097
8426
  }
8098
8427
  }
8099
8428
  if (results.length === 0) {
8100
- passed.push(check36.id);
8429
+ passed.push(check37.id);
8101
8430
  } else {
8102
8431
  allResults.push(...results);
8103
8432
  }
8104
8433
  } catch (err) {
8105
8434
  allResults.push({
8106
- checkId: check36.id,
8435
+ checkId: check37.id,
8107
8436
  severity: "error",
8108
- message: `Check "${check36.id}" threw: ${err instanceof Error ? err.message : String(err)}`
8437
+ message: `Check "${check37.id}" threw: ${err instanceof Error ? err.message : String(err)}`
8109
8438
  });
8110
8439
  }
8111
8440
  }
@@ -8145,15 +8474,15 @@ function emptyReport() {
8145
8474
  async function checkToolAvailability(config, opts = {}) {
8146
8475
  const { checks: checks2 } = selectChecks(opts, config);
8147
8476
  const toolToChecks = new Map;
8148
- for (const check36 of checks2) {
8149
- if (!check36.requiredTools?.length)
8477
+ for (const check37 of checks2) {
8478
+ if (!check37.requiredTools?.length)
8150
8479
  continue;
8151
- for (const tool of check36.requiredTools) {
8480
+ for (const tool of check37.requiredTools) {
8152
8481
  const existing = toolToChecks.get(tool);
8153
8482
  if (existing) {
8154
- existing.push(check36.id);
8483
+ existing.push(check37.id);
8155
8484
  } else {
8156
- toolToChecks.set(tool, [check36.id]);
8485
+ toolToChecks.set(tool, [check37.id]);
8157
8486
  }
8158
8487
  }
8159
8488
  }
@@ -8169,11 +8498,11 @@ async function checkToolAvailability(config, opts = {}) {
8169
8498
  const missing = results.filter((r) => !r.found).map((r) => r.tool);
8170
8499
  const missingSet = new Set(missing);
8171
8500
  const skippedChecks = [];
8172
- for (const check36 of checks2) {
8173
- if (!check36.requiredTools?.length)
8501
+ for (const check37 of checks2) {
8502
+ if (!check37.requiredTools?.length)
8174
8503
  continue;
8175
- if (check36.requiredTools.some((t) => missingSet.has(t))) {
8176
- skippedChecks.push(check36.id);
8504
+ if (check37.requiredTools.some((t) => missingSet.has(t))) {
8505
+ skippedChecks.push(check37.id);
8177
8506
  }
8178
8507
  }
8179
8508
  return { available, missing, skippedChecks, toolToChecks };
@@ -8580,245 +8909,6 @@ function computeGates(format, opts, config) {
8580
8909
  return { lint: lint2, preValidate, postValidate };
8581
8910
  }
8582
8911
 
8583
- // src/lib/build-staging.ts
8584
- import path8 from "node:path";
8585
- import os from "node:os";
8586
- import fsp from "node:fs/promises";
8587
-
8588
- // src/lib/missing-asset-placeholder.ts
8589
- import { createHash as createHash5 } from "node:crypto";
8590
- import { deflateSync } from "node:zlib";
8591
- function placeholderOutputPath(missingOutputPath) {
8592
- const hash = createHash5("sha256").update(missingOutputPath).digest("hex").slice(0, 16);
8593
- return `assets/gutterpress-missing/${hash}.png`;
8594
- }
8595
- function decodeHtmlAttribute(value) {
8596
- return value.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
8597
- }
8598
- function rewriteMissingImageReferences(html, replacements) {
8599
- if (replacements.size === 0)
8600
- return html;
8601
- const replacementFor = (raw) => replacements.get(raw) ?? replacements.get(decodeHtmlAttribute(raw));
8602
- const cssUrl = /url\(\s*(?:"([^"]*)"|'([^']*)'|&quot;((?:(?!&quot;).)*)&quot;|([^'"()\s]*))\s*\)/giy;
8603
- const rewriteCssUrls = (css) => {
8604
- let out2 = "";
8605
- let copiedThrough = 0;
8606
- let index = 0;
8607
- while (index < css.length) {
8608
- if (css.startsWith("/*", index)) {
8609
- const end = css.indexOf("*/", index + 2);
8610
- index = end < 0 ? css.length : end + 2;
8611
- continue;
8612
- }
8613
- const char = css[index];
8614
- if (char === '"' || char === "'") {
8615
- const quote = char;
8616
- index++;
8617
- while (index < css.length) {
8618
- if (css[index] === "\\") {
8619
- index += 2;
8620
- continue;
8621
- }
8622
- const current = css[index++];
8623
- if (current === quote)
8624
- break;
8625
- }
8626
- continue;
8627
- }
8628
- const previous = index > 0 ? css[index - 1] : "";
8629
- if (!/[a-z0-9_-]/i.test(previous) && css.slice(index, index + 3).toLowerCase() === "url") {
8630
- cssUrl.lastIndex = index;
8631
- const match = cssUrl.exec(css);
8632
- if (match) {
8633
- const raw = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
8634
- const replacement = replacementFor(raw);
8635
- if (replacement) {
8636
- out2 += css.slice(copiedThrough, index);
8637
- out2 += match[3] !== undefined ? `url(&quot;${replacement}&quot;)` : `url("${replacement}")`;
8638
- index = cssUrl.lastIndex;
8639
- copiedThrough = index;
8640
- continue;
8641
- }
8642
- }
8643
- }
8644
- index++;
8645
- }
8646
- return copiedThrough === 0 ? css : out2 + css.slice(copiedThrough);
8647
- };
8648
- const rewriteTag = (tag) => {
8649
- let out2 = tag;
8650
- if (/^<img\b/i.test(tag)) {
8651
- out2 = out2.replace(/(\s+src\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
8652
- const raw = double ?? single ?? bare ?? "";
8653
- const replacement = replacementFor(raw);
8654
- if (!replacement)
8655
- return whole;
8656
- if (double !== undefined)
8657
- return `${prefix}"${replacement}"`;
8658
- if (single !== undefined)
8659
- return `${prefix}'${replacement}'`;
8660
- return `${prefix}${replacement}`;
8661
- });
8662
- }
8663
- if (/^<(?:img|source)\b/i.test(tag)) {
8664
- out2 = out2.replace(/(\s+srcset\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
8665
- const value = double ?? single ?? bare ?? "";
8666
- let rewritten = "";
8667
- let copiedThrough = 0;
8668
- let changed = false;
8669
- for (const candidate of parseSrcsetUrlCandidates(value)) {
8670
- const replacement = replacementFor(candidate.url);
8671
- if (!replacement)
8672
- continue;
8673
- rewritten += value.slice(copiedThrough, candidate.start) + replacement;
8674
- copiedThrough = candidate.end;
8675
- changed = true;
8676
- }
8677
- if (!changed)
8678
- return whole;
8679
- rewritten += value.slice(copiedThrough);
8680
- if (double !== undefined)
8681
- return `${prefix}"${rewritten}"`;
8682
- if (single !== undefined)
8683
- return `${prefix}'${rewritten}'`;
8684
- return `${prefix}${rewritten}`;
8685
- });
8686
- }
8687
- out2 = out2.replace(/(\s+style\s*=\s*)(["'])(.*?)\2/gi, (whole, prefix, quote, value) => {
8688
- const rewritten = rewriteCssUrls(value);
8689
- return rewritten === value ? whole : `${prefix}${quote}${rewritten}${quote}`;
8690
- });
8691
- return out2;
8692
- };
8693
- const rewriteActiveHtml = (active) => active.replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g, (tag) => rewriteTag(tag));
8694
- const protectedRegion = /<!--[\s\S]*?-->|<(script|style|pre|code|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
8695
- let out = "";
8696
- let last = 0;
8697
- for (const match of html.matchAll(protectedRegion)) {
8698
- out += rewriteActiveHtml(html.slice(last, match.index));
8699
- if (match[1]?.toLowerCase() === "style") {
8700
- const style = /^(<style\b[^>]*>)([\s\S]*)(<\/style\s*>)$/i.exec(match[0]);
8701
- out += style ? `${rewriteTag(style[1])}${rewriteCssUrls(style[2])}${style[3]}` : match[0];
8702
- } else {
8703
- out += match[0];
8704
- }
8705
- last = (match.index ?? 0) + match[0].length;
8706
- }
8707
- out += rewriteActiveHtml(html.slice(last));
8708
- return out;
8709
- }
8710
- function crc32(buf) {
8711
- let c = ~0;
8712
- for (let i = 0;i < buf.length; i++) {
8713
- c ^= buf[i];
8714
- for (let k = 0;k < 8; k++)
8715
- c = c >>> 1 ^ 3988292384 & -(c & 1);
8716
- }
8717
- return ~c >>> 0;
8718
- }
8719
- function chunk(type, data) {
8720
- const out = new Uint8Array(data.length + 12);
8721
- const view = new DataView(out.buffer);
8722
- view.setUint32(0, data.length);
8723
- for (let i = 0;i < 4; i++)
8724
- out[4 + i] = type.charCodeAt(i);
8725
- out.set(data, 8);
8726
- view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
8727
- return out;
8728
- }
8729
- function placeholderPng(width = 640, height = 480, cell = 32) {
8730
- const A = [217, 70, 239];
8731
- const B = [26, 26, 26];
8732
- const raw = new Uint8Array(height * (1 + width * 3));
8733
- let p = 0;
8734
- for (let y = 0;y < height; y++) {
8735
- raw[p++] = 0;
8736
- for (let x = 0;x < width; x++) {
8737
- const c = ((x / cell | 0) + (y / cell | 0)) % 2 === 0 ? A : B;
8738
- raw[p++] = c[0];
8739
- raw[p++] = c[1];
8740
- raw[p++] = c[2];
8741
- }
8742
- }
8743
- const ihdr = new Uint8Array(13);
8744
- const hv = new DataView(ihdr.buffer);
8745
- hv.setUint32(0, width);
8746
- hv.setUint32(4, height);
8747
- ihdr[8] = 8;
8748
- ihdr[9] = 2;
8749
- const parts = [
8750
- new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
8751
- chunk("IHDR", ihdr),
8752
- chunk("IDAT", new Uint8Array(deflateSync(raw))),
8753
- chunk("IEND", new Uint8Array(0))
8754
- ];
8755
- const total = parts.reduce((n, x) => n + x.length, 0);
8756
- const png = new Uint8Array(total);
8757
- let o = 0;
8758
- for (const part of parts) {
8759
- png.set(part, o);
8760
- o += part.length;
8761
- }
8762
- return png;
8763
- }
8764
-
8765
- // src/lib/build-staging.ts
8766
- async function shipViewerHtml(htmlFile, outDir) {
8767
- await fsp.mkdir(path8.join(outDir, "engine"), { recursive: true });
8768
- await fsp.copyFile(await getAssetPath("engine/gutterpress-viewer.js"), path8.join(outDir, "engine/gutterpress-viewer.js"));
8769
- const tag = ` <script src="engine/gutterpress-viewer.js"></script>
8770
- `;
8771
- const html = await fsp.readFile(htmlFile, "utf-8");
8772
- await fsp.writeFile(htmlFile, /<\/head>/i.test(html) ? html.replace(/<\/head>/i, tag + "</head>") : tag + html, "utf-8");
8773
- }
8774
- async function stageBookAssets(options) {
8775
- const { renderDir, outDir, htmlFile, imageRefs, cssAssets, onPlan } = options;
8776
- const { copies: imageCopies, errors, destinations } = await planImageCopies(renderDir, imageRefs);
8777
- const copies = [...cssAssets, ...imageCopies];
8778
- onPlan?.({ unresolved: errors, copyCount: copies.length });
8779
- const missingPlaceholders = copies.length ? await copyReferencedAssets(copies, outDir) : new Map;
8780
- let staged = await fsp.readFile(htmlFile, "utf8");
8781
- if (missingPlaceholders.size > 0) {
8782
- const rewrites = new Map(missingPlaceholders);
8783
- for (const [ref, dest] of destinations) {
8784
- const placeholder = missingPlaceholders.get(dest);
8785
- if (placeholder)
8786
- rewrites.set(ref, placeholder);
8787
- }
8788
- staged = rewriteMissingImageReferences(staged, rewrites);
8789
- await fsp.writeFile(htmlFile, staged, "utf8");
8790
- }
8791
- if (staged.includes("--gp-shape:")) {
8792
- await fsp.writeFile(htmlFile, await inlineShapeUrls(staged, outDir), "utf8");
8793
- }
8794
- return { missing: [...missingPlaceholders.keys()].sort() };
8795
- }
8796
- async function copyReferencedAssets(copies, outDir) {
8797
- const dirs = new Set(copies.map((c) => path8.dirname(path8.resolve(outDir, c.to))));
8798
- await Promise.all([...dirs].map((d) => fsp.mkdir(d, { recursive: true })));
8799
- const missing = new Map;
8800
- await Promise.all(copies.map(async (c) => {
8801
- const dest = path8.resolve(outDir, c.to);
8802
- try {
8803
- await fsp.copyFile(c.from, dest);
8804
- } catch (err) {
8805
- if (err?.code === "ENOENT") {
8806
- const placeholder = placeholderOutputPath(c.to);
8807
- const placeholderDest = path8.resolve(outDir, placeholder);
8808
- await fsp.mkdir(path8.dirname(placeholderDest), { recursive: true });
8809
- await fsp.writeFile(placeholderDest, placeholderPng());
8810
- missing.set(c.to, placeholder);
8811
- return;
8812
- }
8813
- throw new BuildError(`Could not copy asset ${c.from} → ${c.to}: ` + (err instanceof Error ? err.message : String(err)), 1);
8814
- }
8815
- }));
8816
- return missing;
8817
- }
8818
- async function createStageRoot() {
8819
- return fsp.mkdtemp(path8.join(os.tmpdir(), "gutterpress-stage-"));
8820
- }
8821
-
8822
8912
  // src/lib/build-runner.ts
8823
8913
  function splitOutPath(outArg, format) {
8824
8914
  if (typeof outArg !== "string" || outArg.length === 0) {
@@ -8914,7 +9004,7 @@ async function runQualityGates(ctx) {
8914
9004
  }
8915
9005
  }
8916
9006
  async function renderBook(ctx) {
8917
- const { config, gates, renderDir, workDir, opts } = ctx;
9007
+ const { config, gates, renderDir, workDir, opts, format } = ctx;
8918
9008
  if (config.source.files && config.source.files.length > 0) {
8919
9009
  log.info(`Using specified files (${config.source.files.length} total)`);
8920
9010
  } else {
@@ -8952,6 +9042,7 @@ async function renderBook(ctx) {
8952
9042
  htmlFile,
8953
9043
  imageRefs,
8954
9044
  cssAssets,
9045
+ dropRelativeLinks: format !== "html",
8955
9046
  onPlan: ({ unresolved, copyCount }) => {
8956
9047
  if (unresolved.length > 0) {
8957
9048
  throw new BuildError(`Cannot resolve ${unresolved.length} image reference(s):