reamkit 1.29.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 (50) hide show
  1. package/dist/esm/core/document-model/types.d.ts +2 -0
  2. package/dist/esm/core/drawingml/shape-render.js +13 -1
  3. package/dist/esm/core/fonts/index.d.ts +1 -1
  4. package/dist/esm/core/fonts/remote-fonts.d.ts +8 -0
  5. package/dist/esm/core/fonts/remote-fonts.js +100 -17
  6. package/dist/esm/core/ir/flow.d.ts +17 -0
  7. package/dist/esm/index.d.ts +1 -1
  8. package/dist/esm/pdf-reader/annot-draw.js +93 -1
  9. package/dist/esm/pdf-reader/annots.d.ts +18 -0
  10. package/dist/esm/pdf-reader/annots.js +86 -9
  11. package/dist/esm/pdf-reader/cmap.js +5 -2
  12. package/dist/esm/pdf-reader/content.d.ts +17 -4
  13. package/dist/esm/pdf-reader/content.js +163 -11
  14. package/dist/esm/pdf-reader/display.js +16 -0
  15. package/dist/esm/pdf-reader/embedded-fonts.d.ts +24 -0
  16. package/dist/esm/pdf-reader/embedded-fonts.js +48 -9
  17. package/dist/esm/pdf-reader/flow-build.d.ts +96 -5
  18. package/dist/esm/pdf-reader/flow-build.js +210 -23
  19. package/dist/esm/pdf-reader/font.d.ts +27 -1
  20. package/dist/esm/pdf-reader/font.js +313 -29
  21. package/dist/esm/pdf-reader/glyf-outline.js +13 -2
  22. package/dist/esm/pdf-reader/glyph-shapes.d.ts +18 -0
  23. package/dist/esm/pdf-reader/glyph-shapes.js +57 -0
  24. package/dist/esm/pdf-reader/image-decode.js +70 -4
  25. package/dist/esm/pdf-reader/images.d.ts +5 -0
  26. package/dist/esm/pdf-reader/images.js +4 -2
  27. package/dist/esm/pdf-reader/jbig2.d.ts +40 -1
  28. package/dist/esm/pdf-reader/jbig2.js +78 -16
  29. package/dist/esm/pdf-reader/jpeg.d.ts +6 -3
  30. package/dist/esm/pdf-reader/jpeg.js +21 -1
  31. package/dist/esm/pdf-reader/layout.d.ts +26 -2
  32. package/dist/esm/pdf-reader/layout.js +638 -92
  33. package/dist/esm/pdf-reader/lexer.d.ts +10 -0
  34. package/dist/esm/pdf-reader/lexer.js +17 -0
  35. package/dist/esm/pdf-reader/pattern-tint.d.ts +11 -1
  36. package/dist/esm/pdf-reader/pattern-tint.js +21 -3
  37. package/dist/esm/pdf-reader/regions.d.ts +25 -0
  38. package/dist/esm/pdf-reader/regions.js +167 -0
  39. package/dist/esm/pdf-reader/shading.d.ts +58 -2
  40. package/dist/esm/pdf-reader/shading.js +181 -11
  41. package/dist/esm/pdf-reader/struct-tree.js +112 -8
  42. package/dist/esm/pdf-reader/tagged.js +27 -7
  43. package/dist/esm/pdf-reader/text-rules.js +1 -1
  44. package/dist/esm/pdf-reader/text.js +9 -14
  45. package/dist/esm/pdf-reader/vector.d.ts +8 -2
  46. package/dist/esm/pdf-reader/vector.js +34 -5
  47. package/dist/esm/word/docx-writer.js +137 -36
  48. package/dist/esm/word/drawing-parser.js +7 -2
  49. package/dist/esm/word/paragraph-properties.js +2 -0
  50. 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,10 @@ 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;
39
45
  const pageMarks = pages.map(() => []);
40
46
  const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
41
47
  const resources = new ResourceStore();
@@ -62,6 +68,7 @@ function reconstructByLayout(file, mode = "flow") {
62
68
  const sectionEnds = [];
63
69
  let sectionFrom = 0;
64
70
  let lastSize = "";
71
+ const leads = [];
65
72
  let curColumns = 1;
66
73
  let curSpace = 0;
67
74
  let pendingContinuous = false;
@@ -80,11 +87,11 @@ function reconstructByLayout(file, mode = "flow") {
80
87
  const addColumn = (allRuns, col) => {
81
88
  const colRuns = allRuns.filter((r) => r.type3 !== true && (mode !== "positional" || r.invisible !== true));
82
89
  if (mode === "positional") {
83
- 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)) {
84
91
  if (line.text.length === 0) continue;
85
92
  const box = turnedBox(line, angle, pageWidth);
86
93
  placed.push({
87
- key: [Number.MAX_SAFE_INTEGER, placed.length],
94
+ key: placedKey(runs, placed.length),
88
95
  col,
89
96
  top: box.y + box.height,
90
97
  make: (z) => positionedText(line.spans, box, frame, z, rotation60kOf(angle))
@@ -95,7 +102,8 @@ function reconstructByLayout(file, mode = "flow") {
95
102
  const columnSize = median(colRuns.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
96
103
  const maths = matrixBlocks(colRuns, columnSize);
97
104
  const inMath = new Set(maths.flatMap((m) => [...m.used]));
98
- const lines = groupIntoLines(inMath.size > 0 ? colRuns.filter((r) => !inMath.has(r)) : colRuns, false, stepped).filter((l) => l.text.length > 0);
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);
99
107
  const measure = measureOf(col, gutters, textEdges) ?? measureOfLines(lines);
100
108
  for (const found of maths) {
101
109
  const { alignment } = alignmentOf([{
@@ -123,38 +131,153 @@ function reconstructByLayout(file, mode = "flow") {
123
131
  }
124
132
  });
125
133
  }
126
- const paras = groupIntoParagraphs(lines, measure, display.height);
127
- const asRows = tabbedRows(paras, measure && {
128
- left: measure.left,
129
- right: Math.max(measure.right, textEdges?.right ?? measure.right)
130
- });
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 = [];
131
176
  for (const [at, para] of paras.entries()) {
132
177
  const table = asRows.get(at);
133
178
  if (table !== void 0) {
134
- if (table !== null) blocks.push({
135
- band: bandAt(para.top),
136
- col,
179
+ if (table !== null) out.push({
137
180
  top: para.top,
138
181
  el: table
139
182
  });
140
183
  continue;
141
184
  }
142
- blocks.push({
143
- band: bandAt(para.top),
144
- col,
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
+ })) } : {},
191
+ ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
192
+ ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
193
+ spacingLine: pt(para.lineHeight),
194
+ spacingLineRule: "exact",
195
+ ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
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({
145
201
  top: para.top,
146
- el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
147
- ...para.stops !== void 0 && para.stops.length > 0 ? { tabs: para.stops.filter((x) => x > 0).map((x) => ({
148
- positionPt: pt(x),
149
- alignment: "left"
150
- })) } : {},
151
- ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
152
- ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
153
- ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
154
- ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
155
- })
202
+ el
156
203
  });
157
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
+ };
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
+ };
158
281
  };
159
282
  const placed = [];
160
283
  const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
@@ -162,6 +285,16 @@ function reconstructByLayout(file, mode = "flow") {
162
285
  left: 0,
163
286
  top: display.height
164
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
+ }
165
298
  const raw = collectPageImages(file, page);
166
299
  const imgs = {
167
300
  images: placeImages(raw.images, display),
@@ -177,9 +310,14 @@ function reconstructByLayout(file, mode = "flow") {
177
310
  })));
178
311
  losses.push(...lifted.losses);
179
312
  const placedVectors = placeVectors(lifted.vectors, display);
180
- const ruled = markDrawnRules(runs, placedVectors);
313
+ const drawnRules = markDrawnRules(runs, placedVectors);
181
314
  const strayGlyphs = strayMarks(placedVectors, runs);
315
+ const ruled = mode !== "positional" ? {
316
+ ...drawnRules,
317
+ runs: readDrawnMarks(drawnRules.runs, strayGlyphs)
318
+ } : drawnRules;
182
319
  const vectors = placedVectors.filter((v) => !ruled.consumed.has(v)).filter((v) => !(mode !== "positional" && strayGlyphs.has(v)));
320
+ const drawn = drawnWords(vectors);
183
321
  const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
184
322
  const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
185
323
  if (asTable) for (const block of asTable) blocks.push({
@@ -194,6 +332,7 @@ function reconstructByLayout(file, mode = "flow") {
194
332
  for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
195
333
  } else addColumn(ruled.runs, 0);
196
334
  const under = mode !== "positional";
335
+ setOff(blocks, far, (line, k) => positionedText(line.spans, turnedBox(line, 0, pageWidth), frame, FAR_Z + k));
197
336
  const givenAway = mode !== "positional" ? ruleBorders(vectors, blocks, display.width) : void 0;
198
337
  [
199
338
  ...imgs.images.map((img) => ({
@@ -202,7 +341,7 @@ function reconstructByLayout(file, mode = "flow") {
202
341
  top: img.y + img.heightPt,
203
342
  make: (z) => imageBlock(img, resources, void 0, frame, z, under)
204
343
  })),
205
- ...vectors.filter((v) => givenAway?.has(v) !== true).map((v) => ({
344
+ ...drawn.filter((v) => givenAway?.has(v) !== true).map((v) => ({
206
345
  key: v.orderKey,
207
346
  col: colOf((v.minX + v.maxX) / 2),
208
347
  top: v.maxY,
@@ -233,12 +372,22 @@ function reconstructByLayout(file, mode = "flow") {
233
372
  pendingContinuous = false;
234
373
  }
235
374
  lastSize = size;
236
- if (i > 0 && !opensSection && blocks.length > 0) body.push({
375
+ if (i > 0 && !opensSection) body.push({
237
376
  kind: "paragraph",
238
377
  paragraph: {
239
378
  properties: {
240
379
  pageBreakBefore: true,
241
- spacingLine: pt(0),
380
+ spacingLine: CARRIER_LINE_PT,
381
+ spacingLineRule: "exact"
382
+ },
383
+ runs: []
384
+ }
385
+ });
386
+ else if (i === 0 && blocks.length === 0) body.push({
387
+ kind: "paragraph",
388
+ paragraph: {
389
+ properties: {
390
+ spacingLine: CARRIER_LINE_PT,
242
391
  spacingLineRule: "exact"
243
392
  },
244
393
  runs: []
@@ -246,7 +395,18 @@ function reconstructByLayout(file, mode = "flow") {
246
395
  });
247
396
  const columnsHere = ruledIntoColumns || !proseColumns(runs, gutters, textEdges) ? 1 : gutters.length + 1;
248
397
  const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
398
+ let led = false;
249
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
+ }
250
410
  const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
251
411
  if (count !== curColumns) {
252
412
  sectionEnds.push({
@@ -267,7 +427,7 @@ function reconstructByLayout(file, mode = "flow") {
267
427
  });
268
428
  const setUp = (from, to) => {
269
429
  const own = sectionFromPdfPages(pages.slice(from, to));
270
- return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to));
430
+ return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to), foot?.band);
271
431
  };
272
432
  sectionEnds.push({
273
433
  at: body.length,
@@ -292,6 +452,25 @@ function reconstructByLayout(file, mode = "flow") {
292
452
  endIndex: end.at
293
453
  }];
294
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
+ }
295
474
  const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
296
475
  const edges0 = pageTextEdges(allRuns[0] ?? []);
297
476
  const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
@@ -311,7 +490,7 @@ function reconstructByLayout(file, mode = "flow") {
311
490
  doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
312
491
  ...s,
313
492
  properties: withFooter(s.properties) ?? s.properties
314
- })), band.length > 0 || headBand.length > 0 ? new Map([...band.length > 0 ? [[FOOTER_PART, band]] : [], ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []]) : void 0),
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)),
315
494
  losses: dedupeLosses(losses)
316
495
  };
317
496
  }
@@ -691,6 +870,32 @@ function bandOf(breaks, top, epsilon) {
691
870
  * does this — a flowing paragraph is meant to be read across.
692
871
  */
693
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
+ }
694
899
  /** Runs by the direction of their baseline, upright first, each angle rounded. */
695
900
  function byAngle(runs) {
696
901
  const groups = /* @__PURE__ */ new Map();
@@ -777,10 +982,12 @@ function groupIntoLines(runs, split = false, stepped = false) {
777
982
  runs: [run]
778
983
  });
779
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));
780
987
  return clusters.flatMap((c) => {
781
- const ordered = c.runs.sort((a, b) => a.x - b.x);
988
+ const ordered = c.runs;
782
989
  const fontSize = c.fontSize || 10;
783
- if (!split) return [lineOf(ordered, c.y, fontSize, stepped)];
990
+ if (!split) return [lineOf(ordered, c.y, fontSize, stepped, stops)];
784
991
  const pieces = [[]];
785
992
  for (const run of ordered) {
786
993
  const prev = pieces[pieces.length - 1];
@@ -789,10 +996,50 @@ function groupIntoLines(runs, split = false, stepped = false) {
789
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([]);
790
997
  pieces[pieces.length - 1].push(run);
791
998
  }
792
- 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));
793
1000
  });
794
1001
  }
795
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
+ }
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;
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;
1042
+ /**
796
1043
  * Where a line's INK is, which is not how far its pen travelled.
797
1044
  *
798
1045
  * A run of spaces advances the pen and marks nothing. basicapi.pdf sets its
@@ -824,8 +1071,8 @@ function inkSpan(runs) {
824
1071
  };
825
1072
  }
826
1073
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
827
- function lineOf(runs, y, fontSize, stepped) {
828
- const { spans: ordered, stops } = 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);
829
1076
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
830
1077
  const ink = inkSpan(runs);
831
1078
  return {
@@ -833,8 +1080,11 @@ function lineOf(runs, y, fontSize, stepped) {
833
1080
  width: ink.width,
834
1081
  y,
835
1082
  fontSize,
836
- ...tabbed(runs, fontSize) ? { tabbed: true } : {},
837
- ...stops.length > 0 ? { stops } : {},
1083
+ ...tabbed(runs, fontSize) || stops.length > 0 ? { tabbed: true } : {},
1084
+ ...stops.length > 0 ? {
1085
+ stops,
1086
+ pieces
1087
+ } : {},
838
1088
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
839
1089
  spans
840
1090
  };
@@ -930,17 +1180,35 @@ function stepsBetweenWords(runs) {
930
1180
  if (runs.length < 8) return false;
931
1181
  return runs.filter((r) => /\s/u.test(r.text)).length / runs.length < .05;
932
1182
  }
933
- function lineSpans(runs, fontSize, stepped) {
1183
+ function lineSpans(runs, fontSize, stepped, shared = []) {
934
1184
  const spans = [];
935
1185
  const stops = [];
1186
+ const pieces = [{
1187
+ from: Infinity,
1188
+ to: -Infinity
1189
+ }];
936
1190
  const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
937
1191
  const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
938
1192
  let prev;
939
- for (const run of runs) {
940
- if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) if (run.x - prev.endX >= (run.fontSizePt || fontSize) * TAB_GAP_EM) {
941
- spans.push({ text: " " });
942
- stops.push(run.x);
943
- } else spans.push({ text: " " });
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
+ }
944
1212
  spans.push({
945
1213
  text: run.text.replaceAll("�", ""),
946
1214
  sizePt: run.fontSizePt,
@@ -957,10 +1225,17 @@ function lineSpans(runs, fontSize, stepped) {
957
1225
  ...run.href !== void 0 ? { href: run.href } : {}
958
1226
  });
959
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
+ }
960
1234
  }
961
1235
  return {
962
1236
  spans,
963
- stops
1237
+ stops,
1238
+ pieces
964
1239
  };
965
1240
  }
966
1241
  /**
@@ -1030,41 +1305,92 @@ function ruleOfCharacters(line) {
1030
1305
  }
1031
1306
  /** How many of them it takes before a row of marks is a rule. */
1032
1307
  var RULE_CHARS = 3;
1033
- function groupIntoParagraphs(lines, column, pageHeight = 0) {
1308
+ function groupIntoParagraphs(lines, column, pageHeight = 0, before, eachLine = false, reach) {
1034
1309
  const groups = [];
1035
1310
  const gaps = [];
1036
1311
  let prev;
1037
1312
  for (const line of lines) {
1038
1313
  const gap = prev !== void 0 ? prev.y - line.y : 0;
1039
1314
  const opened = prev !== void 0 && gap > line.fontSize * 1.5;
1040
- if (groups.length === 0 || opened || ruleOfCharacters(line) || prev !== void 0 && (ruleOfCharacters(prev) || 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)) {
1041
1316
  groups.push([]);
1042
- gaps.push(prev === void 0 ? 0 : gap);
1317
+ gaps.push(prev === void 0 ? before !== void 0 ? before.y - line.y : 0 : gap);
1043
1318
  }
1044
1319
  groups[groups.length - 1].push(line);
1045
1320
  prev = line;
1046
1321
  }
1047
1322
  const starts = lines.map((l) => l.x).sort((a, b) => a - b);
1048
1323
  const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
1324
+ const heights = [];
1049
1325
  return groups.map((g, i) => {
1050
1326
  const first = g[0];
1051
1327
  const fontSize = Math.max(...g.map((l) => l.fontSize));
1052
- 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;
1053
1338
  const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
1054
- const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
1339
+ const spacingBefore = opened > SPACING_NOISE_PT ? Math.min(opened, most) : void 0;
1055
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);
1056
1342
  return {
1057
1343
  spans: joinLines(g),
1058
1344
  fontSize,
1059
1345
  top: first.y,
1060
- ...stops.length > 0 ? { stops: stops.map((x) => x - (column?.left ?? columnLeft)) } : {},
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
+ } : {},
1061
1354
  ...spacingBefore !== void 0 ? { spacingBefore } : {},
1062
- ...alignmentOf(g, column),
1063
- ...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 } : {}
1064
1361
  };
1065
1362
  });
1066
1363
  }
1067
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
+ /**
1068
1394
  * A paragraph's lines as one run of spans, joined the way the page broke them.
1069
1395
  *
1070
1396
  * A line that ends in a hyphen was broken THERE, and the break is not part of
@@ -1091,7 +1417,7 @@ function joinLines(lines) {
1091
1417
  ...prev,
1092
1418
  text: ends.slice(0, -1)
1093
1419
  };
1094
- 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));
1095
1421
  }
1096
1422
  out.push(...line.spans);
1097
1423
  });
@@ -1145,6 +1471,69 @@ function indentOf(lines, columnLeft, alignment) {
1145
1471
  };
1146
1472
  }
1147
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
+ /**
1148
1537
  * How far, in ems, a paragraph has to be set in before it is indented rather
1149
1538
  * than merely started. Half an em clears the rounding a producer leaves at the
1150
1539
  * head of a line and is well under the smallest indent anybody sets.
@@ -1165,8 +1554,14 @@ var INDENT_EM = .5;
1165
1554
  * placed rather than set — a centred title's every line is short of the measure
1166
1555
  * and none of them ends anything.
1167
1556
  *
1168
- * @param prev The line before: where it starts, how wide it is, its face.
1169
- * @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.
1170
1565
  * @param column The measure both were set in, when it is known.
1171
1566
  * @returns Whether the first line ended a paragraph.
1172
1567
  */
@@ -1175,6 +1570,11 @@ function endedParagraph(prev, next, column) {
1175
1570
  const width = column.right - column.left;
1176
1571
  if (!(width > 0)) return false;
1177
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
+ }
1178
1578
  const shift = next.x - prev.x;
1179
1579
  if (shift < -step || shift > step * INDENT_LINES) return false;
1180
1580
  return column.right - (prev.x + prev.width) > width * .25;
@@ -1242,7 +1642,8 @@ function compareOrder(a, b) {
1242
1642
  * `undefined` where the document repeats nothing.
1243
1643
  */
1244
1644
  function runningFoot(pageRuns, shown, where) {
1245
- 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));
1246
1647
  const found = feet.filter((f) => f !== void 0);
1247
1648
  if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
1248
1649
  const ys = found.map((f) => f.y);
@@ -1273,7 +1674,8 @@ var FOOT_DRIFT = 4;
1273
1674
  * its own, and a two-page document came out as four.
1274
1675
  */
1275
1676
  function edgeLine(runs, pageHeight, where) {
1276
- 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;
1277
1679
  const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1278
1680
  const rows = rowsOf(runs, fontSize);
1279
1681
  if (rows.length === 0) return void 0;
@@ -1281,9 +1683,9 @@ function edgeLine(runs, pageHeight, where) {
1281
1683
  const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
1282
1684
  const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
1283
1685
  const y = edgeY(at(0));
1284
- if (rows.length < 4) {
1285
- const inked = rows.filter((row) => row.some((r) => r.text.trim() !== ""));
1286
- const inBand = inked.length > 0 && inked.every((row) => where === "foot" ? edgeY(row) <= pageHeight * FOOT_BAND : edgeY(row) >= pageHeight * (1 - FOOT_BAND));
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)) {
1287
1689
  const all = inked.flat();
1288
1690
  const text = all.map((r) => r.text).join("").trim();
1289
1691
  return inBand && text.length > 0 && text.length <= FOOT_CHARS * inked.length ? {
@@ -1293,8 +1695,9 @@ function edgeLine(runs, pageHeight, where) {
1293
1695
  }
1294
1696
  let found;
1295
1697
  let group = [];
1296
- const most = where === "foot" ? FOOT_ROWS : 1;
1297
- 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++) {
1298
1701
  const row = at(i);
1299
1702
  const edge = edgeY(row);
1300
1703
  if (where === "foot" ? edge > pageHeight * FOOT_BAND : edge < pageHeight * (1 - FOOT_BAND)) break;
@@ -1313,6 +1716,8 @@ function edgeLine(runs, pageHeight, where) {
1313
1716
  var FOOT_GAP_EM = 2;
1314
1717
  /** How many lines a running foot may hold before it is a paragraph. */
1315
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;
1316
1721
  /** How far up the sheet a running foot may sit. */
1317
1722
  var FOOT_BAND = .12;
1318
1723
  /** A running foot is a line, not a paragraph. */
@@ -1426,9 +1831,11 @@ function bandLine(row, fontSize, stepped, measure) {
1426
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);
1427
1832
  if (lines.length === 0) return void 0;
1428
1833
  if (lines.length === 1 || lines.length > BAND_REGIONS || !measure) {
1429
- 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;
1430
1837
  return {
1431
- 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]),
1432
1839
  properties: alignment ? { alignment } : {}
1433
1840
  };
1434
1841
  }
@@ -1586,8 +1993,8 @@ function strayMarks(vectors, runs) {
1586
1993
  const readable = runs.filter((r) => r.text.trim() !== "" && !r.text.includes("�"));
1587
1994
  for (const v of glyphs) {
1588
1995
  const mid = (v.minY + v.maxY) / 2;
1589
- const size = v.maxY - v.minY || 10;
1590
- const words = readable.filter((r) => Math.abs(r.y - mid) <= size);
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;
1591
1998
  const before = words.some((r) => r.endX <= v.minX + size);
1592
1999
  const after = words.some((r) => r.x >= v.maxX - size);
1593
2000
  if (!before && !after) continue;
@@ -1595,6 +2002,68 @@ function strayMarks(vectors, runs) {
1595
2002
  }
1596
2003
  return out;
1597
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;
1598
2067
  /** How few drawn glyphs a line may hold before they are its text, not marks in it. */
1599
2068
  var MOST_STRAY_MARKS = 2;
1600
2069
  /** …and how many readable runs must stand on that line for them to be strays. */
@@ -1639,11 +2108,35 @@ function tabbedRows(paras, measure) {
1639
2108
  const rows = paras.slice(i, to);
1640
2109
  const bounds = stops.map((_, k) => Math.min(...rows.map((r) => r.stops[k])));
1641
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
+ });
1642
2126
  const edges = [
1643
2127
  0,
1644
2128
  ...bounds,
1645
2129
  width
1646
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)));
1647
2140
  out.set(i, {
1648
2141
  kind: "table",
1649
2142
  table: {
@@ -1652,19 +2145,33 @@ function tabbedRows(paras, measure) {
1652
2145
  left: pt(0),
1653
2146
  right: pt(0)
1654
2147
  },
1655
- layout: "fixed"
2148
+ layout: "fixed",
2149
+ widthType: "dxa",
2150
+ widthPt: pt(grid.reduce((sum, w) => sum + w, 0))
1656
2151
  },
1657
- grid: edges.slice(0, -1).map((from, k) => pt(Math.max(edges[k + 1] - from, 1))),
2152
+ grid,
1658
2153
  rows: rows.map((row, r) => {
1659
2154
  const prev = rows[r - 1];
1660
- const pitch = prev ? prev.top - row.top - row.fontSize * 1.2 : 0;
1661
- const opening = r === 0 ? row.spacingBefore : pitch > row.fontSize * .3 ? pitch : void 0;
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;
1662
2157
  return {
1663
2158
  properties: {},
1664
- cells: splitAtTabs(row.spans).map((cell) => ({
1665
- properties: {},
1666
- content: [paragraphFromRuns(cell, void 0, opening !== void 0 ? { spacingBefore: pt(opening) } : {})]
1667
- }))
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
+ })
1668
2175
  };
1669
2176
  })
1670
2177
  }
@@ -1674,6 +2181,8 @@ function tabbedRows(paras, measure) {
1674
2181
  }
1675
2182
  return out;
1676
2183
  }
2184
+ /** How far apart the ends of a column's cells may stand and still be flush. */
2185
+ var FLUSH_SLACK_PT = 2;
1677
2186
  /** How many stops a line must stand on before a run of them is a table. */
1678
2187
  var LEAST_TABLE_STOPS = 2;
1679
2188
  /**
@@ -1709,8 +2218,9 @@ function ruleBorders(vectors, blocks, width) {
1709
2218
  const given = /* @__PURE__ */ new Set();
1710
2219
  const paragraphs = blocks.filter((b) => b.el.kind === "paragraph");
1711
2220
  if (paragraphs.length === 0) return given;
1712
- for (const v of vectors) {
1713
- if (!separatorRule(v, width)) continue;
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;
1714
2224
  const y = (v.minY + v.maxY) / 2;
1715
2225
  const below = paragraphs.filter((b) => b.top < y).sort((a, b) => b.top - a.top)[0];
1716
2226
  const above = paragraphs.filter((b) => b.top >= y).sort((a, b) => a.top - b.top)[0];
@@ -1728,20 +2238,24 @@ function ruleBorders(vectors, blocks, width) {
1728
2238
  colorHex: v.strokeHex ?? v.fillHex ?? "000000"
1729
2239
  };
1730
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)) } : {};
1731
2243
  side.block.el = {
1732
2244
  kind: "paragraph",
1733
2245
  paragraph: {
1734
2246
  ...paragraph,
1735
2247
  properties: {
1736
2248
  ...paragraph.properties,
2249
+ ...room,
1737
2250
  borders: {
1738
2251
  ...paragraph.properties.borders,
1739
- [side.edge]: border
2252
+ [side.edge]: border,
2253
+ ...side.edge === "top" ? { insideH: border } : {}
1740
2254
  }
1741
2255
  }
1742
2256
  }
1743
2257
  };
1744
- given.add(v);
2258
+ for (const piece of band.pieces) given.add(piece);
1745
2259
  }
1746
2260
  return given;
1747
2261
  }
@@ -1750,22 +2264,54 @@ var RULE_REACH_PT = 14;
1750
2264
  /** The thinnest a border may be drawn and still be seen. */
1751
2265
  var RULE_MIN_PT = .5;
1752
2266
  /**
1753
- * Whether a painted path is a RULE — a line drawn to separate one block from
1754
- * the next, rather than a piece of the page's artwork.
1755
- *
1756
- * Flat and long: a hairline the width of a column under a table's headings, or
1757
- * the line a total is written over. Everything else — boxes, panels, drawings —
1758
- * keeps the place on the page it was drawn at.
1759
- *
1760
- * @param v The painted path.
1761
- * @param width The page's width, which the rule is long RELATIVE to.
1762
- * @returns Whether it separates blocks rather than drawing something.
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.
1763
2276
  */
1764
- function separatorRule(v, width) {
2277
+ function flatBar(v) {
1765
2278
  const w = v.maxX - v.minX;
1766
2279
  const h = v.maxY - v.minY;
1767
- return h <= RULE_THICK_PT && w >= width * RULE_SHARE && w > h * RULE_RATIO;
2280
+ return h <= RULE_THICK_PT && w > h * RULE_RATIO;
1768
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;
1769
2315
  /** How thick a mark may be and still be a rule. */
1770
2316
  var RULE_THICK_PT = 2;
1771
2317
  /** How much of the sheet it must run across. */