@json-to-office/jto-ops 6.0.0 → 6.8.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
@@ -2149,6 +2149,14 @@ var RENDERED_QUALITY_RULES = {
2149
2149
  defaultSeverity: "warning",
2150
2150
  formats: ["docx"]
2151
2151
  }),
2152
+ renderedRule({
2153
+ id: "rendered/table-split",
2154
+ code: QUALITY_CODES.RENDERED_TABLE_SPLIT,
2155
+ description: "A table broken across a page badly: its header left alone at the foot of a page (`kind: header-alone`), or one row alone on either side of the break (`kind: orphan-row` or `widow-row`).",
2156
+ category: "composition",
2157
+ defaultSeverity: "info",
2158
+ formats: ["docx"]
2159
+ }),
2152
2160
  renderedRule({
2153
2161
  id: "rendered/paragraph-split",
2154
2162
  code: QUALITY_CODES.RENDERED_PARAGRAPH_SPLIT,
@@ -2176,43 +2184,77 @@ function indexDocument(pages, exclude = /* @__PURE__ */ new Set()) {
2176
2184
  const fragments = [];
2177
2185
  const positions = [];
2178
2186
  let cursor = 0;
2179
- pages.forEach((page, pageIndex) => {
2180
- for (const index of readingOrder(page.words)) {
2181
- const word = page.words[index];
2182
- const key = `${pageIndex}:${index}`;
2183
- const fragment = exclude.has(key) ? "" : normalizeForMatch(word.text);
2184
- refs.push({ pageIndex, word: index });
2185
- fragments.push(fragment);
2186
- positions.push(cursor);
2187
- cursor += fragment.length;
2188
- }
2187
+ const push = (pageIndex, index) => {
2188
+ const word = pages[pageIndex].words[index];
2189
+ const key = `${pageIndex}:${index}`;
2190
+ const fragment = exclude.has(key) ? "" : normalizeForMatch(word.text);
2191
+ refs.push({ pageIndex, word: index });
2192
+ fragments.push(fragment);
2193
+ positions.push(cursor);
2194
+ cursor += fragment.length;
2195
+ };
2196
+ const orders = pages.map((page) => pageOrder(page.words, page.lines));
2197
+ orders.forEach((order, pageIndex) => {
2198
+ for (const index of order.upright) push(pageIndex, index);
2199
+ });
2200
+ const rotatedFrom = cursor;
2201
+ orders.forEach((order, pageIndex) => {
2202
+ for (const index of order.rotated) push(pageIndex, index);
2189
2203
  });
2190
- return { pages, refs, fragments, positions, stream: fragments.join("") };
2204
+ return {
2205
+ pages,
2206
+ refs,
2207
+ fragments,
2208
+ positions,
2209
+ stream: fragments.join(""),
2210
+ rotatedFrom
2211
+ };
2191
2212
  }
2192
- function readingOrder(words2) {
2213
+ function readingOrder(words2, lines = []) {
2214
+ const { upright, rotated } = pageOrder(words2, lines);
2215
+ return [...upright, ...rotated];
2216
+ }
2217
+ function pageOrder(words2, lines = []) {
2218
+ const rotated = rotatedWords(words2, lines);
2219
+ return {
2220
+ upright: uprightOrder(
2221
+ words2,
2222
+ words2.map((_, i) => i).filter((i) => !rotated.has(i))
2223
+ ),
2224
+ rotated: [...rotated].sort((a, b) => a - b)
2225
+ };
2226
+ }
2227
+ function rotatedWords(words2, lines) {
2193
2228
  const rotated = new Set(
2194
2229
  words2.map((_, i) => i).filter((i) => {
2195
2230
  const w = words2[i];
2196
2231
  return w.text.length > 2 && w.yMax - w.yMin > 1.5 * (w.xMax - w.xMin);
2197
2232
  })
2198
2233
  );
2199
- const upright = uprightOrder(
2200
- words2,
2201
- words2.map((_, i) => i).filter((i) => !rotated.has(i))
2202
- );
2203
- const order = [];
2204
- let next = 0;
2205
- for (let i = 0; i < words2.length; i++) {
2206
- if (rotated.has(i)) order.push(i);
2207
- else order.push(upright[next++]);
2234
+ for (const line of lines) {
2235
+ if (line.words.length < 2) continue;
2236
+ const stacked = line.words.slice(1).every((index, n) => {
2237
+ const a = words2[line.words[n]];
2238
+ const b = words2[index];
2239
+ const across = Math.min(a.xMax, b.xMax) - Math.max(a.xMin, b.xMin);
2240
+ const along = Math.min(a.yMax, b.yMax) - Math.max(a.yMin, b.yMin);
2241
+ return across > 0 && along <= 0;
2242
+ });
2243
+ if (stacked) for (const index of line.words) rotated.add(index);
2208
2244
  }
2209
- return order;
2245
+ return rotated;
2210
2246
  }
2211
2247
  function median(values) {
2212
2248
  const sorted = [...values].sort((a, b) => a - b);
2213
2249
  const mid = sorted.length >> 1;
2214
2250
  return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2215
2251
  }
2252
+ var ROW_SIZE_RATIO = 2;
2253
+ function comparableSize(a, b) {
2254
+ const ha = a.yMax - a.yMin;
2255
+ const hb = b.yMax - b.yMin;
2256
+ return Math.max(ha, hb) <= ROW_SIZE_RATIO * Math.min(ha, hb);
2257
+ }
2216
2258
  function groupRows(words2, indices) {
2217
2259
  const rows = [];
2218
2260
  const byY = [...indices].sort(
@@ -2221,7 +2263,8 @@ function groupRows(words2, indices) {
2221
2263
  for (const i of byY) {
2222
2264
  const word = words2[i];
2223
2265
  const row = rows[rows.length - 1];
2224
- if (row && sameRow(words2[row.words[0]], word)) {
2266
+ const peer = row?.words.find((w) => comparableSize(words2[w], word));
2267
+ if (row && peer !== void 0 && sameRow(words2[peer], word)) {
2225
2268
  row.words.push(i);
2226
2269
  row.yMin = Math.min(row.yMin, word.yMin);
2227
2270
  row.yMax = Math.max(row.yMax, word.yMax);
@@ -2259,20 +2302,25 @@ function uprightOrder(words2, indices) {
2259
2302
  const fragments = rows.map(fragmentsOf);
2260
2303
  const boxes = grouped;
2261
2304
  const order = [];
2305
+ const read = /* @__PURE__ */ new Set();
2306
+ const readColumn = (column) => {
2307
+ column.fragments.sort(
2308
+ (a, b) => words2[a.words[0]].yMin - words2[b.words[0]].yMin || a.xMin - b.xMin
2309
+ );
2310
+ for (const f of column.fragments) {
2311
+ order.push(...f.words);
2312
+ read.add(f);
2313
+ }
2314
+ };
2262
2315
  let cluster = null;
2263
2316
  const flush = () => {
2264
2317
  if (!cluster) return;
2265
2318
  if (cluster.columns.length < 2) {
2266
2319
  for (const r of cluster.rows)
2267
- for (const f of fragments[r]) order.push(...f.words);
2320
+ for (const f of fragments[r]) if (!read.has(f)) order.push(...f.words);
2268
2321
  } else {
2269
2322
  cluster.columns.sort((a, b) => a.xMin - b.xMin);
2270
- for (const column of cluster.columns) {
2271
- column.fragments.sort(
2272
- (a, b) => words2[a.words[0]].yMin - words2[b.words[0]].yMin || a.xMin - b.xMin
2273
- );
2274
- for (const f of column.fragments) order.push(...f.words);
2275
- }
2323
+ for (const column of cluster.columns) readColumn(column);
2276
2324
  }
2277
2325
  cluster = null;
2278
2326
  };
@@ -2286,6 +2334,16 @@ function uprightOrder(words2, indices) {
2286
2334
  if (!adjacent || wide) flush();
2287
2335
  if (!cluster) cluster = { rows: [], columns: [] };
2288
2336
  cluster.rows.push(r);
2337
+ const landing = /* @__PURE__ */ new Map();
2338
+ for (const f of fragments[r]) {
2339
+ const hit = overlapping(cluster.columns, f)[0];
2340
+ if (hit) landing.set(hit, (landing.get(hit) ?? 0) + 1);
2341
+ }
2342
+ for (const [column, count] of landing) {
2343
+ if (count < 2) continue;
2344
+ readColumn(column);
2345
+ cluster.columns.splice(cluster.columns.indexOf(column), 1);
2346
+ }
2289
2347
  for (const f of fragments[r]) {
2290
2348
  const hit = overlapping(cluster.columns, f)[0];
2291
2349
  if (hit) {
@@ -2353,6 +2411,7 @@ function occurrence(index, at, end) {
2353
2411
  const parts = partsOf(index, at, end);
2354
2412
  return {
2355
2413
  at,
2414
+ end,
2356
2415
  pageIndex: parts[0].pageIndex,
2357
2416
  endPageIndex: parts[parts.length - 1].pageIndex,
2358
2417
  parts
@@ -2378,7 +2437,11 @@ function findOccurrences(index, segments) {
2378
2437
  end = next + segments[s].length;
2379
2438
  }
2380
2439
  if (!complete || !alignedToFragments(index, at, end)) continue;
2381
- hits.push(occurrence(index, at, end));
2440
+ const hit = occurrence(index, at, end);
2441
+ if (at < index.rotatedFrom ? end > index.rotatedFrom : hit.pageIndex !== hit.endPageIndex) {
2442
+ continue;
2443
+ }
2444
+ hits.push(hit);
2382
2445
  searchFrom = end;
2383
2446
  }
2384
2447
  return hits;
@@ -2386,7 +2449,7 @@ function findOccurrences(index, segments) {
2386
2449
  var MIN_NEEDLE_LENGTH = 3;
2387
2450
  var MIN_PARTIAL_PREFIX = 16;
2388
2451
  var CHROME_BAND = 0.2;
2389
- function longestRenderedPrefix(index, needle) {
2452
+ function longestRenderedPrefix(index, needle, page, region) {
2390
2453
  if (needle.length < MIN_PARTIAL_PREFIX) return void 0;
2391
2454
  const anchor = needle.slice(0, MIN_PARTIAL_PREFIX);
2392
2455
  let best;
@@ -2399,14 +2462,36 @@ function longestRenderedPrefix(index, needle) {
2399
2462
  if (first >= index.fragments.length || index.positions[first] !== at) {
2400
2463
  continue;
2401
2464
  }
2465
+ if (page !== void 0 && index.refs[first].pageIndex !== page) continue;
2466
+ const rotated = at >= index.rotatedFrom;
2467
+ const limit = Math.min(
2468
+ page !== void 0 || rotated ? pageEnd(index, first) : index.stream.length,
2469
+ rotated ? index.stream.length : index.rotatedFrom
2470
+ );
2471
+ if (at + MIN_PARTIAL_PREFIX > limit) continue;
2402
2472
  let length = MIN_PARTIAL_PREFIX;
2403
- while (length < needle.length && index.stream.charCodeAt(at + length) === needle.charCodeAt(length)) {
2473
+ while (length < needle.length && at + length < limit && index.stream.charCodeAt(at + length) === needle.charCodeAt(length)) {
2404
2474
  length++;
2405
2475
  }
2476
+ if (region && !touchesRegion(partsOf(index, at, at + length), region)) {
2477
+ continue;
2478
+ }
2406
2479
  if (!best || length > best.length) best = { length, at };
2407
2480
  }
2408
2481
  return best;
2409
2482
  }
2483
+ function pageEnd(index, from) {
2484
+ const page = index.refs[from].pageIndex;
2485
+ let i = from;
2486
+ while (i < index.refs.length && index.refs[i].pageIndex === page) i++;
2487
+ return i < index.positions.length ? index.positions[i] : index.stream.length;
2488
+ }
2489
+ var REGION_TOLERANCE_PT = 2;
2490
+ function touchesRegion(parts, region) {
2491
+ return parts.some(
2492
+ (part) => part.xMax >= region.xMin - REGION_TOLERANCE_PT && part.xMin <= region.xMax + REGION_TOLERANCE_PT && part.yMax >= region.yMin - REGION_TOLERANCE_PT && part.yMin <= region.yMax + REGION_TOLERANCE_PT
2493
+ );
2494
+ }
2410
2495
  function sameRow(a, b) {
2411
2496
  const overlap = Math.min(a.yMax, b.yMax) - Math.max(a.yMin, b.yMin);
2412
2497
  const shorter = Math.min(a.yMax - a.yMin, b.yMax - b.yMin);
@@ -2462,6 +2547,96 @@ function pageNumberRows(pages, claimed) {
2462
2547
  )
2463
2548
  );
2464
2549
  }
2550
+ function insideRegion(occurrences, region) {
2551
+ let best;
2552
+ for (const o of occurrences) {
2553
+ let inside = 0;
2554
+ let total = 0;
2555
+ for (const part of o.parts) {
2556
+ const w = Math.min(part.xMax, region.xMax) - Math.max(part.xMin, region.xMin);
2557
+ const h = Math.min(part.yMax, region.yMax) - Math.max(part.yMin, region.yMin);
2558
+ if (w > 0 && h > 0) inside += w * h;
2559
+ total += (part.xMax - part.xMin) * (part.yMax - part.yMin);
2560
+ }
2561
+ const share = total > 0 ? inside / total : 0;
2562
+ if (share > 0 && (!best || share > best.share)) {
2563
+ best = { occurrence: o, share };
2564
+ }
2565
+ }
2566
+ return best?.occurrence;
2567
+ }
2568
+ function overprintedOccurrence(index, page, region, needle, claimed) {
2569
+ const words2 = index.pages[page]?.words;
2570
+ if (!words2) return void 0;
2571
+ const fragmentOf = /* @__PURE__ */ new Map();
2572
+ index.refs.forEach((ref, i) => {
2573
+ if (ref.pageIndex !== page || index.fragments[i] === "") return;
2574
+ const w = words2[ref.word];
2575
+ const centre = (w.xMin + w.xMax) / 2;
2576
+ if (centre < region.xMin || centre > region.xMax) return;
2577
+ if (w.yMax < region.yMin) return;
2578
+ fragmentOf.set(ref.word, i);
2579
+ });
2580
+ const sequence = groupRows(words2, [...fragmentOf.keys()]).flatMap(
2581
+ (row) => [...row.words].sort((a, b) => words2[a].xMin - words2[b].xMin)
2582
+ );
2583
+ let reach = /* @__PURE__ */ new Map();
2584
+ let best;
2585
+ for (const word of sequence) {
2586
+ const i = fragmentOf.get(word);
2587
+ const text = index.fragments[i];
2588
+ const usable = !claimed(index.positions[i]);
2589
+ const next = /* @__PURE__ */ new Map();
2590
+ const offer = (consumed, path9) => {
2591
+ const held = next.get(consumed);
2592
+ if (!held || path9.skips < held.skips) next.set(consumed, path9);
2593
+ };
2594
+ const inside = words2[word].yMin + words2[word].yMax <= 2 * region.yMax && words2[word].yMin + words2[word].yMax >= 2 * region.yMin;
2595
+ if (usable && inside && needle.startsWith(text)) {
2596
+ offer(text.length, { skips: 0, words: [word] });
2597
+ }
2598
+ for (const [consumed, path9] of reach) {
2599
+ if (usable && needle.startsWith(text, consumed)) {
2600
+ offer(consumed + text.length, {
2601
+ skips: path9.skips,
2602
+ words: [...path9.words, word]
2603
+ });
2604
+ }
2605
+ offer(consumed, { skips: path9.skips + 1, words: path9.words });
2606
+ }
2607
+ const done = next.get(needle.length);
2608
+ if (done && (!best || done.skips < best.skips)) best = done;
2609
+ next.delete(needle.length);
2610
+ reach = next;
2611
+ }
2612
+ if (!best || best.skips > best.words.length) return void 0;
2613
+ const first = fragmentOf.get(best.words[0]);
2614
+ const part = {
2615
+ pageIndex: page,
2616
+ words: best.words,
2617
+ xMin: Math.min(...best.words.map((w) => words2[w].xMin)),
2618
+ yMin: Math.min(...best.words.map((w) => words2[w].yMin)),
2619
+ xMax: Math.max(...best.words.map((w) => words2[w].xMax)),
2620
+ yMax: Math.max(...best.words.map((w) => words2[w].yMax))
2621
+ };
2622
+ const at = index.positions[first];
2623
+ return {
2624
+ occurrence: {
2625
+ at,
2626
+ end: at,
2627
+ pageIndex: page,
2628
+ endPageIndex: page,
2629
+ parts: [part]
2630
+ },
2631
+ claims: best.words.map((w) => {
2632
+ const i = fragmentOf.get(w);
2633
+ return {
2634
+ at: index.positions[i],
2635
+ end: index.positions[i] + index.fragments[i].length
2636
+ };
2637
+ })
2638
+ };
2639
+ }
2465
2640
  function assignInventory(pages, inventory) {
2466
2641
  const results = /* @__PURE__ */ new Map();
2467
2642
  const chromeWords = /* @__PURE__ */ new Set();
@@ -2501,7 +2676,8 @@ function assignInventory(pages, inventory) {
2501
2676
  for (const row of pageNumberRows(pages, chromeWords))
2502
2677
  claimRow(row.pageIndex, row.box);
2503
2678
  const body = indexDocument(pages, chromeWords);
2504
- const claimed = /* @__PURE__ */ new Map();
2679
+ const claims = [];
2680
+ const claimsOver = (o) => claims.filter((claim) => o.at < claim.end && claim.at < o.end);
2505
2681
  let cursor = 0;
2506
2682
  for (const entry of inventory) {
2507
2683
  if (entry.repeats) continue;
@@ -2511,10 +2687,31 @@ function assignInventory(pages, inventory) {
2511
2687
  results.set(entry, { entry, needle, status: "skipped", occurrences: [] });
2512
2688
  continue;
2513
2689
  }
2514
- let all = findOccurrences(body, segments);
2690
+ let all = findOccurrences(body, segments).filter(
2691
+ (o) => (entry.page === void 0 || o.pageIndex === entry.page && o.endPageIndex === entry.page) && (!entry.region || touchesRegion(o.parts, entry.region))
2692
+ );
2515
2693
  let partial;
2694
+ let sparse;
2695
+ if (all.length === 0 && segments.length === 1 && entry.page !== void 0 && entry.region) {
2696
+ const overprinted = overprintedOccurrence(
2697
+ body,
2698
+ entry.page,
2699
+ entry.region,
2700
+ needle,
2701
+ (offset) => claims.some((claim) => claim.at <= offset && offset < claim.end)
2702
+ );
2703
+ if (overprinted) {
2704
+ all = [overprinted.occurrence];
2705
+ sparse = overprinted.claims;
2706
+ }
2707
+ }
2516
2708
  if (all.length === 0 && segments.length === 1 && !entry.optional) {
2517
- const prefix = longestRenderedPrefix(body, needle);
2709
+ const prefix = longestRenderedPrefix(
2710
+ body,
2711
+ needle,
2712
+ entry.page,
2713
+ entry.region
2714
+ );
2518
2715
  if (prefix) {
2519
2716
  all = [occurrence(body, prefix.at, prefix.at + prefix.length)];
2520
2717
  partial = { matchedChars: prefix.length, totalChars: needle.length };
@@ -2529,18 +2726,22 @@ function assignInventory(pages, inventory) {
2529
2726
  });
2530
2727
  continue;
2531
2728
  }
2532
- let free = all.filter((o) => !claimed.has(o.at));
2729
+ let free = all.filter((o) => claimsOver(o).length === 0);
2533
2730
  if (free.length === 0 && !entry.optional) {
2534
- const held = all.find((o) => claimed.get(o.at)?.optional);
2731
+ const held = all.find((o) => {
2732
+ const over = claimsOver(o);
2733
+ return over.length > 0 && over.every((claim) => claim.entry.optional);
2734
+ });
2535
2735
  if (held) {
2536
- const holder = claimed.get(held.at);
2537
- claimed.delete(held.at);
2538
- results.set(holder, {
2539
- entry: holder,
2540
- needle: results.get(holder)?.needle ?? "",
2541
- status: "skipped",
2542
- occurrences: []
2543
- });
2736
+ for (const claim of claimsOver(held)) {
2737
+ claims.splice(claims.indexOf(claim), 1);
2738
+ results.set(claim.entry, {
2739
+ entry: claim.entry,
2740
+ needle: results.get(claim.entry)?.needle ?? "",
2741
+ status: "skipped",
2742
+ occurrences: []
2743
+ });
2744
+ }
2544
2745
  free = [held];
2545
2746
  }
2546
2747
  }
@@ -2553,9 +2754,10 @@ function assignInventory(pages, inventory) {
2553
2754
  });
2554
2755
  continue;
2555
2756
  }
2556
- const chosen = free.find((o) => o.at >= cursor) ?? free[0];
2557
- claimed.set(chosen.at, entry);
2558
- cursor = chosen.at;
2757
+ const chosen = (entry.region && insideRegion(free, entry.region)) ?? free.find((o) => o.at >= cursor) ?? free[0];
2758
+ for (const range of sparse ?? [chosen])
2759
+ claims.push({ at: range.at, end: range.end, entry });
2760
+ if (chosen.at < body.rotatedFrom) cursor = chosen.at;
2559
2761
  results.set(entry, {
2560
2762
  entry,
2561
2763
  needle,
@@ -2587,6 +2789,11 @@ function renderedDraftFor(fact) {
2587
2789
  }
2588
2790
  var VISIBLE_SPILL_PT = 2;
2589
2791
  var OVERLAP_FRACTION = 0.3;
2792
+ var WORD_BOX_SLACK = 0.2;
2793
+ function inkBand(word) {
2794
+ const slack = WORD_BOX_SLACK * Math.max(0, word.yMax - word.yMin);
2795
+ return { yMin: word.yMin + slack, yMax: word.yMax - slack };
2796
+ }
2590
2797
  function finding(draft) {
2591
2798
  const { mapping, page, context, ...rest } = draft;
2592
2799
  return {
@@ -2602,12 +2809,15 @@ function round(value) {
2602
2809
  return Math.round(value * 10) / 10;
2603
2810
  }
2604
2811
  function intersects(a, b) {
2812
+ const inkA = inkBand(a);
2813
+ const inkB = inkBand(b);
2605
2814
  const w = Math.min(a.xMax, b.xMax) - Math.max(a.xMin, b.xMin);
2606
- const h = Math.min(a.yMax, b.yMax) - Math.max(a.yMin, b.yMin);
2815
+ const h = Math.min(inkA.yMax, inkB.yMax) - Math.max(inkA.yMin, inkB.yMin);
2607
2816
  return w > 0 && h > 0 ? w * h : 0;
2608
2817
  }
2609
2818
  function area(w) {
2610
- return Math.max(0, w.xMax - w.xMin) * Math.max(0, w.yMax - w.yMin);
2819
+ const ink = inkBand(w);
2820
+ return Math.max(0, w.xMax - w.xMin) * Math.max(0, ink.yMax - ink.yMin);
2611
2821
  }
2612
2822
  function excerpt(text, max = 60) {
2613
2823
  const flat = text.replace(/\s+/g, " ").trim();
@@ -2723,14 +2933,51 @@ function draftRenderedFindings(input) {
2723
2933
  }
2724
2934
  for (const match of matches) {
2725
2935
  const box = match.entry.box;
2936
+ const region = match.entry.region;
2726
2937
  if (!box || match.status !== "mapped") continue;
2727
2938
  for (const o of match.occurrences) {
2939
+ const boxHeight = box.heightPt;
2940
+ const byEdges = region !== void 0 && boxHeight !== void 0;
2941
+ if (region && boxHeight !== void 0) {
2942
+ const words2 = o.parts.flatMap(
2943
+ (part) => part.words.map((w) => pages[part.pageIndex].words[w])
2944
+ );
2945
+ const inkTop = Math.min(...words2.map((w) => inkBand(w).yMin));
2946
+ const inkBottom = Math.max(...words2.map((w) => inkBand(w).yMax));
2947
+ const edges = [
2948
+ { edge: "bottom", over: inkBottom - region.yMax, at: inkBottom },
2949
+ { edge: "top", over: region.yMin - inkTop, at: inkTop }
2950
+ ].sort((a, b) => b.over - a.over);
2951
+ const [worst2] = edges;
2952
+ if (worst2.over > VISIBLE_SPILL_PT) {
2953
+ findings.push(
2954
+ finding({
2955
+ ruleId: "rendered/spill",
2956
+ mapping: "mapped",
2957
+ page: o.pageIndex + 1,
2958
+ path: match.entry.path,
2959
+ message: `"${excerpt(match.entry.text)}" runs ${round(worst2.over)} pt past the ${worst2.edge} of its ${round(boxHeight)} pt box on page ${o.pageIndex + 1}.`,
2960
+ suggestion: "Shorten the text, reduce its size, or enlarge the box; the renderer let it spill past the edge the author drew.",
2961
+ evidence: {
2962
+ summary: "Rendered ink against the edge of the declared box",
2963
+ actual: round(worst2.at),
2964
+ expected: round(
2965
+ worst2.edge === "bottom" ? region.yMax : region.yMin
2966
+ ),
2967
+ unit: "pt",
2968
+ values: { edge: worst2.edge, overPt: round(worst2.over) }
2969
+ }
2970
+ })
2971
+ );
2972
+ continue;
2973
+ }
2974
+ }
2728
2975
  const extent = unionBox(o.parts);
2729
2976
  const rendered = {
2730
2977
  heightPt: extent.yMax - extent.yMin,
2731
2978
  widthPt: extent.xMax - extent.xMin
2732
2979
  };
2733
- const worst = ["heightPt", "widthPt"].filter((axis) => box[axis] !== void 0).map((axis) => ({
2980
+ const worst = ["heightPt", "widthPt"].filter((axis) => box[axis] !== void 0).filter((axis) => !(byEdges && axis === "heightPt")).map((axis) => ({
2734
2981
  axis,
2735
2982
  declared: box[axis],
2736
2983
  rendered: rendered[axis],
@@ -2975,6 +3222,79 @@ function draftRenderedFindings(input) {
2975
3222
  })
2976
3223
  );
2977
3224
  }
3225
+ const TABLE_CELL = /^(.*)\/props\/(?:slots\/)?columns\/(\d+)\/(?:header|cells\/(\d+))(?:\/.*)?$/;
3226
+ const tables = /* @__PURE__ */ new Map();
3227
+ for (const match of matches) {
3228
+ if (match.entry.role !== "table-header" && match.entry.role !== "table-cell" || match.status !== "mapped")
3229
+ continue;
3230
+ const parsed = TABLE_CELL.exec(match.entry.path);
3231
+ if (!parsed) continue;
3232
+ const [, table, column, row] = parsed;
3233
+ const rowIndex = row === void 0 ? -1 : Number(row);
3234
+ const entry = tables.get(table) ?? {
3235
+ pages: /* @__PURE__ */ new Map(),
3236
+ paths: /* @__PURE__ */ new Map()
3237
+ };
3238
+ for (const occurrence2 of match.occurrences)
3239
+ for (const part of occurrence2.parts) {
3240
+ const rows = entry.pages.get(part.pageIndex) ?? /* @__PURE__ */ new Set();
3241
+ rows.add(rowIndex);
3242
+ entry.pages.set(part.pageIndex, rows);
3243
+ }
3244
+ const held = entry.paths.get(rowIndex);
3245
+ if (!held || Number(column) < held.column)
3246
+ entry.paths.set(rowIndex, {
3247
+ column: Number(column),
3248
+ path: match.entry.path
3249
+ });
3250
+ tables.set(table, entry);
3251
+ }
3252
+ for (const [table, { pages: onPage, paths }] of tables) {
3253
+ const ordered = [...onPage.keys()].sort((a, b) => a - b);
3254
+ if (ordered.length < 2) continue;
3255
+ const bodyRows = (page) => [...onPage.get(page) ?? []].filter((row) => row >= 0);
3256
+ const report = (kind, page, path9, message, suggestion) => findings.push(
3257
+ finding({
3258
+ ruleId: "rendered/table-split",
3259
+ mapping: "mapped",
3260
+ page: page + 1,
3261
+ path: path9,
3262
+ message,
3263
+ suggestion,
3264
+ context: { kind, table }
3265
+ })
3266
+ );
3267
+ const firstPage = ordered[0];
3268
+ const lastPage = ordered[ordered.length - 1];
3269
+ if (onPage.get(firstPage).has(-1) && bodyRows(firstPage).length === 0) {
3270
+ report(
3271
+ "header-alone",
3272
+ firstPage,
3273
+ paths.get(-1).path,
3274
+ `The table's header is the only part of it on page ${firstPage + 1}; its rows start on the next page.`,
3275
+ "Keep the header with its first rows, or move the table so it starts on the next page."
3276
+ );
3277
+ continue;
3278
+ }
3279
+ const opening = bodyRows(firstPage);
3280
+ const closing = bodyRows(lastPage);
3281
+ if (opening.length === 1)
3282
+ report(
3283
+ "orphan-row",
3284
+ firstPage,
3285
+ paths.get(opening[0]).path,
3286
+ `The table leaves one row alone at the foot of page ${firstPage + 1}.`,
3287
+ "Keep the table together on one page, or edit the rows above it so the break falls elsewhere."
3288
+ );
3289
+ else if (closing.length === 1)
3290
+ report(
3291
+ "widow-row",
3292
+ lastPage,
3293
+ paths.get(closing[0]).path,
3294
+ `The table leaves one row alone at the top of page ${lastPage + 1}.`,
3295
+ "Keep the table together on one page, or edit the rows above it so the break falls elsewhere."
3296
+ );
3297
+ }
2978
3298
  }
2979
3299
  const inventory = {
2980
3300
  mapped: 0,
@@ -3071,6 +3391,93 @@ function analyzeRenderedDocument(input, options = {}) {
3071
3391
  };
3072
3392
  }
3073
3393
 
3394
+ // src/rendered-inventory.ts
3395
+ function renderedInventoryFromFacts(format, facts) {
3396
+ const entries = [];
3397
+ for (const fact of facts) {
3398
+ if (format === "docx" && fact.kind === "docx/text") {
3399
+ const frame = fact.frame;
3400
+ entries.push({
3401
+ path: fact.path,
3402
+ text: String(fact.text),
3403
+ role: fact.role,
3404
+ ...typeof fact.level === "number" && { level: fact.level },
3405
+ ...fact.repeats === true && { repeats: true },
3406
+ ...fact.optional === true && { optional: true },
3407
+ ...frame && {
3408
+ box: {
3409
+ ...frame.widthPt !== void 0 && { widthPt: frame.widthPt },
3410
+ ...frame.heightPt !== void 0 && { heightPt: frame.heightPt }
3411
+ }
3412
+ }
3413
+ });
3414
+ } else if (format === "pptx" && fact.kind === "pptx/text") {
3415
+ if (fact.slideHidden === true) continue;
3416
+ const turned = typeof fact.rotationDeg === "number" && (fact.rotationDeg % 360 + 360) % 360 !== 0;
3417
+ if (turned) {
3418
+ entries.push({
3419
+ path: fact.path,
3420
+ text: String(fact.text),
3421
+ role: "slide-text",
3422
+ optional: true,
3423
+ ...typeof fact.page === "number" && { page: fact.page }
3424
+ });
3425
+ continue;
3426
+ }
3427
+ const { boxXPt: x, boxYPt: y } = fact;
3428
+ const widthPt = fact.boxWidthPt;
3429
+ const heightPt = fact.boxHeightPt;
3430
+ entries.push({
3431
+ path: fact.path,
3432
+ text: String(fact.text),
3433
+ role: "slide-text",
3434
+ // A slide is its own page and its boxes are where the deck put
3435
+ // them, so the text is looked for there before anywhere else.
3436
+ ...typeof fact.page === "number" && { page: fact.page },
3437
+ ...typeof x === "number" && typeof y === "number" && typeof widthPt === "number" && typeof heightPt === "number" && {
3438
+ region: {
3439
+ xMin: x,
3440
+ yMin: y,
3441
+ xMax: x + widthPt,
3442
+ yMax: y + heightPt
3443
+ }
3444
+ },
3445
+ ...(typeof widthPt === "number" || typeof heightPt === "number") && {
3446
+ box: {
3447
+ ...typeof widthPt === "number" && { widthPt },
3448
+ ...typeof heightPt === "number" && { heightPt }
3449
+ }
3450
+ }
3451
+ });
3452
+ }
3453
+ }
3454
+ return entries;
3455
+ }
3456
+ function requestedFontsFromFacts(format, facts, resolvedFonts) {
3457
+ const kind = format === "docx" ? "docx/font-family" : "pptx/font-family";
3458
+ const declared = new Set(
3459
+ resolvedFonts.filter((font) => font.declared).map((font) => font.family.trim().toLowerCase())
3460
+ );
3461
+ const seen = /* @__PURE__ */ new Set();
3462
+ const requested = [];
3463
+ const add = (family, path9) => {
3464
+ if (typeof family !== "string" || family.trim() === "") return;
3465
+ const key = family.trim().toLowerCase();
3466
+ if (seen.has(key)) return;
3467
+ seen.add(key);
3468
+ requested.push({
3469
+ family: family.trim(),
3470
+ ...path9 && { path: path9 },
3471
+ ...declared.has(key) && { declared: true }
3472
+ });
3473
+ };
3474
+ for (const fact of facts) {
3475
+ if (fact.kind === kind) add(fact.family, fact.path);
3476
+ }
3477
+ for (const font of resolvedFonts) if (font.declared) add(font.family);
3478
+ return requested;
3479
+ }
3480
+
3074
3481
  // src/block-matrix.ts
3075
3482
  import {
3076
3483
  blockDependencies,
@@ -3517,7 +3924,9 @@ export {
3517
3924
  pdffontsAvailable,
3518
3925
  pdftotextAvailable,
3519
3926
  readingOrder,
3927
+ renderedInventoryFromFacts,
3520
3928
  reportCaseDocument,
3929
+ requestedFontsFromFacts,
3521
3930
  runWithDiagnosticSink,
3522
3931
  stderrDiagnosticSink,
3523
3932
  widestNumber