@json-to-office/jto-ops 5.0.0 → 6.0.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
@@ -2208,40 +2208,61 @@ function readingOrder(words2) {
2208
2208
  }
2209
2209
  return order;
2210
2210
  }
2211
- function uprightOrder(words2, indices) {
2211
+ function median(values) {
2212
+ const sorted = [...values].sort((a, b) => a - b);
2213
+ const mid = sorted.length >> 1;
2214
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2215
+ }
2216
+ function groupRows(words2, indices) {
2212
2217
  const rows = [];
2213
2218
  const byY = [...indices].sort(
2214
2219
  (a, b) => words2[a].yMin - words2[b].yMin || words2[a].xMin - words2[b].xMin
2215
2220
  );
2216
2221
  for (const i of byY) {
2222
+ const word = words2[i];
2217
2223
  const row = rows[rows.length - 1];
2218
- if (row && sameRow(words2[row[0]], words2[i])) row.push(i);
2219
- else rows.push([i]);
2224
+ if (row && sameRow(words2[row.words[0]], word)) {
2225
+ row.words.push(i);
2226
+ row.yMin = Math.min(row.yMin, word.yMin);
2227
+ row.yMax = Math.max(row.yMax, word.yMax);
2228
+ } else rows.push({ words: [i], yMin: word.yMin, yMax: word.yMax });
2220
2229
  }
2230
+ return rows;
2231
+ }
2232
+ function uprightOrder(words2, indices) {
2233
+ const grouped = groupRows(words2, indices);
2234
+ const rows = grouped.map((row) => row.words);
2221
2235
  const fragmentsOf = (row) => {
2222
2236
  const sorted = [...row].sort((a, b) => words2[a].xMin - words2[b].xMin);
2237
+ const gaps = sorted.slice(1).map((i, n) => {
2238
+ const height = words2[i].yMax - words2[i].yMin;
2239
+ const gap = words2[i].xMin - words2[sorted[n]].xMax;
2240
+ return { gap, height, wide: gap > height / 2 };
2241
+ });
2242
+ const spaces = gaps.filter((g) => !g.wide).map((g) => g.gap);
2243
+ const typical = spaces.length > 1 ? median(spaces) : void 0;
2244
+ const isCellBoundary = (n) => {
2245
+ const g = gaps[n - 1];
2246
+ return g.wide || typical !== void 0 && g.gap > g.height / 3 && g.gap >= typical * 1.5;
2247
+ };
2223
2248
  const out = [];
2224
- for (const i of sorted) {
2249
+ sorted.forEach((i, n) => {
2225
2250
  const last = out[out.length - 1];
2226
2251
  const w = words2[i];
2227
- const height = w.yMax - w.yMin;
2228
- if (last && w.xMin - last.xMax <= height / 2) {
2252
+ if (last && !isCellBoundary(n)) {
2229
2253
  last.words.push(i);
2230
2254
  last.xMax = Math.max(last.xMax, w.xMax);
2231
2255
  } else out.push({ xMin: w.xMin, xMax: w.xMax, words: [i] });
2232
- }
2256
+ });
2233
2257
  return out;
2234
2258
  };
2235
2259
  const fragments = rows.map(fragmentsOf);
2236
- const boxes = rows.map((row) => ({
2237
- yMin: Math.min(...row.map((i) => words2[i].yMin)),
2238
- yMax: Math.max(...row.map((i) => words2[i].yMax))
2239
- }));
2260
+ const boxes = grouped;
2240
2261
  const order = [];
2241
2262
  let cluster = null;
2242
2263
  const flush = () => {
2243
2264
  if (!cluster) return;
2244
- if (!cluster.multi) {
2265
+ if (cluster.columns.length < 2) {
2245
2266
  for (const r of cluster.rows)
2246
2267
  for (const f of fragments[r]) order.push(...f.words);
2247
2268
  } else {
@@ -2263,9 +2284,8 @@ function uprightOrder(words2, indices) {
2263
2284
  const adjacent = cluster !== null && boxes[r].yMin - boxes[r - 1].yMax <= lineHeight * 0.9;
2264
2285
  const wide = cluster !== null && fragments[r].some((f) => overlapping(cluster.columns, f).length > 1);
2265
2286
  if (!adjacent || wide) flush();
2266
- if (!cluster) cluster = { rows: [], columns: [], multi: false };
2287
+ if (!cluster) cluster = { rows: [], columns: [] };
2267
2288
  cluster.rows.push(r);
2268
- if (fragments[r].length > 1) cluster.multi = true;
2269
2289
  for (const f of fragments[r]) {
2270
2290
  const hit = overlapping(cluster.columns, f)[0];
2271
2291
  if (hit) {
@@ -2414,6 +2434,34 @@ function chromeOccurrences(occurrences, pages) {
2414
2434
  )
2415
2435
  );
2416
2436
  }
2437
+ function pageNumberRows(pages, claimed) {
2438
+ const hits = [];
2439
+ pages.forEach((page, pageIndex) => {
2440
+ const rows = groupRows(
2441
+ page.words,
2442
+ page.words.map((_, i) => i)
2443
+ );
2444
+ const free = rows.map(
2445
+ (row) => row.words.some((i) => !claimed.has(`${pageIndex}:${i}`))
2446
+ );
2447
+ rows.forEach((row, r) => {
2448
+ if (!free[r] || !inChromeBand(page, row)) return;
2449
+ if (!row.words.every((i) => PAGE_NUMBER_ROW.test(page.words[i].text)))
2450
+ return;
2451
+ const line = row.yMax - row.yMin;
2452
+ const attached = rows.some(
2453
+ (other, o) => o !== r && free[o] && Math.max(other.yMin - row.yMax, row.yMin - other.yMax) < line
2454
+ );
2455
+ if (!attached) hits.push({ pageIndex, box: row });
2456
+ });
2457
+ });
2458
+ if (pages.length < 2) return hits;
2459
+ return hits.filter(
2460
+ (hit) => hits.some(
2461
+ (other) => other.pageIndex !== hit.pageIndex && Math.abs(other.box.yMin - hit.box.yMin) <= CHROME_ROW_TOLERANCE_PT
2462
+ )
2463
+ );
2464
+ }
2417
2465
  function assignInventory(pages, inventory) {
2418
2466
  const results = /* @__PURE__ */ new Map();
2419
2467
  const chromeWords = /* @__PURE__ */ new Set();
@@ -2450,16 +2498,8 @@ function assignInventory(pages, inventory) {
2450
2498
  });
2451
2499
  }
2452
2500
  }
2453
- pages.forEach((page, pageIndex) => {
2454
- page.words.forEach((word, i) => {
2455
- if (chromeWords.has(`${pageIndex}:${i}`)) return;
2456
- if (!inChromeBand(page, word)) return;
2457
- const row = page.words.filter((w) => sameRow(w, word));
2458
- if (row.every((w) => PAGE_NUMBER_ROW.test(w.text))) {
2459
- claimRow(pageIndex, word);
2460
- }
2461
- });
2462
- });
2501
+ for (const row of pageNumberRows(pages, chromeWords))
2502
+ claimRow(row.pageIndex, row.box);
2463
2503
  const body = indexDocument(pages, chromeWords);
2464
2504
  const claimed = /* @__PURE__ */ new Map();
2465
2505
  let cursor = 0;
@@ -2855,7 +2895,8 @@ function draftRenderedFindings(input) {
2855
2895
  }
2856
2896
  }
2857
2897
  }
2858
- const section = owner ? /^\/children\/\d+/.exec(owner.path)?.[0] : void 0;
2898
+ const owned = owner ? /^\/children\/\d+/.exec(owner.path)?.[0] : void 0;
2899
+ const section = owned && last ? closingSection(input.inventory) ?? owned : owned;
2859
2900
  findings.push(
2860
2901
  finding({
2861
2902
  ruleId: "rendered/page-underfilled",
@@ -2863,7 +2904,7 @@ function draftRenderedFindings(input) {
2863
2904
  page: pageIndex + 1,
2864
2905
  path: section ?? "",
2865
2906
  message: last ? `Page ${pageIndex + 1}, the last, is ${Math.round(fill * 100)}% filled: only the tail of the document reached it.` : `Page ${pageIndex + 1} is ${Math.round(fill * 100)}% filled: its content stops ${Math.round(footerTop - bottom)}pt above the footer and the next page begins anyway.`,
2866
- suggestion: last ? "Keep the notes and sources with the paragraph before them, or tighten the page before so the document closes on a designed page rather than a stub." : "Merge this section into its neighbour, or give the page the table or chart its argument owes. Do not pad it with prose: a short section on its own page reads as unfinished, a padded one reads as filler.",
2907
+ suggestion: last ? "Set `pageBreak: true` on this section \u2014 or on the one before it, when this section already starts a page \u2014 so the closing argument and its notes share one designed page instead of the last page taking only their tail, then re-preview. Keeping the notes with the paragraph above them moves the tail, not the page." : "Merge this section into its neighbour, or give the page the table or chart its argument owes. Do not pad it with prose: a short section on its own page reads as unfinished, a padded one reads as filler.",
2867
2908
  evidence: {
2868
2909
  summary: "Share of the body area the page paints, header to footer",
2869
2910
  expected: `\u2265 ${Math.round((last ? MINIMUM_LAST_PAGE_FILL : MINIMUM_PAGE_FILL) * 100)}%`,
@@ -2964,6 +3005,17 @@ function renderedEngine() {
2964
3005
  engine ??= new QualityEngine(RENDERED_QUALITY_RULES.rules);
2965
3006
  return engine;
2966
3007
  }
3008
+ function closingSection(inventory) {
3009
+ let highest;
3010
+ for (const entry of inventory) {
3011
+ if (entry.repeats) continue;
3012
+ const index = /^\/children\/(\d+)/.exec(entry.path)?.[1];
3013
+ if (index === void 0) continue;
3014
+ const value = Number(index);
3015
+ if (highest === void 0 || value > highest) highest = value;
3016
+ }
3017
+ return highest === void 0 ? void 0 : `/children/${highest}`;
3018
+ }
2967
3019
  function analyzeRenderedDocument(input, options = {}) {
2968
3020
  const fact = {
2969
3021
  id: "rendered:geometry",