reamkit 1.26.0 → 1.28.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 (85) hide show
  1. package/README.md +14 -8
  2. package/dist/esm/core/converter/ream.d.ts +0 -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.d.ts +65 -0
  17. package/dist/esm/pdf-reader/annot-draw.js +487 -0
  18. package/dist/esm/pdf-reader/annots.d.ts +0 -12
  19. package/dist/esm/pdf-reader/annots.js +30 -4
  20. package/dist/esm/pdf-reader/ccitt.d.ts +20 -3
  21. package/dist/esm/pdf-reader/ccitt.js +102 -6
  22. package/dist/esm/pdf-reader/cff-outline.d.ts +36 -0
  23. package/dist/esm/pdf-reader/cff-outline.js +1122 -0
  24. package/dist/esm/pdf-reader/cie-color.d.ts +33 -0
  25. package/dist/esm/pdf-reader/cie-color.js +112 -0
  26. package/dist/esm/pdf-reader/cmap.js +5 -2
  27. package/dist/esm/pdf-reader/content.d.ts +123 -3
  28. package/dist/esm/pdf-reader/content.js +232 -54
  29. package/dist/esm/pdf-reader/dingbats.d.ts +11 -0
  30. package/dist/esm/pdf-reader/dingbats.js +1033 -0
  31. package/dist/esm/pdf-reader/display.d.ts +1 -1
  32. package/dist/esm/pdf-reader/display.js +61 -6
  33. package/dist/esm/pdf-reader/document.d.ts +6 -0
  34. package/dist/esm/pdf-reader/document.js +55 -10
  35. package/dist/esm/pdf-reader/embedded-fonts.d.ts +23 -3
  36. package/dist/esm/pdf-reader/embedded-fonts.js +36 -4
  37. package/dist/esm/pdf-reader/encodings.d.ts +25 -0
  38. package/dist/esm/pdf-reader/encodings.js +110 -0
  39. package/dist/esm/pdf-reader/flow-build.d.ts +19 -5
  40. package/dist/esm/pdf-reader/flow-build.js +111 -11
  41. package/dist/esm/pdf-reader/font.js +403 -21
  42. package/dist/esm/pdf-reader/function.d.ts +16 -0
  43. package/dist/esm/pdf-reader/function.js +414 -0
  44. package/dist/esm/pdf-reader/glyf-outline.d.ts +43 -0
  45. package/dist/esm/pdf-reader/glyf-outline.js +351 -0
  46. package/dist/esm/pdf-reader/glyph-names.js +20 -0
  47. package/dist/esm/pdf-reader/icc.d.ts +10 -0
  48. package/dist/esm/pdf-reader/icc.js +210 -0
  49. package/dist/esm/pdf-reader/image-decode.d.ts +9 -4
  50. package/dist/esm/pdf-reader/image-decode.js +274 -72
  51. package/dist/esm/pdf-reader/images.d.ts +18 -0
  52. package/dist/esm/pdf-reader/images.js +154 -11
  53. package/dist/esm/pdf-reader/jbig2.d.ts +23 -0
  54. package/dist/esm/pdf-reader/jbig2.js +126 -32
  55. package/dist/esm/pdf-reader/layout.d.ts +32 -0
  56. package/dist/esm/pdf-reader/layout.js +1316 -64
  57. package/dist/esm/pdf-reader/lexer.d.ts +2 -0
  58. package/dist/esm/pdf-reader/lexer.js +4 -0
  59. package/dist/esm/pdf-reader/math-rows.d.ts +23 -0
  60. package/dist/esm/pdf-reader/math-rows.js +198 -0
  61. package/dist/esm/pdf-reader/optional-content.d.ts +36 -0
  62. package/dist/esm/pdf-reader/optional-content.js +93 -0
  63. package/dist/esm/pdf-reader/predefined-cmap.d.ts +21 -0
  64. package/dist/esm/pdf-reader/predefined-cmap.js +102 -0
  65. package/dist/esm/pdf-reader/reader.d.ts +6 -6
  66. package/dist/esm/pdf-reader/reader.js +102 -16
  67. package/dist/esm/pdf-reader/shading.d.ts +139 -8
  68. package/dist/esm/pdf-reader/shading.js +309 -39
  69. package/dist/esm/pdf-reader/standard-metrics.d.ts +8 -0
  70. package/dist/esm/pdf-reader/standard-metrics.js +18 -0
  71. package/dist/esm/pdf-reader/standard-widths.d.ts +20 -0
  72. package/dist/esm/pdf-reader/standard-widths.js +62 -0
  73. package/dist/esm/pdf-reader/stream-filters.d.ts +6 -0
  74. package/dist/esm/pdf-reader/stream-filters.js +67 -0
  75. package/dist/esm/pdf-reader/tagged.js +204 -32
  76. package/dist/esm/pdf-reader/text-rules.d.ts +16 -0
  77. package/dist/esm/pdf-reader/text-rules.js +185 -0
  78. package/dist/esm/pdf-reader/text.js +157 -4
  79. package/dist/esm/pdf-reader/type1-outline.d.ts +21 -0
  80. package/dist/esm/pdf-reader/type1-outline.js +576 -0
  81. package/dist/esm/pdf-reader/vector.d.ts +5 -0
  82. package/dist/esm/pdf-reader/vector.js +79 -27
  83. package/dist/esm/word/document-parser.js +4 -1
  84. package/dist/esm/word/docx-writer.js +74 -11
  85. package/package.json +1 -1
@@ -1,14 +1,19 @@
1
1
  import { pt } from "../core/ir/units.js";
2
2
  import { ResourceStore } from "../core/ir/resources.js";
3
3
  import { FEATURES } from "../core/ir/features.js";
4
- import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
5
- import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock } from "./flow-build.js";
6
- import { collectEmbeddedFonts } from "./embedded-fonts.js";
7
4
  import { isRightToLeft } from "./content.js";
8
- import { collectPageImages } from "./images.js";
5
+ import { collectEmbeddedFonts } from "./embedded-fonts.js";
9
6
  import { extractPageText } from "./text.js";
10
7
  import { collectPageVectors } from "./vector.js";
8
+ import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
9
+ import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock, withMeasuredMargins } from "./flow-build.js";
10
+ import { collectPageImages } from "./images.js";
11
+ import { markDrawnRules } from "./text-rules.js";
12
+ import { matrixBlocks } from "./math-rows.js";
11
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,26 +32,53 @@ import { collectPageVectors } from "./vector.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;
31
39
  const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
32
40
  const resources = new ResourceStore();
33
41
  const losses = [];
42
+ if (pageRuns.some((page) => page.some((r) => r.text.includes("�")))) losses.push({
43
+ severity: "dropped",
44
+ feature: FEATURES.text,
45
+ detail: "some glyphs map to no character — the font states no /ToUnicode and its program says nothing either, so that text is unrecoverable"
46
+ });
34
47
  if (pageRuns.some((page) => page.some((r) => r.fillPatternName !== void 0))) losses.push({
35
48
  severity: "degraded",
36
49
  feature: FEATURES.text,
37
50
  detail: "text filled with a tiling pattern is drawn as a flat tint of the pattern’s colour, not as the pattern"
38
51
  });
52
+ if (pageRuns.some((page) => page.some((r) => r.gradientFill === true))) losses.push({
53
+ severity: "degraded",
54
+ feature: FEATURES.text,
55
+ detail: "text filled with a shading pattern is drawn in the middle colour of the gradient, not as the gradient"
56
+ });
57
+ const perPage = pages.map((_, i) => detectGutters(pageRuns[i], shown[i].width));
58
+ const shared = commonGutters(perPage);
59
+ const pageGutters = perPage.map((own, i) => own.length > 0 ? own : shared && fitsGutters(pageRuns[i], shared) ? shared : own);
39
60
  const body = [];
61
+ const sectionEnds = [];
62
+ let sectionFrom = 0;
63
+ let lastSize = "";
64
+ let curColumns = 1;
65
+ let curSpace = 0;
66
+ let pendingContinuous = false;
40
67
  pages.forEach((page, i) => {
41
68
  const runs = pageRuns[i];
42
69
  const display = shown[i];
70
+ const gutters = pageGutters[i];
43
71
  const pageWidth = display.width;
44
- const gutter = detectGutter(runs, pageWidth);
72
+ const stepped = stepsBetweenWords(runs);
45
73
  const blocks = [];
74
+ const split = gutters.length > 0 ? assignColumns(runs, gutters) : void 0;
75
+ const textEdges = pageTextEdges(runs);
76
+ const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
77
+ const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
46
78
  const addColumn = (allRuns, col) => {
47
79
  const colRuns = mode === "positional" ? allRuns.filter((r) => r.type3 !== true && r.invisible !== true) : allRuns;
48
80
  if (mode === "positional") {
49
- for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true)) {
81
+ for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
50
82
  if (line.text.length === 0) continue;
51
83
  const box = turnedBox(line, angle, pageWidth);
52
84
  placed.push({
@@ -58,19 +90,51 @@ function reconstructByLayout(file, mode = "flow") {
58
90
  }
59
91
  return;
60
92
  }
61
- const lines = groupIntoLines(colRuns).filter((l) => l.text.length > 0);
62
- for (const para of groupIntoParagraphs(lines)) blocks.push({
93
+ const columnSize = median(colRuns.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
94
+ const maths = matrixBlocks(colRuns, columnSize);
95
+ const inMath = new Set(maths.flatMap((m) => [...m.used]));
96
+ const lines = groupIntoLines(inMath.size > 0 ? colRuns.filter((r) => !inMath.has(r)) : colRuns, false, stepped).filter((l) => l.text.length > 0);
97
+ const measure = measureOf(col, gutters, textEdges) ?? measureOfLines(lines);
98
+ for (const found of maths) {
99
+ const { alignment } = alignmentOf([{
100
+ y: found.top,
101
+ fontSize: columnSize,
102
+ text: "",
103
+ spans: [],
104
+ x: found.x,
105
+ width: found.width
106
+ }], measure);
107
+ blocks.push({
108
+ band: bandAt(found.top),
109
+ col,
110
+ top: found.top,
111
+ el: {
112
+ kind: "paragraph",
113
+ paragraph: {
114
+ properties: alignment ? { alignment } : {},
115
+ runs: [{
116
+ text: "",
117
+ properties: {},
118
+ math: found.math
119
+ }]
120
+ }
121
+ }
122
+ });
123
+ }
124
+ for (const para of groupIntoParagraphs(lines, measure, display.height)) blocks.push({
125
+ band: bandAt(para.top),
63
126
  col,
64
127
  top: para.top,
65
- el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont))
128
+ el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
129
+ ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
130
+ ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
131
+ ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
132
+ ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
133
+ })
66
134
  });
67
135
  };
68
136
  const placed = [];
69
- if (gutter !== void 0) {
70
- addColumn(runs.filter((r) => r.x < gutter), 0);
71
- addColumn(runs.filter((r) => r.x >= gutter), 1);
72
- } else addColumn(runs, 0);
73
- const colOf = (centerX) => gutter !== void 0 && centerX >= gutter ? 1 : 0;
137
+ const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
74
138
  const frame = {
75
139
  left: 0,
76
140
  top: display.height
@@ -88,30 +152,62 @@ function reconstructByLayout(file, mode = "flow") {
88
152
  maxY: img.y + img.heightPt
89
153
  })));
90
154
  losses.push(...lifted.losses);
91
- const vectors = placeVectors(lifted.vectors, display);
155
+ const placedVectors = placeVectors(lifted.vectors, display);
156
+ const ruled = markDrawnRules(runs, placedVectors);
157
+ const vectors = placedVectors.filter((v) => !ruled.consumed.has(v));
158
+ const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
159
+ const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
160
+ if (asTable) for (const block of asTable) blocks.push({
161
+ band: bandAt(block.top),
162
+ col: 0,
163
+ top: block.top,
164
+ el: block.el
165
+ });
166
+ else if (split && !ruledIntoColumns) {
167
+ const columnFor = (r) => split.columnOf.get(r) ?? colOf(r.x);
168
+ const columns = Array.from({ length: gutters.length + 1 }, (_, n) => n);
169
+ for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
170
+ } else addColumn(ruled.runs, 0);
171
+ const under = mode !== "positional";
92
172
  [
93
173
  ...imgs.images.map((img) => ({
94
174
  key: img.orderKey,
95
175
  col: colOf(img.x + img.widthPt / 2),
96
176
  top: img.y + img.heightPt,
97
- make: (z) => imageBlock(img, resources, void 0, frame, z)
177
+ make: (z) => imageBlock(img, resources, void 0, frame, z, under)
98
178
  })),
99
179
  ...vectors.map((v) => ({
100
180
  key: v.orderKey,
101
181
  col: colOf((v.minX + v.maxX) / 2),
102
182
  top: v.maxY,
103
- make: (z) => shapeBlock(v, frame, z)
183
+ make: (z) => shapeBlock(v, frame, z, under)
104
184
  })),
105
185
  ...placed
106
186
  ].sort((a, b) => compareOrder(a.key, b.key)).forEach((mark, z) => {
107
187
  blocks.push({
188
+ band: bandAt(mark.top),
108
189
  col: mark.col,
109
190
  top: mark.top,
110
191
  el: mark.make(z)
111
192
  });
112
193
  });
113
- blocks.sort((a, b) => a.col - b.col || b.top - a.top);
114
- if (i > 0 && blocks.length > 0) body.push({
194
+ blocks.sort((a, b) => a.band - b.band || columnOrder(a.col) - columnOrder(b.col) || b.top - a.top);
195
+ const size = `${shown[i].width.toFixed(2)}x${shown[i].height.toFixed(2)}`;
196
+ const opensSection = i > 0 && size !== lastSize;
197
+ if (opensSection) {
198
+ sectionEnds.push({
199
+ at: body.length,
200
+ from: sectionFrom,
201
+ to: i,
202
+ columns: curColumns,
203
+ spacePt: curSpace,
204
+ continuous: pendingContinuous
205
+ });
206
+ sectionFrom = i;
207
+ pendingContinuous = false;
208
+ }
209
+ lastSize = size;
210
+ if (i > 0 && !opensSection && blocks.length > 0) body.push({
115
211
  kind: "paragraph",
116
212
  paragraph: {
117
213
  properties: {
@@ -122,35 +218,416 @@ function reconstructByLayout(file, mode = "flow") {
122
218
  runs: []
123
219
  }
124
220
  });
125
- for (const block of blocks) body.push(block.el);
221
+ const columnsHere = ruledIntoColumns ? 1 : gutters.length + 1;
222
+ const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
223
+ for (const block of blocks) {
224
+ const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
225
+ if (count !== curColumns) {
226
+ sectionEnds.push({
227
+ at: body.length,
228
+ from: sectionFrom,
229
+ to: i + 1,
230
+ columns: curColumns,
231
+ spacePt: curSpace,
232
+ continuous: pendingContinuous
233
+ });
234
+ sectionFrom = i;
235
+ pendingContinuous = true;
236
+ curColumns = count;
237
+ curSpace = spacePt;
238
+ }
239
+ body.push(block.el);
240
+ }
126
241
  });
242
+ const setUp = (from, to) => {
243
+ const own = sectionFromPdfPages(pages.slice(from, to));
244
+ return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to));
245
+ };
246
+ sectionEnds.push({
247
+ at: body.length,
248
+ from: sectionFrom,
249
+ to: pages.length,
250
+ columns: curColumns,
251
+ spacePt: curSpace,
252
+ continuous: pendingContinuous
253
+ });
254
+ const sections = sectionEnds.length > 1 ? sectionEnds.flatMap((end) => {
255
+ const base = setUp(end.from, end.to);
256
+ if (!base) return [];
257
+ return [{
258
+ properties: {
259
+ ...base,
260
+ ...end.columns > 1 && mode !== "positional" ? { columns: {
261
+ count: end.columns,
262
+ spacePt: end.spacePt
263
+ } } : {},
264
+ ...end.continuous ? { sectionStart: "continuous" } : {}
265
+ },
266
+ endIndex: end.at
267
+ }];
268
+ }) : [];
269
+ const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
270
+ const edges0 = pageTextEdges(allRuns[0] ?? []);
271
+ const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
272
+ const headBand = head ? footerBand(head.band, stepped0, edges0, head.numbered) : [];
273
+ const withFooter = (properties) => properties ? {
274
+ ...properties,
275
+ ...band.length > 0 ? { footers: [{
276
+ type: "default",
277
+ relationshipId: FOOTER_PART
278
+ }] } : {},
279
+ ...headBand.length > 0 ? { headers: [{
280
+ type: "default",
281
+ relationshipId: HEADER_PART
282
+ }] } : {}
283
+ } : properties;
127
284
  return {
128
- doc: buildFlowDoc(body, resources, sectionFromPdfPages(pages), collectEmbeddedFonts(file, pages)),
285
+ doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
286
+ ...s,
287
+ properties: withFooter(s.properties) ?? s.properties
288
+ })), band.length > 0 || headBand.length > 0 ? new Map([...band.length > 0 ? [[FOOTER_PART, band]] : [], ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []]) : void 0),
129
289
  losses: dedupeLosses(losses)
130
290
  };
131
291
  }
132
- function detectGutter(runs, pageWidth) {
133
- if (runs.length < 30 || pageWidth <= 0) return void 0;
292
+ function detectGutters(runs, pageWidth) {
293
+ if (runs.length < 30 || pageWidth <= 0) return [];
134
294
  const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
135
- const intervals = runs.map((r) => [r.x, Math.max(r.endX, r.x + 1)]).sort((a, b) => a[0] - b[0]);
136
- const minX = intervals[0][0];
137
- const span = Math.max(...intervals.map((iv) => iv[1])) - minX;
138
- if (span < pageWidth * .5) return void 0;
139
- let curEnd = intervals[0][1];
140
- let gapMid = 0;
141
- let gapW = 0;
142
- for (const [l, r] of intervals) {
143
- if (l - curEnd > gapW) {
144
- gapW = l - curEnd;
145
- gapMid = (curEnd + l) / 2;
146
- }
295
+ const spans = runs.flatMap((r) => {
296
+ const ink = runInk(r);
297
+ return ink ? [ink] : [];
298
+ });
299
+ if (spans.length === 0) return [];
300
+ const minX = Math.min(...spans.map((iv) => iv[0]));
301
+ const span = Math.max(...spans.map((iv) => iv[1])) - minX;
302
+ if (span < pageWidth * .5) return [];
303
+ const sorted = [...spans].sort((a, b) => a[0] - b[0]);
304
+ let curEnd = sorted[0][1];
305
+ const empty = [];
306
+ for (const [l, r] of sorted) {
307
+ if (l - curEnd >= fontSize * 3) empty.push({
308
+ mid: (curEnd + l) / 2,
309
+ from: curEnd,
310
+ to: l
311
+ });
147
312
  if (r > curEnd) curEnd = r;
148
313
  }
149
- const frac = (gapMid - minX) / span;
150
- if (gapW < fontSize * 3 || frac < .35 || frac > .65) return void 0;
151
- const left = runs.filter((r) => r.x < gapMid).length;
152
- if (left < runs.length * .25 || left > runs.length * .75) return void 0;
153
- return gapMid;
314
+ const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
315
+ const ink = runInk(r);
316
+ return ink ? [ink] : [];
317
+ }).sort((a, b) => a[0] - b[0]));
318
+ const voted = [];
319
+ for (const x of sample(minX + span * .1, minX + span * .9, 1)) {
320
+ let columned = 0;
321
+ let crossing = 0;
322
+ for (const row of rows) {
323
+ if (row.some(([l, r]) => l < x && r > x)) {
324
+ crossing++;
325
+ continue;
326
+ }
327
+ const before = row.filter(([, r]) => r <= x);
328
+ const after = row.filter(([l]) => l >= x);
329
+ if (before.length === 0 || after.length === 0) continue;
330
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
331
+ }
332
+ if (columned < MIN_COLUMNED_ROWS || crossing >= columned) continue;
333
+ const last = voted[voted.length - 1];
334
+ if (last && x - last.to <= 1.5) voted[voted.length - 1] = {
335
+ ...last,
336
+ to: x,
337
+ mid: (last.from + x) / 2
338
+ };
339
+ else voted.push({
340
+ mid: x,
341
+ from: x,
342
+ to: x
343
+ });
344
+ }
345
+ const near = (band) => empty.some((e) => band.mid > e.from - fontSize && band.mid < e.to + fontSize);
346
+ return separating([...empty, ...voted.filter((v) => !near(v))].sort((a, b) => a.mid - b.mid), spans);
347
+ }
348
+ /**
349
+ * The candidate gutters that actually separate something, left to right.
350
+ *
351
+ * A gutter with nothing on one side of it is a margin, and two of them with a
352
+ * word between are one column and a stray. Each band is kept only where the
353
+ * region since the last kept one holds a real share of the page's runs — and
354
+ * the last one only if something follows it.
355
+ *
356
+ * @param bands The candidates, in order.
357
+ * @param spans Every run's horizontal extent.
358
+ * @returns The gutters worth splitting on.
359
+ */
360
+ function separating(bands, spans) {
361
+ const need = spans.length * MIN_COLUMN_SHARE;
362
+ const out = [];
363
+ let from = -Infinity;
364
+ for (const band of bands) {
365
+ if (spans.filter(([l, r]) => (l + r) / 2 > from && (l + r) / 2 < band.mid).length < need) continue;
366
+ out.push(band);
367
+ from = band.mid;
368
+ }
369
+ const tail = spans.filter(([l, r]) => (l + r) / 2 > from).length;
370
+ if (out.length > 0 && tail < need) out.pop();
371
+ return out;
372
+ }
373
+ /** How much of a page's text the narrowest column holds before it is a column. */
374
+ var MIN_COLUMN_SHARE = .08;
375
+ /**
376
+ * The gutters MOST of the document's pages agree on.
377
+ *
378
+ * A document is set one way: §17.6.4 puts the column setup on the section, and
379
+ * a paper does not change it from sheet to sheet. So a page that says nothing
380
+ * on its own — one carrying a figure across both columns, with too few clean
381
+ * lines left for the vote — can be asked the question the rest of the document
382
+ * already answered.
383
+ *
384
+ * @param perPage Each page's own answer, in page order.
385
+ * @returns The answer given by more pages than any other, or `undefined` where
386
+ * no two pages agree.
387
+ */
388
+ function commonGutters(perPage) {
389
+ const seen = /* @__PURE__ */ new Map();
390
+ for (const gutters of perPage) {
391
+ if (gutters.length === 0) continue;
392
+ const key = gutters.map((g) => Math.round(g.mid / 2)).join(",");
393
+ const had = seen.get(key);
394
+ if (had) had.pages++;
395
+ else seen.set(key, {
396
+ gutters,
397
+ pages: 1
398
+ });
399
+ }
400
+ let best;
401
+ for (const entry of seen.values()) if (!best || entry.pages > best.pages) best = entry;
402
+ return best?.gutters;
403
+ }
404
+ /**
405
+ * Whether a page's own lines agree with gutters the document states.
406
+ *
407
+ * The document's answer is a suggestion, not a licence: a sheet whose lines run
408
+ * straight through the gutter is set in one column whatever its neighbours do.
409
+ * The bar is lower than the vote's own — the evidence from the other pages is
410
+ * already in — but it is still the page that decides.
411
+ *
412
+ * @param runs The page's runs.
413
+ * @param gutters The document's gutters.
414
+ */
415
+ function fitsGutters(runs, gutters) {
416
+ if (runs.length < 30) return false;
417
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
418
+ const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
419
+ const ink = runInk(r);
420
+ return ink ? [ink] : [];
421
+ }).sort((a, b) => a[0] - b[0]));
422
+ for (const gutter of gutters) {
423
+ let columned = 0;
424
+ let crossing = 0;
425
+ for (const row of rows) {
426
+ if (row.some(([l, r]) => l < gutter.mid && r > gutter.mid)) {
427
+ crossing++;
428
+ continue;
429
+ }
430
+ const before = row.filter(([, r]) => r <= gutter.mid);
431
+ const after = row.filter(([l]) => l >= gutter.mid);
432
+ if (before.length === 0 || after.length === 0) continue;
433
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
434
+ }
435
+ if (crossing === 0) continue;
436
+ if (columned < MIN_SHARED_ROWS || crossing >= columned) return false;
437
+ }
438
+ return true;
439
+ }
440
+ /** How many lines a page must split at a gutter the DOCUMENT already states. */
441
+ var MIN_SHARED_ROWS = 3;
442
+ /** How many lines have to be split at the same place before the page is in columns. */
443
+ var MIN_COLUMNED_ROWS = 12;
444
+ /**
445
+ * The page's runs grouped by baseline.
446
+ *
447
+ * Swept in order rather than bucketed: a superscript sits a few points above
448
+ * the baseline it belongs to, and a bucket boundary between the two would leave
449
+ * it a line of its own — bug1885505.pdf's author block came back with the
450
+ * asterisks and daggers standing alone on five separate lines.
451
+ */
452
+ function rowsOf(runs, fontSize) {
453
+ const tolerance = Math.max(fontSize * .6, 1);
454
+ const rows = [];
455
+ let row = [];
456
+ let rowY = Number.POSITIVE_INFINITY;
457
+ for (const run of [...runs].sort((a, b) => b.y - a.y)) {
458
+ if (row.length > 0 && rowY - run.y > tolerance) {
459
+ rows.push(row);
460
+ row = [];
461
+ }
462
+ if (row.length === 0) rowY = run.y;
463
+ row.push(run);
464
+ }
465
+ if (row.length > 0) rows.push(row);
466
+ return rows;
467
+ }
468
+ /**
469
+ * A run's INK — where its letters are, which a trailing space is not.
470
+ *
471
+ * A run carries the advance it stepped, and the space that ends a line of a
472
+ * column is part of it: chrome-text-selection-markedContent.pdf sets two
473
+ * columns 21 points apart and its left column's lines end ", " — five of those
474
+ * points — so every measurement of the gutter came back a third short and the
475
+ * page was read straight across.
476
+ *
477
+ * @param run The run.
478
+ * @returns Its ink, or `undefined` for a run that is nothing but space.
479
+ */
480
+ function runInk(run) {
481
+ const from = Math.min(run.x, run.endX);
482
+ const to = Math.max(run.endX, run.x + 1);
483
+ const chars = [...run.text];
484
+ if (chars.length === 0) return [from, to];
485
+ let head = 0;
486
+ while (head < chars.length && SPACE.test(chars[head])) head++;
487
+ if (head === chars.length) return void 0;
488
+ let tail = 0;
489
+ while (tail < chars.length - head && SPACE.test(chars[chars.length - 1 - tail])) tail++;
490
+ if (head === 0 && tail === 0) return [from, to];
491
+ const step = (to - from) / chars.length;
492
+ return [from + head * step, to - tail * step];
493
+ }
494
+ var SPACE = /\s/u;
495
+ /** Where the page's text starts and ends, ignoring what is only a space. */
496
+ function pageTextEdges(runs) {
497
+ let left = Number.POSITIVE_INFINITY;
498
+ let right = Number.NEGATIVE_INFINITY;
499
+ for (const run of runs) {
500
+ const ink = runInk(run);
501
+ if (!ink) continue;
502
+ left = Math.min(left, ink[0]);
503
+ right = Math.max(right, ink[1]);
504
+ }
505
+ return right > left ? {
506
+ left,
507
+ right
508
+ } : void 0;
509
+ }
510
+ /**
511
+ * The measure a column is set across: from the gutter on its left to the one
512
+ * on its right, and to the page's own text edge where there is none.
513
+ *
514
+ * A line that spans the page (see {@link SPANNING_COLUMN}) is set across all of
515
+ * them, which is what makes a centred title centred.
516
+ *
517
+ * @param col The column, or {@link SPANNING_COLUMN}.
518
+ * @param gutters The page's gutters, left to right.
519
+ * @param edges Where the page's text starts and ends.
520
+ */
521
+ function measureOf(col, gutters, edges) {
522
+ if (!edges) return void 0;
523
+ if (col === SPANNING_COLUMN || gutters.length === 0) return edges;
524
+ return {
525
+ left: col === 0 ? edges.left : gutters[col - 1]?.to ?? edges.left,
526
+ right: col >= gutters.length ? edges.right : gutters[col]?.from ?? edges.right
527
+ };
528
+ }
529
+ /** The measure the lines themselves reach across, where the page states none. */
530
+ function measureOfLines(lines) {
531
+ if (lines.length === 0) return void 0;
532
+ return {
533
+ left: Math.min(...lines.map((l) => l.x)),
534
+ right: Math.max(...lines.map((l) => l.x + l.width))
535
+ };
536
+ }
537
+ /** The x's to measure at, from `a` to `b` inclusive. */
538
+ function sample(a, b, step) {
539
+ const out = [];
540
+ for (let x = a; x <= b; x += Math.max(step, .5)) out.push(x);
541
+ return out;
542
+ }
543
+ /**
544
+ * EP17 — which column each run belongs to, and where the page's bands break.
545
+ *
546
+ * The decision is made a LINE at a time, not a run at a time: a title is drawn
547
+ * as several runs and only one of them may reach across the gutter, so judging
548
+ * each on its own would leave the rest of the title standing in a column.
549
+ *
550
+ * @param runs The page's runs.
551
+ * @param gutter The band from {@link detectGutter}.
552
+ * @returns The column of each run (0, 1, or {@link SPANNING_COLUMN}) and the
553
+ * baselines of the full-width lines, which separate the bands.
554
+ */
555
+ function assignColumns(runs, gutters) {
556
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
557
+ const rows = rowsOf(runs, fontSize);
558
+ const columnOf = /* @__PURE__ */ new Map();
559
+ const breaks = [];
560
+ const columnAt = (x) => gutters.filter((g) => x >= g.mid).length;
561
+ for (const row of rows) {
562
+ const inked = row.flatMap((run) => {
563
+ const ink = runInk(run);
564
+ return ink ? [{
565
+ run,
566
+ from: ink[0],
567
+ to: ink[1]
568
+ }] : [];
569
+ }).sort((a, b) => a.from - b.from);
570
+ if (inked.length === 0) continue;
571
+ const leftmost = inked[0].from;
572
+ const rightmost = Math.max(...inked.map((r) => r.to));
573
+ const straddled = gutters.filter((g) => leftmost < g.mid && rightmost > g.mid);
574
+ if (straddled.length === 0) {
575
+ const col = columnAt(leftmost);
576
+ for (const run of row) columnOf.set(run, col);
577
+ continue;
578
+ }
579
+ const gapAt = (mid) => {
580
+ let cur = inked[0].to;
581
+ let gap = 0;
582
+ for (const { from, to } of inked.slice(1)) {
583
+ if (from > cur && cur <= mid && from >= mid) gap = from - cur;
584
+ cur = Math.max(cur, to);
585
+ }
586
+ return gap;
587
+ };
588
+ if (straddled.every((g) => gapAt(g.mid) >= fontSize * MIN_GUTTER_EM)) {
589
+ for (const { run, from } of inked) columnOf.set(run, columnAt(from));
590
+ continue;
591
+ }
592
+ for (const run of row) columnOf.set(run, SPANNING_COLUMN);
593
+ breaks.push(Math.max(...row.map((r) => r.y)));
594
+ }
595
+ return {
596
+ columnOf,
597
+ breaks: breaks.sort((a, b) => b - a)
598
+ };
599
+ }
600
+ /**
601
+ * A line that spans the page belongs to no column: it stands between the bands
602
+ * it separates.
603
+ *
604
+ * Which side of them? A spanning line is what BREAKS a band, and a band runs
605
+ * from one break to the next, so the line is always at the FOOT of its own —
606
+ * the columns of that band are the ones above it. Read ahead of them,
607
+ * bug1997343.pdf's page number came out between the date and the abstract.
608
+ */
609
+ var SPANNING_COLUMN = -1;
610
+ /** Where a column reads in its band: the spanning line at the foot of it. */
611
+ function columnOrder(col) {
612
+ return col === SPANNING_COLUMN ? Number.MAX_SAFE_INTEGER : col;
613
+ }
614
+ /**
615
+ * How wide, in ems, the gap over the middle has to be for a line to be two
616
+ * lines. A word space is a quarter of an em (see `SPACE_GAP_EM`) and a
617
+ * justified one no more than half; a gutter is an em and more.
618
+ *
619
+ * It stood at one and a half, which is wider than some magazines set:
620
+ * chrome-text-selection-markedContent.pdf puts fourteen and a half points
621
+ * between columns of eleven-point type — 1.31 em — and every line of it was
622
+ * read straight across, the left column's sentence running into the right
623
+ * column's.
624
+ */
625
+ var MIN_GUTTER_EM = 1;
626
+ /** Which band a mark at this height belongs to — how many breaks stand above it. */
627
+ function bandOf(breaks, top, epsilon) {
628
+ let n = 0;
629
+ for (const y of breaks) if (y > top + epsilon) n++;
630
+ return n;
154
631
  }
155
632
  /**
156
633
  * The gap, in ems, past which the placed reader cuts a line rather than write a
@@ -241,7 +718,7 @@ function rotation60kOf(angleDeg) {
241
718
  * 0.05, and thirty at 0.05 em or more. A twentieth of an em sits in that gap.
242
719
  */
243
720
  var BASELINE_STEP_EM = .05;
244
- function groupIntoLines(runs, split = false) {
721
+ function groupIntoLines(runs, split = false, stepped = false) {
245
722
  const sorted = [...runs].sort((a, b) => b.y - a.y || a.x - b.x);
246
723
  const clusters = [];
247
724
  for (const run of sorted) {
@@ -259,7 +736,7 @@ function groupIntoLines(runs, split = false) {
259
736
  return clusters.flatMap((c) => {
260
737
  const ordered = c.runs.sort((a, b) => a.x - b.x);
261
738
  const fontSize = c.fontSize || 10;
262
- if (!split) return [lineOf(ordered, c.y, fontSize)];
739
+ if (!split) return [lineOf(ordered, c.y, fontSize, stepped)];
263
740
  const pieces = [[]];
264
741
  for (const run of ordered) {
265
742
  const prev = pieces[pieces.length - 1];
@@ -268,30 +745,155 @@ function groupIntoLines(runs, split = false) {
268
745
  if (last !== void 0 && (Math.abs(run.x - last.endX) > size * SPACE_GAP_EM || Math.abs(run.y - prev[0].y) > size * BASELINE_STEP_EM || isRightToLeft(run.text) || isRightToLeft(last.text))) pieces.push([]);
269
746
  pieces[pieces.length - 1].push(run);
270
747
  }
271
- return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize));
748
+ return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize, stepped));
272
749
  });
273
750
  }
751
+ /**
752
+ * Where a line's INK is, which is not how far its pen travelled.
753
+ *
754
+ * A run of spaces advances the pen and marks nothing. basicapi.pdf sets its
755
+ * page number as thirty-one spaces and "page 1 / 3" in ONE run, reaching 635pt
756
+ * across a 595pt sheet — and the measure taken off that line was wide enough
757
+ * that the centred title in the same column no longer looked centred.
758
+ *
759
+ * The blanks are deducted at the face's own space width, which the run carries
760
+ * (§9.4.4); where the face states none, at a quarter of the size.
761
+ */
762
+ function inkSpan(runs) {
763
+ const marked = runs.filter((r) => r.text.trim().length > 0);
764
+ if (marked.length === 0) {
765
+ const x = runs[0].x;
766
+ return {
767
+ x,
768
+ width: runs[runs.length - 1].endX - x
769
+ };
770
+ }
771
+ const first = marked[0];
772
+ const last = marked[marked.length - 1];
773
+ const space = (r) => r.spaceWidthPt !== void 0 && r.spaceWidthPt > 0 ? r.spaceWidthPt : (r.fontSizePt || 10) * .25;
774
+ const lead = (/^\s*/u.exec(first.text)?.[0].length ?? 0) * space(first);
775
+ const trail = (/\s*$/u.exec(last.text)?.[0].length ?? 0) * space(last);
776
+ const x = first.x + lead;
777
+ return {
778
+ x,
779
+ width: Math.max(0, last.endX - trail - x)
780
+ };
781
+ }
274
782
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
275
- function lineOf(runs, y, fontSize) {
276
- const ordered = lineSpans(runs, fontSize);
783
+ function lineOf(runs, y, fontSize, stepped) {
784
+ const ordered = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
277
785
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
278
- const x = runs[0].x;
786
+ const ink = inkSpan(runs);
279
787
  return {
280
- x,
281
- width: runs[runs.length - 1].endX - x,
788
+ x: ink.x,
789
+ width: ink.width,
282
790
  y,
283
791
  fontSize,
792
+ ...tabbed(runs, fontSize) ? { tabbed: true } : {},
284
793
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
285
794
  spans
286
795
  };
287
796
  }
288
- function lineSpans(runs, fontSize) {
797
+ /**
798
+ * Whether a TAB stands inside the line — a gap no word space could be.
799
+ *
800
+ * A line with one is a line the page SET OUT, not a line of prose: a contents
801
+ * entry with its page number at the measure, a two-column list, a label and its
802
+ * value. It does not run on into the line below it, and read as prose it does:
803
+ * bug1997343.pdf's contents came back as "2 Document structures 1 2.1
804
+ * Mathematics ............. 1", two entries in one line, where the file sets
805
+ * one to a line.
806
+ *
807
+ * A leader says the same thing (see {@link carriesLeader}) but only where the
808
+ * entry is dotted; a top-level entry is spaced, and nothing else marks it.
809
+ *
810
+ * @param runs The line's runs.
811
+ * @param fontSize The line's size.
812
+ */
813
+ function tabbed(runs, fontSize) {
814
+ const inked = runs.flatMap((r) => {
815
+ const ink = runInk(r);
816
+ return ink ? [ink] : [];
817
+ }).sort((a, b) => a[0] - b[0]);
818
+ let cur = Number.NEGATIVE_INFINITY;
819
+ for (const [from, to] of inked) {
820
+ if (cur > Number.NEGATIVE_INFINITY && from - cur >= fontSize * TAB_GAP_EM) return true;
821
+ cur = Math.max(cur, to);
822
+ }
823
+ return false;
824
+ }
825
+ /**
826
+ * How wide a gap has to be, in ems, before a word space could not have stood
827
+ * there. Justification stretches a space to about half an em; two and a half is
828
+ * a jump nothing but a tab makes.
829
+ */
830
+ var TAB_GAP_EM = 2.5;
831
+ /**
832
+ * The gap between two runs that means a WORD SPACE stood there.
833
+ *
834
+ * A page that draws its own spaces has already said where its words divide, and
835
+ * a gap between two of its runs is a COLUMN or a placement — 160F-2019.pdf is
836
+ * ruled into fields a quarter-inch apart and a generous threshold keeps them
837
+ * apart. A page that draws none has said nothing, and every word boundary on it
838
+ * is a gap: bigboundingbox.pdf steps 0.226 em between words and never writes a
839
+ * space, so at a quarter em its every line ran together — "OrangeDemoInc.",
840
+ * "Whenpayingbycheck,pleasecompletethispaymentadvice".
841
+ *
842
+ * The two want different thresholds, and the page says which it is (see
843
+ * {@link stepsBetweenWords}). The tight one still clears the gaps a producer
844
+ * leaves INSIDE a word when it splits one for kerning, which measure eight
845
+ * hundredths of an em at their widest across this corpus.
846
+ */
847
+ function spaceGap(prev, fontSize, stepped) {
848
+ return (prev.fontSizePt || fontSize) * (stepped ? STEPPED_SPACE_EM : DRAWN_SPACE_EM);
849
+ }
850
+ /**
851
+ * §17.3.1.25 — one character of a LEADER, the dotted rule that carries the eye
852
+ * across a table of contents.
853
+ *
854
+ * A leader is drawn one character at a time with a step about as wide as a word
855
+ * space, so every threshold that tells a space from a kern says "space" between
856
+ * every dot. bug886717.pdf's contents came back as
857
+ * "Abstract . . . . . . . . . . . . 3", four times as long as the page sets it,
858
+ * and its forty entries spilled onto a second page. What tells a leader from
859
+ * words is that it is the SAME character over and over.
860
+ */
861
+ function isLeader(text) {
862
+ return text.length === 1 && LEADER_CHARS.has(text);
863
+ }
864
+ var LEADER_CHARS = new Set([
865
+ ".",
866
+ "·",
867
+ "_",
868
+ "-",
869
+ "‐",
870
+ "–"
871
+ ]);
872
+ /** A page that writes its own spaces: only a wide gap means anything more. */
873
+ var DRAWN_SPACE_EM = .25;
874
+ /** A page that writes none: the step between its words is all there is. */
875
+ var STEPPED_SPACE_EM = .12;
876
+ /**
877
+ * Whether this page STEPS between its words rather than writing spaces.
878
+ *
879
+ * Counted rather than guessed: bigboundingbox.pdf writes a space in one run in
880
+ * a hundred, TAMReview.pdf in a third of them, and no page does a little of
881
+ * both. A page with almost no text says nothing either way and keeps the
882
+ * cautious reading.
883
+ */
884
+ function stepsBetweenWords(runs) {
885
+ if (runs.length < 8) return false;
886
+ return runs.filter((r) => /\s/u.test(r.text)).length / runs.length < .05;
887
+ }
888
+ function lineSpans(runs, fontSize, stepped) {
289
889
  const spans = [];
290
- let prevEnd;
890
+ const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
891
+ const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
892
+ let prev;
291
893
  for (const run of runs) {
292
- if (prevEnd !== void 0 && run.x - prevEnd > fontSize * .25) spans.push({ text: " " });
894
+ if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) spans.push({ text: " " });
293
895
  spans.push({
294
- text: run.text,
896
+ text: run.text.replaceAll("�", ""),
295
897
  sizePt: run.fontSizePt,
296
898
  ...run.colorHex !== "000000" ? { colorHex: run.colorHex } : {},
297
899
  ...run.fontName !== void 0 ? { fontName: run.fontName } : {},
@@ -301,26 +903,234 @@ function lineSpans(runs, fontSize) {
301
903
  } } : {},
302
904
  ...run.bold ? { bold: true } : {},
303
905
  ...run.italic ? { italic: true } : {},
906
+ ...script(run, baseline, fontSize),
907
+ ...run.markup !== void 0 ? { markup: run.markup } : {},
304
908
  ...run.href !== void 0 ? { href: run.href } : {}
305
909
  });
306
- prevEnd = run.endX;
910
+ prev = run;
307
911
  }
308
912
  return spans;
309
913
  }
310
- function groupIntoParagraphs(lines) {
914
+ /**
915
+ * §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
916
+ * is.
917
+ *
918
+ * A PDF states no such property: an exponent is a smaller face set a little
919
+ * higher, and an index a smaller face set a little lower. Read flat, the whole
920
+ * of mathematics comes back on one line — bug1997343.pdf sets `n^p = n mod p`
921
+ * and we read "np", and every prime on the page landed beside its letter
922
+ * instead of over it.
923
+ *
924
+ * The size the span keeps is the LINE's, not the mark's: a document states the
925
+ * nominal size and the layout shrinks a script, so the drawn seven points under
926
+ * a superscript would come out at five.
927
+ *
928
+ * @param run The run.
929
+ * @param baseline The line's own baseline.
930
+ * @param fontSize The line's size.
931
+ */
932
+ function script(run, baseline, fontSize) {
933
+ if ((run.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE) return {};
934
+ const rise = run.y - baseline;
935
+ if (rise > fontSize * SCRIPT_RISE) return {
936
+ script: "superscript",
937
+ sizePt: fontSize
938
+ };
939
+ if (rise < -fontSize * SCRIPT_DROP) return {
940
+ script: "subscript",
941
+ sizePt: fontSize
942
+ };
943
+ return {};
944
+ }
945
+ /** How much smaller than its line a run must be set to be a script of it. */
946
+ var SCRIPT_SIZE = .85;
947
+ /** How far above the baseline a superscript stands, and below it a subscript. */
948
+ var SCRIPT_RISE = .15;
949
+ var SCRIPT_DROP = .08;
950
+ /**
951
+ * §17.3.1.25 — whether a line carries a LEADER, which makes it an entry in a
952
+ * directory rather than a line of prose.
953
+ *
954
+ * A contents line runs the full measure — the dots are there to make it — so
955
+ * the ragged-edge test that ends every other paragraph never fires on one, and
956
+ * bug886717.pdf's forty entries came back as a single reflowing paragraph.
957
+ */
958
+ function carriesLeader(line) {
959
+ return LEADER_RUN.test(line.text);
960
+ }
961
+ /** Three of the same leader character in a row is a leader and not punctuation. */
962
+ var LEADER_RUN = /([.\u00b7_])\1{2,}/u;
963
+ function groupIntoParagraphs(lines, column, pageHeight = 0) {
311
964
  const groups = [];
312
- let prevY;
965
+ const gaps = [];
966
+ let prev;
313
967
  for (const line of lines) {
314
- const gap = prevY !== void 0 ? prevY - line.y : 0;
315
- if (groups.length === 0 || prevY !== void 0 && gap > line.fontSize * 1.5) groups.push([]);
968
+ const gap = prev !== void 0 ? prev.y - line.y : 0;
969
+ const opened = prev !== void 0 && gap > line.fontSize * 1.5;
970
+ if (groups.length === 0 || opened || prev !== void 0 && (endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
971
+ groups.push([]);
972
+ gaps.push(prev === void 0 ? 0 : gap);
973
+ }
316
974
  groups[groups.length - 1].push(line);
317
- prevY = line.y;
975
+ prev = line;
318
976
  }
319
- return groups.map((g) => ({
320
- spans: g.flatMap((l, i) => i > 0 ? [{ text: " " }, ...l.spans] : [...l.spans]),
321
- fontSize: Math.max(...g.map((l) => l.fontSize)),
322
- top: g[0].y
977
+ const starts = lines.map((l) => l.x).sort((a, b) => a - b);
978
+ const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
979
+ return groups.map((g, i) => {
980
+ const first = g[0];
981
+ const fontSize = Math.max(...g.map((l) => l.fontSize));
982
+ const opened = (gaps[i] ?? 0) - fontSize * 1.2;
983
+ const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
984
+ const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
985
+ return {
986
+ spans: joinLines(g),
987
+ fontSize,
988
+ top: first.y,
989
+ ...spacingBefore !== void 0 ? { spacingBefore } : {},
990
+ ...alignmentOf(g, column),
991
+ ...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
992
+ };
993
+ });
994
+ }
995
+ /**
996
+ * A paragraph's lines as one run of spans, joined the way the page broke them.
997
+ *
998
+ * A line that ends in a hyphen was broken THERE, and the break is not part of
999
+ * the text: re-set at another measure the word has to come back together. Which
1000
+ * hyphen decides what is left of it — U+00AD is the discretionary one, put in
1001
+ * to mark a place a word MAY break, and it goes with the break; a plain hyphen
1002
+ * belongs to the word ("two-" and "column" are "two-column") and stays.
1003
+ *
1004
+ * Read as prose with a space between every line, bug1997343.pdf came back
1005
+ * "typical two-column docu ment incorporating tables, figures and mathemat
1006
+ * ics" — the soft hyphens dropped by the page and a space in their place.
1007
+ *
1008
+ * @param lines The paragraph's lines, in order.
1009
+ */
1010
+ function joinLines(lines) {
1011
+ const out = [];
1012
+ lines.forEach((line, i) => {
1013
+ if (i > 0) {
1014
+ const prev = out[out.length - 1];
1015
+ const ends = prev?.text ?? "";
1016
+ const soft = ends.endsWith(SOFT_HYPHEN);
1017
+ const hard = HYPHENS.has(ends.slice(-1));
1018
+ if (soft && prev) out[out.length - 1] = {
1019
+ ...prev,
1020
+ text: ends.slice(0, -1)
1021
+ };
1022
+ else if (!hard) out.push({ text: " " });
1023
+ }
1024
+ out.push(...line.spans);
1025
+ });
1026
+ return out;
1027
+ }
1028
+ /** §17.3.3.29 — the hyphen that is a PLACE a word may break, not a hyphen. */
1029
+ var SOFT_HYPHEN = "­";
1030
+ /** The hyphens that belong to the word they end. */
1031
+ var HYPHENS = new Set([
1032
+ "-",
1033
+ "‐",
1034
+ "‑"
1035
+ ]);
1036
+ /** How many lines' worth of indent still reads as a first line, not a placement. */
1037
+ var INDENT_LINES = 3;
1038
+ /** Where in the run of line starts the column's own left edge is looked for. */
1039
+ var COLUMN_LEFT_QUANTILE = .15;
1040
+ /**
1041
+ * §17.3.1.12 `w:ind` — how far a paragraph is set in from its column, and where
1042
+ * its first line begins.
1043
+ *
1044
+ * A PDF states neither: every line is placed absolutely, and read flat every
1045
+ * paragraph came back against the left edge. That is most of what a list looks
1046
+ * like — bug1997343.pdf sets "• They may be unordered bullet lists" ten points
1047
+ * in and its nested "1. lists may also be nested" twenty more, and we set all
1048
+ * of them flush left — and it is the whole of a first-line indent, which is how
1049
+ * most of the world's prose marks a new paragraph.
1050
+ *
1051
+ * The first line is measured against the REST of the paragraph, which is what
1052
+ * `indentFirstLine` means: positive is a first line set in (a new paragraph),
1053
+ * negative a hanging one (a list, its marker standing out to the left).
1054
+ *
1055
+ * A paragraph that is centred or set to the right is placed, not indented, and
1056
+ * keeps neither.
1057
+ *
1058
+ * @param lines The paragraph's lines.
1059
+ * @param columnLeft Where the column's own text begins.
1060
+ * @param alignment What {@link alignmentOf} made of it.
1061
+ */
1062
+ function indentOf(lines, columnLeft, alignment) {
1063
+ if (alignment !== void 0 || lines.length === 0) return {};
1064
+ const size = Math.max(...lines.map((l) => l.fontSize)) || 10;
1065
+ const rest = lines.slice(1);
1066
+ const body = rest.length > 0 ? Math.min(...rest.map((l) => l.x)) : lines[0].x;
1067
+ const left = body - columnLeft;
1068
+ const first = lines[0].x - body;
1069
+ const enough = size * INDENT_EM;
1070
+ return {
1071
+ ...Math.abs(left) >= enough ? { indentLeft: left } : {},
1072
+ ...Math.abs(first) >= enough ? { indentFirstLine: first } : {}
1073
+ };
1074
+ }
1075
+ /**
1076
+ * How far, in ems, a paragraph has to be set in before it is indented rather
1077
+ * than merely started. Half an em clears the rounding a producer leaves at the
1078
+ * head of a line and is well under the smallest indent anybody sets.
1079
+ */
1080
+ var INDENT_EM = .5;
1081
+ /**
1082
+ * Whether a line ENDED a paragraph, rather than wrapping into the next.
1083
+ *
1084
+ * Leading alone cannot tell the two apart: five labels stacked at 15pt with a
1085
+ * 12pt face look exactly like five wrapped lines, and alphatrans.pdf's five are
1086
+ * read as one paragraph and re-wrapped into two. But a wrapping engine pulls
1087
+ * the next word UP — so a line that stops well short of the measure stopped
1088
+ * because its author stopped it, and the line after it begins something new.
1089
+ * The same rule separates two paragraphs set with no extra space between them,
1090
+ * which used to run together for the same reason.
1091
+ *
1092
+ * Only where both lines start at the same edge. Where they do not, the block is
1093
+ * placed rather than set — a centred title's every line is short of the measure
1094
+ * and none of them ends anything.
1095
+ *
1096
+ * @param prev The line before: where it starts, how wide it is, its face.
1097
+ * @param next The line after — only where it starts matters.
1098
+ * @param column The measure both were set in, when it is known.
1099
+ * @returns Whether the first line ended a paragraph.
1100
+ */
1101
+ function endedParagraph(prev, next, column) {
1102
+ if (!column) return false;
1103
+ const width = column.right - column.left;
1104
+ if (!(width > 0)) return false;
1105
+ const step = Math.max(prev.fontSize, 4);
1106
+ const shift = next.x - prev.x;
1107
+ if (shift < -step || shift > step * INDENT_LINES) return false;
1108
+ return column.right - (prev.x + prev.width) > width * .25;
1109
+ }
1110
+ /**
1111
+ * §17.3.1.13 — where a paragraph sits across its column, which is the only
1112
+ * witness a PDF leaves of how it was set: every line is placed absolutely and
1113
+ * nothing says "centred".
1114
+ *
1115
+ * A paragraph whose lines are inset by about as much on each side is centred; a
1116
+ * one-line paragraph pushed to the right edge is right-aligned. Everything else
1117
+ * is left alone — a justified paragraph and a ragged-right one look the same
1118
+ * from here, and guessing between them would re-set the body of every document.
1119
+ */
1120
+ function alignmentOf(lines, column, least) {
1121
+ if (!column || lines.length === 0) return {};
1122
+ const width = column.right - column.left;
1123
+ if (!(width > 0)) return {};
1124
+ const insets = lines.map((l) => ({
1125
+ lead: l.x - column.left,
1126
+ trail: column.right - (l.x + l.width)
323
1127
  }));
1128
+ const meaningful = Math.min(width * .1, least ?? width);
1129
+ const even = width * .06;
1130
+ 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" };
1131
+ const leads = insets.map((i) => i.lead);
1132
+ if (insets.every((i) => i.trail <= even) && Math.max(...leads) >= meaningful && Math.max(...leads) - Math.min(...leads) > even) return { alignment: "right" };
1133
+ return {};
324
1134
  }
325
1135
  function headingLevel(fontSize, medianFont) {
326
1136
  if (fontSize >= medianFont * 1.5) return 0;
@@ -341,5 +1151,447 @@ function compareOrder(a, b) {
341
1151
  for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
342
1152
  return a.length - b.length;
343
1153
  }
1154
+ /**
1155
+ * §17.6.13 — the running foot a document repeats at the bottom of its pages.
1156
+ *
1157
+ * A page number, a title, a URL: it stands below the text block, in the margin,
1158
+ * and it is not part of what the page SAYS. Read as body it goes wherever the
1159
+ * reflow puts it — bug1997343.pdf's "1" came out on a sheet of its own between
1160
+ * the two the paper has, and TAMReview.pdf's "Sprouts — http://…" landed in the
1161
+ * middle of the abstract.
1162
+ *
1163
+ * What makes it a running foot is that it RUNS: the same place, page after
1164
+ * page, cut off from the text above it by more than a line of white. One page
1165
+ * proves nothing, so two are asked for.
1166
+ *
1167
+ * @param pageRuns Each page's runs, placed.
1168
+ * @param shown Each page's shown geometry.
1169
+ * @returns The runs to lift off each page and the band to put them in, or
1170
+ * `undefined` where the document repeats nothing.
1171
+ */
1172
+ function runningFoot(pageRuns, shown, where) {
1173
+ const feet = pageRuns.map((runs, i) => edgeLine(runs, shown[i]?.height ?? 0, where));
1174
+ const found = feet.filter((f) => f !== void 0);
1175
+ if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
1176
+ const ys = found.map((f) => f.y);
1177
+ const mid = median(ys);
1178
+ if (ys.some((y) => Math.abs(y - mid) > FOOT_DRIFT)) return void 0;
1179
+ const texts = found.map((f) => f.runs.map((r) => r.text).join("").trim());
1180
+ return {
1181
+ lift: feet.map((f) => new Set(f?.runs ?? [])),
1182
+ band: found[0].runs,
1183
+ numbered: new Set(texts).size > 1
1184
+ };
1185
+ }
1186
+ /** How many of a document's pages must carry the foot before it is running. */
1187
+ var FOOT_SHARE = .6;
1188
+ /** How far, in points, a running foot may drift from page to page. */
1189
+ var FOOT_DRIFT = 4;
1190
+ /**
1191
+ * The lines at the very bottom of a page, where they stand ALONE below the text
1192
+ * block.
1193
+ *
1194
+ * Alone means the white above them is more than the page's own leading — a last
1195
+ * paragraph is a line's gap from the one before it, a running foot is several.
1196
+ *
1197
+ * A foot need not be ONE line. ZapfDingbats.pdf signs each page twice, the
1198
+ * publisher's line and the suite's title on one baseline and the build stamp
1199
+ * thirty points below it, and taking only the bottom line left the other in the
1200
+ * body: after a table that fills the sheet it had nowhere to go but a page of
1201
+ * its own, and a two-page document came out as four.
1202
+ */
1203
+ function edgeLine(runs, pageHeight, where) {
1204
+ if (runs.length < 4 || pageHeight <= 0) return void 0;
1205
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1206
+ const rows = rowsOf(runs, fontSize);
1207
+ if (rows.length < 4) return void 0;
1208
+ const at = (i) => where === "foot" ? rows[rows.length - 1 - i] : rows[i];
1209
+ const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
1210
+ const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
1211
+ const y = edgeY(at(0));
1212
+ let found;
1213
+ let group = [];
1214
+ const most = where === "foot" ? FOOT_ROWS : 1;
1215
+ for (let i = 0; i < Math.min(most, rows.length - 2); i++) {
1216
+ const row = at(i);
1217
+ const edge = edgeY(row);
1218
+ if (where === "foot" ? edge > pageHeight * FOOT_BAND : edge < pageHeight * (1 - FOOT_BAND)) break;
1219
+ group = [...group, ...row];
1220
+ const next = at(i + 1);
1221
+ if ((where === "foot" ? inside(next) - edgeY(row) : edgeY(row) - inside(next)) < fontSize * FOOT_GAP_EM) continue;
1222
+ const text = group.map((r) => r.text).join("").trim();
1223
+ if (text.length > 0 && text.length <= FOOT_CHARS * (i + 1)) found = group;
1224
+ }
1225
+ return found ? {
1226
+ y,
1227
+ runs: found
1228
+ } : void 0;
1229
+ }
1230
+ /** How much white, in ems, stands between the text block and a running foot. */
1231
+ var FOOT_GAP_EM = 2;
1232
+ /** How many lines a running foot may hold before it is a paragraph. */
1233
+ var FOOT_ROWS = 3;
1234
+ /** How far up the sheet a running foot may sit. */
1235
+ var FOOT_BAND = .12;
1236
+ /** A running foot is a line, not a paragraph. */
1237
+ var FOOT_CHARS = 90;
1238
+ /**
1239
+ * §17.16.5.35 — the number in a run, made the field it stands for.
1240
+ *
1241
+ * A foot reads "1" or "Chapter 3 — 47", and the number is not the text: it is
1242
+ * the number of the page it is drawn on, which is what lets ONE band serve
1243
+ * every page. The run is cut around it and the middle becomes the field.
1244
+ *
1245
+ * @param run The footer's run.
1246
+ * @returns The run, or the two or three it is cut into.
1247
+ */
1248
+ function pageNumbered(run) {
1249
+ const found = /(^|\s)(\d{1,4})(\s|$)/u.exec(run.text);
1250
+ if (!found || run.field !== void 0) return [run];
1251
+ const at = found.index + found[1].length;
1252
+ const number = found[2];
1253
+ const before = run.text.slice(0, at);
1254
+ const after = run.text.slice(at + number.length);
1255
+ return [
1256
+ ...before === "" ? [] : [{
1257
+ ...run,
1258
+ text: before
1259
+ }],
1260
+ {
1261
+ ...run,
1262
+ text: number,
1263
+ field: "PAGE"
1264
+ },
1265
+ ...after === "" ? [] : [{
1266
+ ...run,
1267
+ text: after
1268
+ }]
1269
+ ];
1270
+ }
1271
+ /**
1272
+ * The band itself: the foot's own line, with the number in it made a field.
1273
+ *
1274
+ * §17.16.5.35 — a page number is not the text "1"; it is the number of the page
1275
+ * it is drawn on, which is why the same band serves every page.
1276
+ *
1277
+ * A foot is written in REGIONS, the way a spreadsheet's is: something at the
1278
+ * left of the sheet, something at the middle, something against the far edge.
1279
+ * ZapfDingbats.pdf signs each page "© RenderX 2000" at the left and "XSL
1280
+ * Formatting Objects Test Suite" at the right, and the two hundred points
1281
+ * between them came back as one word space, the two texts crowding each other
1282
+ * at the left. They stand on TAB STOPS instead (§17.3.1.38), which is what the
1283
+ * two hundred points are.
1284
+ *
1285
+ * @param runs The foot's runs.
1286
+ * @param stepped Whether the page steps between its words.
1287
+ * @param measure The measure it was set across, for its alignment.
1288
+ * @param numbered Whether a number in it is the page's own.
1289
+ */
1290
+ function footerBand(runs, stepped, measure, numbered) {
1291
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1292
+ return rowsOf(runs, fontSize).map((row) => bandLine(row, fontSize, stepped, measure)).filter((line) => line !== void 0).map(({ spans, properties }) => {
1293
+ const el = paragraphFromRuns(spans, void 0, properties);
1294
+ if (el.kind !== "paragraph") return el;
1295
+ return {
1296
+ kind: "paragraph",
1297
+ paragraph: {
1298
+ ...el.paragraph,
1299
+ runs: numbered ? el.paragraph.runs.flatMap((run) => pageNumbered(run)) : el.paragraph.runs
1300
+ }
1301
+ };
1302
+ });
1303
+ }
1304
+ /**
1305
+ * One line of a running head or foot: its spans, and the placement that puts
1306
+ * them where the page had them.
1307
+ *
1308
+ * A wide gap inside such a line is not a word space, it is the space BETWEEN
1309
+ * REGIONS — and each region after the first stands on a tab stop, at the middle
1310
+ * of the band or against its far edge, whichever it was written at.
1311
+ *
1312
+ * @param row The line's runs.
1313
+ * @param fontSize The band's size, for the runs that state none.
1314
+ * @param stepped Whether the page steps between its words.
1315
+ * @param measure The measure the band was set across.
1316
+ * @returns The line, or `undefined` where it holds no text.
1317
+ */
1318
+ function bandLine(row, fontSize, stepped, measure) {
1319
+ const ordered = [...row].sort((a, b) => a.x - b.x);
1320
+ const pieces = [[]];
1321
+ for (const run of ordered) {
1322
+ const last = pieces[pieces.length - 1];
1323
+ const prev = last[last.length - 1];
1324
+ if (prev && run.x - prev.endX > (run.fontSizePt || fontSize) * BAND_REGION_EM) pieces.push([]);
1325
+ pieces[pieces.length - 1].push(run);
1326
+ }
1327
+ const y = Math.max(...ordered.map((r) => r.y));
1328
+ 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);
1329
+ if (lines.length === 0) return void 0;
1330
+ if (lines.length === 1 || lines.length > BAND_REGIONS || !measure) {
1331
+ const { alignment } = alignmentOf(lines.slice(0, 1), measure);
1332
+ return {
1333
+ spans: lines.flatMap((line, i) => i === 0 ? line.spans : [{ text: " " }, ...line.spans]),
1334
+ properties: alignment ? { alignment } : {}
1335
+ };
1336
+ }
1337
+ const width = measure.right - measure.left;
1338
+ const spans = [...lines[0].spans];
1339
+ const tabs = [];
1340
+ for (const line of lines.slice(1)) {
1341
+ const flush = measure.right - (line.x + line.width) <= width * BAND_RIGHT_SHARE;
1342
+ tabs.push({
1343
+ positionPt: pt(0),
1344
+ relativeTo: flush ? "right" : "center",
1345
+ alignment: flush ? "right" : "center"
1346
+ });
1347
+ spans.push({ text: " " }, ...line.spans);
1348
+ }
1349
+ return {
1350
+ spans,
1351
+ properties: { tabs }
1352
+ };
1353
+ }
1354
+ /** A gap this wide, in ems, stands BETWEEN the regions of a head or foot. */
1355
+ var BAND_REGION_EM = 4;
1356
+ /** How many regions a band line may hold — left, centre, right. */
1357
+ var BAND_REGIONS = 3;
1358
+ /** How near the far edge a region has to end to be set against it. */
1359
+ var BAND_RIGHT_SHARE = .05;
1360
+ /**
1361
+ * Whether the page is RULED into columns rather than SET in them.
1362
+ *
1363
+ * A page of two columns and a page of two columns of a table look alike from
1364
+ * here: both have gutters, and both put their lines on one baseline grid — a
1365
+ * paper's columns are set on the same grid as a matter of course, so "the rows
1366
+ * line up" says nothing. What separates them is the CELL: a line of prose fills
1367
+ * its measure and a cell does not. Across this corpus a paper's lines cover 84
1368
+ * to 95 per cent of their column, and ZapfDingbats.pdf's cells cover 45.
1369
+ *
1370
+ * Read by column, its five hundred entries came back one column at a time with
1371
+ * every row torn into three — "1 a17", "[x2711]", "2 a18" — over five pages.
1372
+ * Read by ROW, which is what a ruled page says, each entry is a line of its own.
1373
+ *
1374
+ * @param runs The page's runs.
1375
+ * @param gutters The page's gutters.
1376
+ * @param edges Where the page's text starts and ends.
1377
+ */
1378
+ function looksRuled(runs, gutters, edges) {
1379
+ if (gutters.length < MIN_RULED_GUTTERS || !edges) return false;
1380
+ const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
1381
+ if (rows.length < MIN_TABLE_ROWS) return false;
1382
+ const bounds = columnBounds(runs, gutters, edges);
1383
+ const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
1384
+ const regionOf = (x) => {
1385
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1386
+ return 0;
1387
+ };
1388
+ const fills = [];
1389
+ let aligned = 0;
1390
+ for (const row of rows) {
1391
+ const byRegion = regions.map(() => []);
1392
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1393
+ byRegion.forEach((inRegion, i) => {
1394
+ if (inRegion.length === 0) return;
1395
+ const from = Math.min(...inRegion.map((r) => r.x));
1396
+ const to = Math.max(...inRegion.map((r) => r.endX));
1397
+ const width = regions[i][1] - regions[i][0];
1398
+ if (width > 0) fills.push((to - from) / width);
1399
+ });
1400
+ if (byRegion.every((inRegion) => inRegion.length > 0)) aligned++;
1401
+ }
1402
+ if (aligned < rows.length * TABLE_ALIGNED_SHARE) return false;
1403
+ fills.sort((a, b) => a - b);
1404
+ return (fills[Math.floor(fills.length / 2)] ?? 1) <= TABLE_CELL_FILL;
1405
+ }
1406
+ /**
1407
+ * §17.4.38 — the page's rows as the TABLE they are.
1408
+ *
1409
+ * The regions between the gutters are the columns and the rows are the rows;
1410
+ * a cell is what one row leaves in one region, and an empty cell is empty. The
1411
+ * grid is measured, so the columns come out where the page put them.
1412
+ *
1413
+ * The lines the page hangs ABOVE its ruling come back as themselves. A line
1414
+ * that crosses every column is not a row of the table — ZapfDingbats.pdf heads
1415
+ * each sheet with two red lines of provenance that run wider than the frame
1416
+ * drawn under them, and squeezed into a cell they wrapped and cost the sheet a
1417
+ * row.
1418
+ *
1419
+ * @param runs The page's runs.
1420
+ * @param gutters The page's gutters.
1421
+ * @param edges Where the page's text starts and ends.
1422
+ * @param stepped Whether the page steps between its words.
1423
+ * @returns The blocks, the table among them, each with where it stands.
1424
+ */
1425
+ function tableFrom(runs, gutters, edges, stepped) {
1426
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1427
+ const all = rowsOf(runs, fontSize).filter((row) => row.length > 0);
1428
+ if (all.length === 0) return void 0;
1429
+ const bounds = columnBounds(runs, gutters, edges);
1430
+ const regionsAt = (from) => {
1431
+ const regions = from.slice(0, -1).map((lo, i) => [lo, from[i + 1]]);
1432
+ return {
1433
+ regions,
1434
+ of: (x) => {
1435
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1436
+ return 0;
1437
+ }
1438
+ };
1439
+ };
1440
+ const first = regionsAt(bounds);
1441
+ const oneWideLine = (row) => {
1442
+ const to = spanEnd(row, 0, first.regions.length, first.of);
1443
+ return to > 0 && row.every((run) => first.of(run.x) <= to);
1444
+ };
1445
+ let start = 0;
1446
+ while (start < all.length - 1 && oneWideLine(all[start])) start++;
1447
+ const above = all.slice(0, start);
1448
+ const rows = all.slice(start);
1449
+ 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);
1450
+ const left = own.length > 0 ? Math.min(...own) : bounds[0];
1451
+ const { regions, of: regionOf } = regionsAt([left, ...bounds.slice(1)]);
1452
+ const flush = regions.map((region, i) => {
1453
+ const starts = [];
1454
+ for (const row of rows) {
1455
+ const at = row.filter((run) => regionOf(run.x) === i).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
1456
+ if (at.length > 0) starts.push(Math.min(...at));
1457
+ }
1458
+ if (starts.length === 0) return region[0];
1459
+ starts.sort((a, b) => a - b);
1460
+ return starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? region[0];
1461
+ });
1462
+ const table = {
1463
+ properties: {
1464
+ defaultCellMargins: {
1465
+ left: pt(0),
1466
+ right: pt(0)
1467
+ },
1468
+ layout: "fixed",
1469
+ ...left > edges.left ? { indentPt: pt(left - edges.left) } : {}
1470
+ },
1471
+ grid: regions.map((r) => pt(Math.max(r[1] - r[0], 1))),
1472
+ rows: rows.map((row, r) => {
1473
+ const byRegion = regions.map(() => []);
1474
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1475
+ const y = Math.max(...row.map((r2) => r2.y));
1476
+ const cells = [];
1477
+ for (let i = 0; i < regions.length;) {
1478
+ const to = spanEnd(row, i, regions.length, regionOf);
1479
+ const inSpan = byRegion.slice(i, to + 1).flat();
1480
+ const width = to - i + 1;
1481
+ const size = inSpan.length > 0 ? Math.max(...inSpan.map((r) => r.fontSizePt || fontSize)) : fontSize;
1482
+ const line = inSpan.length > 0 ? lineOf(inSpan, y, size, stepped) : void 0;
1483
+ const placed = line && line.x - flush[i] >= size / 4 ? alignmentOf([line], {
1484
+ left: regions[i][0],
1485
+ right: regions[to][1]
1486
+ }, size / 2) : {};
1487
+ cells.push({
1488
+ properties: width > 1 ? { colSpan: width } : {},
1489
+ content: line === void 0 ? [{
1490
+ kind: "paragraph",
1491
+ paragraph: {
1492
+ properties: {},
1493
+ runs: []
1494
+ }
1495
+ }] : [paragraphFromRuns(line.spans, void 0, placed.alignment ? { alignment: placed.alignment } : {})]
1496
+ });
1497
+ i = to + 1;
1498
+ }
1499
+ const next = rows[r + 1];
1500
+ const pitch = next ? y - Math.max(...next.map((r2) => r2.y)) : 0;
1501
+ return {
1502
+ properties: pitch > 0 ? {
1503
+ height: pt(pitch),
1504
+ heightRule: "atLeast"
1505
+ } : {},
1506
+ cells
1507
+ };
1508
+ })
1509
+ };
1510
+ return [...above.map((row) => {
1511
+ const y = Math.max(...row.map((r) => r.y));
1512
+ return {
1513
+ el: paragraphFromRuns(lineOf(row, y, Math.max(...row.map((r) => r.fontSizePt || fontSize)), stepped).spans),
1514
+ top: y
1515
+ };
1516
+ }), {
1517
+ el: {
1518
+ kind: "table",
1519
+ table
1520
+ },
1521
+ top: Math.max(...rows[0].map((r) => r.y))
1522
+ }];
1523
+ }
1524
+ /**
1525
+ * Where one column of a ruled page ends and the next begins.
1526
+ *
1527
+ * The gutter is a BAND, and its middle is only a guess at the line inside it: a
1528
+ * gutter is where the fewest lines cross, not where none do (see
1529
+ * `detectGutters`), so a page whose prose overhangs the first column by a few
1530
+ * points has that prose crossing the middle. Cut there, ZapfDingbats.pdf's
1531
+ * heading and its lead paragraph either wrapped inside a cell too narrow for
1532
+ * them or swallowed the glyph standing beside them. The boundary is put past
1533
+ * the crossing ink instead, as far as the band allows — where the columns
1534
+ * really do divide.
1535
+ *
1536
+ * @param runs The page's runs.
1537
+ * @param gutters The page's gutters.
1538
+ * @param edges Where the page's text starts and ends.
1539
+ * @returns The column boundaries, left edge first and right edge last.
1540
+ */
1541
+ function columnBounds(runs, gutters, edges) {
1542
+ const inks = runs.map((r) => runInk(r)).filter((ink) => ink !== void 0);
1543
+ const clearance = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 4;
1544
+ return [
1545
+ edges.left,
1546
+ ...gutters.map((g) => {
1547
+ let at = g.mid;
1548
+ for (const [from, to] of inks) if (from < g.mid && to > at && to <= g.to) at = Math.min(to + clearance, g.to);
1549
+ return at;
1550
+ }),
1551
+ edges.right
1552
+ ];
1553
+ }
1554
+ /**
1555
+ * The last column a cell starting at `from` covers — the one where no run
1556
+ * reaches any further right.
1557
+ *
1558
+ * A run whose INK ends past a column boundary was drawn as one line across
1559
+ * both, so both belong to one cell; and a cell widened that way may pick up a
1560
+ * run that crosses the next boundary in turn.
1561
+ *
1562
+ * @param row The row's runs.
1563
+ * @param from The column the cell starts in.
1564
+ * @param count How many columns the table has.
1565
+ * @param regionOf Which column an x falls in.
1566
+ */
1567
+ function spanEnd(row, from, count, regionOf) {
1568
+ let to = from;
1569
+ for (let grew = true; grew && to < count - 1;) {
1570
+ grew = false;
1571
+ for (const run of row) {
1572
+ const ink = runInk(run);
1573
+ if (ink === void 0) continue;
1574
+ const start = regionOf(ink[0]);
1575
+ if (start < from || start > to) continue;
1576
+ const end = regionOf(ink[1] - 1);
1577
+ if (end > to) {
1578
+ to = Math.min(end, count - 1);
1579
+ grew = true;
1580
+ }
1581
+ }
1582
+ }
1583
+ return to;
1584
+ }
1585
+ /** Below this many gutters a page in columns is read as columns. */
1586
+ var MIN_RULED_GUTTERS = 2;
1587
+ /** Below this many rows a page is not ruled into anything. */
1588
+ var MIN_TABLE_ROWS = 6;
1589
+ /** How many of a table's rows must carry a cell in every one of its columns. */
1590
+ var TABLE_ALIGNED_SHARE = .6;
1591
+ /**
1592
+ * How much of its column a CELL covers, at the median. A line of prose fills
1593
+ * its measure — 84 to 95 per cent across this corpus — and a cell does not.
1594
+ */
1595
+ var TABLE_CELL_FILL = .65;
344
1596
  //#endregion
345
- export { reconstructByLayout };
1597
+ export { endedParagraph, reconstructByLayout };