@json-to-office/jto-ops 4.1.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1746,7 +1746,7 @@ var LINE_OR_WORD_PATTERN = /<line\s+xMin="(-?[\d.]+)"\s+yMin="(-?[\d.]+)"\s+xMax
1746
1746
  function parsePdfTextBbox(bboxXml) {
1747
1747
  const pages = [];
1748
1748
  for (const pageMatch of bboxXml.matchAll(PAGE_PATTERN)) {
1749
- const words = [];
1749
+ const words2 = [];
1750
1750
  const lines = [];
1751
1751
  let open;
1752
1752
  for (const token of pageMatch[3].matchAll(LINE_OR_WORD_PATTERN)) {
@@ -1762,8 +1762,8 @@ function parsePdfTextBbox(bboxXml) {
1762
1762
  words: []
1763
1763
  };
1764
1764
  } else {
1765
- open?.words.push(words.length);
1766
- words.push({
1765
+ open?.words.push(words2.length);
1766
+ words2.push({
1767
1767
  xMin: Number(token[5]),
1768
1768
  yMin: Number(token[6]),
1769
1769
  xMax: Number(token[7]),
@@ -1775,7 +1775,7 @@ function parsePdfTextBbox(bboxXml) {
1775
1775
  pages.push({
1776
1776
  widthPt: Number(pageMatch[1]),
1777
1777
  heightPt: Number(pageMatch[2]),
1778
- words,
1778
+ words: words2,
1779
1779
  lines
1780
1780
  });
1781
1781
  }
@@ -1947,9 +1947,93 @@ async function extractPdfFonts(pdfPath, options = {}) {
1947
1947
  }
1948
1948
 
1949
1949
  // src/rendered-analysis.ts
1950
+ import {
1951
+ QualityEngine
1952
+ } from "@json-to-office/quality";
1953
+
1954
+ // src/rendered-rules.ts
1950
1955
  import {
1951
1956
  QUALITY_CODES
1952
1957
  } from "@json-to-office/quality";
1958
+ function renderedRule(spec) {
1959
+ return {
1960
+ id: spec.id,
1961
+ code: spec.code,
1962
+ description: spec.description,
1963
+ category: spec.category,
1964
+ defaultSeverity: spec.defaultSeverity,
1965
+ defaultCertainty: "rendered",
1966
+ ...spec.formats && { formats: spec.formats },
1967
+ evaluate({ facts }) {
1968
+ const fact = facts.find(isRenderedGeometryFact);
1969
+ if (!fact) return [];
1970
+ return renderedDraftFor(fact).byRule.get(spec.id) ?? [];
1971
+ }
1972
+ };
1973
+ }
1974
+ var RENDERED_QUALITY_RULES = {
1975
+ id: "rendered",
1976
+ rules: [
1977
+ renderedRule({
1978
+ id: "rendered/clip",
1979
+ code: QUALITY_CODES.RENDERED_CLIP,
1980
+ description: "Words rendered past the page edge by more than 2 pt, or an authored string of which only a leading part rendered (`kind: truncated`, with the share found).",
1981
+ category: "integrity",
1982
+ defaultSeverity: "warning"
1983
+ }),
1984
+ renderedRule({
1985
+ id: "rendered/spill",
1986
+ code: QUALITY_CODES.RENDERED_SPILL,
1987
+ description: "A framed paragraph or slide text box drawn wider (DOCX) or larger (PPTX) than the box it declared.",
1988
+ category: "integrity",
1989
+ defaultSeverity: "warning"
1990
+ }),
1991
+ renderedRule({
1992
+ id: "rendered/overlap",
1993
+ code: QUALITY_CODES.RENDERED_OVERLAP,
1994
+ description: "Two words from different lines whose boxes intersect by more than 30% of the smaller.",
1995
+ category: "integrity",
1996
+ defaultSeverity: "warning"
1997
+ }),
1998
+ renderedRule({
1999
+ id: "rendered/text-missing",
2000
+ code: QUALITY_CODES.RENDERED_TEXT_MISSING,
2001
+ description: "An authored string that appears nowhere in the PDF: fully clipped, hidden, or dropped by the renderer.",
2002
+ category: "integrity",
2003
+ defaultSeverity: "warning"
2004
+ }),
2005
+ renderedRule({
2006
+ id: "rendered/font-substituted",
2007
+ code: QUALITY_CODES.RENDERED_FONT_SUBSTITUTED,
2008
+ description: "A requested family the PDF embeds no face of; a warning when the document declared a source for it, information when the host simply lacks it.",
2009
+ category: "brand",
2010
+ defaultSeverity: "info"
2011
+ }),
2012
+ renderedRule({
2013
+ id: "rendered/empty-page",
2014
+ code: QUALITY_CODES.RENDERED_EMPTY_PAGE,
2015
+ description: "A page with no text at all: a full-page figure is fine, a blank page from a stray break is not.",
2016
+ category: "integrity",
2017
+ defaultSeverity: "info"
2018
+ }),
2019
+ renderedRule({
2020
+ id: "rendered/heading-stranded",
2021
+ code: QUALITY_CODES.RENDERED_HEADING_STRANDED,
2022
+ description: "A heading that is the last body line on its page.",
2023
+ category: "composition",
2024
+ defaultSeverity: "warning",
2025
+ formats: ["docx"]
2026
+ }),
2027
+ renderedRule({
2028
+ id: "rendered/paragraph-split",
2029
+ code: QUALITY_CODES.RENDERED_PARAGRAPH_SPLIT,
2030
+ description: "A paragraph leaving one line alone on either side of a page break (`kind: orphan` or `widow`).",
2031
+ category: "composition",
2032
+ defaultSeverity: "info",
2033
+ formats: ["docx"]
2034
+ })
2035
+ ]
2036
+ };
1953
2037
 
1954
2038
  // src/rendered-text-match.ts
1955
2039
  function normalizeForMatch(value) {
@@ -1968,17 +2052,108 @@ function indexDocument(pages, exclude = /* @__PURE__ */ new Set()) {
1968
2052
  const positions = [];
1969
2053
  let cursor = 0;
1970
2054
  pages.forEach((page, pageIndex) => {
1971
- page.words.forEach((word, index) => {
2055
+ for (const index of readingOrder(page.words)) {
2056
+ const word = page.words[index];
1972
2057
  const key = `${pageIndex}:${index}`;
1973
2058
  const fragment = exclude.has(key) ? "" : normalizeForMatch(word.text);
1974
2059
  refs.push({ pageIndex, word: index });
1975
2060
  fragments.push(fragment);
1976
2061
  positions.push(cursor);
1977
2062
  cursor += fragment.length;
1978
- });
2063
+ }
1979
2064
  });
1980
2065
  return { pages, refs, fragments, positions, stream: fragments.join("") };
1981
2066
  }
2067
+ function readingOrder(words2) {
2068
+ const rotated = new Set(
2069
+ words2.map((_, i) => i).filter((i) => {
2070
+ const w = words2[i];
2071
+ return w.text.length > 2 && w.yMax - w.yMin > 1.5 * (w.xMax - w.xMin);
2072
+ })
2073
+ );
2074
+ const upright = uprightOrder(
2075
+ words2,
2076
+ words2.map((_, i) => i).filter((i) => !rotated.has(i))
2077
+ );
2078
+ const order = [];
2079
+ let next = 0;
2080
+ for (let i = 0; i < words2.length; i++) {
2081
+ if (rotated.has(i)) order.push(i);
2082
+ else order.push(upright[next++]);
2083
+ }
2084
+ return order;
2085
+ }
2086
+ function uprightOrder(words2, indices) {
2087
+ const rows = [];
2088
+ const byY = [...indices].sort(
2089
+ (a, b) => words2[a].yMin - words2[b].yMin || words2[a].xMin - words2[b].xMin
2090
+ );
2091
+ for (const i of byY) {
2092
+ const row = rows[rows.length - 1];
2093
+ if (row && sameRow(words2[row[0]], words2[i])) row.push(i);
2094
+ else rows.push([i]);
2095
+ }
2096
+ const fragmentsOf = (row) => {
2097
+ const sorted = [...row].sort((a, b) => words2[a].xMin - words2[b].xMin);
2098
+ const out = [];
2099
+ for (const i of sorted) {
2100
+ const last = out[out.length - 1];
2101
+ const w = words2[i];
2102
+ const height = w.yMax - w.yMin;
2103
+ if (last && w.xMin - last.xMax <= height / 2) {
2104
+ last.words.push(i);
2105
+ last.xMax = Math.max(last.xMax, w.xMax);
2106
+ } else out.push({ xMin: w.xMin, xMax: w.xMax, words: [i] });
2107
+ }
2108
+ return out;
2109
+ };
2110
+ const fragments = rows.map(fragmentsOf);
2111
+ const boxes = rows.map((row) => ({
2112
+ yMin: Math.min(...row.map((i) => words2[i].yMin)),
2113
+ yMax: Math.max(...row.map((i) => words2[i].yMax))
2114
+ }));
2115
+ const order = [];
2116
+ let cluster = null;
2117
+ const flush = () => {
2118
+ if (!cluster) return;
2119
+ if (!cluster.multi) {
2120
+ for (const r of cluster.rows)
2121
+ for (const f of fragments[r]) order.push(...f.words);
2122
+ } else {
2123
+ cluster.columns.sort((a, b) => a.xMin - b.xMin);
2124
+ for (const column of cluster.columns) {
2125
+ column.fragments.sort(
2126
+ (a, b) => words2[a.words[0]].yMin - words2[b.words[0]].yMin || a.xMin - b.xMin
2127
+ );
2128
+ for (const f of column.fragments) order.push(...f.words);
2129
+ }
2130
+ }
2131
+ cluster = null;
2132
+ };
2133
+ const overlapping = (columns, f) => columns.filter(
2134
+ (c) => Math.min(c.xMax, f.xMax) - Math.max(c.xMin, f.xMin) > 0
2135
+ );
2136
+ for (let r = 0; r < rows.length; r++) {
2137
+ const lineHeight = boxes[r].yMax - boxes[r].yMin;
2138
+ const adjacent = cluster !== null && boxes[r].yMin - boxes[r - 1].yMax <= lineHeight * 0.9;
2139
+ const wide = cluster !== null && fragments[r].some((f) => overlapping(cluster.columns, f).length > 1);
2140
+ if (!adjacent || wide) flush();
2141
+ if (!cluster) cluster = { rows: [], columns: [], multi: false };
2142
+ cluster.rows.push(r);
2143
+ if (fragments[r].length > 1) cluster.multi = true;
2144
+ for (const f of fragments[r]) {
2145
+ const hit = overlapping(cluster.columns, f)[0];
2146
+ if (hit) {
2147
+ hit.xMin = Math.min(hit.xMin, f.xMin);
2148
+ hit.xMax = Math.max(hit.xMax, f.xMax);
2149
+ hit.fragments.push(f);
2150
+ } else
2151
+ cluster.columns.push({ xMin: f.xMin, xMax: f.xMax, fragments: [f] });
2152
+ }
2153
+ }
2154
+ flush();
2155
+ return order;
2156
+ }
1982
2157
  function fragmentAt(index, offset) {
1983
2158
  let low = 0;
1984
2159
  let high = index.positions.length - 1;
@@ -2142,7 +2317,7 @@ function assignInventory(pages, inventory) {
2142
2317
  });
2143
2318
  });
2144
2319
  const body = indexDocument(pages, chromeWords);
2145
- const claimed = /* @__PURE__ */ new Set();
2320
+ const claimed = /* @__PURE__ */ new Map();
2146
2321
  let cursor = 0;
2147
2322
  for (const entry of inventory) {
2148
2323
  if (entry.repeats) continue;
@@ -2154,7 +2329,7 @@ function assignInventory(pages, inventory) {
2154
2329
  }
2155
2330
  let all = findOccurrences(body, segments);
2156
2331
  let partial;
2157
- if (all.length === 0 && segments.length === 1) {
2332
+ if (all.length === 0 && segments.length === 1 && !entry.optional) {
2158
2333
  const prefix = longestRenderedPrefix(body, needle);
2159
2334
  if (prefix) {
2160
2335
  all = [occurrence(body, prefix.at, prefix.at + prefix.length)];
@@ -2162,10 +2337,29 @@ function assignInventory(pages, inventory) {
2162
2337
  }
2163
2338
  }
2164
2339
  if (all.length === 0) {
2165
- results.set(entry, { entry, needle, status: "missing", occurrences: [] });
2340
+ results.set(entry, {
2341
+ entry,
2342
+ needle,
2343
+ status: entry.optional ? "skipped" : "missing",
2344
+ occurrences: []
2345
+ });
2166
2346
  continue;
2167
2347
  }
2168
- const free = all.filter((o) => !claimed.has(o.at));
2348
+ let free = all.filter((o) => !claimed.has(o.at));
2349
+ if (free.length === 0 && !entry.optional) {
2350
+ const held = all.find((o) => claimed.get(o.at)?.optional);
2351
+ if (held) {
2352
+ const holder = claimed.get(held.at);
2353
+ claimed.delete(held.at);
2354
+ results.set(holder, {
2355
+ entry: holder,
2356
+ needle: results.get(holder)?.needle ?? "",
2357
+ status: "skipped",
2358
+ occurrences: []
2359
+ });
2360
+ free = [held];
2361
+ }
2362
+ }
2169
2363
  if (free.length === 0) {
2170
2364
  results.set(entry, {
2171
2365
  entry,
@@ -2176,7 +2370,7 @@ function assignInventory(pages, inventory) {
2176
2370
  continue;
2177
2371
  }
2178
2372
  const chosen = free.find((o) => o.at >= cursor) ?? free[0];
2179
- claimed.add(chosen.at);
2373
+ claimed.set(chosen.at, entry);
2180
2374
  cursor = chosen.at;
2181
2375
  results.set(entry, {
2182
2376
  entry,
@@ -2193,15 +2387,23 @@ function assignInventory(pages, inventory) {
2193
2387
  }
2194
2388
 
2195
2389
  // src/rendered-analysis.ts
2390
+ function isRenderedGeometryFact(fact) {
2391
+ return fact.kind === "rendered/geometry";
2392
+ }
2393
+ var drafts = /* @__PURE__ */ new WeakMap();
2394
+ function renderedDraftFor(fact) {
2395
+ let draft = drafts.get(fact);
2396
+ if (!draft) {
2397
+ draft = draftRenderedFindings(fact.input);
2398
+ drafts.set(fact, draft);
2399
+ }
2400
+ return draft;
2401
+ }
2196
2402
  var VISIBLE_SPILL_PT = 2;
2197
2403
  var OVERLAP_FRACTION = 0.3;
2198
2404
  function finding(draft) {
2199
- const { ruleId, mapping, page, context, ...rest } = draft;
2405
+ const { mapping, page, context, ...rest } = draft;
2200
2406
  return {
2201
- source: "quality",
2202
- ruleId,
2203
- certainty: "rendered",
2204
- blocking: false,
2205
2407
  ...rest,
2206
2408
  context: {
2207
2409
  ...context,
@@ -2259,7 +2461,7 @@ function unionBox(parts) {
2259
2461
  { xMin: Infinity, yMin: Infinity, xMax: -Infinity, yMax: -Infinity }
2260
2462
  );
2261
2463
  }
2262
- function analyzeRenderedDocument(input) {
2464
+ function draftRenderedFindings(input) {
2263
2465
  const { pages, format } = input;
2264
2466
  const { matches, chromeWords } = assignInventory(pages, input.inventory);
2265
2467
  const owners = wordOwners(matches);
@@ -2293,9 +2495,6 @@ function analyzeRenderedDocument(input) {
2293
2495
  findings.push(
2294
2496
  finding({
2295
2497
  ruleId: "rendered/clip",
2296
- code: QUALITY_CODES.RENDERED_CLIP,
2297
- category: "integrity",
2298
- severity: "warning",
2299
2498
  mapping: bucket.match ? "mapped" : "unmapped",
2300
2499
  page: bucket.page,
2301
2500
  path: bucket.match?.entry.path ?? "",
@@ -2320,9 +2519,6 @@ function analyzeRenderedDocument(input) {
2320
2519
  findings.push(
2321
2520
  finding({
2322
2521
  ruleId: "rendered/clip",
2323
- code: QUALITY_CODES.RENDERED_CLIP,
2324
- category: "integrity",
2325
- severity: "warning",
2326
2522
  mapping: "mapped",
2327
2523
  page: o.endPageIndex + 1,
2328
2524
  path: match.entry.path,
@@ -2359,9 +2555,6 @@ function analyzeRenderedDocument(input) {
2359
2555
  findings.push(
2360
2556
  finding({
2361
2557
  ruleId: "rendered/spill",
2362
- code: QUALITY_CODES.RENDERED_SPILL,
2363
- category: "integrity",
2364
- severity: "warning",
2365
2558
  mapping: "mapped",
2366
2559
  page: o.pageIndex + 1,
2367
2560
  path: match.entry.path,
@@ -2381,14 +2574,14 @@ function analyzeRenderedDocument(input) {
2381
2574
  pages.forEach((page, pageIndex) => {
2382
2575
  const lines = lineOfWord(page);
2383
2576
  const seen = /* @__PURE__ */ new Set();
2384
- const words = page.words;
2385
- const byTop = words.map((_, i) => i).sort((a, b) => words[a].yMin - words[b].yMin);
2577
+ const words2 = page.words;
2578
+ const byTop = words2.map((_, i) => i).sort((a, b) => words2[a].yMin - words2[b].yMin);
2386
2579
  for (let p = 0; p < byTop.length; p++) {
2387
2580
  const i = byTop[p];
2388
- const a = words[i];
2581
+ const a = words2[i];
2389
2582
  for (let q = p + 1; q < byTop.length; q++) {
2390
2583
  const j = byTop[q];
2391
- const b = words[j];
2584
+ const b = words2[j];
2392
2585
  if (b.yMin >= a.yMax) break;
2393
2586
  const shared = intersects(a, b);
2394
2587
  if (shared <= 0) continue;
@@ -2407,9 +2600,6 @@ function analyzeRenderedDocument(input) {
2407
2600
  findings.push(
2408
2601
  finding({
2409
2602
  ruleId: "rendered/overlap",
2410
- code: QUALITY_CODES.RENDERED_OVERLAP,
2411
- category: "integrity",
2412
- severity: "warning",
2413
2603
  mapping,
2414
2604
  page: pageIndex + 1,
2415
2605
  path: pathA || pathB,
@@ -2432,9 +2622,6 @@ function analyzeRenderedDocument(input) {
2432
2622
  findings.push(
2433
2623
  finding({
2434
2624
  ruleId: "rendered/text-missing",
2435
- code: QUALITY_CODES.RENDERED_TEXT_MISSING,
2436
- category: "integrity",
2437
- severity: "warning",
2438
2625
  mapping: "mapped",
2439
2626
  path: match.entry.path,
2440
2627
  message: `"${excerpt(match.entry.text)}" does not appear anywhere in the rendered pages.`,
@@ -2456,8 +2643,6 @@ function analyzeRenderedDocument(input) {
2456
2643
  findings.push(
2457
2644
  finding({
2458
2645
  ruleId: "rendered/font-substituted",
2459
- code: QUALITY_CODES.RENDERED_FONT_SUBSTITUTED,
2460
- category: "brand",
2461
2646
  severity: requested.declared ? "warning" : "info",
2462
2647
  mapping: requested.path ? "mapped" : "unmapped",
2463
2648
  path: requested.path ?? "",
@@ -2478,9 +2663,6 @@ function analyzeRenderedDocument(input) {
2478
2663
  findings.push(
2479
2664
  finding({
2480
2665
  ruleId: "rendered/empty-page",
2481
- code: QUALITY_CODES.RENDERED_EMPTY_PAGE,
2482
- category: "integrity",
2483
- severity: "info",
2484
2666
  mapping: "unmapped",
2485
2667
  page: pageIndex + 1,
2486
2668
  path: "",
@@ -2503,9 +2685,6 @@ function analyzeRenderedDocument(input) {
2503
2685
  findings.push(
2504
2686
  finding({
2505
2687
  ruleId: "rendered/heading-stranded",
2506
- code: QUALITY_CODES.RENDERED_HEADING_STRANDED,
2507
- category: "composition",
2508
- severity: "warning",
2509
2688
  mapping: "mapped",
2510
2689
  page: part.pageIndex + 1,
2511
2690
  path: match.entry.path,
@@ -2539,9 +2718,6 @@ function analyzeRenderedDocument(input) {
2539
2718
  findings.push(
2540
2719
  finding({
2541
2720
  ruleId: "rendered/paragraph-split",
2542
- code: QUALITY_CODES.RENDERED_PARAGRAPH_SPLIT,
2543
- category: "composition",
2544
- severity: "info",
2545
2721
  mapping: "mapped",
2546
2722
  page: (which === "orphan" ? first.pageIndex : last.pageIndex) + 1,
2547
2723
  path: match.entry.path,
@@ -2563,56 +2739,526 @@ function analyzeRenderedDocument(input) {
2563
2739
  skipped: 0
2564
2740
  };
2565
2741
  for (const match of matches) inventory[match.status] += 1;
2742
+ const byRule = /* @__PURE__ */ new Map();
2743
+ for (const draft of findings) {
2744
+ const bucket = byRule.get(draft.ruleId);
2745
+ if (bucket) bucket.push(draft);
2746
+ else byRule.set(draft.ruleId, [draft]);
2747
+ }
2748
+ return {
2749
+ drafts: findings,
2750
+ byRule,
2751
+ inventory,
2752
+ // The same gate the check itself runs under: an empty font list
2753
+ // verified nothing, and must not read as "nothing was substituted".
2754
+ ...input.fonts && input.fonts.length > 0 && input.requestedFonts && {
2755
+ fonts: { requested: input.requestedFonts.length, substituted }
2756
+ }
2757
+ };
2758
+ }
2759
+ var engine;
2760
+ function renderedEngine() {
2761
+ engine ??= new QualityEngine(RENDERED_QUALITY_RULES.rules);
2762
+ return engine;
2763
+ }
2764
+ function analyzeRenderedDocument(input, options = {}) {
2765
+ const fact = {
2766
+ id: "rendered:geometry",
2767
+ kind: "rendered/geometry",
2768
+ path: "",
2769
+ input
2770
+ };
2771
+ const analysis = renderedEngine().analyzeSync(
2772
+ {
2773
+ format: input.format,
2774
+ model: void 0,
2775
+ facts: [fact],
2776
+ provenance: {},
2777
+ ...input.renderer !== void 0 && { renderer: input.renderer }
2778
+ },
2779
+ options
2780
+ );
2781
+ let draft;
2782
+ try {
2783
+ draft = renderedDraftFor(fact);
2784
+ } catch {
2785
+ draft = void 0;
2786
+ }
2566
2787
  const byMapping = {
2567
2788
  mapped: 0,
2568
2789
  ambiguous: 0,
2569
2790
  unmapped: 0
2570
2791
  };
2571
- for (const f of findings) {
2792
+ for (const f of analysis.diagnostics) {
2572
2793
  byMapping[f.context?.mapping ?? "unmapped"] += 1;
2573
2794
  }
2574
2795
  return {
2575
- findings,
2796
+ findings: analysis.diagnostics,
2576
2797
  summary: {
2577
- pages: pages.length,
2578
- words: pages.reduce((n, p) => n + p.words.length, 0),
2579
- inventory,
2798
+ pages: input.pages.length,
2799
+ words: input.pages.reduce((n, p) => n + p.words.length, 0),
2800
+ inventory: draft?.inventory ?? {
2801
+ mapped: 0,
2802
+ ambiguous: 0,
2803
+ missing: 0,
2804
+ skipped: 0
2805
+ },
2580
2806
  findings: byMapping,
2581
- // The same gate the check itself runs under: an empty font list
2582
- // verified nothing, and must not read as "nothing was substituted".
2583
- ...input.fonts && input.fonts.length > 0 && input.requestedFonts && {
2584
- fonts: { requested: input.requestedFonts.length, substituted }
2807
+ ...draft?.fonts && { fonts: draft.fonts },
2808
+ suppressed: analysis.suppressedCount,
2809
+ blocked: analysis.blocked,
2810
+ truncated: analysis.truncated,
2811
+ ...analysis.profileId !== void 0 && {
2812
+ profileId: analysis.profileId
2585
2813
  }
2814
+ },
2815
+ analysis
2816
+ };
2817
+ }
2818
+
2819
+ // src/block-matrix.ts
2820
+ import {
2821
+ blockDependencies,
2822
+ readBlockDefinitions
2823
+ } from "@json-to-office/shared";
2824
+ var FALLBACK_FONTS = {
2825
+ heading: { family: "DejaVu Sans" },
2826
+ body: { family: "DejaVu Sans" }
2827
+ };
2828
+ var MATRIX_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAABytg0kAAAAFElEQVR42mNk+M9QzwAFjDAGACPuA/8fMSCgAAAAAElFTkSuQmCC";
2829
+ var WORDS = [
2830
+ "performance",
2831
+ "delivery",
2832
+ "retention",
2833
+ "operating",
2834
+ "programme",
2835
+ "contracted",
2836
+ "recommendation",
2837
+ "quarter",
2838
+ "measurement",
2839
+ "ownership",
2840
+ "renewal",
2841
+ "segment",
2842
+ "margin",
2843
+ "baseline",
2844
+ "procurement",
2845
+ "integration"
2846
+ ];
2847
+ function words(count, seed, sentence) {
2848
+ if (count <= 0) return "";
2849
+ const out = [`Item${seed}`];
2850
+ for (let i = 1; i < count; i += 1) out.push(WORDS[(seed + i) % WORDS.length]);
2851
+ return sentence ? `${out.join(" ")}.` : out.join(" ");
2852
+ }
2853
+ function widestNumber(length, seed = 0) {
2854
+ if (length <= 0) return "";
2855
+ if (length === 1) return "9";
2856
+ if (length < 4) return `\u2212${"9".repeat(length - 1)}`;
2857
+ const group = (digits2) => Array.from({ length: digits2 }, (_, i) => String((i + seed) % 9 + 1)).join("").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
2858
+ let digits = 1;
2859
+ while (group(digits + 1).length + 3 <= length) digits += 1;
2860
+ const integer = group(digits);
2861
+ return `\u2212${integer}.${"0".repeat(length - integer.length - 2)}`;
2862
+ }
2863
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2864
+ var NUMERIC_SLOTS = /* @__PURE__ */ new Set(["value", "delta", "cells"]);
2865
+ function stringValue(slot, name, edge, seed) {
2866
+ if (slot.enum && slot.enum.length > 0) {
2867
+ const values = slot.enum.map(String);
2868
+ return edge === "max" ? values.reduce((a, b) => b.length > a.length ? b : a) : values[0];
2869
+ }
2870
+ if (slot.maxLength !== void 0 && NUMERIC_SLOTS.has(name)) {
2871
+ return widestNumber(
2872
+ edge === "max" ? slot.maxLength : Math.min(3, slot.maxLength),
2873
+ seed
2874
+ );
2875
+ }
2876
+ if (slot.maxLength !== void 0 && name === "unit") {
2877
+ return edge === "max" ? ` \u20AC${"m".repeat(Math.max(0, slot.maxLength - 2))}`.slice(
2878
+ 0,
2879
+ slot.maxLength
2880
+ ) : "%";
2881
+ }
2882
+ if (slot.maxLength !== void 0) {
2883
+ const target = edge === "max" ? slot.maxLength : Math.max(1, slot.minLength ?? 1);
2884
+ let text = `Item${seed}`.slice(0, target);
2885
+ let i = 0;
2886
+ for (; ; ) {
2887
+ const next = WORDS[(seed + i) % WORDS.length];
2888
+ i += 1;
2889
+ if (text.length + 1 + next.length > target) {
2890
+ if (i > WORDS.length) break;
2891
+ continue;
2892
+ }
2893
+ text += ` ${next}`;
2894
+ }
2895
+ return text;
2896
+ }
2897
+ const budget = slot.maxWords ?? 12;
2898
+ const count = edge === "max" ? budget : Math.max(1, Math.min(2, budget));
2899
+ const sentence = !slot.oneLine && slot.role !== "source";
2900
+ if (slot.role === "source") return `Source: ${words(count - 1, seed, false)}`;
2901
+ if (name === "number")
2902
+ return edge === "max" ? words(count, seed, false) : "01";
2903
+ return words(count, seed, sentence);
2904
+ }
2905
+ function componentValue(name, edge, seed) {
2906
+ if (name === "chart") {
2907
+ const categories = edge === "max" ? 8 : 2;
2908
+ const series = edge === "max" ? 3 : 1;
2909
+ return {
2910
+ name: "chart",
2911
+ props: {
2912
+ type: "column",
2913
+ valAxisTitle: edge === "max" ? `${words(3, seed, false)} (\u20ACm)` : "Revenue (\u20ACm)",
2914
+ ...edge === "max" && { catAxisTitle: words(3, seed + 3, false) },
2915
+ chartColors: ["primary", "secondary", "accent"].slice(0, series),
2916
+ data: Array.from({ length: series }, (_, s) => ({
2917
+ name: words(edge === "max" ? 3 : 1, seed + s, false),
2918
+ // Two words per category is where LibreOffice's native chart
2919
+ // still keeps rotated labels clear of the axis title; at three
2920
+ // the title is drawn over them, at any category count.
2921
+ labels: Array.from(
2922
+ { length: categories },
2923
+ (_2, c) => edge === "max" ? words(2, seed + c, false) : `Q${c + 1}`
2924
+ ),
2925
+ values: Array.from(
2926
+ { length: categories },
2927
+ (_2, c) => 1.2 * (c + 1) + s
2928
+ )
2929
+ }))
2930
+ }
2931
+ };
2932
+ }
2933
+ return {
2934
+ name: "image",
2935
+ props: {
2936
+ base64: MATRIX_IMAGE,
2937
+ width: edge === "max" ? name === "logo" ? "40%" : "100%" : "25%",
2938
+ alt: words(6, seed, false)
2939
+ }
2940
+ };
2941
+ }
2942
+ function boundarySlotValue(slot, name, edge, seed = 0) {
2943
+ switch (slot.type) {
2944
+ case "string":
2945
+ return stringValue(slot, name, edge, seed);
2946
+ case "number":
2947
+ case "integer":
2948
+ return edge === "max" ? slot.maximum ?? 9999 : slot.minimum ?? 0;
2949
+ case "boolean":
2950
+ return edge === "max" ? true : slot.default ?? true;
2951
+ case "component":
2952
+ return componentValue(name, edge, seed);
2953
+ case "object": {
2954
+ const out = {};
2955
+ for (const [key, property] of Object.entries(slot.properties ?? {})) {
2956
+ if (edge === "min" && !property.required) continue;
2957
+ const value = boundarySlotValue(property, key, edge, seed + key.length);
2958
+ if (value !== void 0) out[key] = value;
2959
+ }
2960
+ return out;
2961
+ }
2962
+ case "array": {
2963
+ const count = edge === "max" ? slot.maxItems ?? 3 : slot.minItems ?? (slot.required ? 1 : 0);
2964
+ const item = slot.items ?? { type: "string" };
2965
+ return Array.from(
2966
+ { length: count },
2967
+ (_, i) => boundarySlotValue(item, name, edge, seed + i)
2968
+ );
2969
+ }
2970
+ default:
2971
+ return void 0;
2972
+ }
2973
+ }
2974
+ function boundaryInvocation(name, definition, edge, seed = 0, requiredRoles = []) {
2975
+ const slots = {};
2976
+ let offset = seed;
2977
+ for (const [slotName, slot] of Object.entries(definition.slots)) {
2978
+ offset += slotName.length;
2979
+ const required = slot.required === true || slot.role !== void 0 && requiredRoles.includes(slot.role);
2980
+ if (edge === "min" && !required) continue;
2981
+ const value = boundarySlotValue(slot, slotName, edge, offset);
2982
+ if (value !== void 0) slots[slotName] = value;
2983
+ }
2984
+ const labels = slots.labels;
2985
+ const columns = slots.columns;
2986
+ if (Array.isArray(labels) && Array.isArray(columns)) {
2987
+ const cells = definition.slots.columns?.items?.properties?.cells;
2988
+ for (const column of columns) {
2989
+ if (isRecord(column) && Array.isArray(column.cells) && cells?.items) {
2990
+ column.cells = labels.map(
2991
+ (_, i) => boundarySlotValue(cells.items, "cells", edge, offset + i)
2992
+ );
2993
+ }
2994
+ }
2995
+ }
2996
+ return { name: "block", props: { ref: name, slots } };
2997
+ }
2998
+ function overBudgetInvocation(name, definition, seed = 0) {
2999
+ const invocation = boundaryInvocation(name, definition, "max", seed);
3000
+ const slots = invocation.props.slots;
3001
+ for (const [slotName, slot] of Object.entries(definition.slots)) {
3002
+ if (slot.type === "string" && slot.maxWords !== void 0) {
3003
+ slots[slotName] = `${words(slot.maxWords + 1, seed, !slot.oneLine)}`;
3004
+ return { invocation, slot: slotName, kind: "words" };
3005
+ }
3006
+ }
3007
+ for (const [slotName, slot] of Object.entries(definition.slots)) {
3008
+ if (slot.type === "array" && slot.maxItems !== void 0 && Array.isArray(slots[slotName])) {
3009
+ const items = slots[slotName];
3010
+ slots[slotName] = [...items, items[0]];
3011
+ return { invocation, slot: slotName, kind: "items" };
3012
+ }
3013
+ }
3014
+ return void 0;
3015
+ }
3016
+ function enumerateBlockDefinitions(template, templateName) {
3017
+ const definitions = readBlockDefinitions(template);
3018
+ const examples = /* @__PURE__ */ new Map();
3019
+ const walk = (node) => {
3020
+ if (Array.isArray(node)) {
3021
+ node.forEach(walk);
3022
+ return;
2586
3023
  }
3024
+ if (!isRecord(node)) return;
3025
+ const props = node.props;
3026
+ if (node.name === "block" && isRecord(props) && typeof props.ref === "string" && !examples.has(props.ref)) {
3027
+ examples.set(props.ref, isRecord(props.slots) ? props.slots : {});
3028
+ }
3029
+ walk(node.children);
2587
3030
  };
3031
+ walk(isRecord(template) ? template.children : void 0);
3032
+ return Object.entries(definitions).map(([name, definition]) => ({
3033
+ name,
3034
+ template: templateName,
3035
+ pointer: `/props/blocks/${name.replace(/~/g, "~0").replace(/\//g, "~1")}`,
3036
+ definition,
3037
+ ...examples.has(name) && { example: examples.get(name) }
3038
+ }));
3039
+ }
3040
+ var CHROME = ["cover", "running-head", "section-opener"];
3041
+ function nominal(entry, name, definitions) {
3042
+ if (!entry) return void 0;
3043
+ if (entry.example) {
3044
+ return {
3045
+ name: "block",
3046
+ props: { ref: name, slots: structuredClone(entry.example) }
3047
+ };
3048
+ }
3049
+ return boundaryInvocation(name, definitions[name], "min");
3050
+ }
3051
+ function inlineImages(value) {
3052
+ if (Array.isArray(value)) return value.map(inlineImages);
3053
+ if (!isRecord(value)) return value;
3054
+ if (value.name === "image" && isRecord(value.props) && typeof value.props.path === "string") {
3055
+ const props = { ...value.props, base64: MATRIX_IMAGE };
3056
+ delete props.path;
3057
+ return { ...value, props };
3058
+ }
3059
+ if (value.name === "highcharts") return componentValue("chart", "min", 0);
3060
+ return Object.fromEntries(
3061
+ Object.entries(value).map(([k, v]) => [k, inlineImages(v)])
3062
+ );
3063
+ }
3064
+ function withDefinitions(definitions, names) {
3065
+ const out = {};
3066
+ for (const name of names) {
3067
+ if (!(name in definitions)) continue;
3068
+ out[name] = definitions[name];
3069
+ for (const dependency of blockDependencies(definitions, name)) {
3070
+ if (dependency in definitions) out[dependency] = definitions[dependency];
3071
+ }
3072
+ }
3073
+ return out;
3074
+ }
3075
+ function conditions(props, children, theme, font, canvas) {
3076
+ const sections = children.map((child) => {
3077
+ if (!isRecord(child) || child.name !== "section") return child;
3078
+ const props2 = isRecord(child.props) ? child.props : {};
3079
+ const page = isRecord(props2.page) ? props2.page : {};
3080
+ return { ...child, props: { ...props2, page: { ...page, size: canvas } } };
3081
+ });
3082
+ const chart = JSON.stringify(sections).includes('"name":"chart"');
3083
+ return {
3084
+ name: "docx",
3085
+ ...chart && { renderer: "office-open" },
3086
+ props: {
3087
+ ...props,
3088
+ theme,
3089
+ ...font === "fallback" && { themeOverrides: { fonts: FALLBACK_FONTS } }
3090
+ },
3091
+ children: sections
3092
+ };
3093
+ }
3094
+ var BODY_COPY = words(40, 1e3, true);
3095
+ function blockCaseDocument(entries, name, edge, theme, { font = "design", canvas = "A4", requiredRoles = [] } = {}) {
3096
+ const definitions = Object.fromEntries(
3097
+ entries.map((e) => [e.name, e.definition])
3098
+ );
3099
+ const byName = new Map(entries.map((e) => [e.name, e]));
3100
+ const under = (chrome) => chrome === name ? boundaryInvocation(name, definitions[name], edge, 0, requiredRoles) : inlineImages(nominal(byName.get(chrome), chrome, definitions));
3101
+ const cover = under("cover");
3102
+ const runningHead = under("running-head");
3103
+ const opener = under("section-opener");
3104
+ const body = [];
3105
+ if (!CHROME.includes(name)) {
3106
+ if (name === "footnotes" && byName.has("figure")) {
3107
+ body.push(
3108
+ inlineImages(nominal(byName.get("figure"), "figure", definitions))
3109
+ );
3110
+ }
3111
+ body.push(
3112
+ boundaryInvocation(name, definitions[name], edge, 0, requiredRoles)
3113
+ );
3114
+ }
3115
+ body.push({ name: "paragraph", props: { text: BODY_COPY } });
3116
+ const used = /* @__PURE__ */ new Set([
3117
+ name,
3118
+ ...CHROME,
3119
+ ...name === "footnotes" ? ["figure"] : []
3120
+ ]);
3121
+ const children = [];
3122
+ if (cover) children.push({ name: "section", children: [cover] });
3123
+ children.push({
3124
+ name: "section",
3125
+ children: [runningHead, opener, ...body].filter((c) => c !== void 0)
3126
+ });
3127
+ return conditions(
3128
+ {
3129
+ metadata: {
3130
+ title: "Boundary report",
3131
+ author: "jto-ops block matrix",
3132
+ company: "Example client",
3133
+ date: "September 2026"
3134
+ },
3135
+ blocks: withDefinitions(definitions, used)
3136
+ },
3137
+ children,
3138
+ theme,
3139
+ font,
3140
+ canvas
3141
+ );
3142
+ }
3143
+ function reportCaseDocument(template, edge, theme, { font = "design", canvas = "A4", requiredRoles = [] } = {}) {
3144
+ if (!isRecord(template) || !isRecord(template.props))
3145
+ throw new Error("A template is a document with props.");
3146
+ const definitions = readBlockDefinitions(template);
3147
+ let seed = 0;
3148
+ const rewrite = (node) => {
3149
+ if (Array.isArray(node)) return node.map(rewrite);
3150
+ if (!isRecord(node)) return node;
3151
+ const props2 = node.props;
3152
+ if (node.name === "block" && isRecord(props2) && typeof props2.ref === "string" && definitions[props2.ref]) {
3153
+ seed += 7;
3154
+ return boundaryInvocation(
3155
+ props2.ref,
3156
+ definitions[props2.ref],
3157
+ edge,
3158
+ seed,
3159
+ requiredRoles
3160
+ );
3161
+ }
3162
+ if (node.name === "paragraph" && isRecord(props2) && typeof props2.text === "string") {
3163
+ return {
3164
+ ...node,
3165
+ props: { ...props2, text: edge === "max" ? BODY_COPY : props2.text }
3166
+ };
3167
+ }
3168
+ return Array.isArray(node.children) ? { ...node, children: rewrite(node.children) } : node;
3169
+ };
3170
+ const props = structuredClone(template.props);
3171
+ delete props.theme;
3172
+ delete props.themeOverrides;
3173
+ return conditions(
3174
+ props,
3175
+ rewrite(template.children),
3176
+ theme,
3177
+ font,
3178
+ canvas
3179
+ );
3180
+ }
3181
+ function generateBlockMatrix(template, templateName, options) {
3182
+ const entries = enumerateBlockDefinitions(template, templateName);
3183
+ const fonts = options.fonts ?? ["design", "fallback"];
3184
+ const edges = options.edges ?? ["min", "max"];
3185
+ const canvases = options.canvases ?? ["A4"];
3186
+ const cases = [];
3187
+ for (const theme of options.themes)
3188
+ for (const font of fonts)
3189
+ for (const canvas of canvases)
3190
+ for (const edge of edges) {
3191
+ if (options.blocks !== false)
3192
+ for (const entry of entries)
3193
+ cases.push({
3194
+ id: `${entry.name}@${edge}/${theme}/${font}/${canvas}`,
3195
+ block: entry.name,
3196
+ edge,
3197
+ theme,
3198
+ font,
3199
+ canvas,
3200
+ document: blockCaseDocument(entries, entry.name, edge, theme, {
3201
+ font,
3202
+ canvas,
3203
+ requiredRoles: options.requiredRoles
3204
+ })
3205
+ });
3206
+ if (options.report !== false)
3207
+ cases.push({
3208
+ id: `report@${edge}/${theme}/${font}/${canvas}`,
3209
+ block: "report",
3210
+ edge,
3211
+ theme,
3212
+ font,
3213
+ canvas,
3214
+ document: reportCaseDocument(template, edge, theme, {
3215
+ font,
3216
+ canvas,
3217
+ requiredRoles: options.requiredRoles
3218
+ })
3219
+ });
3220
+ }
3221
+ return cases;
2588
3222
  }
2589
3223
  export {
2590
3224
  DocxFormatAdapter,
3225
+ FALLBACK_FONTS,
2591
3226
  FontconfigStager,
3227
+ MATRIX_IMAGE,
2592
3228
  MacOSCoreTextStager,
2593
3229
  NoopFontStager,
2594
3230
  PptxFormatAdapter,
3231
+ RENDERED_QUALITY_RULES,
2595
3232
  VISIBLE_SPILL_PT,
2596
3233
  WindowsFontStager,
2597
3234
  analyzeRenderedDocument,
2598
3235
  assignInventory,
2599
3236
  authoredTextForMatch,
3237
+ blockCaseDocument,
3238
+ boundaryInvocation,
3239
+ boundarySlotValue,
2600
3240
  clearRasterizerCache,
2601
3241
  createAdapter,
2602
3242
  createLibreOfficePptxBatchRasterizer,
2603
3243
  createLibreOfficePptxRasterizer,
2604
3244
  emitDiagnostic,
3245
+ enumerateBlockDefinitions,
2605
3246
  extractPdfFonts,
2606
3247
  extractPdfTextGeometry,
2607
3248
  familyRendered,
3249
+ generateBlockMatrix,
2608
3250
  getFontStager,
2609
3251
  getRasterizerCacheStats,
2610
3252
  normalizeForMatch,
3253
+ overBudgetInvocation,
2611
3254
  parsePdfFonts,
2612
3255
  parsePdfTextBbox,
2613
3256
  pdffontsAvailable,
2614
3257
  pdftotextAvailable,
3258
+ readingOrder,
3259
+ reportCaseDocument,
2615
3260
  runWithDiagnosticSink,
2616
- stderrDiagnosticSink
3261
+ stderrDiagnosticSink,
3262
+ widestNumber
2617
3263
  };
2618
3264
  //# sourceMappingURL=index.js.map