@json-to-office/jto-ops 4.0.0 → 4.2.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
@@ -1742,24 +1742,41 @@ function decodeEntities(value) {
1742
1742
  );
1743
1743
  }
1744
1744
  var PAGE_PATTERN = /<page\s+width="([\d.]+)"\s+height="([\d.]+)">([\s\S]*?)<\/page>/g;
1745
- var WORD_PATTERN = /<word\s+xMin="(-?[\d.]+)"\s+yMin="(-?[\d.]+)"\s+xMax="(-?[\d.]+)"\s+yMax="(-?[\d.]+)">([\s\S]*?)<\/word>/g;
1745
+ var LINE_OR_WORD_PATTERN = /<line\s+xMin="(-?[\d.]+)"\s+yMin="(-?[\d.]+)"\s+xMax="(-?[\d.]+)"\s+yMax="(-?[\d.]+)">|<\/line>|<word\s+xMin="(-?[\d.]+)"\s+yMin="(-?[\d.]+)"\s+xMax="(-?[\d.]+)"\s+yMax="(-?[\d.]+)">([\s\S]*?)<\/word>/g;
1746
1746
  function parsePdfTextBbox(bboxXml) {
1747
1747
  const pages = [];
1748
1748
  for (const pageMatch of bboxXml.matchAll(PAGE_PATTERN)) {
1749
1749
  const words = [];
1750
- for (const wordMatch of pageMatch[3].matchAll(WORD_PATTERN)) {
1751
- words.push({
1752
- xMin: Number(wordMatch[1]),
1753
- yMin: Number(wordMatch[2]),
1754
- xMax: Number(wordMatch[3]),
1755
- yMax: Number(wordMatch[4]),
1756
- text: decodeEntities(wordMatch[5])
1757
- });
1750
+ const lines = [];
1751
+ let open;
1752
+ for (const token of pageMatch[3].matchAll(LINE_OR_WORD_PATTERN)) {
1753
+ if (token[0] === "</line>") {
1754
+ if (open) lines.push(open);
1755
+ open = void 0;
1756
+ } else if (token[1] !== void 0) {
1757
+ open = {
1758
+ xMin: Number(token[1]),
1759
+ yMin: Number(token[2]),
1760
+ xMax: Number(token[3]),
1761
+ yMax: Number(token[4]),
1762
+ words: []
1763
+ };
1764
+ } else {
1765
+ open?.words.push(words.length);
1766
+ words.push({
1767
+ xMin: Number(token[5]),
1768
+ yMin: Number(token[6]),
1769
+ xMax: Number(token[7]),
1770
+ yMax: Number(token[8]),
1771
+ text: decodeEntities(token[9])
1772
+ });
1773
+ }
1758
1774
  }
1759
1775
  pages.push({
1760
1776
  widthPt: Number(pageMatch[1]),
1761
1777
  heightPt: Number(pageMatch[2]),
1762
- words
1778
+ words,
1779
+ lines
1763
1780
  });
1764
1781
  }
1765
1782
  return pages;
@@ -1827,27 +1844,912 @@ async function extractPdfTextGeometry(pdfPath, options = {}) {
1827
1844
  const binary = await resolvePdftotext();
1828
1845
  const stdout = await run(
1829
1846
  binary,
1830
- ["-bbox", pdfPath, "-"],
1847
+ [options.layout ? "-bbox-layout" : "-bbox", pdfPath, "-"],
1831
1848
  options.timeoutMs ?? 6e4
1832
1849
  );
1833
1850
  return parsePdfTextBbox(stdout);
1834
1851
  }
1852
+
1853
+ // src/pdf-fonts.ts
1854
+ import { execFile as execFile3 } from "child_process";
1855
+ import * as path7 from "path";
1856
+ var ROW_PATTERN = /^(\S+)\s+(.+?)\s+(\S+)\s+(yes|no)\s+(yes|no)\s+(yes|no)\s+/;
1857
+ function parsePdfFonts(stdout) {
1858
+ const fonts = [];
1859
+ for (const line of stdout.split(/\r?\n/)) {
1860
+ if (line.startsWith("name ") || line.startsWith("---") || !line.trim()) {
1861
+ continue;
1862
+ }
1863
+ const match = ROW_PATTERN.exec(line);
1864
+ if (!match) continue;
1865
+ const name = match[1];
1866
+ fonts.push({
1867
+ name,
1868
+ baseName: name.replace(/^[A-Z]{6}\+/, ""),
1869
+ type: match[2].trim(),
1870
+ embedded: match[4] === "yes"
1871
+ });
1872
+ }
1873
+ return fonts;
1874
+ }
1875
+ function foldFontName(value) {
1876
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1877
+ }
1878
+ function familyRendered(family, fonts) {
1879
+ const folded = foldFontName(family);
1880
+ if (folded === "") return true;
1881
+ return fonts.some((font) => foldFontName(font.baseName).startsWith(folded));
1882
+ }
1883
+ function pdffontsCandidates() {
1884
+ const candidates = [];
1885
+ const configured = process.env.PDFFONTS_PATH?.trim();
1886
+ if (configured) candidates.push(configured);
1887
+ const sibling = process.env.PDFTOTEXT_PATH?.trim();
1888
+ if (sibling) {
1889
+ candidates.push(
1890
+ path7.join(
1891
+ path7.dirname(sibling),
1892
+ process.platform === "win32" ? "pdffonts.exe" : "pdffonts"
1893
+ )
1894
+ );
1895
+ }
1896
+ candidates.push("pdffonts");
1897
+ return [...new Set(candidates)];
1898
+ }
1899
+ async function run2(binary, args, timeoutMs) {
1900
+ return new Promise((resolve2, reject) => {
1901
+ execFile3(
1902
+ binary,
1903
+ args,
1904
+ { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
1905
+ (error, stdout) => {
1906
+ if (error) reject(error);
1907
+ else resolve2(stdout);
1908
+ }
1909
+ );
1910
+ });
1911
+ }
1912
+ var pdffontsPromise;
1913
+ async function resolvePdffonts() {
1914
+ if (!pdffontsPromise) {
1915
+ pdffontsPromise = (async () => {
1916
+ for (const candidate of pdffontsCandidates()) {
1917
+ try {
1918
+ await run2(candidate, ["-v"], 1e4);
1919
+ return candidate;
1920
+ } catch (error) {
1921
+ const code = error.code;
1922
+ if (code !== "ENOENT" && code !== "EACCES") return candidate;
1923
+ }
1924
+ }
1925
+ throw new Error(
1926
+ `Font inspection needs pdffonts (poppler), which was not found. Install poppler-utils or set PDFFONTS_PATH (searched: ${pdffontsCandidates().join(", ")}).`
1927
+ );
1928
+ })().catch((error) => {
1929
+ pdffontsPromise = void 0;
1930
+ throw error;
1931
+ });
1932
+ }
1933
+ return pdffontsPromise;
1934
+ }
1935
+ async function pdffontsAvailable() {
1936
+ try {
1937
+ await resolvePdffonts();
1938
+ return true;
1939
+ } catch {
1940
+ return false;
1941
+ }
1942
+ }
1943
+ async function extractPdfFonts(pdfPath, options = {}) {
1944
+ const binary = await resolvePdffonts();
1945
+ const stdout = await run2(binary, [pdfPath], options.timeoutMs ?? 3e4);
1946
+ return parsePdfFonts(stdout);
1947
+ }
1948
+
1949
+ // src/rendered-analysis.ts
1950
+ import {
1951
+ QualityEngine
1952
+ } from "@json-to-office/quality";
1953
+
1954
+ // src/rendered-rules.ts
1955
+ import {
1956
+ QUALITY_CODES
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
+ };
2037
+
2038
+ // src/rendered-text-match.ts
2039
+ function normalizeForMatch(value) {
2040
+ return value.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]/g, "");
2041
+ }
2042
+ var FIELD = "";
2043
+ function authoredTextForMatch(text) {
2044
+ return text.replace(/\{[^}\s]+\}/g, FIELD).replace(/\[\^[^\]\s]+\]/g, FIELD).replace(/\[@[^\]\s]+\]/g, FIELD).replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`]+/g, "");
2045
+ }
2046
+ function needleSegments(text) {
2047
+ return authoredTextForMatch(text).split(FIELD).map(normalizeForMatch).filter((segment) => segment !== "");
2048
+ }
2049
+ function indexDocument(pages, exclude = /* @__PURE__ */ new Set()) {
2050
+ const refs = [];
2051
+ const fragments = [];
2052
+ const positions = [];
2053
+ let cursor = 0;
2054
+ pages.forEach((page, pageIndex) => {
2055
+ page.words.forEach((word, index) => {
2056
+ const key = `${pageIndex}:${index}`;
2057
+ const fragment = exclude.has(key) ? "" : normalizeForMatch(word.text);
2058
+ refs.push({ pageIndex, word: index });
2059
+ fragments.push(fragment);
2060
+ positions.push(cursor);
2061
+ cursor += fragment.length;
2062
+ });
2063
+ });
2064
+ return { pages, refs, fragments, positions, stream: fragments.join("") };
2065
+ }
2066
+ function fragmentAt(index, offset) {
2067
+ let low = 0;
2068
+ let high = index.positions.length - 1;
2069
+ while (low < high) {
2070
+ const mid = Math.ceil((low + high) / 2);
2071
+ if (index.positions[mid] <= offset) low = mid;
2072
+ else high = mid - 1;
2073
+ }
2074
+ while (low < index.fragments.length && index.fragments[low] === "") low++;
2075
+ return low;
2076
+ }
2077
+ function alignedToFragments(index, at, end) {
2078
+ const first = fragmentAt(index, at);
2079
+ if (first >= index.fragments.length || index.positions[first] !== at) {
2080
+ return false;
2081
+ }
2082
+ let i = first;
2083
+ while (i < index.fragments.length && index.positions[i] + index.fragments[i].length < end) {
2084
+ i++;
2085
+ }
2086
+ return i < index.fragments.length && index.positions[i] + index.fragments[i].length === end;
2087
+ }
2088
+ function partsOf(index, at, end) {
2089
+ const parts = [];
2090
+ for (let i = fragmentAt(index, at); i < index.fragments.length; i++) {
2091
+ const start = index.positions[i];
2092
+ if (start >= end) break;
2093
+ if (index.fragments[i] === "") continue;
2094
+ const ref = index.refs[i];
2095
+ const w = index.pages[ref.pageIndex].words[ref.word];
2096
+ let part = parts[parts.length - 1];
2097
+ if (!part || part.pageIndex !== ref.pageIndex) {
2098
+ part = {
2099
+ pageIndex: ref.pageIndex,
2100
+ words: [],
2101
+ xMin: Infinity,
2102
+ yMin: Infinity,
2103
+ xMax: -Infinity,
2104
+ yMax: -Infinity
2105
+ };
2106
+ parts.push(part);
2107
+ }
2108
+ part.words.push(ref.word);
2109
+ part.xMin = Math.min(part.xMin, w.xMin);
2110
+ part.yMin = Math.min(part.yMin, w.yMin);
2111
+ part.xMax = Math.max(part.xMax, w.xMax);
2112
+ part.yMax = Math.max(part.yMax, w.yMax);
2113
+ }
2114
+ return parts;
2115
+ }
2116
+ function occurrence(index, at, end) {
2117
+ const parts = partsOf(index, at, end);
2118
+ return {
2119
+ at,
2120
+ pageIndex: parts[0].pageIndex,
2121
+ endPageIndex: parts[parts.length - 1].pageIndex,
2122
+ parts
2123
+ };
2124
+ }
2125
+ var MAX_FIELD_GAP = 24;
2126
+ function findOccurrences(index, segments) {
2127
+ if (segments.length === 0 || segments[0] === "") return [];
2128
+ const hits = [];
2129
+ let searchFrom = 0;
2130
+ for (; ; ) {
2131
+ const at = index.stream.indexOf(segments[0], searchFrom);
2132
+ if (at === -1) break;
2133
+ searchFrom = at + 1;
2134
+ let end = at + segments[0].length;
2135
+ let complete = true;
2136
+ for (let s = 1; s < segments.length; s++) {
2137
+ const next = index.stream.indexOf(segments[s], end);
2138
+ if (next === -1 || next - end > MAX_FIELD_GAP) {
2139
+ complete = false;
2140
+ break;
2141
+ }
2142
+ end = next + segments[s].length;
2143
+ }
2144
+ if (!complete || !alignedToFragments(index, at, end)) continue;
2145
+ hits.push(occurrence(index, at, end));
2146
+ searchFrom = end;
2147
+ }
2148
+ return hits;
2149
+ }
2150
+ var MIN_NEEDLE_LENGTH = 3;
2151
+ var MIN_PARTIAL_PREFIX = 16;
2152
+ var CHROME_BAND = 0.2;
2153
+ function longestRenderedPrefix(index, needle) {
2154
+ if (needle.length < MIN_PARTIAL_PREFIX) return void 0;
2155
+ const anchor = needle.slice(0, MIN_PARTIAL_PREFIX);
2156
+ let best;
2157
+ let searchFrom = 0;
2158
+ for (; ; ) {
2159
+ const at = index.stream.indexOf(anchor, searchFrom);
2160
+ if (at === -1) break;
2161
+ searchFrom = at + 1;
2162
+ const first = fragmentAt(index, at);
2163
+ if (first >= index.fragments.length || index.positions[first] !== at) {
2164
+ continue;
2165
+ }
2166
+ let length = MIN_PARTIAL_PREFIX;
2167
+ while (length < needle.length && index.stream.charCodeAt(at + length) === needle.charCodeAt(length)) {
2168
+ length++;
2169
+ }
2170
+ if (!best || length > best.length) best = { length, at };
2171
+ }
2172
+ return best;
2173
+ }
2174
+ function sameRow(a, b) {
2175
+ const overlap = Math.min(a.yMax, b.yMax) - Math.max(a.yMin, b.yMin);
2176
+ const shorter = Math.min(a.yMax - a.yMin, b.yMax - b.yMin);
2177
+ return shorter > 0 && overlap > shorter / 2;
2178
+ }
2179
+ function inChromeBand(page, box) {
2180
+ return box.yMax <= page.heightPt * CHROME_BAND || box.yMin >= page.heightPt * (1 - CHROME_BAND);
2181
+ }
2182
+ var PAGE_NUMBER_ROW = /^[0-9\s./|-]*$/;
2183
+ function assignInventory(pages, inventory) {
2184
+ const results = /* @__PURE__ */ new Map();
2185
+ const chromeWords = /* @__PURE__ */ new Set();
2186
+ const claimRow = (pageIndex, box) => {
2187
+ pages[pageIndex].words.forEach((w, i) => {
2188
+ if (sameRow(w, box)) chromeWords.add(`${pageIndex}:${i}`);
2189
+ });
2190
+ };
2191
+ const repeating = inventory.filter((entry) => entry.repeats);
2192
+ if (repeating.length > 0) {
2193
+ const full = indexDocument(pages);
2194
+ for (const entry of repeating) {
2195
+ const segments = needleSegments(entry.text);
2196
+ const needle = segments.join("");
2197
+ if (needle.length < MIN_NEEDLE_LENGTH) {
2198
+ results.set(entry, {
2199
+ entry,
2200
+ needle,
2201
+ status: "skipped",
2202
+ occurrences: []
2203
+ });
2204
+ continue;
2205
+ }
2206
+ const occurrences = findOccurrences(full, segments).filter(
2207
+ (o) => o.pageIndex === o.endPageIndex && inChromeBand(pages[o.pageIndex], o.parts[0])
2208
+ );
2209
+ for (const o of occurrences) claimRow(o.pageIndex, o.parts[0]);
2210
+ results.set(entry, {
2211
+ entry,
2212
+ needle,
2213
+ status: occurrences.length > 0 ? "mapped" : "missing",
2214
+ occurrences
2215
+ });
2216
+ }
2217
+ }
2218
+ pages.forEach((page, pageIndex) => {
2219
+ page.words.forEach((word, i) => {
2220
+ if (chromeWords.has(`${pageIndex}:${i}`)) return;
2221
+ if (!inChromeBand(page, word)) return;
2222
+ const row = page.words.filter((w) => sameRow(w, word));
2223
+ if (row.every((w) => PAGE_NUMBER_ROW.test(w.text))) {
2224
+ claimRow(pageIndex, word);
2225
+ }
2226
+ });
2227
+ });
2228
+ const body = indexDocument(pages, chromeWords);
2229
+ const claimed = /* @__PURE__ */ new Map();
2230
+ let cursor = 0;
2231
+ for (const entry of inventory) {
2232
+ if (entry.repeats) continue;
2233
+ const segments = needleSegments(entry.text);
2234
+ const needle = segments.join("");
2235
+ if (needle.length < MIN_NEEDLE_LENGTH) {
2236
+ results.set(entry, { entry, needle, status: "skipped", occurrences: [] });
2237
+ continue;
2238
+ }
2239
+ let all = findOccurrences(body, segments);
2240
+ let partial;
2241
+ if (all.length === 0 && segments.length === 1 && !entry.optional) {
2242
+ const prefix = longestRenderedPrefix(body, needle);
2243
+ if (prefix) {
2244
+ all = [occurrence(body, prefix.at, prefix.at + prefix.length)];
2245
+ partial = { matchedChars: prefix.length, totalChars: needle.length };
2246
+ }
2247
+ }
2248
+ if (all.length === 0) {
2249
+ results.set(entry, {
2250
+ entry,
2251
+ needle,
2252
+ status: entry.optional ? "skipped" : "missing",
2253
+ occurrences: []
2254
+ });
2255
+ continue;
2256
+ }
2257
+ let free = all.filter((o) => !claimed.has(o.at));
2258
+ if (free.length === 0 && !entry.optional) {
2259
+ const held = all.find((o) => claimed.get(o.at)?.optional);
2260
+ if (held) {
2261
+ const holder = claimed.get(held.at);
2262
+ claimed.delete(held.at);
2263
+ results.set(holder, {
2264
+ entry: holder,
2265
+ needle: results.get(holder)?.needle ?? "",
2266
+ status: "skipped",
2267
+ occurrences: []
2268
+ });
2269
+ free = [held];
2270
+ }
2271
+ }
2272
+ if (free.length === 0) {
2273
+ results.set(entry, {
2274
+ entry,
2275
+ needle,
2276
+ status: "ambiguous",
2277
+ occurrences: all
2278
+ });
2279
+ continue;
2280
+ }
2281
+ const chosen = free.find((o) => o.at >= cursor) ?? free[0];
2282
+ claimed.set(chosen.at, entry);
2283
+ cursor = chosen.at;
2284
+ results.set(entry, {
2285
+ entry,
2286
+ needle,
2287
+ status: "mapped",
2288
+ occurrences: [chosen],
2289
+ ...partial && { partial }
2290
+ });
2291
+ }
2292
+ return {
2293
+ matches: inventory.map((entry) => results.get(entry)),
2294
+ chromeWords
2295
+ };
2296
+ }
2297
+
2298
+ // src/rendered-analysis.ts
2299
+ function isRenderedGeometryFact(fact) {
2300
+ return fact.kind === "rendered/geometry";
2301
+ }
2302
+ var drafts = /* @__PURE__ */ new WeakMap();
2303
+ function renderedDraftFor(fact) {
2304
+ let draft = drafts.get(fact);
2305
+ if (!draft) {
2306
+ draft = draftRenderedFindings(fact.input);
2307
+ drafts.set(fact, draft);
2308
+ }
2309
+ return draft;
2310
+ }
2311
+ var VISIBLE_SPILL_PT = 2;
2312
+ var OVERLAP_FRACTION = 0.3;
2313
+ function finding(draft) {
2314
+ const { mapping, page, context, ...rest } = draft;
2315
+ return {
2316
+ ...rest,
2317
+ context: {
2318
+ ...context,
2319
+ mapping,
2320
+ ...page !== void 0 && { page }
2321
+ }
2322
+ };
2323
+ }
2324
+ function round(value) {
2325
+ return Math.round(value * 10) / 10;
2326
+ }
2327
+ function intersects(a, b) {
2328
+ const w = Math.min(a.xMax, b.xMax) - Math.max(a.xMin, b.xMin);
2329
+ const h = Math.min(a.yMax, b.yMax) - Math.max(a.yMin, b.yMin);
2330
+ return w > 0 && h > 0 ? w * h : 0;
2331
+ }
2332
+ function area(w) {
2333
+ return Math.max(0, w.xMax - w.xMin) * Math.max(0, w.yMax - w.yMin);
2334
+ }
2335
+ function excerpt(text, max = 60) {
2336
+ const flat = text.replace(/\s+/g, " ").trim();
2337
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
2338
+ }
2339
+ function wordOwners(matches) {
2340
+ const owners = /* @__PURE__ */ new Map();
2341
+ for (const match of matches) {
2342
+ if (match.status !== "mapped") continue;
2343
+ for (const o of match.occurrences) {
2344
+ for (const part of o.parts) {
2345
+ for (const w of part.words) owners.set(`${part.pageIndex}:${w}`, match);
2346
+ }
2347
+ }
2348
+ }
2349
+ return owners;
2350
+ }
2351
+ var lineMaps = /* @__PURE__ */ new WeakMap();
2352
+ function lineOfWord(page) {
2353
+ const cached2 = lineMaps.get(page);
2354
+ if (cached2) return cached2;
2355
+ const lines = /* @__PURE__ */ new Map();
2356
+ page.lines.forEach((line, index) => {
2357
+ for (const w of line.words) lines.set(w, index);
2358
+ });
2359
+ lineMaps.set(page, lines);
2360
+ return lines;
2361
+ }
2362
+ function unionBox(parts) {
2363
+ return parts.reduce(
2364
+ (box, part) => ({
2365
+ xMin: Math.min(box.xMin, part.xMin),
2366
+ yMin: Math.min(box.yMin, part.yMin),
2367
+ xMax: Math.max(box.xMax, part.xMax),
2368
+ yMax: Math.max(box.yMax, part.yMax)
2369
+ }),
2370
+ { xMin: Infinity, yMin: Infinity, xMax: -Infinity, yMax: -Infinity }
2371
+ );
2372
+ }
2373
+ function draftRenderedFindings(input) {
2374
+ const { pages, format } = input;
2375
+ const { matches, chromeWords } = assignInventory(pages, input.inventory);
2376
+ const owners = wordOwners(matches);
2377
+ const findings = [];
2378
+ const ownerOf = (pageIndex, word) => owners.get(`${pageIndex}:${word}`);
2379
+ const clipped = /* @__PURE__ */ new Map();
2380
+ pages.forEach((page, pageIndex) => {
2381
+ page.words.forEach((word, index) => {
2382
+ const beyond = Math.max(
2383
+ -word.xMin,
2384
+ word.xMax - page.widthPt,
2385
+ -word.yMin,
2386
+ word.yMax - page.heightPt
2387
+ );
2388
+ if (beyond <= VISIBLE_SPILL_PT) return;
2389
+ const owner = ownerOf(pageIndex, index);
2390
+ const key = owner ? `entry:${owner.entry.path}` : `page:${pageIndex}`;
2391
+ const bucket = clipped.get(key) ?? {
2392
+ match: owner,
2393
+ words: [],
2394
+ page: pageIndex + 1,
2395
+ maxPt: 0
2396
+ };
2397
+ bucket.words.push(word);
2398
+ bucket.maxPt = Math.max(bucket.maxPt, beyond);
2399
+ clipped.set(key, bucket);
2400
+ });
2401
+ });
2402
+ for (const bucket of clipped.values()) {
2403
+ const sample = excerpt(bucket.words.map((w) => w.text).join(" "));
2404
+ findings.push(
2405
+ finding({
2406
+ ruleId: "rendered/clip",
2407
+ mapping: bucket.match ? "mapped" : "unmapped",
2408
+ page: bucket.page,
2409
+ path: bucket.match?.entry.path ?? "",
2410
+ message: `${bucket.words.length} word${bucket.words.length === 1 ? "" : "s"} rendered past the page edge on page ${bucket.page} by up to ${round(bucket.maxPt)} pt: "${sample}".`,
2411
+ suggestion: "Shorten the text, allow it to wrap, or widen the column or frame that holds it; text outside the page is cut off in print and in Word.",
2412
+ evidence: {
2413
+ summary: "Word boxes beyond the page bounds",
2414
+ actual: round(bucket.maxPt),
2415
+ expected: 0,
2416
+ unit: "pt",
2417
+ values: { words: bucket.words.length }
2418
+ }
2419
+ })
2420
+ );
2421
+ }
2422
+ for (const match of matches) {
2423
+ if (!match.partial || match.status !== "mapped") continue;
2424
+ const o = match.occurrences[0];
2425
+ const shown = Math.round(
2426
+ 100 * match.partial.matchedChars / match.partial.totalChars
2427
+ );
2428
+ findings.push(
2429
+ finding({
2430
+ ruleId: "rendered/clip",
2431
+ mapping: "mapped",
2432
+ page: o.endPageIndex + 1,
2433
+ path: match.entry.path,
2434
+ message: `"${excerpt(match.entry.text)}" is cut off: about ${shown}% of it rendered on page ${o.endPageIndex + 1} and the rest is nowhere on the page.`,
2435
+ suggestion: "Shorten the text or enlarge the frame, box or cell that holds it; what did not render here will not print either.",
2436
+ evidence: {
2437
+ summary: "Share of the authored text found in the PDF",
2438
+ actual: shown,
2439
+ expected: 100,
2440
+ unit: "%",
2441
+ values: match.partial
2442
+ },
2443
+ context: { kind: "truncated" }
2444
+ })
2445
+ );
2446
+ }
2447
+ for (const match of matches) {
2448
+ const box = match.entry.box;
2449
+ if (!box || match.status !== "mapped") continue;
2450
+ for (const o of match.occurrences) {
2451
+ const extent = unionBox(o.parts);
2452
+ const rendered = {
2453
+ heightPt: extent.yMax - extent.yMin,
2454
+ widthPt: extent.xMax - extent.xMin
2455
+ };
2456
+ const worst = ["heightPt", "widthPt"].filter((axis) => box[axis] !== void 0).map((axis) => ({
2457
+ axis,
2458
+ declared: box[axis],
2459
+ rendered: rendered[axis],
2460
+ over: rendered[axis] - box[axis]
2461
+ })).sort((a, b) => b.over - a.over)[0];
2462
+ if (!worst || worst.over <= VISIBLE_SPILL_PT) continue;
2463
+ const word = worst.axis === "heightPt" ? "taller" : "wider";
2464
+ findings.push(
2465
+ finding({
2466
+ ruleId: "rendered/spill",
2467
+ mapping: "mapped",
2468
+ page: o.pageIndex + 1,
2469
+ path: match.entry.path,
2470
+ message: `"${excerpt(match.entry.text)}" rendered ${round(worst.over)} pt ${word} than its ${round(worst.declared)} pt box on page ${o.pageIndex + 1}.`,
2471
+ suggestion: "Shorten the text, reduce its size, or enlarge the box; the renderer let it spill past the edge the author drew.",
2472
+ evidence: {
2473
+ summary: "Rendered extent against the declared box",
2474
+ actual: round(worst.rendered),
2475
+ expected: round(worst.declared),
2476
+ unit: "pt",
2477
+ values: { marginPt: round(-worst.over) }
2478
+ }
2479
+ })
2480
+ );
2481
+ }
2482
+ }
2483
+ pages.forEach((page, pageIndex) => {
2484
+ const lines = lineOfWord(page);
2485
+ const seen = /* @__PURE__ */ new Set();
2486
+ const words = page.words;
2487
+ const byTop = words.map((_, i) => i).sort((a, b) => words[a].yMin - words[b].yMin);
2488
+ for (let p = 0; p < byTop.length; p++) {
2489
+ const i = byTop[p];
2490
+ const a = words[i];
2491
+ for (let q = p + 1; q < byTop.length; q++) {
2492
+ const j = byTop[q];
2493
+ const b = words[j];
2494
+ if (b.yMin >= a.yMax) break;
2495
+ const shared = intersects(a, b);
2496
+ if (shared <= 0) continue;
2497
+ const lineA = lines.get(i);
2498
+ if (lineA !== void 0 && lineA === lines.get(j)) continue;
2499
+ const smaller = Math.min(area(a), area(b));
2500
+ if (smaller <= 0 || shared / smaller < OVERLAP_FRACTION) continue;
2501
+ const ownerA = ownerOf(pageIndex, i);
2502
+ const ownerB = ownerOf(pageIndex, j);
2503
+ const pathA = ownerA?.entry.path ?? "";
2504
+ const pathB = ownerB?.entry.path ?? "";
2505
+ const key = pathA && pathB ? [pathA, pathB].sort().join("|") : `${Math.min(i, j)}:${Math.max(i, j)}`;
2506
+ if (seen.has(key)) continue;
2507
+ seen.add(key);
2508
+ const mapping = ownerA && ownerB ? "mapped" : ownerA || ownerB ? "ambiguous" : "unmapped";
2509
+ findings.push(
2510
+ finding({
2511
+ ruleId: "rendered/overlap",
2512
+ mapping,
2513
+ page: pageIndex + 1,
2514
+ path: pathA || pathB,
2515
+ ...pathA && pathB && pathA !== pathB && { relatedPaths: [pathB] },
2516
+ message: `"${a.text}" and "${b.text}" are drawn over each other on page ${pageIndex + 1}.`,
2517
+ suggestion: "Give the two elements separate room: move one, reduce the text, or increase the spacing between them.",
2518
+ evidence: {
2519
+ summary: "Word boxes intersect",
2520
+ actual: round(100 * shared / smaller),
2521
+ expected: 0,
2522
+ unit: "% of the smaller word"
2523
+ }
2524
+ })
2525
+ );
2526
+ }
2527
+ }
2528
+ });
2529
+ for (const match of matches) {
2530
+ if (match.status !== "missing") continue;
2531
+ findings.push(
2532
+ finding({
2533
+ ruleId: "rendered/text-missing",
2534
+ mapping: "mapped",
2535
+ path: match.entry.path,
2536
+ message: `"${excerpt(match.entry.text)}" does not appear anywhere in the rendered pages.`,
2537
+ suggestion: "The text was fully clipped, hidden behind another element, or set somewhere the renderer could not place it; preview the page it belongs to.",
2538
+ evidence: { summary: "No rendered occurrence of the authored text" },
2539
+ context: { role: match.entry.role }
2540
+ })
2541
+ );
2542
+ }
2543
+ let substituted = 0;
2544
+ if (input.fonts && input.fonts.length > 0 && input.requestedFonts) {
2545
+ const seen = /* @__PURE__ */ new Set();
2546
+ for (const requested of input.requestedFonts) {
2547
+ const family = requested.family.trim();
2548
+ if (family === "" || seen.has(family.toLowerCase())) continue;
2549
+ seen.add(family.toLowerCase());
2550
+ if (familyRendered(family, input.fonts)) continue;
2551
+ substituted += 1;
2552
+ findings.push(
2553
+ finding({
2554
+ ruleId: "rendered/font-substituted",
2555
+ severity: requested.declared ? "warning" : "info",
2556
+ mapping: requested.path ? "mapped" : "unmapped",
2557
+ path: requested.path ?? "",
2558
+ message: requested.declared ? `"${family}" was declared with a source but the rendered PDF embeds no face of it; the renderer substituted another family.` : `"${family}" is not installed on this host, so the preview substituted another family; a recipient with the font sees the intended face.`,
2559
+ suggestion: requested.declared ? "Check the font source: the file or URL must resolve to a face LibreOffice can load, or the recipient will see the same substitution." : "Install the family on the host for a faithful preview, or declare a source for it in the document fonts so every renderer has it.",
2560
+ context: { declared: requested.declared === true },
2561
+ evidence: {
2562
+ summary: "Requested family against embedded PDF fonts",
2563
+ expected: family,
2564
+ actual: [...new Set(input.fonts.map((f) => f.baseName))].join(", ")
2565
+ }
2566
+ })
2567
+ );
2568
+ }
2569
+ }
2570
+ pages.forEach((page, pageIndex) => {
2571
+ if (page.words.length > 0) return;
2572
+ findings.push(
2573
+ finding({
2574
+ ruleId: "rendered/empty-page",
2575
+ mapping: "unmapped",
2576
+ page: pageIndex + 1,
2577
+ path: "",
2578
+ message: `Page ${pageIndex + 1} carries no text. A full-page figure is fine; a blank page from a stray break is not.`,
2579
+ suggestion: "Preview the page; remove the page break or the empty section if nothing was meant to be there."
2580
+ })
2581
+ );
2582
+ });
2583
+ if (format === "docx") {
2584
+ for (const match of matches) {
2585
+ if (match.entry.role !== "heading" || match.status !== "mapped") continue;
2586
+ const o = match.occurrences[0];
2587
+ const part = o.parts[o.parts.length - 1];
2588
+ if (part.pageIndex >= pages.length - 1) continue;
2589
+ const page = pages[part.pageIndex];
2590
+ const below = page.words.some(
2591
+ (w, i) => !chromeWords.has(`${part.pageIndex}:${i}`) && !part.words.includes(i) && w.yMin >= part.yMax - 1
2592
+ );
2593
+ if (below) continue;
2594
+ findings.push(
2595
+ finding({
2596
+ ruleId: "rendered/heading-stranded",
2597
+ mapping: "mapped",
2598
+ page: part.pageIndex + 1,
2599
+ path: match.entry.path,
2600
+ message: `Heading "${excerpt(match.entry.text)}" is the last line on page ${part.pageIndex + 1}; its content starts on the next page.`,
2601
+ suggestion: "Keep the heading with its first paragraph (keep-with-next), or move a page break so they land together."
2602
+ })
2603
+ );
2604
+ }
2605
+ for (const match of matches) {
2606
+ const role = match.entry.role;
2607
+ if (role !== "body" && role !== "list-item" || match.status !== "mapped")
2608
+ continue;
2609
+ const o = match.occurrences[0];
2610
+ if (o.pageIndex === o.endPageIndex) continue;
2611
+ const first = o.parts[0];
2612
+ const last = o.parts[o.parts.length - 1];
2613
+ const linesOn = (part) => {
2614
+ const lines = lineOfWord(pages[part.pageIndex]);
2615
+ const set = /* @__PURE__ */ new Set();
2616
+ for (const w of part.words) {
2617
+ const line = lines.get(w);
2618
+ if (line !== void 0) set.add(line);
2619
+ }
2620
+ return set.size;
2621
+ };
2622
+ const before = linesOn(first);
2623
+ const after = linesOn(last);
2624
+ if (before === 0 || after === 0) continue;
2625
+ if (before > 1 && after > 1) continue;
2626
+ const which = before === 1 ? "orphan" : "widow";
2627
+ findings.push(
2628
+ finding({
2629
+ ruleId: "rendered/paragraph-split",
2630
+ mapping: "mapped",
2631
+ page: (which === "orphan" ? first.pageIndex : last.pageIndex) + 1,
2632
+ path: match.entry.path,
2633
+ message: which === "orphan" ? `"${excerpt(match.entry.text)}" leaves one line alone at the foot of page ${first.pageIndex + 1}.` : `"${excerpt(match.entry.text)}" leaves one line alone at the top of page ${last.pageIndex + 1}.`,
2634
+ suggestion: "Edit the paragraph a few words shorter or longer, or set widow/orphan control on the style.",
2635
+ evidence: {
2636
+ summary: "Lines on each side of the page break",
2637
+ values: { linesBefore: before, linesAfter: after }
2638
+ },
2639
+ context: { kind: which }
2640
+ })
2641
+ );
2642
+ }
2643
+ }
2644
+ const inventory = {
2645
+ mapped: 0,
2646
+ ambiguous: 0,
2647
+ missing: 0,
2648
+ skipped: 0
2649
+ };
2650
+ for (const match of matches) inventory[match.status] += 1;
2651
+ const byRule = /* @__PURE__ */ new Map();
2652
+ for (const draft of findings) {
2653
+ const bucket = byRule.get(draft.ruleId);
2654
+ if (bucket) bucket.push(draft);
2655
+ else byRule.set(draft.ruleId, [draft]);
2656
+ }
2657
+ return {
2658
+ drafts: findings,
2659
+ byRule,
2660
+ inventory,
2661
+ // The same gate the check itself runs under: an empty font list
2662
+ // verified nothing, and must not read as "nothing was substituted".
2663
+ ...input.fonts && input.fonts.length > 0 && input.requestedFonts && {
2664
+ fonts: { requested: input.requestedFonts.length, substituted }
2665
+ }
2666
+ };
2667
+ }
2668
+ var engine;
2669
+ function renderedEngine() {
2670
+ engine ??= new QualityEngine(RENDERED_QUALITY_RULES.rules);
2671
+ return engine;
2672
+ }
2673
+ function analyzeRenderedDocument(input, options = {}) {
2674
+ const fact = {
2675
+ id: "rendered:geometry",
2676
+ kind: "rendered/geometry",
2677
+ path: "",
2678
+ input
2679
+ };
2680
+ const analysis = renderedEngine().analyzeSync(
2681
+ {
2682
+ format: input.format,
2683
+ model: void 0,
2684
+ facts: [fact],
2685
+ provenance: {},
2686
+ ...input.renderer !== void 0 && { renderer: input.renderer }
2687
+ },
2688
+ options
2689
+ );
2690
+ let draft;
2691
+ try {
2692
+ draft = renderedDraftFor(fact);
2693
+ } catch {
2694
+ draft = void 0;
2695
+ }
2696
+ const byMapping = {
2697
+ mapped: 0,
2698
+ ambiguous: 0,
2699
+ unmapped: 0
2700
+ };
2701
+ for (const f of analysis.diagnostics) {
2702
+ byMapping[f.context?.mapping ?? "unmapped"] += 1;
2703
+ }
2704
+ return {
2705
+ findings: analysis.diagnostics,
2706
+ summary: {
2707
+ pages: input.pages.length,
2708
+ words: input.pages.reduce((n, p) => n + p.words.length, 0),
2709
+ inventory: draft?.inventory ?? {
2710
+ mapped: 0,
2711
+ ambiguous: 0,
2712
+ missing: 0,
2713
+ skipped: 0
2714
+ },
2715
+ findings: byMapping,
2716
+ ...draft?.fonts && { fonts: draft.fonts },
2717
+ suppressed: analysis.suppressedCount,
2718
+ blocked: analysis.blocked,
2719
+ truncated: analysis.truncated,
2720
+ ...analysis.profileId !== void 0 && {
2721
+ profileId: analysis.profileId
2722
+ }
2723
+ },
2724
+ analysis
2725
+ };
2726
+ }
1835
2727
  export {
1836
2728
  DocxFormatAdapter,
1837
2729
  FontconfigStager,
1838
2730
  MacOSCoreTextStager,
1839
2731
  NoopFontStager,
1840
2732
  PptxFormatAdapter,
2733
+ RENDERED_QUALITY_RULES,
2734
+ VISIBLE_SPILL_PT,
1841
2735
  WindowsFontStager,
2736
+ analyzeRenderedDocument,
2737
+ assignInventory,
2738
+ authoredTextForMatch,
1842
2739
  clearRasterizerCache,
1843
2740
  createAdapter,
1844
2741
  createLibreOfficePptxBatchRasterizer,
1845
2742
  createLibreOfficePptxRasterizer,
1846
2743
  emitDiagnostic,
2744
+ extractPdfFonts,
1847
2745
  extractPdfTextGeometry,
2746
+ familyRendered,
1848
2747
  getFontStager,
1849
2748
  getRasterizerCacheStats,
2749
+ normalizeForMatch,
2750
+ parsePdfFonts,
1850
2751
  parsePdfTextBbox,
2752
+ pdffontsAvailable,
1851
2753
  pdftotextAvailable,
1852
2754
  runWithDiagnosticSink,
1853
2755
  stderrDiagnosticSink