reamkit 1.27.0 → 1.29.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.
Files changed (68) hide show
  1. package/README.md +6 -5
  2. package/dist/esm/core/converter/ream.d.ts +8 -9
  3. package/dist/esm/core/converter/ream.js +0 -1
  4. package/dist/esm/core/document-model/types.d.ts +5 -3
  5. package/dist/esm/core/font/index.d.ts +1 -0
  6. package/dist/esm/core/font/ligatures.d.ts +17 -0
  7. package/dist/esm/core/font/ligatures.js +49 -0
  8. package/dist/esm/core/font/ttf-parser.d.ts +2 -1
  9. package/dist/esm/core/font/ttf-parser.js +11 -2
  10. package/dist/esm/core/fonts/remote-fonts.d.ts +1 -1
  11. package/dist/esm/core/fonts/remote-fonts.js +47 -2
  12. package/dist/esm/core/fonts/scripts.js +10 -5
  13. package/dist/esm/excel/header-footer.js +53 -8
  14. package/dist/esm/layout/styled-layout.js +53 -3
  15. package/dist/esm/pdf/cid-font.js +35 -8
  16. package/dist/esm/pdf-reader/annot-draw.js +114 -1
  17. package/dist/esm/pdf-reader/annots.d.ts +0 -14
  18. package/dist/esm/pdf-reader/annots.js +13 -1
  19. package/dist/esm/pdf-reader/ccitt.d.ts +2 -3
  20. package/dist/esm/pdf-reader/ccitt.js +32 -4
  21. package/dist/esm/pdf-reader/cff-outline.d.ts +36 -0
  22. package/dist/esm/pdf-reader/cff-outline.js +1122 -0
  23. package/dist/esm/pdf-reader/cmap.js +16 -6
  24. package/dist/esm/pdf-reader/content.d.ts +91 -0
  25. package/dist/esm/pdf-reader/content.js +156 -60
  26. package/dist/esm/pdf-reader/dingbats.d.ts +11 -0
  27. package/dist/esm/pdf-reader/dingbats.js +1033 -0
  28. package/dist/esm/pdf-reader/display.js +41 -1
  29. package/dist/esm/pdf-reader/document.d.ts +8 -0
  30. package/dist/esm/pdf-reader/document.js +26 -9
  31. package/dist/esm/pdf-reader/embedded-fonts.d.ts +11 -0
  32. package/dist/esm/pdf-reader/embedded-fonts.js +14 -1
  33. package/dist/esm/pdf-reader/encodings.d.ts +25 -0
  34. package/dist/esm/pdf-reader/encodings.js +110 -0
  35. package/dist/esm/pdf-reader/flow-build.d.ts +15 -4
  36. package/dist/esm/pdf-reader/flow-build.js +24 -11
  37. package/dist/esm/pdf-reader/font.js +329 -8
  38. package/dist/esm/pdf-reader/glyf-outline.d.ts +43 -0
  39. package/dist/esm/pdf-reader/glyf-outline.js +351 -0
  40. package/dist/esm/pdf-reader/glyph-names.js +20 -0
  41. package/dist/esm/pdf-reader/icc.d.ts +10 -0
  42. package/dist/esm/pdf-reader/icc.js +210 -0
  43. package/dist/esm/pdf-reader/image-decode.d.ts +4 -3
  44. package/dist/esm/pdf-reader/image-decode.js +85 -64
  45. package/dist/esm/pdf-reader/images.d.ts +6 -0
  46. package/dist/esm/pdf-reader/images.js +50 -7
  47. package/dist/esm/pdf-reader/jbig2.js +104 -19
  48. package/dist/esm/pdf-reader/layout.js +1531 -55
  49. package/dist/esm/pdf-reader/math-rows.d.ts +23 -0
  50. package/dist/esm/pdf-reader/math-rows.js +198 -0
  51. package/dist/esm/pdf-reader/predefined-cmap.d.ts +21 -0
  52. package/dist/esm/pdf-reader/predefined-cmap.js +102 -0
  53. package/dist/esm/pdf-reader/reader.d.ts +6 -9
  54. package/dist/esm/pdf-reader/reader.js +34 -21
  55. package/dist/esm/pdf-reader/shading.d.ts +81 -2
  56. package/dist/esm/pdf-reader/shading.js +191 -25
  57. package/dist/esm/pdf-reader/stream-filters.d.ts +6 -0
  58. package/dist/esm/pdf-reader/stream-filters.js +67 -0
  59. package/dist/esm/pdf-reader/tagged.js +1 -1
  60. package/dist/esm/pdf-reader/text-rules.js +83 -10
  61. package/dist/esm/pdf-reader/text.js +82 -3
  62. package/dist/esm/pdf-reader/type1-outline.d.ts +21 -0
  63. package/dist/esm/pdf-reader/type1-outline.js +576 -0
  64. package/dist/esm/pdf-reader/vector.d.ts +2 -0
  65. package/dist/esm/pdf-reader/vector.js +44 -4
  66. package/dist/esm/word/document-parser.js +4 -1
  67. package/dist/esm/word/docx-writer.js +28 -11
  68. package/package.json +1 -1
@@ -9,6 +9,11 @@ import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
9
9
  import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock, withMeasuredMargins } from "./flow-build.js";
10
10
  import { collectPageImages } from "./images.js";
11
11
  import { markDrawnRules } from "./text-rules.js";
12
+ import { matrixBlocks } from "./math-rows.js";
13
+ //#region src/pdf-reader/layout.ts
14
+ /** The relationships the reconstruction files its running head and foot under. */
15
+ var FOOTER_PART = "pdf-running-foot";
16
+ var HEADER_PART = "pdf-running-head";
12
17
  /**
13
18
  * Heuristically reconstruct an untagged PDF into a {@link Reconstruction}
14
19
  * (E-PDF EP4). With no structure tree there is only positioned content, so
@@ -27,7 +32,11 @@ import { markDrawnRules } from "./text-rules.js";
27
32
  function reconstructByLayout(file, mode = "flow") {
28
33
  const pages = file.pages();
29
34
  const shown = pages.map((page) => displayOf(page));
30
- const pageRuns = pages.map((page, i) => placeRuns(extractPageText(file, page), shown[i]));
35
+ const allRuns = pages.map((page, i) => placeRuns(extractPageText(file, page), shown[i]));
36
+ const foot = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "foot");
37
+ const head = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "head");
38
+ const pageRuns = foot || head ? allRuns.map((runs, i) => runs.filter((r) => foot?.lift[i]?.has(r) !== true && head?.lift[i]?.has(r) !== true)) : allRuns;
39
+ const pageMarks = pages.map(() => []);
31
40
  const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
32
41
  const resources = new ResourceStore();
33
42
  const losses = [];
@@ -41,16 +50,35 @@ function reconstructByLayout(file, mode = "flow") {
41
50
  feature: FEATURES.text,
42
51
  detail: "text filled with a tiling pattern is drawn as a flat tint of the pattern’s colour, not as the pattern"
43
52
  });
53
+ if (pageRuns.some((page) => page.some((r) => r.gradientFill === true))) losses.push({
54
+ severity: "degraded",
55
+ feature: FEATURES.text,
56
+ detail: "text filled with a shading pattern is drawn in the middle colour of the gradient, not as the gradient"
57
+ });
58
+ const perPage = pages.map((_, i) => detectGutters(pageRuns[i], shown[i].width));
59
+ const shared = commonGutters(perPage);
60
+ const pageGutters = perPage.map((own, i) => own.length > 0 ? own : shared && fitsGutters(pageRuns[i], shared) ? shared : own);
44
61
  const body = [];
62
+ const sectionEnds = [];
63
+ let sectionFrom = 0;
64
+ let lastSize = "";
65
+ let curColumns = 1;
66
+ let curSpace = 0;
67
+ let pendingContinuous = false;
45
68
  pages.forEach((page, i) => {
46
69
  const runs = pageRuns[i];
47
70
  const display = shown[i];
71
+ const gutters = pageGutters[i];
48
72
  const pageWidth = display.width;
49
- const gutter = detectGutter(runs, pageWidth);
50
73
  const stepped = stepsBetweenWords(runs);
51
74
  const blocks = [];
75
+ const textEdges = pageTextEdges(runs);
76
+ const inColumns = proseColumns(runs, gutters, textEdges);
77
+ const split = gutters.length > 0 && inColumns ? assignColumns(runs, gutters) : void 0;
78
+ const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
79
+ const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
52
80
  const addColumn = (allRuns, col) => {
53
- const colRuns = mode === "positional" ? allRuns.filter((r) => r.type3 !== true && r.invisible !== true) : allRuns;
81
+ const colRuns = allRuns.filter((r) => r.type3 !== true && (mode !== "positional" || r.invisible !== true));
54
82
  if (mode === "positional") {
55
83
  for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
56
84
  if (line.text.length === 0) continue;
@@ -64,22 +92,72 @@ function reconstructByLayout(file, mode = "flow") {
64
92
  }
65
93
  return;
66
94
  }
67
- const lines = groupIntoLines(colRuns, false, stepped).filter((l) => l.text.length > 0);
68
- const measure = lines.length > 0 ? {
69
- left: Math.min(...lines.map((l) => l.x)),
70
- right: Math.max(...lines.map((l) => l.x + l.width))
71
- } : void 0;
72
- for (const para of groupIntoParagraphs(lines, measure, display.height)) blocks.push({
73
- col,
74
- top: para.top,
75
- el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
76
- ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
77
- ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {}
78
- })
95
+ const columnSize = median(colRuns.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
96
+ const maths = matrixBlocks(colRuns, columnSize);
97
+ const inMath = new Set(maths.flatMap((m) => [...m.used]));
98
+ const lines = groupIntoLines(inMath.size > 0 ? colRuns.filter((r) => !inMath.has(r)) : colRuns, false, stepped).filter((l) => l.text.length > 0);
99
+ const measure = measureOf(col, gutters, textEdges) ?? measureOfLines(lines);
100
+ for (const found of maths) {
101
+ const { alignment } = alignmentOf([{
102
+ y: found.top,
103
+ fontSize: columnSize,
104
+ text: "",
105
+ spans: [],
106
+ x: found.x,
107
+ width: found.width
108
+ }], measure);
109
+ blocks.push({
110
+ band: bandAt(found.top),
111
+ col,
112
+ top: found.top,
113
+ el: {
114
+ kind: "paragraph",
115
+ paragraph: {
116
+ properties: alignment ? { alignment } : {},
117
+ runs: [{
118
+ text: "",
119
+ properties: {},
120
+ math: found.math
121
+ }]
122
+ }
123
+ }
124
+ });
125
+ }
126
+ const paras = groupIntoParagraphs(lines, measure, display.height);
127
+ const asRows = tabbedRows(paras, measure && {
128
+ left: measure.left,
129
+ right: Math.max(measure.right, textEdges?.right ?? measure.right)
79
130
  });
131
+ for (const [at, para] of paras.entries()) {
132
+ const table = asRows.get(at);
133
+ if (table !== void 0) {
134
+ if (table !== null) blocks.push({
135
+ band: bandAt(para.top),
136
+ col,
137
+ top: para.top,
138
+ el: table
139
+ });
140
+ continue;
141
+ }
142
+ blocks.push({
143
+ band: bandAt(para.top),
144
+ col,
145
+ top: para.top,
146
+ el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
147
+ ...para.stops !== void 0 && para.stops.length > 0 ? { tabs: para.stops.filter((x) => x > 0).map((x) => ({
148
+ positionPt: pt(x),
149
+ alignment: "left"
150
+ })) } : {},
151
+ ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
152
+ ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
153
+ ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
154
+ ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
155
+ })
156
+ });
157
+ }
80
158
  };
81
159
  const placed = [];
82
- const colOf = (centerX) => gutter !== void 0 && centerX >= gutter ? 1 : 0;
160
+ const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
83
161
  const frame = {
84
162
  left: 0,
85
163
  top: display.height
@@ -90,6 +168,7 @@ function reconstructByLayout(file, mode = "flow") {
90
168
  losses: raw.losses
91
169
  };
92
170
  losses.push(...imgs.losses);
171
+ pageMarks[i] = imgs.images;
93
172
  const lifted = collectPageVectors(file, page, imgs.images.map((img) => ({
94
173
  minX: img.x,
95
174
  minY: img.y,
@@ -99,12 +178,23 @@ function reconstructByLayout(file, mode = "flow") {
99
178
  losses.push(...lifted.losses);
100
179
  const placedVectors = placeVectors(lifted.vectors, display);
101
180
  const ruled = markDrawnRules(runs, placedVectors);
102
- const vectors = placedVectors.filter((v) => !ruled.consumed.has(v));
103
- if (gutter !== void 0) {
104
- addColumn(ruled.runs.filter((r) => r.x < gutter), 0);
105
- addColumn(ruled.runs.filter((r) => r.x >= gutter), 1);
181
+ const strayGlyphs = strayMarks(placedVectors, runs);
182
+ const vectors = placedVectors.filter((v) => !ruled.consumed.has(v)).filter((v) => !(mode !== "positional" && strayGlyphs.has(v)));
183
+ const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
184
+ const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
185
+ if (asTable) for (const block of asTable) blocks.push({
186
+ band: bandAt(block.top),
187
+ col: 0,
188
+ top: block.top,
189
+ el: block.el
190
+ });
191
+ else if (split && !ruledIntoColumns) {
192
+ const columnFor = (r) => split.columnOf.get(r) ?? colOf(r.x);
193
+ const columns = Array.from({ length: gutters.length + 1 }, (_, n) => n);
194
+ for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
106
195
  } else addColumn(ruled.runs, 0);
107
196
  const under = mode !== "positional";
197
+ const givenAway = mode !== "positional" ? ruleBorders(vectors, blocks, display.width) : void 0;
108
198
  [
109
199
  ...imgs.images.map((img) => ({
110
200
  key: img.orderKey,
@@ -112,7 +202,7 @@ function reconstructByLayout(file, mode = "flow") {
112
202
  top: img.y + img.heightPt,
113
203
  make: (z) => imageBlock(img, resources, void 0, frame, z, under)
114
204
  })),
115
- ...vectors.map((v) => ({
205
+ ...vectors.filter((v) => givenAway?.has(v) !== true).map((v) => ({
116
206
  key: v.orderKey,
117
207
  col: colOf((v.minX + v.maxX) / 2),
118
208
  top: v.maxY,
@@ -121,13 +211,29 @@ function reconstructByLayout(file, mode = "flow") {
121
211
  ...placed
122
212
  ].sort((a, b) => compareOrder(a.key, b.key)).forEach((mark, z) => {
123
213
  blocks.push({
214
+ band: bandAt(mark.top),
124
215
  col: mark.col,
125
216
  top: mark.top,
126
217
  el: mark.make(z)
127
218
  });
128
219
  });
129
- blocks.sort((a, b) => a.col - b.col || b.top - a.top);
130
- if (i > 0 && blocks.length > 0) body.push({
220
+ blocks.sort((a, b) => a.band - b.band || columnOrder(a.col) - columnOrder(b.col) || b.top - a.top);
221
+ const size = `${shown[i].width.toFixed(2)}x${shown[i].height.toFixed(2)}`;
222
+ const opensSection = i > 0 && size !== lastSize;
223
+ if (opensSection) {
224
+ sectionEnds.push({
225
+ at: body.length,
226
+ from: sectionFrom,
227
+ to: i,
228
+ columns: curColumns,
229
+ spacePt: curSpace,
230
+ continuous: pendingContinuous
231
+ });
232
+ sectionFrom = i;
233
+ pendingContinuous = false;
234
+ }
235
+ lastSize = size;
236
+ if (i > 0 && !opensSection && blocks.length > 0) body.push({
131
237
  kind: "paragraph",
132
238
  paragraph: {
133
239
  properties: {
@@ -138,36 +244,434 @@ function reconstructByLayout(file, mode = "flow") {
138
244
  runs: []
139
245
  }
140
246
  });
141
- for (const block of blocks) body.push(block.el);
247
+ const columnsHere = ruledIntoColumns || !proseColumns(runs, gutters, textEdges) ? 1 : gutters.length + 1;
248
+ const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
249
+ for (const block of blocks) {
250
+ const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
251
+ if (count !== curColumns) {
252
+ sectionEnds.push({
253
+ at: body.length,
254
+ from: sectionFrom,
255
+ to: i + 1,
256
+ columns: curColumns,
257
+ spacePt: curSpace,
258
+ continuous: pendingContinuous
259
+ });
260
+ sectionFrom = i;
261
+ pendingContinuous = true;
262
+ curColumns = count;
263
+ curSpace = spacePt;
264
+ }
265
+ body.push(block.el);
266
+ }
142
267
  });
143
- const section = sectionFromPdfPages(pages);
268
+ const setUp = (from, to) => {
269
+ const own = sectionFromPdfPages(pages.slice(from, to));
270
+ return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to));
271
+ };
272
+ sectionEnds.push({
273
+ at: body.length,
274
+ from: sectionFrom,
275
+ to: pages.length,
276
+ columns: curColumns,
277
+ spacePt: curSpace,
278
+ continuous: pendingContinuous
279
+ });
280
+ const sections = sectionEnds.length > 1 ? sectionEnds.flatMap((end) => {
281
+ const base = setUp(end.from, end.to);
282
+ if (!base) return [];
283
+ return [{
284
+ properties: {
285
+ ...base,
286
+ ...end.columns > 1 && mode !== "positional" ? { columns: {
287
+ count: end.columns,
288
+ spacePt: end.spacePt
289
+ } } : {},
290
+ ...end.continuous ? { sectionStart: "continuous" } : {}
291
+ },
292
+ endIndex: end.at
293
+ }];
294
+ }) : [];
295
+ const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
296
+ const edges0 = pageTextEdges(allRuns[0] ?? []);
297
+ const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
298
+ const headBand = head ? footerBand(head.band, stepped0, edges0, head.numbered) : [];
299
+ const withFooter = (properties) => properties ? {
300
+ ...properties,
301
+ ...band.length > 0 ? { footers: [{
302
+ type: "default",
303
+ relationshipId: FOOTER_PART
304
+ }] } : {},
305
+ ...headBand.length > 0 ? { headers: [{
306
+ type: "default",
307
+ relationshipId: HEADER_PART
308
+ }] } : {}
309
+ } : properties;
144
310
  return {
145
- doc: buildFlowDoc(body, resources, mode === "positional" ? section : withMeasuredMargins(section, shown, pageRuns), collectEmbeddedFonts(file, pages, losses)),
311
+ doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
312
+ ...s,
313
+ properties: withFooter(s.properties) ?? s.properties
314
+ })), band.length > 0 || headBand.length > 0 ? new Map([...band.length > 0 ? [[FOOTER_PART, band]] : [], ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []]) : void 0),
146
315
  losses: dedupeLosses(losses)
147
316
  };
148
317
  }
149
- function detectGutter(runs, pageWidth) {
150
- if (runs.length < 30 || pageWidth <= 0) return void 0;
318
+ function detectGutters(runs, pageWidth) {
319
+ if (runs.length < 30 || pageWidth <= 0) return [];
151
320
  const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
152
- const intervals = runs.map((r) => [r.x, Math.max(r.endX, r.x + 1)]).sort((a, b) => a[0] - b[0]);
153
- const minX = intervals[0][0];
154
- const span = Math.max(...intervals.map((iv) => iv[1])) - minX;
155
- if (span < pageWidth * .5) return void 0;
156
- let curEnd = intervals[0][1];
157
- let gapMid = 0;
158
- let gapW = 0;
159
- for (const [l, r] of intervals) {
160
- if (l - curEnd > gapW) {
161
- gapW = l - curEnd;
162
- gapMid = (curEnd + l) / 2;
163
- }
321
+ const spans = runs.flatMap((r) => {
322
+ const ink = runInk(r);
323
+ return ink ? [ink] : [];
324
+ });
325
+ if (spans.length === 0) return [];
326
+ const minX = Math.min(...spans.map((iv) => iv[0]));
327
+ const span = Math.max(...spans.map((iv) => iv[1])) - minX;
328
+ if (span < pageWidth * .5) return [];
329
+ const sorted = [...spans].sort((a, b) => a[0] - b[0]);
330
+ let curEnd = sorted[0][1];
331
+ const bands = [];
332
+ for (const [l, r] of sorted) {
333
+ if (l - curEnd >= fontSize * 3) bands.push({
334
+ mid: (curEnd + l) / 2,
335
+ from: curEnd,
336
+ to: l
337
+ });
164
338
  if (r > curEnd) curEnd = r;
165
339
  }
166
- const frac = (gapMid - minX) / span;
167
- if (gapW < fontSize * 3 || frac < .35 || frac > .65) return void 0;
168
- const left = runs.filter((r) => r.x < gapMid).length;
169
- if (left < runs.length * .25 || left > runs.length * .75) return void 0;
170
- return gapMid;
340
+ const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
341
+ const ink = runInk(r);
342
+ return ink ? [ink] : [];
343
+ }).sort((a, b) => a[0] - b[0]));
344
+ const straddling = (band) => {
345
+ let n = 0;
346
+ for (const row of rows) {
347
+ const before = row.filter(([, r]) => r <= band.from);
348
+ const after = row.filter(([l]) => l >= band.to);
349
+ if (before.length === 0 || after.length === 0) continue;
350
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) n++;
351
+ }
352
+ return n;
353
+ };
354
+ const empty = bands.filter((b) => straddling(b) >= MIN_EMPTY_BAND_ROWS);
355
+ const voted = [];
356
+ for (const x of sample(minX + span * .1, minX + span * .9, 1)) {
357
+ let columned = 0;
358
+ let crossing = 0;
359
+ for (const row of rows) {
360
+ if (row.some(([l, r]) => l < x && r > x)) {
361
+ crossing++;
362
+ continue;
363
+ }
364
+ const before = row.filter(([, r]) => r <= x);
365
+ const after = row.filter(([l]) => l >= x);
366
+ if (before.length === 0 || after.length === 0) continue;
367
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
368
+ }
369
+ if (columned < MIN_COLUMNED_ROWS || crossing >= columned) continue;
370
+ const last = voted[voted.length - 1];
371
+ if (last && x - last.to <= 1.5) voted[voted.length - 1] = {
372
+ ...last,
373
+ to: x,
374
+ mid: (last.from + x) / 2
375
+ };
376
+ else voted.push({
377
+ mid: x,
378
+ from: x,
379
+ to: x
380
+ });
381
+ }
382
+ const near = (band) => empty.some((e) => band.mid > e.from - fontSize && band.mid < e.to + fontSize);
383
+ return separating([...empty, ...voted.filter((v) => !near(v))].sort((a, b) => a.mid - b.mid), spans);
384
+ }
385
+ /**
386
+ * The candidate gutters that actually separate something, left to right.
387
+ *
388
+ * A gutter with nothing on one side of it is a margin, and two of them with a
389
+ * word between are one column and a stray. Each band is kept only where the
390
+ * region since the last kept one holds a real share of the page's runs — and
391
+ * the last one only if something follows it.
392
+ *
393
+ * @param bands The candidates, in order.
394
+ * @param spans Every run's horizontal extent.
395
+ * @returns The gutters worth splitting on.
396
+ */
397
+ function separating(bands, spans) {
398
+ const need = spans.length * MIN_COLUMN_SHARE;
399
+ const out = [];
400
+ let from = -Infinity;
401
+ for (const band of bands) {
402
+ if (spans.filter(([l, r]) => (l + r) / 2 > from && (l + r) / 2 < band.mid).length < need) continue;
403
+ out.push(band);
404
+ from = band.mid;
405
+ }
406
+ const tail = spans.filter(([l, r]) => (l + r) / 2 > from).length;
407
+ if (out.length > 0 && tail < need) out.pop();
408
+ return out;
409
+ }
410
+ /** How much of a page's text the narrowest column holds before it is a column. */
411
+ var MIN_COLUMN_SHARE = .08;
412
+ /**
413
+ * The gutters MOST of the document's pages agree on.
414
+ *
415
+ * A document is set one way: §17.6.4 puts the column setup on the section, and
416
+ * a paper does not change it from sheet to sheet. So a page that says nothing
417
+ * on its own — one carrying a figure across both columns, with too few clean
418
+ * lines left for the vote — can be asked the question the rest of the document
419
+ * already answered.
420
+ *
421
+ * @param perPage Each page's own answer, in page order.
422
+ * @returns The answer given by more pages than any other, or `undefined` where
423
+ * no two pages agree.
424
+ */
425
+ function commonGutters(perPage) {
426
+ const seen = /* @__PURE__ */ new Map();
427
+ for (const gutters of perPage) {
428
+ if (gutters.length === 0) continue;
429
+ const key = gutters.map((g) => Math.round(g.mid / 2)).join(",");
430
+ const had = seen.get(key);
431
+ if (had) had.pages++;
432
+ else seen.set(key, {
433
+ gutters,
434
+ pages: 1
435
+ });
436
+ }
437
+ let best;
438
+ for (const entry of seen.values()) if (!best || entry.pages > best.pages) best = entry;
439
+ return best?.gutters;
440
+ }
441
+ /**
442
+ * Whether a page's own lines agree with gutters the document states.
443
+ *
444
+ * The document's answer is a suggestion, not a licence: a sheet whose lines run
445
+ * straight through the gutter is set in one column whatever its neighbours do.
446
+ * The bar is lower than the vote's own — the evidence from the other pages is
447
+ * already in — but it is still the page that decides.
448
+ *
449
+ * @param runs The page's runs.
450
+ * @param gutters The document's gutters.
451
+ */
452
+ function fitsGutters(runs, gutters) {
453
+ if (runs.length < 30) return false;
454
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
455
+ const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
456
+ const ink = runInk(r);
457
+ return ink ? [ink] : [];
458
+ }).sort((a, b) => a[0] - b[0]));
459
+ for (const gutter of gutters) {
460
+ let columned = 0;
461
+ let crossing = 0;
462
+ for (const row of rows) {
463
+ if (row.some(([l, r]) => l < gutter.mid && r > gutter.mid)) {
464
+ crossing++;
465
+ continue;
466
+ }
467
+ const before = row.filter(([, r]) => r <= gutter.mid);
468
+ const after = row.filter(([l]) => l >= gutter.mid);
469
+ if (before.length === 0 || after.length === 0) continue;
470
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
471
+ }
472
+ if (crossing === 0) continue;
473
+ if (columned < MIN_SHARED_ROWS || crossing >= columned) return false;
474
+ }
475
+ return true;
476
+ }
477
+ /** How many lines a page must split at a gutter the DOCUMENT already states. */
478
+ var MIN_SHARED_ROWS = 3;
479
+ /** How many lines have to be split at the same place before the page is in columns. */
480
+ var MIN_COLUMNED_ROWS = 12;
481
+ /**
482
+ * How many lines a band NO run crosses must separate before it is a gutter
483
+ * rather than an empty half of a page. Lower than the vote's bar, because a
484
+ * band nothing crosses is stronger evidence than a band most lines avoid — but
485
+ * not zero, which is what an invoice's blank left half offers.
486
+ */
487
+ var MIN_EMPTY_BAND_ROWS = 8;
488
+ /**
489
+ * The page's runs grouped by baseline.
490
+ *
491
+ * Swept in order rather than bucketed: a superscript sits a few points above
492
+ * the baseline it belongs to, and a bucket boundary between the two would leave
493
+ * it a line of its own — bug1885505.pdf's author block came back with the
494
+ * asterisks and daggers standing alone on five separate lines.
495
+ */
496
+ function rowsOf(runs, fontSize) {
497
+ const tolerance = Math.max(fontSize * .6, 1);
498
+ const rows = [];
499
+ let row = [];
500
+ let rowY = Number.POSITIVE_INFINITY;
501
+ for (const run of [...runs].sort((a, b) => b.y - a.y)) {
502
+ if (row.length > 0 && rowY - run.y > tolerance) {
503
+ rows.push(row);
504
+ row = [];
505
+ }
506
+ if (row.length === 0) rowY = run.y;
507
+ row.push(run);
508
+ }
509
+ if (row.length > 0) rows.push(row);
510
+ return rows;
511
+ }
512
+ /**
513
+ * A run's INK — where its letters are, which a trailing space is not.
514
+ *
515
+ * A run carries the advance it stepped, and the space that ends a line of a
516
+ * column is part of it: chrome-text-selection-markedContent.pdf sets two
517
+ * columns 21 points apart and its left column's lines end ", " — five of those
518
+ * points — so every measurement of the gutter came back a third short and the
519
+ * page was read straight across.
520
+ *
521
+ * @param run The run.
522
+ * @returns Its ink, or `undefined` for a run that is nothing but space.
523
+ */
524
+ function runInk(run) {
525
+ const from = Math.min(run.x, run.endX);
526
+ const to = Math.max(run.endX, run.x + 1);
527
+ const chars = [...run.text];
528
+ if (chars.length === 0) return [from, to];
529
+ let head = 0;
530
+ while (head < chars.length && SPACE.test(chars[head])) head++;
531
+ if (head === chars.length) return void 0;
532
+ let tail = 0;
533
+ while (tail < chars.length - head && SPACE.test(chars[chars.length - 1 - tail])) tail++;
534
+ if (head === 0 && tail === 0) return [from, to];
535
+ const step = (to - from) / chars.length;
536
+ return [from + head * step, to - tail * step];
537
+ }
538
+ var SPACE = /\s/u;
539
+ /** Where the page's text starts and ends, ignoring what is only a space. */
540
+ function pageTextEdges(runs) {
541
+ let left = Number.POSITIVE_INFINITY;
542
+ let right = Number.NEGATIVE_INFINITY;
543
+ for (const run of runs) {
544
+ const ink = runInk(run);
545
+ if (!ink) continue;
546
+ left = Math.min(left, ink[0]);
547
+ right = Math.max(right, ink[1]);
548
+ }
549
+ return right > left ? {
550
+ left,
551
+ right
552
+ } : void 0;
553
+ }
554
+ /**
555
+ * The measure a column is set across: from the gutter on its left to the one
556
+ * on its right, and to the page's own text edge where there is none.
557
+ *
558
+ * A line that spans the page (see {@link SPANNING_COLUMN}) is set across all of
559
+ * them, which is what makes a centred title centred.
560
+ *
561
+ * @param col The column, or {@link SPANNING_COLUMN}.
562
+ * @param gutters The page's gutters, left to right.
563
+ * @param edges Where the page's text starts and ends.
564
+ */
565
+ function measureOf(col, gutters, edges) {
566
+ if (!edges) return void 0;
567
+ if (col === SPANNING_COLUMN || gutters.length === 0) return edges;
568
+ return {
569
+ left: col === 0 ? edges.left : gutters[col - 1]?.to ?? edges.left,
570
+ right: col >= gutters.length ? edges.right : gutters[col]?.from ?? edges.right
571
+ };
572
+ }
573
+ /** The measure the lines themselves reach across, where the page states none. */
574
+ function measureOfLines(lines) {
575
+ if (lines.length === 0) return void 0;
576
+ return {
577
+ left: Math.min(...lines.map((l) => l.x)),
578
+ right: Math.max(...lines.map((l) => l.x + l.width))
579
+ };
580
+ }
581
+ /** The x's to measure at, from `a` to `b` inclusive. */
582
+ function sample(a, b, step) {
583
+ const out = [];
584
+ for (let x = a; x <= b; x += Math.max(step, .5)) out.push(x);
585
+ return out;
586
+ }
587
+ /**
588
+ * EP17 — which column each run belongs to, and where the page's bands break.
589
+ *
590
+ * The decision is made a LINE at a time, not a run at a time: a title is drawn
591
+ * as several runs and only one of them may reach across the gutter, so judging
592
+ * each on its own would leave the rest of the title standing in a column.
593
+ *
594
+ * @param runs The page's runs.
595
+ * @param gutter The band from {@link detectGutter}.
596
+ * @returns The column of each run (0, 1, or {@link SPANNING_COLUMN}) and the
597
+ * baselines of the full-width lines, which separate the bands.
598
+ */
599
+ function assignColumns(runs, gutters) {
600
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
601
+ const rows = rowsOf(runs, fontSize);
602
+ const columnOf = /* @__PURE__ */ new Map();
603
+ const breaks = [];
604
+ const columnAt = (x) => gutters.filter((g) => x >= g.mid).length;
605
+ for (const row of rows) {
606
+ const inked = row.flatMap((run) => {
607
+ const ink = runInk(run);
608
+ return ink ? [{
609
+ run,
610
+ from: ink[0],
611
+ to: ink[1]
612
+ }] : [];
613
+ }).sort((a, b) => a.from - b.from);
614
+ if (inked.length === 0) continue;
615
+ const leftmost = inked[0].from;
616
+ const rightmost = Math.max(...inked.map((r) => r.to));
617
+ const straddled = gutters.filter((g) => leftmost < g.mid && rightmost > g.mid);
618
+ if (straddled.length === 0) {
619
+ const col = columnAt(leftmost);
620
+ for (const run of row) columnOf.set(run, col);
621
+ continue;
622
+ }
623
+ const gapAt = (mid) => {
624
+ let cur = inked[0].to;
625
+ let gap = 0;
626
+ for (const { from, to } of inked.slice(1)) {
627
+ if (from > cur && cur <= mid && from >= mid) gap = from - cur;
628
+ cur = Math.max(cur, to);
629
+ }
630
+ return gap;
631
+ };
632
+ if (straddled.every((g) => gapAt(g.mid) >= fontSize * MIN_GUTTER_EM)) {
633
+ for (const { run, from } of inked) columnOf.set(run, columnAt(from));
634
+ continue;
635
+ }
636
+ for (const run of row) columnOf.set(run, SPANNING_COLUMN);
637
+ breaks.push(Math.max(...row.map((r) => r.y)));
638
+ }
639
+ return {
640
+ columnOf,
641
+ breaks: breaks.sort((a, b) => b - a)
642
+ };
643
+ }
644
+ /**
645
+ * A line that spans the page belongs to no column: it stands between the bands
646
+ * it separates.
647
+ *
648
+ * Which side of them? A spanning line is what BREAKS a band, and a band runs
649
+ * from one break to the next, so the line is always at the FOOT of its own —
650
+ * the columns of that band are the ones above it. Read ahead of them,
651
+ * bug1997343.pdf's page number came out between the date and the abstract.
652
+ */
653
+ var SPANNING_COLUMN = -1;
654
+ /** Where a column reads in its band: the spanning line at the foot of it. */
655
+ function columnOrder(col) {
656
+ return col === SPANNING_COLUMN ? Number.MAX_SAFE_INTEGER : col;
657
+ }
658
+ /**
659
+ * How wide, in ems, the gap over the middle has to be for a line to be two
660
+ * lines. A word space is a quarter of an em (see `SPACE_GAP_EM`) and a
661
+ * justified one no more than half; a gutter is an em and more.
662
+ *
663
+ * It stood at one and a half, which is wider than some magazines set:
664
+ * chrome-text-selection-markedContent.pdf puts fourteen and a half points
665
+ * between columns of eleven-point type — 1.31 em — and every line of it was
666
+ * read straight across, the left column's sentence running into the right
667
+ * column's.
668
+ */
669
+ var MIN_GUTTER_EM = 1;
670
+ /** Which band a mark at this height belongs to — how many breaks stand above it. */
671
+ function bandOf(breaks, top, epsilon) {
672
+ let n = 0;
673
+ for (const y of breaks) if (y > top + epsilon) n++;
674
+ return n;
171
675
  }
172
676
  /**
173
677
  * The gap, in ems, past which the placed reader cuts a line rather than write a
@@ -321,7 +825,7 @@ function inkSpan(runs) {
321
825
  }
322
826
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
323
827
  function lineOf(runs, y, fontSize, stepped) {
324
- const ordered = lineSpans(runs, fontSize, stepped);
828
+ const { spans: ordered, stops } = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
325
829
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
326
830
  const ink = inkSpan(runs);
327
831
  return {
@@ -329,11 +833,47 @@ function lineOf(runs, y, fontSize, stepped) {
329
833
  width: ink.width,
330
834
  y,
331
835
  fontSize,
836
+ ...tabbed(runs, fontSize) ? { tabbed: true } : {},
837
+ ...stops.length > 0 ? { stops } : {},
332
838
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
333
839
  spans
334
840
  };
335
841
  }
336
842
  /**
843
+ * Whether a TAB stands inside the line — a gap no word space could be.
844
+ *
845
+ * A line with one is a line the page SET OUT, not a line of prose: a contents
846
+ * entry with its page number at the measure, a two-column list, a label and its
847
+ * value. It does not run on into the line below it, and read as prose it does:
848
+ * bug1997343.pdf's contents came back as "2 Document structures 1 2.1
849
+ * Mathematics ............. 1", two entries in one line, where the file sets
850
+ * one to a line.
851
+ *
852
+ * A leader says the same thing (see {@link carriesLeader}) but only where the
853
+ * entry is dotted; a top-level entry is spaced, and nothing else marks it.
854
+ *
855
+ * @param runs The line's runs.
856
+ * @param fontSize The line's size.
857
+ */
858
+ function tabbed(runs, fontSize) {
859
+ const inked = runs.flatMap((r) => {
860
+ const ink = runInk(r);
861
+ return ink ? [ink] : [];
862
+ }).sort((a, b) => a[0] - b[0]);
863
+ let cur = Number.NEGATIVE_INFINITY;
864
+ for (const [from, to] of inked) {
865
+ if (cur > Number.NEGATIVE_INFINITY && from - cur >= fontSize * TAB_GAP_EM) return true;
866
+ cur = Math.max(cur, to);
867
+ }
868
+ return false;
869
+ }
870
+ /**
871
+ * How wide a gap has to be, in ems, before a word space could not have stood
872
+ * there. Justification stretches a space to about half an em; two and a half is
873
+ * a jump nothing but a tab makes.
874
+ */
875
+ var TAB_GAP_EM = 2.5;
876
+ /**
337
877
  * The gap between two runs that means a WORD SPACE stood there.
338
878
  *
339
879
  * A page that draws its own spaces has already said where its words divide, and
@@ -352,6 +892,28 @@ function lineOf(runs, y, fontSize, stepped) {
352
892
  function spaceGap(prev, fontSize, stepped) {
353
893
  return (prev.fontSizePt || fontSize) * (stepped ? STEPPED_SPACE_EM : DRAWN_SPACE_EM);
354
894
  }
895
+ /**
896
+ * §17.3.1.25 — one character of a LEADER, the dotted rule that carries the eye
897
+ * across a table of contents.
898
+ *
899
+ * A leader is drawn one character at a time with a step about as wide as a word
900
+ * space, so every threshold that tells a space from a kern says "space" between
901
+ * every dot. bug886717.pdf's contents came back as
902
+ * "Abstract . . . . . . . . . . . . 3", four times as long as the page sets it,
903
+ * and its forty entries spilled onto a second page. What tells a leader from
904
+ * words is that it is the SAME character over and over.
905
+ */
906
+ function isLeader(text) {
907
+ return text.length === 1 && LEADER_CHARS.has(text);
908
+ }
909
+ var LEADER_CHARS = new Set([
910
+ ".",
911
+ "·",
912
+ "_",
913
+ "-",
914
+ "‐",
915
+ "–"
916
+ ]);
355
917
  /** A page that writes its own spaces: only a wide gap means anything more. */
356
918
  var DRAWN_SPACE_EM = .25;
357
919
  /** A page that writes none: the step between its words is all there is. */
@@ -370,9 +932,15 @@ function stepsBetweenWords(runs) {
370
932
  }
371
933
  function lineSpans(runs, fontSize, stepped) {
372
934
  const spans = [];
935
+ const stops = [];
936
+ const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
937
+ const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
373
938
  let prev;
374
939
  for (const run of runs) {
375
- if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped)) spans.push({ text: " " });
940
+ if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) if (run.x - prev.endX >= (run.fontSizePt || fontSize) * TAB_GAP_EM) {
941
+ spans.push({ text: " " });
942
+ stops.push(run.x);
943
+ } else spans.push({ text: " " });
376
944
  spans.push({
377
945
  text: run.text.replaceAll("�", ""),
378
946
  sizePt: run.fontSizePt,
@@ -384,13 +952,84 @@ function lineSpans(runs, fontSize, stepped) {
384
952
  } } : {},
385
953
  ...run.bold ? { bold: true } : {},
386
954
  ...run.italic ? { italic: true } : {},
955
+ ...script(run, baseline, fontSize),
387
956
  ...run.markup !== void 0 ? { markup: run.markup } : {},
388
957
  ...run.href !== void 0 ? { href: run.href } : {}
389
958
  });
390
959
  prev = run;
391
960
  }
392
- return spans;
961
+ return {
962
+ spans,
963
+ stops
964
+ };
393
965
  }
966
+ /**
967
+ * §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
968
+ * is.
969
+ *
970
+ * A PDF states no such property: an exponent is a smaller face set a little
971
+ * higher, and an index a smaller face set a little lower. Read flat, the whole
972
+ * of mathematics comes back on one line — bug1997343.pdf sets `n^p = n mod p`
973
+ * and we read "np", and every prime on the page landed beside its letter
974
+ * instead of over it.
975
+ *
976
+ * The size the span keeps is the LINE's, not the mark's: a document states the
977
+ * nominal size and the layout shrinks a script, so the drawn seven points under
978
+ * a superscript would come out at five.
979
+ *
980
+ * @param run The run.
981
+ * @param baseline The line's own baseline.
982
+ * @param fontSize The line's size.
983
+ */
984
+ function script(run, baseline, fontSize) {
985
+ if ((run.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE) return {};
986
+ const rise = run.y - baseline;
987
+ if (rise > fontSize * SCRIPT_RISE) return {
988
+ script: "superscript",
989
+ sizePt: fontSize
990
+ };
991
+ if (rise < -fontSize * SCRIPT_DROP) return {
992
+ script: "subscript",
993
+ sizePt: fontSize
994
+ };
995
+ return {};
996
+ }
997
+ /** How much smaller than its line a run must be set to be a script of it. */
998
+ var SCRIPT_SIZE = .85;
999
+ /** How far above the baseline a superscript stands, and below it a subscript. */
1000
+ var SCRIPT_RISE = .15;
1001
+ var SCRIPT_DROP = .08;
1002
+ /**
1003
+ * §17.3.1.25 — whether a line carries a LEADER, which makes it an entry in a
1004
+ * directory rather than a line of prose.
1005
+ *
1006
+ * A contents line runs the full measure — the dots are there to make it — so
1007
+ * the ragged-edge test that ends every other paragraph never fires on one, and
1008
+ * bug886717.pdf's forty entries came back as a single reflowing paragraph.
1009
+ */
1010
+ function carriesLeader(line) {
1011
+ return LEADER_RUN.test(line.text);
1012
+ }
1013
+ /** Three of the same leader character in a row is a leader and not punctuation. */
1014
+ var LEADER_RUN = /([.\u00b7_])\1{2,}/u;
1015
+ /**
1016
+ * Whether the line is a RULE drawn out of characters — a row of hyphens, dots
1017
+ * or underscores and nothing else.
1018
+ *
1019
+ * A page with no rule to draw types one. It is not a sentence, it does not run
1020
+ * on into the line under it and the line over it does not run on into it.
1021
+ *
1022
+ * @param line The line.
1023
+ * @returns Whether it is a rule rather than words.
1024
+ */
1025
+ function ruleOfCharacters(line) {
1026
+ const text = line.text.trim();
1027
+ if (text.length < RULE_CHARS) return false;
1028
+ for (const ch of text) if (!LEADER_CHARS.has(ch)) return false;
1029
+ return true;
1030
+ }
1031
+ /** How many of them it takes before a row of marks is a rule. */
1032
+ var RULE_CHARS = 3;
394
1033
  function groupIntoParagraphs(lines, column, pageHeight = 0) {
395
1034
  const groups = [];
396
1035
  const gaps = [];
@@ -398,29 +1037,120 @@ function groupIntoParagraphs(lines, column, pageHeight = 0) {
398
1037
  for (const line of lines) {
399
1038
  const gap = prev !== void 0 ? prev.y - line.y : 0;
400
1039
  const opened = prev !== void 0 && gap > line.fontSize * 1.5;
401
- if (groups.length === 0 || opened || prev !== void 0 && endedParagraph(prev, line, column)) {
1040
+ if (groups.length === 0 || opened || ruleOfCharacters(line) || prev !== void 0 && (ruleOfCharacters(prev) || endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
402
1041
  groups.push([]);
403
1042
  gaps.push(prev === void 0 ? 0 : gap);
404
1043
  }
405
1044
  groups[groups.length - 1].push(line);
406
1045
  prev = line;
407
1046
  }
1047
+ const starts = lines.map((l) => l.x).sort((a, b) => a - b);
1048
+ const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
408
1049
  return groups.map((g, i) => {
409
1050
  const first = g[0];
410
1051
  const fontSize = Math.max(...g.map((l) => l.fontSize));
411
1052
  const opened = (gaps[i] ?? 0) - fontSize * 1.2;
412
1053
  const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
413
1054
  const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
1055
+ const stops = g.length === 1 ? first.stops ?? [] : [];
414
1056
  return {
415
- spans: g.flatMap((l, k) => k > 0 ? [{ text: " " }, ...l.spans] : [...l.spans]),
1057
+ spans: joinLines(g),
416
1058
  fontSize,
417
1059
  top: first.y,
1060
+ ...stops.length > 0 ? { stops: stops.map((x) => x - (column?.left ?? columnLeft)) } : {},
418
1061
  ...spacingBefore !== void 0 ? { spacingBefore } : {},
419
- ...alignmentOf(g, column)
1062
+ ...alignmentOf(g, column),
1063
+ ...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
420
1064
  };
421
1065
  });
422
1066
  }
423
1067
  /**
1068
+ * A paragraph's lines as one run of spans, joined the way the page broke them.
1069
+ *
1070
+ * A line that ends in a hyphen was broken THERE, and the break is not part of
1071
+ * the text: re-set at another measure the word has to come back together. Which
1072
+ * hyphen decides what is left of it — U+00AD is the discretionary one, put in
1073
+ * to mark a place a word MAY break, and it goes with the break; a plain hyphen
1074
+ * belongs to the word ("two-" and "column" are "two-column") and stays.
1075
+ *
1076
+ * Read as prose with a space between every line, bug1997343.pdf came back
1077
+ * "typical two-column docu ment incorporating tables, figures and mathemat
1078
+ * ics" — the soft hyphens dropped by the page and a space in their place.
1079
+ *
1080
+ * @param lines The paragraph's lines, in order.
1081
+ */
1082
+ function joinLines(lines) {
1083
+ const out = [];
1084
+ lines.forEach((line, i) => {
1085
+ if (i > 0) {
1086
+ const prev = out[out.length - 1];
1087
+ const ends = prev?.text ?? "";
1088
+ const soft = ends.endsWith(SOFT_HYPHEN);
1089
+ const hard = HYPHENS.has(ends.slice(-1));
1090
+ if (soft && prev) out[out.length - 1] = {
1091
+ ...prev,
1092
+ text: ends.slice(0, -1)
1093
+ };
1094
+ else if (!hard) out.push({ text: " " });
1095
+ }
1096
+ out.push(...line.spans);
1097
+ });
1098
+ return out;
1099
+ }
1100
+ /** §17.3.3.29 — the hyphen that is a PLACE a word may break, not a hyphen. */
1101
+ var SOFT_HYPHEN = "­";
1102
+ /** The hyphens that belong to the word they end. */
1103
+ var HYPHENS = new Set([
1104
+ "-",
1105
+ "‐",
1106
+ "‑"
1107
+ ]);
1108
+ /** How many lines' worth of indent still reads as a first line, not a placement. */
1109
+ var INDENT_LINES = 3;
1110
+ /** Where in the run of line starts the column's own left edge is looked for. */
1111
+ var COLUMN_LEFT_QUANTILE = .15;
1112
+ /**
1113
+ * §17.3.1.12 `w:ind` — how far a paragraph is set in from its column, and where
1114
+ * its first line begins.
1115
+ *
1116
+ * A PDF states neither: every line is placed absolutely, and read flat every
1117
+ * paragraph came back against the left edge. That is most of what a list looks
1118
+ * like — bug1997343.pdf sets "• They may be unordered bullet lists" ten points
1119
+ * in and its nested "1. lists may also be nested" twenty more, and we set all
1120
+ * of them flush left — and it is the whole of a first-line indent, which is how
1121
+ * most of the world's prose marks a new paragraph.
1122
+ *
1123
+ * The first line is measured against the REST of the paragraph, which is what
1124
+ * `indentFirstLine` means: positive is a first line set in (a new paragraph),
1125
+ * negative a hanging one (a list, its marker standing out to the left).
1126
+ *
1127
+ * A paragraph that is centred or set to the right is placed, not indented, and
1128
+ * keeps neither.
1129
+ *
1130
+ * @param lines The paragraph's lines.
1131
+ * @param columnLeft Where the column's own text begins.
1132
+ * @param alignment What {@link alignmentOf} made of it.
1133
+ */
1134
+ function indentOf(lines, columnLeft, alignment) {
1135
+ if (alignment !== void 0 || lines.length === 0) return {};
1136
+ const size = Math.max(...lines.map((l) => l.fontSize)) || 10;
1137
+ const rest = lines.slice(1);
1138
+ const body = rest.length > 0 ? Math.min(...rest.map((l) => l.x)) : lines[0].x;
1139
+ const left = body - columnLeft;
1140
+ const first = lines[0].x - body;
1141
+ const enough = size * INDENT_EM;
1142
+ return {
1143
+ ...Math.abs(left) >= enough ? { indentLeft: left } : {},
1144
+ ...Math.abs(first) >= enough ? { indentFirstLine: first } : {}
1145
+ };
1146
+ }
1147
+ /**
1148
+ * How far, in ems, a paragraph has to be set in before it is indented rather
1149
+ * than merely started. Half an em clears the rounding a producer leaves at the
1150
+ * head of a line and is well under the smallest indent anybody sets.
1151
+ */
1152
+ var INDENT_EM = .5;
1153
+ /**
424
1154
  * Whether a line ENDED a paragraph, rather than wrapping into the next.
425
1155
  *
426
1156
  * Leading alone cannot tell the two apart: five labels stacked at 15pt with a
@@ -444,7 +1174,9 @@ function endedParagraph(prev, next, column) {
444
1174
  if (!column) return false;
445
1175
  const width = column.right - column.left;
446
1176
  if (!(width > 0)) return false;
447
- if (Math.abs(prev.x - next.x) > Math.max(prev.fontSize, 4)) return false;
1177
+ const step = Math.max(prev.fontSize, 4);
1178
+ const shift = next.x - prev.x;
1179
+ if (shift < -step || shift > step * INDENT_LINES) return false;
448
1180
  return column.right - (prev.x + prev.width) > width * .25;
449
1181
  }
450
1182
  /**
@@ -457,7 +1189,7 @@ function endedParagraph(prev, next, column) {
457
1189
  * is left alone — a justified paragraph and a ragged-right one look the same
458
1190
  * from here, and guessing between them would re-set the body of every document.
459
1191
  */
460
- function alignmentOf(lines, column) {
1192
+ function alignmentOf(lines, column, least) {
461
1193
  if (!column || lines.length === 0) return {};
462
1194
  const width = column.right - column.left;
463
1195
  if (!(width > 0)) return {};
@@ -465,10 +1197,11 @@ function alignmentOf(lines, column) {
465
1197
  lead: l.x - column.left,
466
1198
  trail: column.right - (l.x + l.width)
467
1199
  }));
468
- const meaningful = width * .1;
1200
+ const meaningful = Math.min(width * .1, least ?? width);
469
1201
  const even = width * .06;
470
1202
  if (insets.every((i) => Math.abs(i.lead - i.trail) <= even) && Math.max(...insets.map((i) => Math.min(i.lead, i.trail))) >= meaningful) return { alignment: "center" };
471
- if (insets.every((i) => i.trail <= even) && Math.max(...insets.map((i) => i.lead)) >= meaningful) return { alignment: "right" };
1203
+ const leads = insets.map((i) => i.lead);
1204
+ if (insets.every((i) => i.trail <= even) && Math.max(...leads) >= meaningful && Math.max(...leads) - Math.min(...leads) > even) return { alignment: "right" };
472
1205
  return {};
473
1206
  }
474
1207
  function headingLevel(fontSize, medianFont) {
@@ -490,5 +1223,748 @@ function compareOrder(a, b) {
490
1223
  for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
491
1224
  return a.length - b.length;
492
1225
  }
1226
+ /**
1227
+ * §17.6.13 — the running foot a document repeats at the bottom of its pages.
1228
+ *
1229
+ * A page number, a title, a URL: it stands below the text block, in the margin,
1230
+ * and it is not part of what the page SAYS. Read as body it goes wherever the
1231
+ * reflow puts it — bug1997343.pdf's "1" came out on a sheet of its own between
1232
+ * the two the paper has, and TAMReview.pdf's "Sprouts — http://…" landed in the
1233
+ * middle of the abstract.
1234
+ *
1235
+ * What makes it a running foot is that it RUNS: the same place, page after
1236
+ * page, cut off from the text above it by more than a line of white. One page
1237
+ * proves nothing, so two are asked for.
1238
+ *
1239
+ * @param pageRuns Each page's runs, placed.
1240
+ * @param shown Each page's shown geometry.
1241
+ * @returns The runs to lift off each page and the band to put them in, or
1242
+ * `undefined` where the document repeats nothing.
1243
+ */
1244
+ function runningFoot(pageRuns, shown, where) {
1245
+ const feet = pageRuns.map((runs, i) => edgeLine(runs, shown[i]?.height ?? 0, where));
1246
+ const found = feet.filter((f) => f !== void 0);
1247
+ if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
1248
+ const ys = found.map((f) => f.y);
1249
+ const mid = median(ys);
1250
+ if (ys.some((y) => Math.abs(y - mid) > FOOT_DRIFT)) return void 0;
1251
+ const texts = found.map((f) => f.runs.map((r) => r.text).join("").trim());
1252
+ return {
1253
+ lift: feet.map((f) => new Set(f?.runs ?? [])),
1254
+ band: found[0].runs,
1255
+ numbered: new Set(texts).size > 1
1256
+ };
1257
+ }
1258
+ /** How many of a document's pages must carry the foot before it is running. */
1259
+ var FOOT_SHARE = .6;
1260
+ /** How far, in points, a running foot may drift from page to page. */
1261
+ var FOOT_DRIFT = 4;
1262
+ /**
1263
+ * The lines at the very bottom of a page, where they stand ALONE below the text
1264
+ * block.
1265
+ *
1266
+ * Alone means the white above them is more than the page's own leading — a last
1267
+ * paragraph is a line's gap from the one before it, a running foot is several.
1268
+ *
1269
+ * A foot need not be ONE line. ZapfDingbats.pdf signs each page twice, the
1270
+ * publisher's line and the suite's title on one baseline and the build stamp
1271
+ * thirty points below it, and taking only the bottom line left the other in the
1272
+ * body: after a table that fills the sheet it had nowhere to go but a page of
1273
+ * its own, and a two-page document came out as four.
1274
+ */
1275
+ function edgeLine(runs, pageHeight, where) {
1276
+ if (runs.length < 4 || pageHeight <= 0) return void 0;
1277
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1278
+ const rows = rowsOf(runs, fontSize);
1279
+ if (rows.length === 0) return void 0;
1280
+ const at = (i) => where === "foot" ? rows[rows.length - 1 - i] : rows[i];
1281
+ const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
1282
+ const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
1283
+ const y = edgeY(at(0));
1284
+ if (rows.length < 4) {
1285
+ const inked = rows.filter((row) => row.some((r) => r.text.trim() !== ""));
1286
+ const inBand = inked.length > 0 && inked.every((row) => where === "foot" ? edgeY(row) <= pageHeight * FOOT_BAND : edgeY(row) >= pageHeight * (1 - FOOT_BAND));
1287
+ const all = inked.flat();
1288
+ const text = all.map((r) => r.text).join("").trim();
1289
+ return inBand && text.length > 0 && text.length <= FOOT_CHARS * inked.length ? {
1290
+ y: edgeY(inked[inked.length - 1] ?? []),
1291
+ runs: all
1292
+ } : void 0;
1293
+ }
1294
+ let found;
1295
+ let group = [];
1296
+ const most = where === "foot" ? FOOT_ROWS : 1;
1297
+ for (let i = 0; i < Math.min(most, rows.length - 2); i++) {
1298
+ const row = at(i);
1299
+ const edge = edgeY(row);
1300
+ if (where === "foot" ? edge > pageHeight * FOOT_BAND : edge < pageHeight * (1 - FOOT_BAND)) break;
1301
+ group = [...group, ...row];
1302
+ const next = at(i + 1);
1303
+ if ((where === "foot" ? inside(next) - edgeY(row) : edgeY(row) - inside(next)) < fontSize * FOOT_GAP_EM) continue;
1304
+ const text = group.map((r) => r.text).join("").trim();
1305
+ if (text.length > 0 && text.length <= FOOT_CHARS * (i + 1)) found = group;
1306
+ }
1307
+ return found ? {
1308
+ y,
1309
+ runs: found
1310
+ } : void 0;
1311
+ }
1312
+ /** How much white, in ems, stands between the text block and a running foot. */
1313
+ var FOOT_GAP_EM = 2;
1314
+ /** How many lines a running foot may hold before it is a paragraph. */
1315
+ var FOOT_ROWS = 3;
1316
+ /** How far up the sheet a running foot may sit. */
1317
+ var FOOT_BAND = .12;
1318
+ /** A running foot is a line, not a paragraph. */
1319
+ var FOOT_CHARS = 90;
1320
+ /**
1321
+ * §17.16.5.35 — the number in a run, made the field it stands for.
1322
+ *
1323
+ * A foot reads "1" or "Chapter 3 — 47", and the number is not the text: it is
1324
+ * the number of the page it is drawn on, which is what lets ONE band serve
1325
+ * every page. The run is cut around it and the middle becomes the field.
1326
+ *
1327
+ * @param run The footer's run.
1328
+ * @returns The run, or the two or three it is cut into.
1329
+ */
1330
+ function pageNumbered(run) {
1331
+ const found = /(^|\s)(\d{1,4})(\s|$)/u.exec(run.text);
1332
+ if (!found || run.field !== void 0) return [run];
1333
+ const at = found.index + found[1].length;
1334
+ const number = found[2];
1335
+ const before = run.text.slice(0, at);
1336
+ const after = run.text.slice(at + number.length);
1337
+ const total = /(^|\s)(\d{1,4})(\s|$)/u.exec(after);
1338
+ const tail = total === null ? after === "" ? [] : [{
1339
+ ...run,
1340
+ text: after
1341
+ }] : [
1342
+ ...after.slice(0, total.index + total[1].length) === "" ? [] : [{
1343
+ ...run,
1344
+ text: after.slice(0, total.index + total[1].length)
1345
+ }],
1346
+ {
1347
+ ...run,
1348
+ text: total[2],
1349
+ field: "NUMPAGES"
1350
+ },
1351
+ ...after.slice(total.index + total[1].length + total[2].length) === "" ? [] : [{
1352
+ ...run,
1353
+ text: after.slice(total.index + total[1].length + total[2].length)
1354
+ }]
1355
+ ];
1356
+ return [
1357
+ ...before === "" ? [] : [{
1358
+ ...run,
1359
+ text: before
1360
+ }],
1361
+ {
1362
+ ...run,
1363
+ text: number,
1364
+ field: "PAGE"
1365
+ },
1366
+ ...tail
1367
+ ];
1368
+ }
1369
+ /**
1370
+ * The band itself: the foot's own line, with the number in it made a field.
1371
+ *
1372
+ * §17.16.5.35 — a page number is not the text "1"; it is the number of the page
1373
+ * it is drawn on, which is why the same band serves every page.
1374
+ *
1375
+ * A foot is written in REGIONS, the way a spreadsheet's is: something at the
1376
+ * left of the sheet, something at the middle, something against the far edge.
1377
+ * ZapfDingbats.pdf signs each page "© RenderX 2000" at the left and "XSL
1378
+ * Formatting Objects Test Suite" at the right, and the two hundred points
1379
+ * between them came back as one word space, the two texts crowding each other
1380
+ * at the left. They stand on TAB STOPS instead (§17.3.1.38), which is what the
1381
+ * two hundred points are.
1382
+ *
1383
+ * @param runs The foot's runs.
1384
+ * @param stepped Whether the page steps between its words.
1385
+ * @param measure The measure it was set across, for its alignment.
1386
+ * @param numbered Whether a number in it is the page's own.
1387
+ */
1388
+ function footerBand(runs, stepped, measure, numbered) {
1389
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1390
+ return rowsOf(runs, fontSize).map((row) => bandLine(row, fontSize, stepped, measure)).filter((line) => line !== void 0).map(({ spans, properties }) => {
1391
+ const el = paragraphFromRuns(spans, void 0, properties);
1392
+ if (el.kind !== "paragraph") return el;
1393
+ return {
1394
+ kind: "paragraph",
1395
+ paragraph: {
1396
+ ...el.paragraph,
1397
+ runs: numbered ? el.paragraph.runs.flatMap((run) => pageNumbered(run)) : el.paragraph.runs
1398
+ }
1399
+ };
1400
+ });
1401
+ }
1402
+ /**
1403
+ * One line of a running head or foot: its spans, and the placement that puts
1404
+ * them where the page had them.
1405
+ *
1406
+ * A wide gap inside such a line is not a word space, it is the space BETWEEN
1407
+ * REGIONS — and each region after the first stands on a tab stop, at the middle
1408
+ * of the band or against its far edge, whichever it was written at.
1409
+ *
1410
+ * @param row The line's runs.
1411
+ * @param fontSize The band's size, for the runs that state none.
1412
+ * @param stepped Whether the page steps between its words.
1413
+ * @param measure The measure the band was set across.
1414
+ * @returns The line, or `undefined` where it holds no text.
1415
+ */
1416
+ function bandLine(row, fontSize, stepped, measure) {
1417
+ const ordered = [...row].sort((a, b) => a.x - b.x);
1418
+ const pieces = [[]];
1419
+ for (const run of ordered) {
1420
+ const last = pieces[pieces.length - 1];
1421
+ const prev = last[last.length - 1];
1422
+ if (prev && run.x - prev.endX > (run.fontSizePt || fontSize) * BAND_REGION_EM) pieces.push([]);
1423
+ pieces[pieces.length - 1].push(run);
1424
+ }
1425
+ const y = Math.max(...ordered.map((r) => r.y));
1426
+ const lines = pieces.filter((piece) => piece.length > 0).map((piece) => lineOf(piece, y, Math.max(...piece.map((r) => r.fontSizePt || fontSize)), stepped)).filter((line) => line.text.length > 0);
1427
+ if (lines.length === 0) return void 0;
1428
+ if (lines.length === 1 || lines.length > BAND_REGIONS || !measure) {
1429
+ const { alignment } = alignmentOf(lines.slice(0, 1), measure);
1430
+ return {
1431
+ spans: lines.flatMap((line, i) => i === 0 ? line.spans : [{ text: " " }, ...line.spans]),
1432
+ properties: alignment ? { alignment } : {}
1433
+ };
1434
+ }
1435
+ const width = measure.right - measure.left;
1436
+ const spans = [...lines[0].spans];
1437
+ const tabs = [];
1438
+ for (const line of lines.slice(1)) {
1439
+ const flush = measure.right - (line.x + line.width) <= width * BAND_RIGHT_SHARE;
1440
+ tabs.push({
1441
+ positionPt: pt(0),
1442
+ relativeTo: flush ? "right" : "center",
1443
+ alignment: flush ? "right" : "center"
1444
+ });
1445
+ spans.push({ text: " " }, ...line.spans);
1446
+ }
1447
+ return {
1448
+ spans,
1449
+ properties: { tabs }
1450
+ };
1451
+ }
1452
+ /** A gap this wide, in ems, stands BETWEEN the regions of a head or foot. */
1453
+ var BAND_REGION_EM = 4;
1454
+ /** How many regions a band line may hold — left, centre, right. */
1455
+ var BAND_REGIONS = 3;
1456
+ /** How near the far edge a region has to end to be set against it. */
1457
+ var BAND_RIGHT_SHARE = .05;
1458
+ /**
1459
+ * Whether the page is RULED into columns rather than SET in them.
1460
+ *
1461
+ * A page of two columns and a page of two columns of a table look alike from
1462
+ * here: both have gutters, and both put their lines on one baseline grid — a
1463
+ * paper's columns are set on the same grid as a matter of course, so "the rows
1464
+ * line up" says nothing. What separates them is the CELL: a line of prose fills
1465
+ * its measure and a cell does not. Across this corpus a paper's lines cover 84
1466
+ * to 95 per cent of their column, and ZapfDingbats.pdf's cells cover 45.
1467
+ *
1468
+ * Read by column, its five hundred entries came back one column at a time with
1469
+ * every row torn into three — "1 a17", "[x2711]", "2 a18" — over five pages.
1470
+ * Read by ROW, which is what a ruled page says, each entry is a line of its own.
1471
+ *
1472
+ * @param runs The page's runs.
1473
+ * @param gutters The page's gutters.
1474
+ * @param edges Where the page's text starts and ends.
1475
+ */
1476
+ function looksRuled(runs, gutters, edges) {
1477
+ if (gutters.length < MIN_RULED_GUTTERS || !edges) return false;
1478
+ const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
1479
+ if (rows.length < MIN_TABLE_ROWS) return false;
1480
+ const bounds = columnBounds(runs, gutters, edges);
1481
+ const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
1482
+ const regionOf = (x) => {
1483
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1484
+ return 0;
1485
+ };
1486
+ const fills = [];
1487
+ let aligned = 0;
1488
+ for (const row of rows) {
1489
+ const byRegion = regions.map(() => []);
1490
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1491
+ byRegion.forEach((inRegion, i) => {
1492
+ if (inRegion.length === 0) return;
1493
+ const from = Math.min(...inRegion.map((r) => r.x));
1494
+ const to = Math.max(...inRegion.map((r) => r.endX));
1495
+ const width = regions[i][1] - regions[i][0];
1496
+ if (width > 0) fills.push((to - from) / width);
1497
+ });
1498
+ if (byRegion.every((inRegion) => inRegion.length > 0)) aligned++;
1499
+ }
1500
+ if (aligned < rows.length * TABLE_ALIGNED_SHARE) return false;
1501
+ fills.sort((a, b) => a - b);
1502
+ return (fills[Math.floor(fills.length / 2)] ?? 1) <= TABLE_CELL_FILL;
1503
+ }
1504
+ /**
1505
+ * Whether the regions the gutters cut the page into are COLUMNS OF PROSE — the
1506
+ * only thing worth re-setting a document in columns for.
1507
+ *
1508
+ * A gutter is evidence about the page's white space, and white space is not
1509
+ * enough: an invoice sets its labels along the left and its amounts against the
1510
+ * right margin, and a dozen lines then break at the same x. Read as two columns
1511
+ * the amounts became a column of their own and every label was indented past
1512
+ * the strip it was given — "Total excluding tax" came back one letter per line.
1513
+ *
1514
+ * What tells them apart is the same thing that tells a cell from a line of
1515
+ * prose: a column of prose FILLS its measure, over and over, because that is
1516
+ * what wrapping does. A column of values never fills anything — its lines are
1517
+ * short and it is their right edges that line up, not their left. So every
1518
+ * region must hold lines of its own and they must reach across it.
1519
+ *
1520
+ * @param runs The page's runs.
1521
+ * @param gutters The page's gutters.
1522
+ * @param edges Where the page's text starts and ends.
1523
+ * @returns Whether the page is set in columns of prose.
1524
+ */
1525
+ function proseColumns(runs, gutters, edges) {
1526
+ if (gutters.length === 0) return false;
1527
+ if (!edges) return true;
1528
+ const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
1529
+ if (rows.length === 0) return true;
1530
+ const bounds = columnBounds(runs, gutters, edges);
1531
+ const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
1532
+ const regionOf = (x) => {
1533
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1534
+ return 0;
1535
+ };
1536
+ const edgesIn = regions.map(() => []);
1537
+ for (const row of rows) {
1538
+ const byRegion = regions.map(() => []);
1539
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1540
+ byRegion.forEach((inRegion, i) => {
1541
+ if (inRegion.length === 0) return;
1542
+ edgesIn[i].push({
1543
+ left: Math.min(...inRegion.map((r) => r.x)),
1544
+ right: Math.max(...inRegion.map((r) => r.endX))
1545
+ });
1546
+ });
1547
+ }
1548
+ const agreeing = (xs) => {
1549
+ let most = 0;
1550
+ for (const x of xs) most = Math.max(most, xs.filter((y) => Math.abs(y - x) <= FLUSH_PT).length);
1551
+ return most;
1552
+ };
1553
+ return edgesIn.every((lines) => {
1554
+ if (lines.length < MIN_PROSE_LINES) return false;
1555
+ return agreeing(lines.map((l) => l.right)) <= agreeing(lines.map((l) => l.left));
1556
+ });
1557
+ }
1558
+ /**
1559
+ * The drawn glyphs that are STRAY marks in a line of ordinary text.
1560
+ *
1561
+ * §9.6.6 — a glyph the file states no character for is drawn, because nothing
1562
+ * downstream can write it. That is the whole content of some pages: a subset
1563
+ * that names its glyphs `g18`, a program with no `cmap`, a font of pictures.
1564
+ * On such a page the drawings ARE the words and they stay.
1565
+ *
1566
+ * A single mark among readable words is the other case, and Stripe's invoices
1567
+ * are full of them — a `/ToUnicode` stating U+0000 for every piece of
1568
+ * punctuation it sets. The flowing reading has nowhere to put a shape inside a
1569
+ * line, so the colon of "Kazakhstan VAT: 86-1696045" was placed as a floating
1570
+ * drawing and landed a word away, on a line of its own, pushing the text
1571
+ * around it aside. Dropped, the line keeps its words and the loss report still
1572
+ * says a character was unrecoverable.
1573
+ *
1574
+ * The line decides: where nearly everything on it is drawn, the drawings are
1575
+ * the text — bug1151216.pdf sets three lines of prices that way. Where one or
1576
+ * two marks stand among several readable runs, they are punctuation.
1577
+ *
1578
+ * @param vectors The page's painted paths.
1579
+ * @param runs The page's runs.
1580
+ * @returns The drawn glyphs to leave out of a flowing reading.
1581
+ */
1582
+ function strayMarks(vectors, runs) {
1583
+ const glyphs = vectors.filter((v) => v.glyph === true);
1584
+ const out = /* @__PURE__ */ new Set();
1585
+ if (glyphs.length === 0) return out;
1586
+ const readable = runs.filter((r) => r.text.trim() !== "" && !r.text.includes("�"));
1587
+ for (const v of glyphs) {
1588
+ const mid = (v.minY + v.maxY) / 2;
1589
+ const size = v.maxY - v.minY || 10;
1590
+ const words = readable.filter((r) => Math.abs(r.y - mid) <= size);
1591
+ const before = words.some((r) => r.endX <= v.minX + size);
1592
+ const after = words.some((r) => r.x >= v.maxX - size);
1593
+ if (!before && !after) continue;
1594
+ if (glyphs.filter((g) => Math.abs((g.minY + g.maxY) / 2 - mid) <= size).length <= MOST_STRAY_MARKS && words.length >= LEAST_READABLE_RUNS) out.add(v);
1595
+ }
1596
+ return out;
1597
+ }
1598
+ /** How few drawn glyphs a line may hold before they are its text, not marks in it. */
1599
+ var MOST_STRAY_MARKS = 2;
1600
+ /** …and how many readable runs must stand on that line for them to be strays. */
1601
+ var LEAST_READABLE_RUNS = 4;
1602
+ /**
1603
+ * §17.4.38 — the consecutive lines a page set out on the SAME stops, as the
1604
+ * TABLE they are.
1605
+ *
1606
+ * A line broken at a gap no word space explains is a line the page laid out
1607
+ * (see {@link tabbed}); several of them one under another, broken in the same
1608
+ * places, is a table's head and its rows. An invoice's item table is exactly
1609
+ * that, and so is the payment history under it.
1610
+ *
1611
+ * Two stops at least, which is three columns: ONE stop is a contents entry with
1612
+ * its page number at the measure, and a list of those is a list.
1613
+ *
1614
+ * The stops do not have to agree to the point — a heading sits over its column
1615
+ * and a figure is set against the far side of it — so they are matched within
1616
+ * an em, and the column is put where the rows agree it is.
1617
+ *
1618
+ * @param paras The column's paragraphs, in order.
1619
+ * @param measure The measure they are set across.
1620
+ * @returns For each paragraph that belongs to a table: the table at its first
1621
+ * row, and `null` at the rows after it (their content is inside it).
1622
+ */
1623
+ function tabbedRows(paras, measure) {
1624
+ const out = /* @__PURE__ */ new Map();
1625
+ if (!measure) return out;
1626
+ for (let i = 0; i < paras.length;) {
1627
+ const stops = paras[i]?.stops ?? [];
1628
+ if (stops.length < LEAST_TABLE_STOPS) {
1629
+ i++;
1630
+ continue;
1631
+ }
1632
+ const near = (a, b) => a.length === b.length && a.every((x, k) => Math.abs(x - b[k]) <= Math.max(paras[i].fontSize, TABLE_STOP_SLACK_PT));
1633
+ let to = i + 1;
1634
+ while (to < paras.length && near(stops, paras[to]?.stops ?? [])) to++;
1635
+ if (to - i < 2) {
1636
+ i++;
1637
+ continue;
1638
+ }
1639
+ const rows = paras.slice(i, to);
1640
+ const bounds = stops.map((_, k) => Math.min(...rows.map((r) => r.stops[k])));
1641
+ const width = measure.right - measure.left;
1642
+ const edges = [
1643
+ 0,
1644
+ ...bounds,
1645
+ width
1646
+ ];
1647
+ out.set(i, {
1648
+ kind: "table",
1649
+ table: {
1650
+ properties: {
1651
+ defaultCellMargins: {
1652
+ left: pt(0),
1653
+ right: pt(0)
1654
+ },
1655
+ layout: "fixed"
1656
+ },
1657
+ grid: edges.slice(0, -1).map((from, k) => pt(Math.max(edges[k + 1] - from, 1))),
1658
+ rows: rows.map((row, r) => {
1659
+ const prev = rows[r - 1];
1660
+ const pitch = prev ? prev.top - row.top - row.fontSize * 1.2 : 0;
1661
+ const opening = r === 0 ? row.spacingBefore : pitch > row.fontSize * .3 ? pitch : void 0;
1662
+ return {
1663
+ properties: {},
1664
+ cells: splitAtTabs(row.spans).map((cell) => ({
1665
+ properties: {},
1666
+ content: [paragraphFromRuns(cell, void 0, opening !== void 0 ? { spacingBefore: pt(opening) } : {})]
1667
+ }))
1668
+ };
1669
+ })
1670
+ }
1671
+ });
1672
+ for (let k = i + 1; k < to; k++) out.set(k, null);
1673
+ i = to;
1674
+ }
1675
+ return out;
1676
+ }
1677
+ /** How many stops a line must stand on before a run of them is a table. */
1678
+ var LEAST_TABLE_STOPS = 2;
1679
+ /**
1680
+ * How far two rows' stops may stand apart and still be one column.
1681
+ *
1682
+ * A heading is set over its column and a figure against the far side of it: an
1683
+ * invoice's "Qty" stands eight points left of the "1" under it, and the heading
1684
+ * is set two sizes smaller, so its own em is not a wide enough tolerance.
1685
+ */
1686
+ var TABLE_STOP_SLACK_PT = 10;
1687
+ /** One line's spans cut into its cells, at the tabs the page set out. */
1688
+ function splitAtTabs(spans) {
1689
+ const cells = [[]];
1690
+ for (const span of spans) if (span.text === " ") cells.push([]);
1691
+ else cells[cells.length - 1].push(span);
1692
+ return cells;
1693
+ }
1694
+ /**
1695
+ * Give each separator rule to the paragraph it separates, as that paragraph's
1696
+ * own border (§17.3.1.24), and say which rules were given away.
1697
+ *
1698
+ * A rule sits in the white between two blocks: under a table's headings, over a
1699
+ * total. The block BELOW it takes it as a top border, because that is the block
1700
+ * the rule introduces; where nothing follows closely enough, the block above
1701
+ * takes it as a bottom one.
1702
+ *
1703
+ * @param vectors The page's painted paths.
1704
+ * @param blocks The blocks read off the page so far, which the rule joins.
1705
+ * @param width The page's width, which a rule is long relative to.
1706
+ * @returns The rules that became borders, and so must not be drawn again.
1707
+ */
1708
+ function ruleBorders(vectors, blocks, width) {
1709
+ const given = /* @__PURE__ */ new Set();
1710
+ const paragraphs = blocks.filter((b) => b.el.kind === "paragraph");
1711
+ if (paragraphs.length === 0) return given;
1712
+ for (const v of vectors) {
1713
+ if (!separatorRule(v, width)) continue;
1714
+ const y = (v.minY + v.maxY) / 2;
1715
+ const below = paragraphs.filter((b) => b.top < y).sort((a, b) => b.top - a.top)[0];
1716
+ const above = paragraphs.filter((b) => b.top >= y).sort((a, b) => a.top - b.top)[0];
1717
+ const side = below !== void 0 && y - below.top <= RULE_REACH_PT ? {
1718
+ block: below,
1719
+ edge: "top"
1720
+ } : above !== void 0 && above.top - y <= RULE_REACH_PT ? {
1721
+ block: above,
1722
+ edge: "bottom"
1723
+ } : void 0;
1724
+ if (!side || side.block.el.kind !== "paragraph") continue;
1725
+ const border = {
1726
+ style: "single",
1727
+ width: pt(Math.max(v.lineWidth ?? v.maxY - v.minY, RULE_MIN_PT)),
1728
+ colorHex: v.strokeHex ?? v.fillHex ?? "000000"
1729
+ };
1730
+ const { paragraph } = side.block.el;
1731
+ side.block.el = {
1732
+ kind: "paragraph",
1733
+ paragraph: {
1734
+ ...paragraph,
1735
+ properties: {
1736
+ ...paragraph.properties,
1737
+ borders: {
1738
+ ...paragraph.properties.borders,
1739
+ [side.edge]: border
1740
+ }
1741
+ }
1742
+ }
1743
+ };
1744
+ given.add(v);
1745
+ }
1746
+ return given;
1747
+ }
1748
+ /** How far from a paragraph a rule may stand and still belong to it. */
1749
+ var RULE_REACH_PT = 14;
1750
+ /** The thinnest a border may be drawn and still be seen. */
1751
+ var RULE_MIN_PT = .5;
1752
+ /**
1753
+ * Whether a painted path is a RULE — a line drawn to separate one block from
1754
+ * the next, rather than a piece of the page's artwork.
1755
+ *
1756
+ * Flat and long: a hairline the width of a column under a table's headings, or
1757
+ * the line a total is written over. Everything else — boxes, panels, drawings —
1758
+ * keeps the place on the page it was drawn at.
1759
+ *
1760
+ * @param v The painted path.
1761
+ * @param width The page's width, which the rule is long RELATIVE to.
1762
+ * @returns Whether it separates blocks rather than drawing something.
1763
+ */
1764
+ function separatorRule(v, width) {
1765
+ const w = v.maxX - v.minX;
1766
+ const h = v.maxY - v.minY;
1767
+ return h <= RULE_THICK_PT && w >= width * RULE_SHARE && w > h * RULE_RATIO;
1768
+ }
1769
+ /** How thick a mark may be and still be a rule. */
1770
+ var RULE_THICK_PT = 2;
1771
+ /** How much of the sheet it must run across. */
1772
+ var RULE_SHARE = .2;
1773
+ /** …and how much longer than it is thick. */
1774
+ var RULE_RATIO = 20;
1775
+ /** How close two edges stand before they count as the same one. */
1776
+ var FLUSH_PT = 1;
1777
+ /** And how many lines it takes before a region is a column at all. */
1778
+ var MIN_PROSE_LINES = 6;
1779
+ /**
1780
+ * §17.4.38 — the page's rows as the TABLE they are.
1781
+ *
1782
+ * The regions between the gutters are the columns and the rows are the rows;
1783
+ * a cell is what one row leaves in one region, and an empty cell is empty. The
1784
+ * grid is measured, so the columns come out where the page put them.
1785
+ *
1786
+ * The lines the page hangs ABOVE its ruling come back as themselves. A line
1787
+ * that crosses every column is not a row of the table — ZapfDingbats.pdf heads
1788
+ * each sheet with two red lines of provenance that run wider than the frame
1789
+ * drawn under them, and squeezed into a cell they wrapped and cost the sheet a
1790
+ * row.
1791
+ *
1792
+ * @param runs The page's runs.
1793
+ * @param gutters The page's gutters.
1794
+ * @param edges Where the page's text starts and ends.
1795
+ * @param stepped Whether the page steps between its words.
1796
+ * @returns The blocks, the table among them, each with where it stands.
1797
+ */
1798
+ function tableFrom(runs, gutters, edges, stepped) {
1799
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1800
+ const all = rowsOf(runs, fontSize).filter((row) => row.length > 0);
1801
+ if (all.length === 0) return void 0;
1802
+ const bounds = columnBounds(runs, gutters, edges);
1803
+ const regionsAt = (from) => {
1804
+ const regions = from.slice(0, -1).map((lo, i) => [lo, from[i + 1]]);
1805
+ return {
1806
+ regions,
1807
+ of: (x) => {
1808
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1809
+ return 0;
1810
+ }
1811
+ };
1812
+ };
1813
+ const first = regionsAt(bounds);
1814
+ const oneWideLine = (row) => {
1815
+ const to = spanEnd(row, 0, first.regions.length, first.of);
1816
+ return to > 0 && row.every((run) => first.of(run.x) <= to);
1817
+ };
1818
+ let start = 0;
1819
+ while (start < all.length - 1 && oneWideLine(all[start])) start++;
1820
+ const above = all.slice(0, start);
1821
+ const rows = all.slice(start);
1822
+ const own = rows.filter((row) => spanEnd(row, 0, first.regions.length, first.of) === 0).flatMap((row) => row.filter((run) => first.of(run.x) === 0)).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
1823
+ const left = own.length > 0 ? Math.min(...own) : bounds[0];
1824
+ const { regions, of: regionOf } = regionsAt([left, ...bounds.slice(1)]);
1825
+ const flush = regions.map((region, i) => {
1826
+ const starts = [];
1827
+ for (const row of rows) {
1828
+ const at = row.filter((run) => regionOf(run.x) === i).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
1829
+ if (at.length > 0) starts.push(Math.min(...at));
1830
+ }
1831
+ if (starts.length === 0) return region[0];
1832
+ starts.sort((a, b) => a - b);
1833
+ return starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? region[0];
1834
+ });
1835
+ const table = {
1836
+ properties: {
1837
+ defaultCellMargins: {
1838
+ left: pt(0),
1839
+ right: pt(0)
1840
+ },
1841
+ layout: "fixed",
1842
+ ...left > edges.left ? { indentPt: pt(left - edges.left) } : {}
1843
+ },
1844
+ grid: regions.map((r) => pt(Math.max(r[1] - r[0], 1))),
1845
+ rows: rows.map((row, r) => {
1846
+ const byRegion = regions.map(() => []);
1847
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1848
+ const y = Math.max(...row.map((r2) => r2.y));
1849
+ const cells = [];
1850
+ for (let i = 0; i < regions.length;) {
1851
+ const to = spanEnd(row, i, regions.length, regionOf);
1852
+ const inSpan = byRegion.slice(i, to + 1).flat();
1853
+ const width = to - i + 1;
1854
+ const size = inSpan.length > 0 ? Math.max(...inSpan.map((r) => r.fontSizePt || fontSize)) : fontSize;
1855
+ const line = inSpan.length > 0 ? lineOf(inSpan, y, size, stepped) : void 0;
1856
+ const placed = line && line.x - flush[i] >= size / 4 ? alignmentOf([line], {
1857
+ left: regions[i][0],
1858
+ right: regions[to][1]
1859
+ }, size / 2) : {};
1860
+ cells.push({
1861
+ properties: width > 1 ? { colSpan: width } : {},
1862
+ content: line === void 0 ? [{
1863
+ kind: "paragraph",
1864
+ paragraph: {
1865
+ properties: {},
1866
+ runs: []
1867
+ }
1868
+ }] : [paragraphFromRuns(line.spans, void 0, placed.alignment ? { alignment: placed.alignment } : {})]
1869
+ });
1870
+ i = to + 1;
1871
+ }
1872
+ const next = rows[r + 1];
1873
+ const pitch = next ? y - Math.max(...next.map((r2) => r2.y)) : 0;
1874
+ return {
1875
+ properties: pitch > 0 ? {
1876
+ height: pt(pitch),
1877
+ heightRule: "atLeast"
1878
+ } : {},
1879
+ cells
1880
+ };
1881
+ })
1882
+ };
1883
+ return [...above.map((row) => {
1884
+ const y = Math.max(...row.map((r) => r.y));
1885
+ return {
1886
+ el: paragraphFromRuns(lineOf(row, y, Math.max(...row.map((r) => r.fontSizePt || fontSize)), stepped).spans),
1887
+ top: y
1888
+ };
1889
+ }), {
1890
+ el: {
1891
+ kind: "table",
1892
+ table
1893
+ },
1894
+ top: Math.max(...rows[0].map((r) => r.y))
1895
+ }];
1896
+ }
1897
+ /**
1898
+ * Where one column of a ruled page ends and the next begins.
1899
+ *
1900
+ * The gutter is a BAND, and its middle is only a guess at the line inside it: a
1901
+ * gutter is where the fewest lines cross, not where none do (see
1902
+ * `detectGutters`), so a page whose prose overhangs the first column by a few
1903
+ * points has that prose crossing the middle. Cut there, ZapfDingbats.pdf's
1904
+ * heading and its lead paragraph either wrapped inside a cell too narrow for
1905
+ * them or swallowed the glyph standing beside them. The boundary is put past
1906
+ * the crossing ink instead, as far as the band allows — where the columns
1907
+ * really do divide.
1908
+ *
1909
+ * @param runs The page's runs.
1910
+ * @param gutters The page's gutters.
1911
+ * @param edges Where the page's text starts and ends.
1912
+ * @returns The column boundaries, left edge first and right edge last.
1913
+ */
1914
+ function columnBounds(runs, gutters, edges) {
1915
+ const inks = runs.map((r) => runInk(r)).filter((ink) => ink !== void 0);
1916
+ const clearance = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 4;
1917
+ return [
1918
+ edges.left,
1919
+ ...gutters.map((g) => {
1920
+ let at = g.mid;
1921
+ for (const [from, to] of inks) if (from < g.mid && to > at && to <= g.to) at = Math.min(to + clearance, g.to);
1922
+ return at;
1923
+ }),
1924
+ edges.right
1925
+ ];
1926
+ }
1927
+ /**
1928
+ * The last column a cell starting at `from` covers — the one where no run
1929
+ * reaches any further right.
1930
+ *
1931
+ * A run whose INK ends past a column boundary was drawn as one line across
1932
+ * both, so both belong to one cell; and a cell widened that way may pick up a
1933
+ * run that crosses the next boundary in turn.
1934
+ *
1935
+ * @param row The row's runs.
1936
+ * @param from The column the cell starts in.
1937
+ * @param count How many columns the table has.
1938
+ * @param regionOf Which column an x falls in.
1939
+ */
1940
+ function spanEnd(row, from, count, regionOf) {
1941
+ let to = from;
1942
+ for (let grew = true; grew && to < count - 1;) {
1943
+ grew = false;
1944
+ for (const run of row) {
1945
+ const ink = runInk(run);
1946
+ if (ink === void 0) continue;
1947
+ const start = regionOf(ink[0]);
1948
+ if (start < from || start > to) continue;
1949
+ const end = regionOf(ink[1] - 1);
1950
+ if (end > to) {
1951
+ to = Math.min(end, count - 1);
1952
+ grew = true;
1953
+ }
1954
+ }
1955
+ }
1956
+ return to;
1957
+ }
1958
+ /** Below this many gutters a page in columns is read as columns. */
1959
+ var MIN_RULED_GUTTERS = 2;
1960
+ /** Below this many rows a page is not ruled into anything. */
1961
+ var MIN_TABLE_ROWS = 6;
1962
+ /** How many of a table's rows must carry a cell in every one of its columns. */
1963
+ var TABLE_ALIGNED_SHARE = .6;
1964
+ /**
1965
+ * How much of its column a CELL covers, at the median. A line of prose fills
1966
+ * its measure — 84 to 95 per cent across this corpus — and a cell does not.
1967
+ */
1968
+ var TABLE_CELL_FILL = .65;
493
1969
  //#endregion
494
1970
  export { endedParagraph, reconstructByLayout };