reamkit 1.28.0 → 1.30.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 (52) hide show
  1. package/dist/esm/core/converter/ream.d.ts +8 -0
  2. package/dist/esm/core/document-model/types.d.ts +2 -0
  3. package/dist/esm/core/drawingml/shape-render.js +13 -1
  4. package/dist/esm/core/fonts/index.d.ts +1 -1
  5. package/dist/esm/core/fonts/remote-fonts.d.ts +8 -0
  6. package/dist/esm/core/fonts/remote-fonts.js +100 -17
  7. package/dist/esm/core/ir/flow.d.ts +17 -0
  8. package/dist/esm/index.d.ts +1 -1
  9. package/dist/esm/pdf-reader/annot-draw.js +93 -1
  10. package/dist/esm/pdf-reader/annots.d.ts +18 -0
  11. package/dist/esm/pdf-reader/annots.js +86 -9
  12. package/dist/esm/pdf-reader/cmap.js +13 -3
  13. package/dist/esm/pdf-reader/content.d.ts +32 -4
  14. package/dist/esm/pdf-reader/content.js +177 -12
  15. package/dist/esm/pdf-reader/display.js +16 -0
  16. package/dist/esm/pdf-reader/document.d.ts +8 -0
  17. package/dist/esm/pdf-reader/embedded-fonts.d.ts +24 -0
  18. package/dist/esm/pdf-reader/embedded-fonts.js +48 -9
  19. package/dist/esm/pdf-reader/flow-build.d.ts +101 -5
  20. package/dist/esm/pdf-reader/flow-build.js +219 -25
  21. package/dist/esm/pdf-reader/font.d.ts +27 -1
  22. package/dist/esm/pdf-reader/font.js +313 -29
  23. package/dist/esm/pdf-reader/glyf-outline.js +13 -2
  24. package/dist/esm/pdf-reader/glyph-shapes.d.ts +18 -0
  25. package/dist/esm/pdf-reader/glyph-shapes.js +57 -0
  26. package/dist/esm/pdf-reader/image-decode.js +70 -4
  27. package/dist/esm/pdf-reader/images.d.ts +5 -0
  28. package/dist/esm/pdf-reader/images.js +4 -2
  29. package/dist/esm/pdf-reader/jbig2.d.ts +40 -1
  30. package/dist/esm/pdf-reader/jbig2.js +78 -16
  31. package/dist/esm/pdf-reader/jpeg.d.ts +6 -3
  32. package/dist/esm/pdf-reader/jpeg.js +21 -1
  33. package/dist/esm/pdf-reader/layout.d.ts +26 -2
  34. package/dist/esm/pdf-reader/layout.js +974 -55
  35. package/dist/esm/pdf-reader/lexer.d.ts +10 -0
  36. package/dist/esm/pdf-reader/lexer.js +17 -0
  37. package/dist/esm/pdf-reader/pattern-tint.d.ts +11 -1
  38. package/dist/esm/pdf-reader/pattern-tint.js +21 -3
  39. package/dist/esm/pdf-reader/regions.d.ts +25 -0
  40. package/dist/esm/pdf-reader/regions.js +167 -0
  41. package/dist/esm/pdf-reader/shading.d.ts +58 -2
  42. package/dist/esm/pdf-reader/shading.js +181 -11
  43. package/dist/esm/pdf-reader/struct-tree.js +112 -8
  44. package/dist/esm/pdf-reader/tagged.js +27 -7
  45. package/dist/esm/pdf-reader/text-rules.js +1 -1
  46. package/dist/esm/pdf-reader/text.js +9 -14
  47. package/dist/esm/pdf-reader/vector.d.ts +10 -2
  48. package/dist/esm/pdf-reader/vector.js +35 -5
  49. package/dist/esm/word/docx-writer.js +156 -39
  50. package/dist/esm/word/drawing-parser.js +7 -2
  51. package/dist/esm/word/paragraph-properties.js +2 -0
  52. package/package.json +5 -3
@@ -3,12 +3,15 @@ import { ResourceStore } from "../core/ir/resources.js";
3
3
  import { FEATURES } from "../core/ir/features.js";
4
4
  import { isRightToLeft } from "./content.js";
5
5
  import { collectEmbeddedFonts } from "./embedded-fonts.js";
6
+ import { collectFaceFamilies } from "./font.js";
6
7
  import { extractPageText } from "./text.js";
7
8
  import { collectPageVectors } from "./vector.js";
8
9
  import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
9
- import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock, withMeasuredMargins } from "./flow-build.js";
10
+ import { BASELINE_AT, CARRIER_LINE_PT, GUESSED_MARGIN, NATURAL_LINE_EM, buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock, spaceAfter, textSizeOf, tooSmallToRead, withMeasuredMargins } from "./flow-build.js";
10
11
  import { collectPageImages } from "./images.js";
11
12
  import { markDrawnRules } from "./text-rules.js";
13
+ import { regionsOf } from "./regions.js";
14
+ import { punctuationOf } from "./glyph-shapes.js";
12
15
  import { matrixBlocks } from "./math-rows.js";
13
16
  //#region src/pdf-reader/layout.ts
14
17
  /** The relationships the reconstruction files its running head and foot under. */
@@ -35,7 +38,11 @@ function reconstructByLayout(file, mode = "flow") {
35
38
  const allRuns = pages.map((page, i) => placeRuns(extractPageText(file, page), shown[i]));
36
39
  const foot = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "foot");
37
40
  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;
41
+ const textSize = textSizeOf(allRuns);
42
+ const stamp = (r) => mode !== "positional" && (r.annotation === true || tooSmallToRead(r, textSize));
43
+ const stamps = allRuns.map((runs) => runs.filter(stamp));
44
+ const pageRuns = foot || head || stamps.some((s) => s.length > 0) ? allRuns.map((runs, i) => runs.filter((r) => foot?.lift[i]?.has(r) !== true && head?.lift[i]?.has(r) !== true && !stamp(r))) : allRuns;
45
+ const pageMarks = pages.map(() => []);
39
46
  const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
40
47
  const resources = new ResourceStore();
41
48
  const losses = [];
@@ -61,6 +68,7 @@ function reconstructByLayout(file, mode = "flow") {
61
68
  const sectionEnds = [];
62
69
  let sectionFrom = 0;
63
70
  let lastSize = "";
71
+ const leads = [];
64
72
  let curColumns = 1;
65
73
  let curSpace = 0;
66
74
  let pendingContinuous = false;
@@ -71,18 +79,19 @@ function reconstructByLayout(file, mode = "flow") {
71
79
  const pageWidth = display.width;
72
80
  const stepped = stepsBetweenWords(runs);
73
81
  const blocks = [];
74
- const split = gutters.length > 0 ? assignColumns(runs, gutters) : void 0;
75
82
  const textEdges = pageTextEdges(runs);
83
+ const inColumns = proseColumns(runs, gutters, textEdges);
84
+ const split = gutters.length > 0 && inColumns ? assignColumns(runs, gutters) : void 0;
76
85
  const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
77
86
  const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
78
87
  const addColumn = (allRuns, col) => {
79
- const colRuns = mode === "positional" ? allRuns.filter((r) => r.type3 !== true && r.invisible !== true) : allRuns;
88
+ const colRuns = allRuns.filter((r) => r.type3 !== true && (mode !== "positional" || r.invisible !== true));
80
89
  if (mode === "positional") {
81
- for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
90
+ for (const [angle, turned] of byAngle(colRuns)) for (const runs of byPainter(turned)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
82
91
  if (line.text.length === 0) continue;
83
92
  const box = turnedBox(line, angle, pageWidth);
84
93
  placed.push({
85
- key: [Number.MAX_SAFE_INTEGER, placed.length],
94
+ key: placedKey(runs, placed.length),
86
95
  col,
87
96
  top: box.y + box.height,
88
97
  make: (z) => positionedText(line.spans, box, frame, z, rotation60kOf(angle))
@@ -93,7 +102,8 @@ function reconstructByLayout(file, mode = "flow") {
93
102
  const columnSize = median(colRuns.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
94
103
  const maths = matrixBlocks(colRuns, columnSize);
95
104
  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);
105
+ const textRuns = inMath.size > 0 ? colRuns.filter((r) => !inMath.has(r)) : colRuns;
106
+ const lines = groupIntoLines(textRuns, false, stepped).filter((l) => l.text.length > 0);
97
107
  const measure = measureOf(col, gutters, textEdges) ?? measureOfLines(lines);
98
108
  for (const found of maths) {
99
109
  const { alignment } = alignmentOf([{
@@ -121,17 +131,153 @@ function reconstructByLayout(file, mode = "flow") {
121
131
  }
122
132
  });
123
133
  }
124
- for (const para of groupIntoParagraphs(lines, measure, display.height)) blocks.push({
125
- band: bandAt(para.top),
126
- col,
127
- top: para.top,
128
- el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
134
+ let above;
135
+ for (const region of regionsOf(textRuns)) {
136
+ if (region.kind === "side") {
137
+ const made = sideBySide(region, measure, above);
138
+ if (made === void 0) continue;
139
+ blocks.push({
140
+ band: bandAt(made.top),
141
+ col,
142
+ top: made.top,
143
+ el: made.el
144
+ });
145
+ above = made.below;
146
+ continue;
147
+ }
148
+ const regionLines = groupIntoLines(region.runs, false, stepped).filter((l) => l.text.length > 0);
149
+ const reach = lines.length < 3 && gutters.length === 0 ? pageWidth * (1 - GUESSED_MARGIN) : void 0;
150
+ const paras = groupIntoParagraphs(regionLines, measure, display.height, above, false, reach);
151
+ for (const set of setParagraphs(paras, measure && {
152
+ left: measure.left,
153
+ right: Math.max(measure.right, textEdges?.right ?? measure.right)
154
+ }, pageWidth)) blocks.push({
155
+ band: bandAt(set.top),
156
+ col,
157
+ top: set.top,
158
+ el: set.el
159
+ });
160
+ const last = paras[paras.length - 1];
161
+ if (last !== void 0) above = {
162
+ y: last.bottom,
163
+ lineHeight: last.lineHeight
164
+ };
165
+ }
166
+ };
167
+ /**
168
+ * A region's paragraphs as the elements they are set in: the lines that
169
+ * stand on the same stops as a table (§17.4.38), the rest as paragraphs
170
+ * placed and spaced the way the page placed and spaced them.
171
+ */
172
+ const far = /* @__PURE__ */ new WeakMap();
173
+ const setParagraphs = (paras, tableMeasure, sheetWidth = 0) => {
174
+ const asRows = tabbedRows(paras, tableMeasure);
175
+ const out = [];
176
+ for (const [at, para] of paras.entries()) {
177
+ const table = asRows.get(at);
178
+ if (table !== void 0) {
179
+ if (table !== null) out.push({
180
+ top: para.top,
181
+ el: table
182
+ });
183
+ continue;
184
+ }
185
+ const overflow = sheetWidth > 0 ? runOn(para, tableMeasure, sheetWidth) : 0;
186
+ const el = paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
187
+ ...para.stops !== void 0 && para.stops.length > 0 ? { tabs: para.stops.filter((x) => x > 0).map((x) => ({
188
+ positionPt: pt(x),
189
+ alignment: "left"
190
+ })) } : {},
129
191
  ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
130
192
  ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
193
+ spacingLine: pt(para.lineHeight),
194
+ spacingLineRule: "exact",
131
195
  ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
132
- ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
133
- })
196
+ ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {},
197
+ ...overflow > 0 ? { indentRight: pt(-overflow) } : {}
198
+ });
199
+ if (sheetWidth > 0 && para.far !== void 0) far.set(el, para.far);
200
+ out.push({
201
+ top: para.top,
202
+ el
203
+ });
204
+ }
205
+ return out;
206
+ };
207
+ /**
208
+ * §17.4.38 — a band of blocks side by side, as the borderless table a
209
+ * writer sets such a band in: one row, a cell a block, each cell's lines
210
+ * read down on their own and spaced from what stands above the band.
211
+ */
212
+ const sideBySide = (region, measure, above) => {
213
+ const left = measure?.left ?? Math.min(...region.cells.map((c) => c.from));
214
+ const right = Math.max(measure?.right ?? 0, textEdges?.right ?? 0, ...region.cells.map((c) => c.to));
215
+ const cells = region.cells.map((cell) => {
216
+ return {
217
+ cell,
218
+ lines: groupIntoLines(cell.runs, false, stepped).filter((l) => l.text.length > 0)
219
+ };
134
220
  });
221
+ if (cells.some((c) => c.lines.length === 0)) return void 0;
222
+ const top = Math.max(...cells.map((c) => c.lines[0].y));
223
+ const edge = above ?? {
224
+ y: Math.max(...cells.map((c) => c.lines[0].y + .8 * 1.2 * c.lines[0].fontSize)),
225
+ lineHeight: 0
226
+ };
227
+ const starts = cells.map((c, k) => k === 0 ? left : c.cell.from);
228
+ let foot = Infinity;
229
+ const row = cells.map(({ cell, lines }, k) => {
230
+ const own = {
231
+ left: cell.from,
232
+ right: cell.to
233
+ };
234
+ const paras = groupIntoParagraphs(lines, own, display.height, edge, true);
235
+ const last = paras[paras.length - 1];
236
+ foot = Math.min(foot, last.bottom - (1 - BASELINE_AT) * last.lineHeight);
237
+ const inset = cell.from - starts[k];
238
+ const content = setParagraphs(paras, own).map(({ el }) => inset > 0 && el.kind === "paragraph" ? {
239
+ ...el,
240
+ paragraph: {
241
+ ...el.paragraph,
242
+ properties: {
243
+ ...el.paragraph.properties,
244
+ indentLeft: pt((el.paragraph.properties.indentLeft ?? 0) + inset)
245
+ }
246
+ }
247
+ } : el);
248
+ const width = (starts[k + 1] ?? right) - starts[k];
249
+ return {
250
+ properties: { width: pt(Math.max(width, 1)) },
251
+ content
252
+ };
253
+ });
254
+ const grid = row.map((c) => c.properties.width);
255
+ return {
256
+ top,
257
+ below: {
258
+ y: foot,
259
+ lineHeight: 0
260
+ },
261
+ el: {
262
+ kind: "table",
263
+ table: {
264
+ properties: {
265
+ defaultCellMargins: {
266
+ left: pt(0),
267
+ right: pt(0)
268
+ },
269
+ layout: "fixed",
270
+ widthType: "dxa",
271
+ widthPt: pt(grid.reduce((sum, w) => sum + w, 0))
272
+ },
273
+ grid,
274
+ rows: [{
275
+ properties: {},
276
+ cells: row
277
+ }]
278
+ }
279
+ }
280
+ };
135
281
  };
136
282
  const placed = [];
137
283
  const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
@@ -139,12 +285,23 @@ function reconstructByLayout(file, mode = "flow") {
139
285
  left: 0,
140
286
  top: display.height
141
287
  };
288
+ for (const runs of byPainter(stamps[i] ?? [])) for (const line of groupIntoLines(runs, true, stepped)) {
289
+ if (line.text.length === 0) continue;
290
+ const box = turnedBox(line, 0, pageWidth);
291
+ placed.push({
292
+ key: placedKey(runs, placed.length),
293
+ col: colOf(box.x + box.width / 2),
294
+ top: box.y + box.height,
295
+ make: (z) => positionedText(line.spans, box, frame, z)
296
+ });
297
+ }
142
298
  const raw = collectPageImages(file, page);
143
299
  const imgs = {
144
300
  images: placeImages(raw.images, display),
145
301
  losses: raw.losses
146
302
  };
147
303
  losses.push(...imgs.losses);
304
+ pageMarks[i] = imgs.images;
148
305
  const lifted = collectPageVectors(file, page, imgs.images.map((img) => ({
149
306
  minX: img.x,
150
307
  minY: img.y,
@@ -153,8 +310,14 @@ function reconstructByLayout(file, mode = "flow") {
153
310
  })));
154
311
  losses.push(...lifted.losses);
155
312
  const placedVectors = placeVectors(lifted.vectors, display);
156
- const ruled = markDrawnRules(runs, placedVectors);
157
- const vectors = placedVectors.filter((v) => !ruled.consumed.has(v));
313
+ const drawnRules = markDrawnRules(runs, placedVectors);
314
+ const strayGlyphs = strayMarks(placedVectors, runs);
315
+ const ruled = mode !== "positional" ? {
316
+ ...drawnRules,
317
+ runs: readDrawnMarks(drawnRules.runs, strayGlyphs)
318
+ } : drawnRules;
319
+ const vectors = placedVectors.filter((v) => !ruled.consumed.has(v)).filter((v) => !(mode !== "positional" && strayGlyphs.has(v)));
320
+ const drawn = drawnWords(vectors);
158
321
  const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
159
322
  const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
160
323
  if (asTable) for (const block of asTable) blocks.push({
@@ -169,6 +332,8 @@ function reconstructByLayout(file, mode = "flow") {
169
332
  for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
170
333
  } else addColumn(ruled.runs, 0);
171
334
  const under = mode !== "positional";
335
+ setOff(blocks, far, (line, k) => positionedText(line.spans, turnedBox(line, 0, pageWidth), frame, FAR_Z + k));
336
+ const givenAway = mode !== "positional" ? ruleBorders(vectors, blocks, display.width) : void 0;
172
337
  [
173
338
  ...imgs.images.map((img) => ({
174
339
  key: img.orderKey,
@@ -176,7 +341,7 @@ function reconstructByLayout(file, mode = "flow") {
176
341
  top: img.y + img.heightPt,
177
342
  make: (z) => imageBlock(img, resources, void 0, frame, z, under)
178
343
  })),
179
- ...vectors.map((v) => ({
344
+ ...drawn.filter((v) => givenAway?.has(v) !== true).map((v) => ({
180
345
  key: v.orderKey,
181
346
  col: colOf((v.minX + v.maxX) / 2),
182
347
  top: v.maxY,
@@ -207,20 +372,41 @@ function reconstructByLayout(file, mode = "flow") {
207
372
  pendingContinuous = false;
208
373
  }
209
374
  lastSize = size;
210
- if (i > 0 && !opensSection && blocks.length > 0) body.push({
375
+ if (i > 0 && !opensSection) body.push({
211
376
  kind: "paragraph",
212
377
  paragraph: {
213
378
  properties: {
214
379
  pageBreakBefore: true,
215
- spacingLine: pt(0),
380
+ spacingLine: CARRIER_LINE_PT,
216
381
  spacingLineRule: "exact"
217
382
  },
218
383
  runs: []
219
384
  }
220
385
  });
221
- const columnsHere = ruledIntoColumns ? 1 : gutters.length + 1;
386
+ else if (i === 0 && blocks.length === 0) body.push({
387
+ kind: "paragraph",
388
+ paragraph: {
389
+ properties: {
390
+ spacingLine: CARRIER_LINE_PT,
391
+ spacingLineRule: "exact"
392
+ },
393
+ runs: []
394
+ }
395
+ });
396
+ const columnsHere = ruledIntoColumns || !proseColumns(runs, gutters, textEdges) ? 1 : gutters.length + 1;
222
397
  const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
398
+ let led = false;
223
399
  for (const block of blocks) {
400
+ if (!led && mode !== "positional") {
401
+ const lead = leadingLine(block.el);
402
+ if (lead !== void 0) leads.push({
403
+ page: i,
404
+ at: body.length,
405
+ baseline: block.top,
406
+ lineHeight: lead
407
+ });
408
+ led = lead !== void 0 || block.el.kind === "table" || block.el.kind === "paragraph";
409
+ }
224
410
  const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
225
411
  if (count !== curColumns) {
226
412
  sectionEnds.push({
@@ -241,7 +427,7 @@ function reconstructByLayout(file, mode = "flow") {
241
427
  });
242
428
  const setUp = (from, to) => {
243
429
  const own = sectionFromPdfPages(pages.slice(from, to));
244
- return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to));
430
+ return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to), foot?.band);
245
431
  };
246
432
  sectionEnds.push({
247
433
  at: body.length,
@@ -266,6 +452,25 @@ function reconstructByLayout(file, mode = "flow") {
266
452
  endIndex: end.at
267
453
  }];
268
454
  }) : [];
455
+ for (const lead of leads) {
456
+ const end = sectionEnds.find((e) => e.at > lead.at);
457
+ const top = (end ? setUp(end.from, end.to) : setUp(0, pages.length))?.margins?.top;
458
+ const page = shown[lead.page];
459
+ const el = body[lead.at];
460
+ if (top === void 0 || page === void 0 || el?.kind !== "paragraph") continue;
461
+ const before = page.height - top - lead.baseline - BASELINE_AT * lead.lineHeight;
462
+ if (before <= SPACING_NOISE_PT) continue;
463
+ body[lead.at] = {
464
+ ...el,
465
+ paragraph: {
466
+ ...el.paragraph,
467
+ properties: {
468
+ ...el.paragraph.properties,
469
+ spacingBefore: pt(before)
470
+ }
471
+ }
472
+ };
473
+ }
269
474
  const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
270
475
  const edges0 = pageTextEdges(allRuns[0] ?? []);
271
476
  const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
@@ -285,7 +490,7 @@ function reconstructByLayout(file, mode = "flow") {
285
490
  doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
286
491
  ...s,
287
492
  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),
493
+ })), band.length > 0 || headBand.length > 0 ? new Map([...band.length > 0 ? [[FOOTER_PART, band]] : [], ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []]) : void 0, collectFaceFamilies(file, pages)),
289
494
  losses: dedupeLosses(losses)
290
495
  };
291
496
  }
@@ -302,9 +507,9 @@ function detectGutters(runs, pageWidth) {
302
507
  if (span < pageWidth * .5) return [];
303
508
  const sorted = [...spans].sort((a, b) => a[0] - b[0]);
304
509
  let curEnd = sorted[0][1];
305
- const empty = [];
510
+ const bands = [];
306
511
  for (const [l, r] of sorted) {
307
- if (l - curEnd >= fontSize * 3) empty.push({
512
+ if (l - curEnd >= fontSize * 3) bands.push({
308
513
  mid: (curEnd + l) / 2,
309
514
  from: curEnd,
310
515
  to: l
@@ -315,6 +520,17 @@ function detectGutters(runs, pageWidth) {
315
520
  const ink = runInk(r);
316
521
  return ink ? [ink] : [];
317
522
  }).sort((a, b) => a[0] - b[0]));
523
+ const straddling = (band) => {
524
+ let n = 0;
525
+ for (const row of rows) {
526
+ const before = row.filter(([, r]) => r <= band.from);
527
+ const after = row.filter(([l]) => l >= band.to);
528
+ if (before.length === 0 || after.length === 0) continue;
529
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) n++;
530
+ }
531
+ return n;
532
+ };
533
+ const empty = bands.filter((b) => straddling(b) >= MIN_EMPTY_BAND_ROWS);
318
534
  const voted = [];
319
535
  for (const x of sample(minX + span * .1, minX + span * .9, 1)) {
320
536
  let columned = 0;
@@ -442,6 +658,13 @@ var MIN_SHARED_ROWS = 3;
442
658
  /** How many lines have to be split at the same place before the page is in columns. */
443
659
  var MIN_COLUMNED_ROWS = 12;
444
660
  /**
661
+ * How many lines a band NO run crosses must separate before it is a gutter
662
+ * rather than an empty half of a page. Lower than the vote's bar, because a
663
+ * band nothing crosses is stronger evidence than a band most lines avoid — but
664
+ * not zero, which is what an invoice's blank left half offers.
665
+ */
666
+ var MIN_EMPTY_BAND_ROWS = 8;
667
+ /**
445
668
  * The page's runs grouped by baseline.
446
669
  *
447
670
  * Swept in order rather than bucketed: a superscript sits a few points above
@@ -647,6 +870,32 @@ function bandOf(breaks, top, epsilon) {
647
870
  * does this — a flowing paragraph is meant to be read across.
648
871
  */
649
872
  var SPACE_GAP_EM = .25;
873
+ /**
874
+ * §12.5.5 — the runs the page's own content paints, and then the ones an
875
+ * annotation's appearance does: two layers, grouped into lines apart.
876
+ */
877
+ function byPainter(runs) {
878
+ return [runs.filter((r) => r.annotation !== true), runs.filter((r) => r.annotation === true)].filter((group) => group.length > 0);
879
+ }
880
+ /**
881
+ * §8.5.3 / §12.5.5 — where a placed line stands among the page's marks, which
882
+ * are ordered as they were painted (see `compareOrder`): the page's own words
883
+ * over everything its content drew, and under every annotation, whose
884
+ * appearances paint after it (`[MAX_SAFE_INTEGER, index, …]`); an
885
+ * annotation's words over all of it. Keyed as one layer, the placed lines
886
+ * fell among the appearances by their count: evaljs.pdf's "Execute" went
887
+ * under the grey of the button it names.
888
+ *
889
+ * @param runs The layer the line was grouped from (see `byPainter`).
890
+ * @param count How many lines are placed already.
891
+ */
892
+ function placedKey(runs, count) {
893
+ return runs[0]?.annotation === true ? [
894
+ Number.MAX_SAFE_INTEGER,
895
+ Number.MAX_SAFE_INTEGER,
896
+ count
897
+ ] : [Number.MAX_SAFE_INTEGER - 1, count];
898
+ }
650
899
  /** Runs by the direction of their baseline, upright first, each angle rounded. */
651
900
  function byAngle(runs) {
652
901
  const groups = /* @__PURE__ */ new Map();
@@ -733,10 +982,12 @@ function groupIntoLines(runs, split = false, stepped = false) {
733
982
  runs: [run]
734
983
  });
735
984
  }
985
+ for (const c of clusters) c.runs.sort((a, b) => a.x - b.x);
986
+ const stops = sharedStops(clusters.map((c) => c.runs));
736
987
  return clusters.flatMap((c) => {
737
- const ordered = c.runs.sort((a, b) => a.x - b.x);
988
+ const ordered = c.runs;
738
989
  const fontSize = c.fontSize || 10;
739
- if (!split) return [lineOf(ordered, c.y, fontSize, stepped)];
990
+ if (!split) return [lineOf(ordered, c.y, fontSize, stepped, stops)];
740
991
  const pieces = [[]];
741
992
  for (const run of ordered) {
742
993
  const prev = pieces[pieces.length - 1];
@@ -745,9 +996,49 @@ function groupIntoLines(runs, split = false, stepped = false) {
745
996
  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([]);
746
997
  pieces[pieces.length - 1].push(run);
747
998
  }
748
- return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize, stepped));
999
+ return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize, stepped, stops));
1000
+ });
1001
+ }
1002
+ /**
1003
+ * §17.3.1.38 — the stops a block of lines is set out on: where text resumes,
1004
+ * after a gap wider than a word space, at the same place on more than one line
1005
+ * — and on at least one of them after a gap only a tab makes.
1006
+ *
1007
+ * A tab's gap is as wide as what stands before it leaves it. An invoice sets
1008
+ * "Date due", "Date of issue" and "Invoice number" over one column of values
1009
+ * at 108pt, and only the shortest label leaves a gap no space could make: the
1010
+ * other two came back with their values a word's width after the label
1011
+ * instead of in the column.
1012
+ *
1013
+ * @param lines Each line's runs, left to right.
1014
+ * @returns The x of every stop two lines share.
1015
+ */
1016
+ function sharedStops(lines) {
1017
+ const seen = [];
1018
+ lines.forEach((runs, line) => {
1019
+ let end;
1020
+ for (const run of runs) {
1021
+ if (run.text.replaceAll("�", "").trim() === "") continue;
1022
+ const size = run.fontSizePt || 10;
1023
+ if (end !== void 0 && run.x - end >= size * STOP_GAP_EM) seen.push({
1024
+ x: run.x,
1025
+ line,
1026
+ tab: run.x - end >= size * TAB_GAP_EM
1027
+ });
1028
+ end = Math.max(end ?? run.endX, run.endX);
1029
+ }
749
1030
  });
1031
+ const stops = [];
1032
+ for (const tab of seen) {
1033
+ if (!tab.tab || stops.some((x) => Math.abs(x - tab.x) <= STOP_SLACK_PT)) continue;
1034
+ if (new Set(seen.filter((o) => Math.abs(o.x - tab.x) <= STOP_SLACK_PT).map((o) => o.line)).size >= 2) stops.push(tab.x);
1035
+ }
1036
+ return stops;
750
1037
  }
1038
+ /** The least gap, in ems, that lands a run on a stop the lines around it share. */
1039
+ var STOP_GAP_EM = .5;
1040
+ /** How far from a shared stop a run may start and still stand on it. */
1041
+ var STOP_SLACK_PT = .75;
751
1042
  /**
752
1043
  * Where a line's INK is, which is not how far its pen travelled.
753
1044
  *
@@ -780,8 +1071,8 @@ function inkSpan(runs) {
780
1071
  };
781
1072
  }
782
1073
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
783
- function lineOf(runs, y, fontSize, stepped) {
784
- const ordered = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
1074
+ function lineOf(runs, y, fontSize, stepped, shared = []) {
1075
+ const { spans: ordered, stops, pieces } = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)), shared);
785
1076
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
786
1077
  const ink = inkSpan(runs);
787
1078
  return {
@@ -789,7 +1080,11 @@ function lineOf(runs, y, fontSize, stepped) {
789
1080
  width: ink.width,
790
1081
  y,
791
1082
  fontSize,
792
- ...tabbed(runs, fontSize) ? { tabbed: true } : {},
1083
+ ...tabbed(runs, fontSize) || stops.length > 0 ? { tabbed: true } : {},
1084
+ ...stops.length > 0 ? {
1085
+ stops,
1086
+ pieces
1087
+ } : {},
793
1088
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
794
1089
  spans
795
1090
  };
@@ -885,13 +1180,35 @@ function stepsBetweenWords(runs) {
885
1180
  if (runs.length < 8) return false;
886
1181
  return runs.filter((r) => /\s/u.test(r.text)).length / runs.length < .05;
887
1182
  }
888
- function lineSpans(runs, fontSize, stepped) {
1183
+ function lineSpans(runs, fontSize, stepped, shared = []) {
889
1184
  const spans = [];
1185
+ const stops = [];
1186
+ const pieces = [{
1187
+ from: Infinity,
1188
+ to: -Infinity
1189
+ }];
890
1190
  const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
891
1191
  const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
892
1192
  let prev;
893
- for (const run of runs) {
894
- if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) spans.push({ text: " " });
1193
+ let inked;
1194
+ for (const [i, run] of runs.entries()) {
1195
+ if (run.text.trim() === "" && run.endX - run.x <= 0 && inked !== void 0) {
1196
+ const next = runs.slice(i + 1).find((r) => r.text.trim() !== "");
1197
+ if (next !== void 0 && next.x - inked < spaceGap(next, fontSize, true)) continue;
1198
+ }
1199
+ if (run.text.replaceAll("�", "") === "" && run.text !== "") continue;
1200
+ if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) {
1201
+ const size = run.fontSizePt || fontSize;
1202
+ const onStop = inked !== void 0 && run.x - inked >= size * STOP_GAP_EM && shared.some((x) => Math.abs(x - run.x) <= STOP_SLACK_PT);
1203
+ if (run.x - prev.endX >= size * TAB_GAP_EM || onStop) {
1204
+ spans.push({ text: " " });
1205
+ stops.push(run.x);
1206
+ pieces.push({
1207
+ from: Infinity,
1208
+ to: -Infinity
1209
+ });
1210
+ } else spans.push(spaceAfter(spans[spans.length - 1]));
1211
+ }
895
1212
  spans.push({
896
1213
  text: run.text.replaceAll("�", ""),
897
1214
  sizePt: run.fontSizePt,
@@ -908,8 +1225,18 @@ function lineSpans(runs, fontSize, stepped) {
908
1225
  ...run.href !== void 0 ? { href: run.href } : {}
909
1226
  });
910
1227
  prev = run;
1228
+ if (run.text.trim() !== "") {
1229
+ inked = Math.max(inked ?? run.endX, run.endX);
1230
+ const piece = pieces[pieces.length - 1];
1231
+ piece.from = Math.min(piece.from, run.x);
1232
+ piece.to = Math.max(piece.to, run.endX);
1233
+ }
911
1234
  }
912
- return spans;
1235
+ return {
1236
+ spans,
1237
+ stops,
1238
+ pieces
1239
+ };
913
1240
  }
914
1241
  /**
915
1242
  * §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
@@ -960,39 +1287,110 @@ function carriesLeader(line) {
960
1287
  }
961
1288
  /** Three of the same leader character in a row is a leader and not punctuation. */
962
1289
  var LEADER_RUN = /([.\u00b7_])\1{2,}/u;
963
- function groupIntoParagraphs(lines, column, pageHeight = 0) {
1290
+ /**
1291
+ * Whether the line is a RULE drawn out of characters — a row of hyphens, dots
1292
+ * or underscores and nothing else.
1293
+ *
1294
+ * A page with no rule to draw types one. It is not a sentence, it does not run
1295
+ * on into the line under it and the line over it does not run on into it.
1296
+ *
1297
+ * @param line The line.
1298
+ * @returns Whether it is a rule rather than words.
1299
+ */
1300
+ function ruleOfCharacters(line) {
1301
+ const text = line.text.trim();
1302
+ if (text.length < RULE_CHARS) return false;
1303
+ for (const ch of text) if (!LEADER_CHARS.has(ch)) return false;
1304
+ return true;
1305
+ }
1306
+ /** How many of them it takes before a row of marks is a rule. */
1307
+ var RULE_CHARS = 3;
1308
+ function groupIntoParagraphs(lines, column, pageHeight = 0, before, eachLine = false, reach) {
964
1309
  const groups = [];
965
1310
  const gaps = [];
966
1311
  let prev;
967
1312
  for (const line of lines) {
968
1313
  const gap = prev !== void 0 ? prev.y - line.y : 0;
969
1314
  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)) {
1315
+ if (groups.length === 0 || eachLine || opened || ruleOfCharacters(line) || prev !== void 0 && (ruleOfCharacters(prev) || endedParagraph(prev, line, column) || reach !== void 0 && roomForWord(prev, line, reach) || carriesLeader(prev) || prev.tabbed === true)) {
971
1316
  groups.push([]);
972
- gaps.push(prev === void 0 ? 0 : gap);
1317
+ gaps.push(prev === void 0 ? before !== void 0 ? before.y - line.y : 0 : gap);
973
1318
  }
974
1319
  groups[groups.length - 1].push(line);
975
1320
  prev = line;
976
1321
  }
977
1322
  const starts = lines.map((l) => l.x).sort((a, b) => a - b);
978
1323
  const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
1324
+ const heights = [];
979
1325
  return groups.map((g, i) => {
980
1326
  const first = g[0];
981
1327
  const fontSize = Math.max(...g.map((l) => l.fontSize));
982
- const opened = (gaps[i] ?? 0) - fontSize * 1.2;
1328
+ const inner = g.slice(1).map((l, k) => g[k].y - l.y);
1329
+ let lineHeight = Math.min(Math.max(inner.length > 0 ? median(inner) : fontSize * NATURAL_LINE_EM, fontSize), fontSize * TALLEST_LINE_EM);
1330
+ const above = i > 0 ? heights[i - 1] : before?.lineHeight;
1331
+ const gap = gaps[i] ?? 0;
1332
+ if (inner.length === 0 && above !== void 0) {
1333
+ const room = (gap - (1 - BASELINE_AT) * above) / BASELINE_AT;
1334
+ if (room < lineHeight) lineHeight = Math.max(room, fontSize);
1335
+ }
1336
+ heights.push(lineHeight);
1337
+ const opened = above !== void 0 ? gap - (1 - BASELINE_AT) * above - BASELINE_AT * lineHeight : 0;
983
1338
  const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
984
- const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
1339
+ const spacingBefore = opened > SPACING_NOISE_PT ? Math.min(opened, most) : void 0;
1340
+ const stops = g.length === 1 ? first.stops ?? [] : [];
1341
+ const aligned = g.every((l) => lines.some((o) => !g.includes(o) && (Math.abs(o.x - l.x) <= STOP_SLACK_PT || (o.stops ?? []).some((x) => Math.abs(x - l.x) <= STOP_SLACK_PT)))) ? {} : alignmentOf(g, column);
985
1342
  return {
986
1343
  spans: joinLines(g),
987
1344
  fontSize,
988
1345
  top: first.y,
1346
+ bottom: g[g.length - 1].y,
1347
+ ...stops.length > 0 ? {
1348
+ stops: stops.map((x) => x - (column?.left ?? columnLeft)),
1349
+ pieces: (first.pieces ?? []).map((p) => ({
1350
+ from: p.from - (column?.left ?? columnLeft),
1351
+ to: p.to - (column?.left ?? columnLeft)
1352
+ }))
1353
+ } : {},
989
1354
  ...spacingBefore !== void 0 ? { spacingBefore } : {},
990
- ...alignmentOf(g, column),
991
- ...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
1355
+ lineHeight,
1356
+ ...aligned,
1357
+ ...indentOf(g, columnLeft, aligned.alignment),
1358
+ lineCount: g.length,
1359
+ right: Math.max(...g.map((l) => l.x + l.width)),
1360
+ ...opened > most ? { far: g } : {}
992
1361
  };
993
1362
  });
994
1363
  }
995
1364
  /**
1365
+ * Whether the first word of `next` would have fit on `prev` short of `reach` —
1366
+ * so the page did not break `prev` for want of room, and ended it.
1367
+ *
1368
+ * The width of the word is its share of its line's: a guess good to a letter,
1369
+ * and the margin it is compared with is the rest of the sheet.
1370
+ */
1371
+ function roomForWord(prev, next, reach) {
1372
+ const text = next.text.trimStart();
1373
+ const word = text.split(/\s/u)[0] ?? "";
1374
+ if (word.length === 0) return false;
1375
+ const width = next.width * word.length / text.length;
1376
+ return reach - (prev.x + prev.width) > width + prev.fontSize * WORD_SPACE_EM;
1377
+ }
1378
+ /** A word space, in ems: what stands between a line's end and the word put after it. */
1379
+ var WORD_SPACE_EM = .3;
1380
+ /**
1381
+ * The exact box a paragraph's first line stands in, where the paragraph is a
1382
+ * line of text set to one (see {@link groupIntoParagraphs}).
1383
+ */
1384
+ function leadingLine(el) {
1385
+ if (el.kind !== "paragraph" || el.paragraph.runs.length === 0) return void 0;
1386
+ const { spacingLine, spacingLineRule } = el.paragraph.properties;
1387
+ return spacingLineRule === "exact" && spacingLine !== void 0 && spacingLine > 0 ? spacingLine : void 0;
1388
+ }
1389
+ /** The tallest box a line is given, in ems — past it the gap is paragraph spacing. */
1390
+ var TALLEST_LINE_EM = 3;
1391
+ /** Spacing under this is the rounding of the page's own numbers, not white put there. */
1392
+ var SPACING_NOISE_PT = .25;
1393
+ /**
996
1394
  * A paragraph's lines as one run of spans, joined the way the page broke them.
997
1395
  *
998
1396
  * A line that ends in a hyphen was broken THERE, and the break is not part of
@@ -1019,7 +1417,7 @@ function joinLines(lines) {
1019
1417
  ...prev,
1020
1418
  text: ends.slice(0, -1)
1021
1419
  };
1022
- else if (!hard) out.push({ text: " " });
1420
+ else if (!hard && !/\s$/u.test(ends) && !/^\s/u.test(line.spans[0]?.text ?? "")) out.push(spaceAfter(prev));
1023
1421
  }
1024
1422
  out.push(...line.spans);
1025
1423
  });
@@ -1073,6 +1471,69 @@ function indentOf(lines, columnLeft, alignment) {
1073
1471
  };
1074
1472
  }
1075
1473
  /**
1474
+ * §17.3.1.12 — how far a line the page set in ONE piece may run on into the
1475
+ * right margin, rather than wrap.
1476
+ *
1477
+ * The line is re-set in a face this reader has, and a face wider than the
1478
+ * page's pushes the last word of a line that ran nearly to the measure onto a
1479
+ * line of its own: bug1252420.pdf's one italic line came back with
1480
+ * "Centuries" under it, and on bug1108301.pdf's sheet, fifty points tall, the
1481
+ * word it pushed down fell off the paper altogether. A line the page did not
1482
+ * break is not broken: it may run on — so far as a sixth of its measure, and
1483
+ * never past the edge of the sheet. Only a line set flush, with nothing on
1484
+ * stops: a centred one would move its centre, and a tabbed one its stops.
1485
+ *
1486
+ * @param para The paragraph.
1487
+ * @param measure The column it was set in.
1488
+ * @param pageWidth The sheet's width.
1489
+ * @returns How far it may run past the measure, in points; 0 where it keeps to it.
1490
+ */
1491
+ function runOn(para, measure, pageWidth) {
1492
+ if (measure === void 0 || para.lineCount !== 1 || para.alignment !== void 0) return 0;
1493
+ if ((para.stops?.length ?? 0) > 0) return 0;
1494
+ const width = measure.right - measure.left;
1495
+ if (!(width > 0) || para.right - measure.left < width * FULL_LINE_SHARE) return 0;
1496
+ return Math.max(0, Math.min(width * RUN_ON_SHARE, pageWidth - measure.right));
1497
+ }
1498
+ /**
1499
+ * §17.3.1.33 — the last text of a sheet, set farther below everything above it
1500
+ * than a paragraph's spacing may say, stands where the page set it.
1501
+ *
1502
+ * The spacing before a paragraph is held to a third of the sheet, so that a
1503
+ * gap misread cannot throw everything after it off the page. The last text on
1504
+ * the sheet has nothing after it to throw, and held to a third it came back
1505
+ * half way up the paper: bug1989304.pdf signs its sheet "World" at the foot
1506
+ * and we set it in the middle. Its lines are placed instead, each where the
1507
+ * page drew it.
1508
+ *
1509
+ * @param blocks The sheet's blocks, in the order they are read.
1510
+ * @param far The paragraphs whose spacing was held, with their lines.
1511
+ * @param place A line as the box that places it, `k` counting them.
1512
+ */
1513
+ function setOff(blocks, far, place) {
1514
+ const after = (a, b) => (a.band - b.band || columnOrder(a.col) - columnOrder(b.col) || b.top - a.top) > 0;
1515
+ let at = -1;
1516
+ blocks.forEach((b, k) => {
1517
+ if (b.el.kind !== "paragraph" && b.el.kind !== "table") return;
1518
+ if (at < 0 || after(b, blocks[at])) at = k;
1519
+ });
1520
+ const block = at >= 0 ? blocks[at] : void 0;
1521
+ const lines = block ? far.get(block.el) : void 0;
1522
+ if (!block || !lines) return;
1523
+ blocks.splice(at, 1, ...lines.map((line, k) => ({
1524
+ band: block.band,
1525
+ col: block.col,
1526
+ top: line.y,
1527
+ el: place(line, k)
1528
+ })));
1529
+ }
1530
+ /** Where the placed last text stands in the page's painting order: over it all. */
1531
+ var FAR_Z = 1e6;
1532
+ /** How far across its measure a line runs before it is at risk of wrapping when re-set. */
1533
+ var FULL_LINE_SHARE = .85;
1534
+ /** How far past its measure, as a share of it, a one-piece line may run on. */
1535
+ var RUN_ON_SHARE = 1 / 6;
1536
+ /**
1076
1537
  * How far, in ems, a paragraph has to be set in before it is indented rather
1077
1538
  * than merely started. Half an em clears the rounding a producer leaves at the
1078
1539
  * head of a line and is well under the smallest indent anybody sets.
@@ -1093,8 +1554,14 @@ var INDENT_EM = .5;
1093
1554
  * placed rather than set — a centred title's every line is short of the measure
1094
1555
  * and none of them ends anything.
1095
1556
  *
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.
1557
+ * A line of a right-to-left script starts at the RIGHT and ends at the left,
1558
+ * and is read the other way round: ArabicCIDTrueType.pdf sets four lines, each
1559
+ * flush right and shorter than the one before, and read as a left-to-right
1560
+ * setting every pair of them ran together as one paragraph.
1561
+ *
1562
+ * @param prev The line before: where it starts, how wide it is, its face,
1563
+ * and its text when it has any.
1564
+ * @param next The line after — where it starts, and how wide it is.
1098
1565
  * @param column The measure both were set in, when it is known.
1099
1566
  * @returns Whether the first line ended a paragraph.
1100
1567
  */
@@ -1103,6 +1570,11 @@ function endedParagraph(prev, next, column) {
1103
1570
  const width = column.right - column.left;
1104
1571
  if (!(width > 0)) return false;
1105
1572
  const step = Math.max(prev.fontSize, 4);
1573
+ if (prev.text !== void 0 && next.width !== void 0 && isRightToLeft(prev.text)) {
1574
+ const indent = prev.x + prev.width - (next.x + next.width);
1575
+ if (indent < -step || indent > step * INDENT_LINES) return false;
1576
+ return prev.x - column.left > width * .25;
1577
+ }
1106
1578
  const shift = next.x - prev.x;
1107
1579
  if (shift < -step || shift > step * INDENT_LINES) return false;
1108
1580
  return column.right - (prev.x + prev.width) > width * .25;
@@ -1170,7 +1642,8 @@ function compareOrder(a, b) {
1170
1642
  * `undefined` where the document repeats nothing.
1171
1643
  */
1172
1644
  function runningFoot(pageRuns, shown, where) {
1173
- const feet = pageRuns.map((runs, i) => edgeLine(runs, shown[i]?.height ?? 0, where));
1645
+ const textSize = textSizeOf(pageRuns);
1646
+ const feet = pageRuns.map((runs, i) => edgeLine(runs.filter((r) => !tooSmallToRead(r, textSize)), shown[i]?.height ?? 0, where));
1174
1647
  const found = feet.filter((f) => f !== void 0);
1175
1648
  if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
1176
1649
  const ys = found.map((f) => f.y);
@@ -1201,18 +1674,30 @@ var FOOT_DRIFT = 4;
1201
1674
  * its own, and a two-page document came out as four.
1202
1675
  */
1203
1676
  function edgeLine(runs, pageHeight, where) {
1204
- if (runs.length < 4 || pageHeight <= 0) return void 0;
1677
+ const head = where === "head";
1678
+ if (runs.length < (head ? HEAD_RUNS : 1) || pageHeight <= 0) return void 0;
1205
1679
  const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1206
1680
  const rows = rowsOf(runs, fontSize);
1207
- if (rows.length < 4) return void 0;
1681
+ if (rows.length === 0) return void 0;
1208
1682
  const at = (i) => where === "foot" ? rows[rows.length - 1 - i] : rows[i];
1209
1683
  const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
1210
1684
  const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
1211
1685
  const y = edgeY(at(0));
1686
+ const inked = rows.filter((row) => row.some((r) => r.text.trim() !== ""));
1687
+ const inBand = inked.length > 0 && inked.every((row) => where === "foot" ? edgeY(row) <= pageHeight * FOOT_BAND : edgeY(row) >= pageHeight * (1 - FOOT_BAND));
1688
+ if (rows.length < 4 && (inBand || head)) {
1689
+ const all = inked.flat();
1690
+ const text = all.map((r) => r.text).join("").trim();
1691
+ return inBand && text.length > 0 && text.length <= FOOT_CHARS * inked.length ? {
1692
+ y: edgeY(inked[inked.length - 1] ?? []),
1693
+ runs: all
1694
+ } : void 0;
1695
+ }
1212
1696
  let found;
1213
1697
  let group = [];
1214
- const most = where === "foot" ? FOOT_ROWS : 1;
1215
- for (let i = 0; i < Math.min(most, rows.length - 2); i++) {
1698
+ const most = head ? 1 : FOOT_ROWS;
1699
+ const above = head ? 2 : 1;
1700
+ for (let i = 0; i < Math.min(most, rows.length - above); i++) {
1216
1701
  const row = at(i);
1217
1702
  const edge = edgeY(row);
1218
1703
  if (where === "foot" ? edge > pageHeight * FOOT_BAND : edge < pageHeight * (1 - FOOT_BAND)) break;
@@ -1231,6 +1716,8 @@ function edgeLine(runs, pageHeight, where) {
1231
1716
  var FOOT_GAP_EM = 2;
1232
1717
  /** How many lines a running foot may hold before it is a paragraph. */
1233
1718
  var FOOT_ROWS = 3;
1719
+ /** How many runs a page must hold before its first line is asked whether it is a running head. */
1720
+ var HEAD_RUNS = 4;
1234
1721
  /** How far up the sheet a running foot may sit. */
1235
1722
  var FOOT_BAND = .12;
1236
1723
  /** A running foot is a line, not a paragraph. */
@@ -1252,6 +1739,25 @@ function pageNumbered(run) {
1252
1739
  const number = found[2];
1253
1740
  const before = run.text.slice(0, at);
1254
1741
  const after = run.text.slice(at + number.length);
1742
+ const total = /(^|\s)(\d{1,4})(\s|$)/u.exec(after);
1743
+ const tail = total === null ? after === "" ? [] : [{
1744
+ ...run,
1745
+ text: after
1746
+ }] : [
1747
+ ...after.slice(0, total.index + total[1].length) === "" ? [] : [{
1748
+ ...run,
1749
+ text: after.slice(0, total.index + total[1].length)
1750
+ }],
1751
+ {
1752
+ ...run,
1753
+ text: total[2],
1754
+ field: "NUMPAGES"
1755
+ },
1756
+ ...after.slice(total.index + total[1].length + total[2].length) === "" ? [] : [{
1757
+ ...run,
1758
+ text: after.slice(total.index + total[1].length + total[2].length)
1759
+ }]
1760
+ ];
1255
1761
  return [
1256
1762
  ...before === "" ? [] : [{
1257
1763
  ...run,
@@ -1262,10 +1768,7 @@ function pageNumbered(run) {
1262
1768
  text: number,
1263
1769
  field: "PAGE"
1264
1770
  },
1265
- ...after === "" ? [] : [{
1266
- ...run,
1267
- text: after
1268
- }]
1771
+ ...tail
1269
1772
  ];
1270
1773
  }
1271
1774
  /**
@@ -1328,9 +1831,11 @@ function bandLine(row, fontSize, stepped, measure) {
1328
1831
  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
1832
  if (lines.length === 0) return void 0;
1330
1833
  if (lines.length === 1 || lines.length > BAND_REGIONS || !measure) {
1331
- const { alignment } = alignmentOf(lines.slice(0, 1), measure);
1834
+ const only = lines.length === 1 ? lines[0] : void 0;
1835
+ const width = measure ? measure.right - measure.left : 0;
1836
+ const alignment = only !== void 0 && measure !== void 0 && measure.right - (only.x + only.width) <= width * BAND_RIGHT_SHARE && only.x - measure.left >= width * BAND_RIGHT_SHARE ? "right" : alignmentOf(lines.slice(0, 1), measure).alignment;
1332
1837
  return {
1333
- spans: lines.flatMap((line, i) => i === 0 ? line.spans : [{ text: " " }, ...line.spans]),
1838
+ spans: lines.flatMap((line, i) => i === 0 ? line.spans : [spaceAfter(lines[i - 1].spans.at(-1)), ...line.spans]),
1334
1839
  properties: alignment ? { alignment } : {}
1335
1840
  };
1336
1841
  }
@@ -1404,6 +1909,420 @@ function looksRuled(runs, gutters, edges) {
1404
1909
  return (fills[Math.floor(fills.length / 2)] ?? 1) <= TABLE_CELL_FILL;
1405
1910
  }
1406
1911
  /**
1912
+ * Whether the regions the gutters cut the page into are COLUMNS OF PROSE — the
1913
+ * only thing worth re-setting a document in columns for.
1914
+ *
1915
+ * A gutter is evidence about the page's white space, and white space is not
1916
+ * enough: an invoice sets its labels along the left and its amounts against the
1917
+ * right margin, and a dozen lines then break at the same x. Read as two columns
1918
+ * the amounts became a column of their own and every label was indented past
1919
+ * the strip it was given — "Total excluding tax" came back one letter per line.
1920
+ *
1921
+ * What tells them apart is the same thing that tells a cell from a line of
1922
+ * prose: a column of prose FILLS its measure, over and over, because that is
1923
+ * what wrapping does. A column of values never fills anything — its lines are
1924
+ * short and it is their right edges that line up, not their left. So every
1925
+ * region must hold lines of its own and they must reach across it.
1926
+ *
1927
+ * @param runs The page's runs.
1928
+ * @param gutters The page's gutters.
1929
+ * @param edges Where the page's text starts and ends.
1930
+ * @returns Whether the page is set in columns of prose.
1931
+ */
1932
+ function proseColumns(runs, gutters, edges) {
1933
+ if (gutters.length === 0) return false;
1934
+ if (!edges) return true;
1935
+ const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
1936
+ if (rows.length === 0) return true;
1937
+ const bounds = columnBounds(runs, gutters, edges);
1938
+ const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
1939
+ const regionOf = (x) => {
1940
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1941
+ return 0;
1942
+ };
1943
+ const edgesIn = regions.map(() => []);
1944
+ for (const row of rows) {
1945
+ const byRegion = regions.map(() => []);
1946
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1947
+ byRegion.forEach((inRegion, i) => {
1948
+ if (inRegion.length === 0) return;
1949
+ edgesIn[i].push({
1950
+ left: Math.min(...inRegion.map((r) => r.x)),
1951
+ right: Math.max(...inRegion.map((r) => r.endX))
1952
+ });
1953
+ });
1954
+ }
1955
+ const agreeing = (xs) => {
1956
+ let most = 0;
1957
+ for (const x of xs) most = Math.max(most, xs.filter((y) => Math.abs(y - x) <= FLUSH_PT).length);
1958
+ return most;
1959
+ };
1960
+ return edgesIn.every((lines) => {
1961
+ if (lines.length < MIN_PROSE_LINES) return false;
1962
+ return agreeing(lines.map((l) => l.right)) <= agreeing(lines.map((l) => l.left));
1963
+ });
1964
+ }
1965
+ /**
1966
+ * The drawn glyphs that are STRAY marks in a line of ordinary text.
1967
+ *
1968
+ * §9.6.6 — a glyph the file states no character for is drawn, because nothing
1969
+ * downstream can write it. That is the whole content of some pages: a subset
1970
+ * that names its glyphs `g18`, a program with no `cmap`, a font of pictures.
1971
+ * On such a page the drawings ARE the words and they stay.
1972
+ *
1973
+ * A single mark among readable words is the other case, and Stripe's invoices
1974
+ * are full of them — a `/ToUnicode` stating U+0000 for every piece of
1975
+ * punctuation it sets. The flowing reading has nowhere to put a shape inside a
1976
+ * line, so the colon of "Kazakhstan VAT: 86-1696045" was placed as a floating
1977
+ * drawing and landed a word away, on a line of its own, pushing the text
1978
+ * around it aside. Dropped, the line keeps its words and the loss report still
1979
+ * says a character was unrecoverable.
1980
+ *
1981
+ * The line decides: where nearly everything on it is drawn, the drawings are
1982
+ * the text — bug1151216.pdf sets three lines of prices that way. Where one or
1983
+ * two marks stand among several readable runs, they are punctuation.
1984
+ *
1985
+ * @param vectors The page's painted paths.
1986
+ * @param runs The page's runs.
1987
+ * @returns The drawn glyphs to leave out of a flowing reading.
1988
+ */
1989
+ function strayMarks(vectors, runs) {
1990
+ const glyphs = vectors.filter((v) => v.glyph === true);
1991
+ const out = /* @__PURE__ */ new Set();
1992
+ if (glyphs.length === 0) return out;
1993
+ const readable = runs.filter((r) => r.text.trim() !== "" && !r.text.includes("�"));
1994
+ for (const v of glyphs) {
1995
+ const mid = (v.minY + v.maxY) / 2;
1996
+ const words = readable.filter((r) => Math.abs(r.y - mid) <= (r.fontSizePt || 10));
1997
+ const size = words.length > 0 ? median(words.map((r) => r.fontSizePt || 10)) : v.maxY - v.minY || 10;
1998
+ const before = words.some((r) => r.endX <= v.minX + size);
1999
+ const after = words.some((r) => r.x >= v.maxX - size);
2000
+ if (!before && !after) continue;
2001
+ if (glyphs.filter((g) => Math.abs((g.minY + g.maxY) / 2 - mid) <= size).length <= MOST_STRAY_MARKS && words.length >= LEAST_READABLE_RUNS) out.add(v);
2002
+ }
2003
+ return out;
2004
+ }
2005
+ /**
2006
+ * §9.10.2 — the runs of a line with each glyph the file names no character for
2007
+ * read off the shape it draws (see {@link punctuationOf}), where it is one of
2008
+ * the stray marks inside a readable line.
2009
+ *
2010
+ * @param runs The page's runs.
2011
+ * @param strays The drawn glyphs that stand inside lines of readable text.
2012
+ * @returns The runs, with every mark that could be read written as text.
2013
+ */
2014
+ function readDrawnMarks(runs, strays) {
2015
+ if (strays.size === 0) return [...runs];
2016
+ const marks = [...strays];
2017
+ return runs.map((run) => {
2018
+ if (!run.text.includes("�") || run.text.replaceAll("�", "").trim() !== "") return run;
2019
+ const size = run.fontSizePt || 10;
2020
+ const mark = marks.find((v) => {
2021
+ const x = (v.minX + v.maxX) / 2;
2022
+ return x >= run.x - .5 && x <= run.endX + .5 && Math.abs((v.minY + v.maxY) / 2 - run.y) <= size;
2023
+ });
2024
+ const read = mark ? punctuationOf(mark, run.y, size) : void 0;
2025
+ return read === void 0 ? run : {
2026
+ ...run,
2027
+ text: run.text.replaceAll("�", read)
2028
+ };
2029
+ });
2030
+ }
2031
+ /**
2032
+ * §9.6.6 — the glyphs a page draws for want of characters, gathered into the
2033
+ * WORDS they stand in: a traced glyph that stands against the one before it,
2034
+ * on its line and in its colour, is drawn as one shape with it.
2035
+ *
2036
+ * Every mark kept apart is a shape of its own in the document, anchored to
2037
+ * the page — and a paper set in a subset whose glyphs name nothing is nothing
2038
+ * but such marks: TAMReview.pdf came back as forty-two thousand shapes, a
2039
+ * package no reader would open in under three minutes. The picture is the
2040
+ * same either way.
2041
+ *
2042
+ * @param vectors The page's painted paths, in painting order.
2043
+ * @returns The same paths, each run of traced glyphs made one.
2044
+ */
2045
+ function drawnWords(vectors) {
2046
+ const out = [];
2047
+ for (const v of vectors) {
2048
+ const last = out[out.length - 1];
2049
+ const size = v.maxY - v.minY;
2050
+ if (v.glyph === true && last?.glyph === true && last.fillHex === v.fillHex && last.strokeHex === v.strokeHex && last.alpha === v.alpha && Math.min(last.maxY, v.maxY) - Math.max(last.minY, v.minY) > Math.min(size, last.maxY - last.minY) * .3 && v.minX >= last.minX && v.minX - last.maxX <= Math.max(size, last.maxY - last.minY) * WORD_GAP_EM) {
2051
+ out[out.length - 1] = {
2052
+ ...last,
2053
+ segs: [...last.segs, ...v.segs],
2054
+ minX: Math.min(last.minX, v.minX),
2055
+ minY: Math.min(last.minY, v.minY),
2056
+ maxX: Math.max(last.maxX, v.maxX),
2057
+ maxY: Math.max(last.maxY, v.maxY)
2058
+ };
2059
+ continue;
2060
+ }
2061
+ out.push(v);
2062
+ }
2063
+ return out;
2064
+ }
2065
+ /** How far apart, in heights of the taller glyph, two drawn glyphs may stand and be one word. */
2066
+ var WORD_GAP_EM = .6;
2067
+ /** How few drawn glyphs a line may hold before they are its text, not marks in it. */
2068
+ var MOST_STRAY_MARKS = 2;
2069
+ /** …and how many readable runs must stand on that line for them to be strays. */
2070
+ var LEAST_READABLE_RUNS = 4;
2071
+ /**
2072
+ * §17.4.38 — the consecutive lines a page set out on the SAME stops, as the
2073
+ * TABLE they are.
2074
+ *
2075
+ * A line broken at a gap no word space explains is a line the page laid out
2076
+ * (see {@link tabbed}); several of them one under another, broken in the same
2077
+ * places, is a table's head and its rows. An invoice's item table is exactly
2078
+ * that, and so is the payment history under it.
2079
+ *
2080
+ * Two stops at least, which is three columns: ONE stop is a contents entry with
2081
+ * its page number at the measure, and a list of those is a list.
2082
+ *
2083
+ * The stops do not have to agree to the point — a heading sits over its column
2084
+ * and a figure is set against the far side of it — so they are matched within
2085
+ * an em, and the column is put where the rows agree it is.
2086
+ *
2087
+ * @param paras The column's paragraphs, in order.
2088
+ * @param measure The measure they are set across.
2089
+ * @returns For each paragraph that belongs to a table: the table at its first
2090
+ * row, and `null` at the rows after it (their content is inside it).
2091
+ */
2092
+ function tabbedRows(paras, measure) {
2093
+ const out = /* @__PURE__ */ new Map();
2094
+ if (!measure) return out;
2095
+ for (let i = 0; i < paras.length;) {
2096
+ const stops = paras[i]?.stops ?? [];
2097
+ if (stops.length < LEAST_TABLE_STOPS) {
2098
+ i++;
2099
+ continue;
2100
+ }
2101
+ const near = (a, b) => a.length === b.length && a.every((x, k) => Math.abs(x - b[k]) <= Math.max(paras[i].fontSize, TABLE_STOP_SLACK_PT));
2102
+ let to = i + 1;
2103
+ while (to < paras.length && near(stops, paras[to]?.stops ?? [])) to++;
2104
+ if (to - i < 2) {
2105
+ i++;
2106
+ continue;
2107
+ }
2108
+ const rows = paras.slice(i, to);
2109
+ const bounds = stops.map((_, k) => Math.min(...rows.map((r) => r.stops[k])));
2110
+ const width = measure.right - measure.left;
2111
+ const inks = [0, ...bounds].map((_, k) => {
2112
+ const cells = rows.map((r) => r.pieces?.[k]);
2113
+ if (cells.some((c) => c === void 0 || !Number.isFinite(c.from))) return void 0;
2114
+ return {
2115
+ from: Math.min(...cells.map((c) => c.from)),
2116
+ to: Math.max(...cells.map((c) => c.to))
2117
+ };
2118
+ });
2119
+ const flush = inks.map((ink, k) => {
2120
+ if (k === 0 || ink === void 0) return false;
2121
+ const tos = rows.map((r) => r.pieces[k].to);
2122
+ const froms = rows.map((r) => r.pieces[k].from);
2123
+ const even = Math.max(...tos) - Math.min(...tos);
2124
+ return even <= FLUSH_SLACK_PT && Math.max(...froms) - Math.min(...froms) > even + FLUSH_SLACK_PT;
2125
+ });
2126
+ const edges = [
2127
+ 0,
2128
+ ...bounds,
2129
+ width
2130
+ ];
2131
+ for (let k = 1; k < bounds.length + 1; k++) {
2132
+ const before = inks[k - 1];
2133
+ const own = inks[k];
2134
+ if (!flush[k] || before === void 0 || own === void 0) continue;
2135
+ const mid = (before.to + own.from) / 2;
2136
+ if (mid > edges[k - 1] && mid < own.from) edges[k] = mid;
2137
+ }
2138
+ const flushRight = flush.map((right, k) => right ? Math.max(0, edges[k + 1] - inks[k].to) : void 0);
2139
+ const grid = edges.slice(0, -1).map((from, k) => pt(Math.max(edges[k + 1] - from, 1)));
2140
+ out.set(i, {
2141
+ kind: "table",
2142
+ table: {
2143
+ properties: {
2144
+ defaultCellMargins: {
2145
+ left: pt(0),
2146
+ right: pt(0)
2147
+ },
2148
+ layout: "fixed",
2149
+ widthType: "dxa",
2150
+ widthPt: pt(grid.reduce((sum, w) => sum + w, 0))
2151
+ },
2152
+ grid,
2153
+ rows: rows.map((row, r) => {
2154
+ const prev = rows[r - 1];
2155
+ const pitch = prev ? prev.top - row.top - (1 - BASELINE_AT) * prev.lineHeight - BASELINE_AT * row.lineHeight : 0;
2156
+ const opening = r === 0 ? row.spacingBefore : pitch > 0 ? pitch : void 0;
2157
+ return {
2158
+ properties: {},
2159
+ cells: splitAtTabs(row.spans).map((cell, k) => {
2160
+ const inset = flushRight[k];
2161
+ const own = grid[k];
2162
+ return {
2163
+ properties: own !== void 0 ? { width: own } : {},
2164
+ content: [paragraphFromRuns(cell, void 0, {
2165
+ ...opening !== void 0 ? { spacingBefore: pt(opening) } : {},
2166
+ spacingLine: pt(row.lineHeight),
2167
+ spacingLineRule: "exact",
2168
+ ...inset !== void 0 ? {
2169
+ alignment: "right",
2170
+ indentRight: pt(inset)
2171
+ } : {}
2172
+ })]
2173
+ };
2174
+ })
2175
+ };
2176
+ })
2177
+ }
2178
+ });
2179
+ for (let k = i + 1; k < to; k++) out.set(k, null);
2180
+ i = to;
2181
+ }
2182
+ return out;
2183
+ }
2184
+ /** How far apart the ends of a column's cells may stand and still be flush. */
2185
+ var FLUSH_SLACK_PT = 2;
2186
+ /** How many stops a line must stand on before a run of them is a table. */
2187
+ var LEAST_TABLE_STOPS = 2;
2188
+ /**
2189
+ * How far two rows' stops may stand apart and still be one column.
2190
+ *
2191
+ * A heading is set over its column and a figure against the far side of it: an
2192
+ * invoice's "Qty" stands eight points left of the "1" under it, and the heading
2193
+ * is set two sizes smaller, so its own em is not a wide enough tolerance.
2194
+ */
2195
+ var TABLE_STOP_SLACK_PT = 10;
2196
+ /** One line's spans cut into its cells, at the tabs the page set out. */
2197
+ function splitAtTabs(spans) {
2198
+ const cells = [[]];
2199
+ for (const span of spans) if (span.text === " ") cells.push([]);
2200
+ else cells[cells.length - 1].push(span);
2201
+ return cells;
2202
+ }
2203
+ /**
2204
+ * Give each separator rule to the paragraph it separates, as that paragraph's
2205
+ * own border (§17.3.1.24), and say which rules were given away.
2206
+ *
2207
+ * A rule sits in the white between two blocks: under a table's headings, over a
2208
+ * total. The block BELOW it takes it as a top border, because that is the block
2209
+ * the rule introduces; where nothing follows closely enough, the block above
2210
+ * takes it as a bottom one.
2211
+ *
2212
+ * @param vectors The page's painted paths.
2213
+ * @param blocks The blocks read off the page so far, which the rule joins.
2214
+ * @param width The page's width, which a rule is long relative to.
2215
+ * @returns The rules that became borders, and so must not be drawn again.
2216
+ */
2217
+ function ruleBorders(vectors, blocks, width) {
2218
+ const given = /* @__PURE__ */ new Set();
2219
+ const paragraphs = blocks.filter((b) => b.el.kind === "paragraph");
2220
+ if (paragraphs.length === 0) return given;
2221
+ for (const band of collinear(vectors.filter((v) => flatBar(v)))) {
2222
+ const v = band.pieces[0];
2223
+ if (band.to - band.from < width * RULE_SHARE) continue;
2224
+ const y = (v.minY + v.maxY) / 2;
2225
+ const below = paragraphs.filter((b) => b.top < y).sort((a, b) => b.top - a.top)[0];
2226
+ const above = paragraphs.filter((b) => b.top >= y).sort((a, b) => a.top - b.top)[0];
2227
+ const side = below !== void 0 && y - below.top <= RULE_REACH_PT ? {
2228
+ block: below,
2229
+ edge: "top"
2230
+ } : above !== void 0 && above.top - y <= RULE_REACH_PT ? {
2231
+ block: above,
2232
+ edge: "bottom"
2233
+ } : void 0;
2234
+ if (!side || side.block.el.kind !== "paragraph") continue;
2235
+ const border = {
2236
+ style: "single",
2237
+ width: pt(Math.max(v.lineWidth ?? v.maxY - v.minY, RULE_MIN_PT)),
2238
+ colorHex: v.strokeHex ?? v.fillHex ?? "000000"
2239
+ };
2240
+ const { paragraph } = side.block.el;
2241
+ const before = paragraph.properties.spacingBefore;
2242
+ const room = side.edge === "top" && before !== void 0 ? { spacingBefore: pt(Math.max(0, before - border.width)) } : {};
2243
+ side.block.el = {
2244
+ kind: "paragraph",
2245
+ paragraph: {
2246
+ ...paragraph,
2247
+ properties: {
2248
+ ...paragraph.properties,
2249
+ ...room,
2250
+ borders: {
2251
+ ...paragraph.properties.borders,
2252
+ [side.edge]: border,
2253
+ ...side.edge === "top" ? { insideH: border } : {}
2254
+ }
2255
+ }
2256
+ }
2257
+ };
2258
+ for (const piece of band.pieces) given.add(piece);
2259
+ }
2260
+ return given;
2261
+ }
2262
+ /** How far from a paragraph a rule may stand and still belong to it. */
2263
+ var RULE_REACH_PT = 14;
2264
+ /** The thinnest a border may be drawn and still be seen. */
2265
+ var RULE_MIN_PT = .5;
2266
+ /**
2267
+ * Whether a painted path is flat and thin enough to be a piece of a RULE — a
2268
+ * line drawn to separate one block from the next, rather than a piece of the
2269
+ * page's artwork. How LONG the rule is is a question for the whole of it (see
2270
+ * {@link collinear}): a hairline the width of a column under a table's
2271
+ * headings, or the line a total is written over. Everything else — boxes,
2272
+ * panels, drawings — keeps the place on the page it was drawn at.
2273
+ *
2274
+ * @param v The painted path.
2275
+ * @returns Whether it could be a rule, or a piece of one.
2276
+ */
2277
+ function flatBar(v) {
2278
+ const w = v.maxX - v.minX;
2279
+ const h = v.maxY - v.minY;
2280
+ return h <= RULE_THICK_PT && w > h * RULE_RATIO;
2281
+ }
2282
+ /**
2283
+ * The flat bars of a page gathered into the rules they draw: pieces on one
2284
+ * baseline, end to end, are one rule from the first's left edge to the last's
2285
+ * right.
2286
+ *
2287
+ * @param bars The page's flat bars.
2288
+ * @returns One band per rule, with the pieces it was drawn in.
2289
+ */
2290
+ function collinear(bars) {
2291
+ const out = [];
2292
+ for (const bar of [...bars].sort((a, b) => b.maxY - a.maxY || a.minX - b.minX)) {
2293
+ const y = (bar.minY + bar.maxY) / 2;
2294
+ const band = out.find((b) => Math.abs((b.pieces[0].minY + b.pieces[0].maxY) / 2 - y) <= RULE_SAME_LINE_PT && bar.minX <= b.to + RULE_JOIN_PT && (bar.strokeHex ?? bar.fillHex) === (b.pieces[0].strokeHex ?? b.pieces[0].fillHex));
2295
+ if (band) {
2296
+ band.from = Math.min(band.from, bar.minX);
2297
+ band.to = Math.max(band.to, bar.maxX);
2298
+ band.pieces.push(bar);
2299
+ } else out.push({
2300
+ from: bar.minX,
2301
+ to: bar.maxX,
2302
+ pieces: [bar]
2303
+ });
2304
+ }
2305
+ return out;
2306
+ }
2307
+ /** How far apart two bars' baselines may be and still be one rule. */
2308
+ var RULE_SAME_LINE_PT = 1.5;
2309
+ /**
2310
+ * …and how wide a gap between two pieces of it the page may leave: an invoice
2311
+ * draws the rule under its item row cell by cell and skips the thirteen points
2312
+ * of gutter between two of them.
2313
+ */
2314
+ var RULE_JOIN_PT = 18;
2315
+ /** How thick a mark may be and still be a rule. */
2316
+ var RULE_THICK_PT = 2;
2317
+ /** How much of the sheet it must run across. */
2318
+ var RULE_SHARE = .2;
2319
+ /** …and how much longer than it is thick. */
2320
+ var RULE_RATIO = 20;
2321
+ /** How close two edges stand before they count as the same one. */
2322
+ var FLUSH_PT = 1;
2323
+ /** And how many lines it takes before a region is a column at all. */
2324
+ var MIN_PROSE_LINES = 6;
2325
+ /**
1407
2326
  * §17.4.38 — the page's rows as the TABLE they are.
1408
2327
  *
1409
2328
  * The regions between the gutters are the columns and the rows are the rows;