reamkit 1.27.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 (65) hide show
  1. package/README.md +6 -5
  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.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 +5 -2
  24. package/dist/esm/pdf-reader/content.d.ts +76 -0
  25. package/dist/esm/pdf-reader/content.js +142 -59
  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.js +26 -9
  30. package/dist/esm/pdf-reader/embedded-fonts.d.ts +11 -0
  31. package/dist/esm/pdf-reader/embedded-fonts.js +14 -1
  32. package/dist/esm/pdf-reader/encodings.d.ts +25 -0
  33. package/dist/esm/pdf-reader/encodings.js +110 -0
  34. package/dist/esm/pdf-reader/flow-build.d.ts +9 -3
  35. package/dist/esm/pdf-reader/flow-build.js +13 -7
  36. package/dist/esm/pdf-reader/font.js +329 -8
  37. package/dist/esm/pdf-reader/glyf-outline.d.ts +43 -0
  38. package/dist/esm/pdf-reader/glyf-outline.js +351 -0
  39. package/dist/esm/pdf-reader/glyph-names.js +20 -0
  40. package/dist/esm/pdf-reader/icc.d.ts +10 -0
  41. package/dist/esm/pdf-reader/icc.js +210 -0
  42. package/dist/esm/pdf-reader/image-decode.d.ts +4 -3
  43. package/dist/esm/pdf-reader/image-decode.js +85 -64
  44. package/dist/esm/pdf-reader/images.d.ts +6 -0
  45. package/dist/esm/pdf-reader/images.js +50 -7
  46. package/dist/esm/pdf-reader/jbig2.js +104 -19
  47. package/dist/esm/pdf-reader/layout.js +1148 -45
  48. package/dist/esm/pdf-reader/math-rows.d.ts +23 -0
  49. package/dist/esm/pdf-reader/math-rows.js +198 -0
  50. package/dist/esm/pdf-reader/predefined-cmap.d.ts +21 -0
  51. package/dist/esm/pdf-reader/predefined-cmap.js +102 -0
  52. package/dist/esm/pdf-reader/reader.d.ts +6 -9
  53. package/dist/esm/pdf-reader/reader.js +34 -21
  54. package/dist/esm/pdf-reader/shading.d.ts +81 -2
  55. package/dist/esm/pdf-reader/shading.js +191 -25
  56. package/dist/esm/pdf-reader/stream-filters.d.ts +6 -0
  57. package/dist/esm/pdf-reader/stream-filters.js +67 -0
  58. package/dist/esm/pdf-reader/text-rules.js +83 -10
  59. package/dist/esm/pdf-reader/text.js +82 -3
  60. package/dist/esm/pdf-reader/type1-outline.d.ts +21 -0
  61. package/dist/esm/pdf-reader/type1-outline.js +576 -0
  62. package/dist/esm/pdf-reader/vector.js +43 -4
  63. package/dist/esm/word/document-parser.js +4 -1
  64. package/dist/esm/word/docx-writer.js +1 -0
  65. 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,10 @@ 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;
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 = [];
@@ -41,14 +49,32 @@ function reconstructByLayout(file, mode = "flow") {
41
49
  feature: FEATURES.text,
42
50
  detail: "text filled with a tiling pattern is drawn as a flat tint of the pattern’s colour, not as the pattern"
43
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);
44
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;
45
67
  pages.forEach((page, i) => {
46
68
  const runs = pageRuns[i];
47
69
  const display = shown[i];
70
+ const gutters = pageGutters[i];
48
71
  const pageWidth = display.width;
49
- const gutter = detectGutter(runs, pageWidth);
50
72
  const stepped = stepsBetweenWords(runs);
51
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);
52
78
  const addColumn = (allRuns, col) => {
53
79
  const colRuns = mode === "positional" ? allRuns.filter((r) => r.type3 !== true && r.invisible !== true) : allRuns;
54
80
  if (mode === "positional") {
@@ -64,22 +90,51 @@ function reconstructByLayout(file, mode = "flow") {
64
90
  }
65
91
  return;
66
92
  }
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;
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
+ }
72
124
  for (const para of groupIntoParagraphs(lines, measure, display.height)) blocks.push({
125
+ band: bandAt(para.top),
73
126
  col,
74
127
  top: para.top,
75
128
  el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
76
129
  ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
77
- ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {}
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) } : {}
78
133
  })
79
134
  });
80
135
  };
81
136
  const placed = [];
82
- const colOf = (centerX) => gutter !== void 0 && centerX >= gutter ? 1 : 0;
137
+ const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
83
138
  const frame = {
84
139
  left: 0,
85
140
  top: display.height
@@ -100,9 +155,18 @@ function reconstructByLayout(file, mode = "flow") {
100
155
  const placedVectors = placeVectors(lifted.vectors, display);
101
156
  const ruled = markDrawnRules(runs, placedVectors);
102
157
  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);
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);
106
170
  } else addColumn(ruled.runs, 0);
107
171
  const under = mode !== "positional";
108
172
  [
@@ -121,13 +185,29 @@ function reconstructByLayout(file, mode = "flow") {
121
185
  ...placed
122
186
  ].sort((a, b) => compareOrder(a.key, b.key)).forEach((mark, z) => {
123
187
  blocks.push({
188
+ band: bandAt(mark.top),
124
189
  col: mark.col,
125
190
  top: mark.top,
126
191
  el: mark.make(z)
127
192
  });
128
193
  });
129
- blocks.sort((a, b) => a.col - b.col || b.top - a.top);
130
- 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({
131
211
  kind: "paragraph",
132
212
  paragraph: {
133
213
  properties: {
@@ -138,36 +218,416 @@ function reconstructByLayout(file, mode = "flow") {
138
218
  runs: []
139
219
  }
140
220
  });
141
- 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
+ }
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
142
253
  });
143
- const section = sectionFromPdfPages(pages);
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;
144
284
  return {
145
- doc: buildFlowDoc(body, resources, mode === "positional" ? section : withMeasuredMargins(section, shown, pageRuns), collectEmbeddedFonts(file, pages, losses)),
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),
146
289
  losses: dedupeLosses(losses)
147
290
  };
148
291
  }
149
- function detectGutter(runs, pageWidth) {
150
- if (runs.length < 30 || pageWidth <= 0) return void 0;
292
+ function detectGutters(runs, pageWidth) {
293
+ if (runs.length < 30 || pageWidth <= 0) return [];
151
294
  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
- }
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
+ });
164
312
  if (r > curEnd) curEnd = r;
165
313
  }
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;
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;
171
631
  }
172
632
  /**
173
633
  * The gap, in ems, past which the placed reader cuts a line rather than write a
@@ -321,7 +781,7 @@ function inkSpan(runs) {
321
781
  }
322
782
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
323
783
  function lineOf(runs, y, fontSize, stepped) {
324
- const ordered = lineSpans(runs, fontSize, stepped);
784
+ const ordered = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
325
785
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
326
786
  const ink = inkSpan(runs);
327
787
  return {
@@ -329,11 +789,46 @@ function lineOf(runs, y, fontSize, stepped) {
329
789
  width: ink.width,
330
790
  y,
331
791
  fontSize,
792
+ ...tabbed(runs, fontSize) ? { tabbed: true } : {},
332
793
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
333
794
  spans
334
795
  };
335
796
  }
336
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
+ /**
337
832
  * The gap between two runs that means a WORD SPACE stood there.
338
833
  *
339
834
  * A page that draws its own spaces has already said where its words divide, and
@@ -352,6 +847,28 @@ function lineOf(runs, y, fontSize, stepped) {
352
847
  function spaceGap(prev, fontSize, stepped) {
353
848
  return (prev.fontSizePt || fontSize) * (stepped ? STEPPED_SPACE_EM : DRAWN_SPACE_EM);
354
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
+ ]);
355
872
  /** A page that writes its own spaces: only a wide gap means anything more. */
356
873
  var DRAWN_SPACE_EM = .25;
357
874
  /** A page that writes none: the step between its words is all there is. */
@@ -370,9 +887,11 @@ function stepsBetweenWords(runs) {
370
887
  }
371
888
  function lineSpans(runs, fontSize, stepped) {
372
889
  const spans = [];
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));
373
892
  let prev;
374
893
  for (const run of runs) {
375
- if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped)) 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: " " });
376
895
  spans.push({
377
896
  text: run.text.replaceAll("�", ""),
378
897
  sizePt: run.fontSizePt,
@@ -384,6 +903,7 @@ function lineSpans(runs, fontSize, stepped) {
384
903
  } } : {},
385
904
  ...run.bold ? { bold: true } : {},
386
905
  ...run.italic ? { italic: true } : {},
906
+ ...script(run, baseline, fontSize),
387
907
  ...run.markup !== void 0 ? { markup: run.markup } : {},
388
908
  ...run.href !== void 0 ? { href: run.href } : {}
389
909
  });
@@ -391,6 +911,55 @@ function lineSpans(runs, fontSize, stepped) {
391
911
  }
392
912
  return spans;
393
913
  }
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;
394
963
  function groupIntoParagraphs(lines, column, pageHeight = 0) {
395
964
  const groups = [];
396
965
  const gaps = [];
@@ -398,13 +967,15 @@ function groupIntoParagraphs(lines, column, pageHeight = 0) {
398
967
  for (const line of lines) {
399
968
  const gap = prev !== void 0 ? prev.y - line.y : 0;
400
969
  const opened = prev !== void 0 && gap > line.fontSize * 1.5;
401
- if (groups.length === 0 || opened || prev !== void 0 && endedParagraph(prev, line, column)) {
970
+ if (groups.length === 0 || opened || prev !== void 0 && (endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
402
971
  groups.push([]);
403
972
  gaps.push(prev === void 0 ? 0 : gap);
404
973
  }
405
974
  groups[groups.length - 1].push(line);
406
975
  prev = line;
407
976
  }
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;
408
979
  return groups.map((g, i) => {
409
980
  const first = g[0];
410
981
  const fontSize = Math.max(...g.map((l) => l.fontSize));
@@ -412,15 +983,102 @@ function groupIntoParagraphs(lines, column, pageHeight = 0) {
412
983
  const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
413
984
  const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
414
985
  return {
415
- spans: g.flatMap((l, k) => k > 0 ? [{ text: " " }, ...l.spans] : [...l.spans]),
986
+ spans: joinLines(g),
416
987
  fontSize,
417
988
  top: first.y,
418
989
  ...spacingBefore !== void 0 ? { spacingBefore } : {},
419
- ...alignmentOf(g, column)
990
+ ...alignmentOf(g, column),
991
+ ...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
420
992
  };
421
993
  });
422
994
  }
423
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
+ /**
424
1082
  * Whether a line ENDED a paragraph, rather than wrapping into the next.
425
1083
  *
426
1084
  * Leading alone cannot tell the two apart: five labels stacked at 15pt with a
@@ -444,7 +1102,9 @@ function endedParagraph(prev, next, column) {
444
1102
  if (!column) return false;
445
1103
  const width = column.right - column.left;
446
1104
  if (!(width > 0)) return false;
447
- if (Math.abs(prev.x - next.x) > Math.max(prev.fontSize, 4)) 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;
448
1108
  return column.right - (prev.x + prev.width) > width * .25;
449
1109
  }
450
1110
  /**
@@ -457,7 +1117,7 @@ function endedParagraph(prev, next, column) {
457
1117
  * is left alone — a justified paragraph and a ragged-right one look the same
458
1118
  * from here, and guessing between them would re-set the body of every document.
459
1119
  */
460
- function alignmentOf(lines, column) {
1120
+ function alignmentOf(lines, column, least) {
461
1121
  if (!column || lines.length === 0) return {};
462
1122
  const width = column.right - column.left;
463
1123
  if (!(width > 0)) return {};
@@ -465,10 +1125,11 @@ function alignmentOf(lines, column) {
465
1125
  lead: l.x - column.left,
466
1126
  trail: column.right - (l.x + l.width)
467
1127
  }));
468
- const meaningful = width * .1;
1128
+ const meaningful = Math.min(width * .1, least ?? width);
469
1129
  const even = width * .06;
470
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" };
471
- if (insets.every((i) => i.trail <= even) && Math.max(...insets.map((i) => i.lead)) >= meaningful) return { alignment: "right" };
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" };
472
1133
  return {};
473
1134
  }
474
1135
  function headingLevel(fontSize, medianFont) {
@@ -490,5 +1151,447 @@ function compareOrder(a, b) {
490
1151
  for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
491
1152
  return a.length - b.length;
492
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;
493
1596
  //#endregion
494
1597
  export { endedParagraph, reconstructByLayout };