reamkit 1.30.0 → 1.32.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 (147) hide show
  1. package/README.md +70 -24
  2. package/dist/esm/core/bidi/index.d.ts +10 -0
  3. package/dist/esm/core/bidi/index.js +18 -2
  4. package/dist/esm/core/converter/facade.js +10 -5
  5. package/dist/esm/core/converter/project.js +4 -1
  6. package/dist/esm/core/converter/ream.js +17 -6
  7. package/dist/esm/core/converter/sheet-images.d.ts +17 -0
  8. package/dist/esm/core/converter/sheet-images.js +33 -0
  9. package/dist/esm/core/crypto/offcrypto.js +1 -1
  10. package/dist/esm/core/document-model/index.d.ts +1 -1
  11. package/dist/esm/core/document-model/types.d.ts +175 -0
  12. package/dist/esm/core/drawingml/chart-geometry.d.ts +21 -8
  13. package/dist/esm/core/drawingml/chart-geometry.js +198 -108
  14. package/dist/esm/core/drawingml/chart-parser.js +73 -2
  15. package/dist/esm/core/drawingml/colors.d.ts +6 -0
  16. package/dist/esm/core/drawingml/colors.js +23 -3
  17. package/dist/esm/core/drawingml/shape-render.d.ts +3 -1
  18. package/dist/esm/core/drawingml/shape-render.js +4 -2
  19. package/dist/esm/core/font/index.d.ts +2 -0
  20. package/dist/esm/core/font/measure.d.ts +9 -0
  21. package/dist/esm/core/font/measure.js +9 -1
  22. package/dist/esm/core/font/ttf-build.d.ts +113 -0
  23. package/dist/esm/core/font/ttf-build.js +1224 -0
  24. package/dist/esm/core/font/ttf-subset.d.ts +19 -0
  25. package/dist/esm/core/font/ttf-subset.js +14 -1
  26. package/dist/esm/core/fonts/families.js +4 -2
  27. package/dist/esm/core/fonts/provider.d.ts +17 -0
  28. package/dist/esm/core/fonts/provider.js +27 -2
  29. package/dist/esm/core/fonts/remote-fonts.js +8 -1
  30. package/dist/esm/core/fonts/scripts.js +4 -2
  31. package/dist/esm/core/ir/flow.d.ts +77 -1
  32. package/dist/esm/core/ir/sheet.d.ts +43 -0
  33. package/dist/esm/core/number-format.js +16 -11
  34. package/dist/esm/core/numbering/index.d.ts +1 -1
  35. package/dist/esm/core/numbering/state.d.ts +11 -1
  36. package/dist/esm/core/numbering/state.js +10 -1
  37. package/dist/esm/core/spreadsheet-model/index.d.ts +1 -1
  38. package/dist/esm/core/spreadsheet-model/types.d.ts +96 -0
  39. package/dist/esm/core/style-cascade/resolver.js +39 -6
  40. package/dist/esm/core/style-cascade/types.d.ts +19 -1
  41. package/dist/esm/core/style-cascade/types.js +3 -0
  42. package/dist/esm/excel/column-bands.d.ts +39 -6
  43. package/dist/esm/excel/column-bands.js +41 -17
  44. package/dist/esm/excel/comments-parser.js +19 -0
  45. package/dist/esm/excel/conditional-format.d.ts +7 -3
  46. package/dist/esm/excel/conditional-format.js +77 -39
  47. package/dist/esm/excel/formula/context.d.ts +18 -0
  48. package/dist/esm/excel/formula/eval.js +51 -0
  49. package/dist/esm/excel/formula/index.d.ts +2 -2
  50. package/dist/esm/excel/formula/lexer.d.ts +1 -1
  51. package/dist/esm/excel/formula/lexer.js +41 -0
  52. package/dist/esm/excel/formula/parser.d.ts +25 -0
  53. package/dist/esm/excel/formula/parser.js +95 -0
  54. package/dist/esm/excel/print-model.d.ts +38 -1
  55. package/dist/esm/excel/print-model.js +522 -80
  56. package/dist/esm/excel/shared-strings-parser.d.ts +9 -0
  57. package/dist/esm/excel/shared-strings-parser.js +11 -1
  58. package/dist/esm/excel/sheet-drawing.d.ts +38 -2
  59. package/dist/esm/excel/sheet-drawing.js +47 -13
  60. package/dist/esm/excel/sheet-shape-parser.d.ts +6 -2
  61. package/dist/esm/excel/sheet-shape-parser.js +18 -6
  62. package/dist/esm/excel/sheet-to-flow.d.ts +17 -0
  63. package/dist/esm/excel/sheet-to-flow.js +434 -47
  64. package/dist/esm/excel/styles-parser.d.ts +9 -0
  65. package/dist/esm/excel/styles-parser.js +80 -26
  66. package/dist/esm/excel/table-parser.js +5 -0
  67. package/dist/esm/excel/table-style-presets.d.ts +26 -0
  68. package/dist/esm/excel/table-style-presets.js +1555 -0
  69. package/dist/esm/excel/table-style.d.ts +13 -0
  70. package/dist/esm/excel/table-style.js +75 -0
  71. package/dist/esm/excel/tint.d.ts +10 -0
  72. package/dist/esm/excel/tint.js +75 -0
  73. package/dist/esm/excel/vml-drawing.d.ts +41 -3
  74. package/dist/esm/excel/vml-drawing.js +143 -10
  75. package/dist/esm/excel/worksheet-parser.js +89 -15
  76. package/dist/esm/excel/xls/biff-reader.js +10 -2
  77. package/dist/esm/excel/xlsx-reader.js +195 -14
  78. package/dist/esm/excel/xlsx-writer.js +8 -7
  79. package/dist/esm/html/html-writer.js +202 -20
  80. package/dist/esm/index.d.ts +2 -2
  81. package/dist/esm/layout/fit-pages.d.ts +11 -0
  82. package/dist/esm/layout/fit-pages.js +86 -0
  83. package/dist/esm/layout/line-paint.d.ts +58 -0
  84. package/dist/esm/layout/line-paint.js +107 -0
  85. package/dist/esm/layout/page-doc.d.ts +61 -3
  86. package/dist/esm/layout/page-doc.js +141 -1
  87. package/dist/esm/layout/styled-layout.d.ts +55 -7
  88. package/dist/esm/layout/styled-layout.js +1154 -169
  89. package/dist/esm/layout/turned-section.d.ts +38 -0
  90. package/dist/esm/layout/turned-section.js +205 -0
  91. package/dist/esm/markdown/markdown-writer.js +17 -6
  92. package/dist/esm/pdf/styled-page-emitter.js +114 -60
  93. package/dist/esm/pdf/styled-page-renderer.js +1 -1
  94. package/dist/esm/pdf-reader/cff-outline.d.ts +27 -0
  95. package/dist/esm/pdf-reader/cff-outline.js +169 -21
  96. package/dist/esm/pdf-reader/content.d.ts +53 -0
  97. package/dist/esm/pdf-reader/content.js +9 -1
  98. package/dist/esm/pdf-reader/display.d.ts +36 -0
  99. package/dist/esm/pdf-reader/display.js +66 -1
  100. package/dist/esm/pdf-reader/document.js +5 -1
  101. package/dist/esm/pdf-reader/embedded-fonts.d.ts +2 -1
  102. package/dist/esm/pdf-reader/embedded-fonts.js +32 -2
  103. package/dist/esm/pdf-reader/encodings.d.ts +8 -0
  104. package/dist/esm/pdf-reader/encodings.js +25 -3
  105. package/dist/esm/pdf-reader/face-outlines.d.ts +78 -0
  106. package/dist/esm/pdf-reader/face-outlines.js +362 -0
  107. package/dist/esm/pdf-reader/figures.d.ts +52 -0
  108. package/dist/esm/pdf-reader/figures.js +433 -0
  109. package/dist/esm/pdf-reader/flow-build.d.ts +131 -5
  110. package/dist/esm/pdf-reader/flow-build.js +345 -26
  111. package/dist/esm/pdf-reader/font.js +321 -36
  112. package/dist/esm/pdf-reader/glyf-outline.d.ts +33 -0
  113. package/dist/esm/pdf-reader/glyf-outline.js +135 -1
  114. package/dist/esm/pdf-reader/glyph-names.js +154 -1
  115. package/dist/esm/pdf-reader/layout.d.ts +93 -0
  116. package/dist/esm/pdf-reader/layout.js +1703 -214
  117. package/dist/esm/pdf-reader/page-numbers.d.ts +53 -0
  118. package/dist/esm/pdf-reader/page-numbers.js +167 -0
  119. package/dist/esm/pdf-reader/tagged.js +182 -23
  120. package/dist/esm/pdf-reader/text.d.ts +6 -3
  121. package/dist/esm/pdf-reader/text.js +98 -9
  122. package/dist/esm/pdf-reader/type1-outline.d.ts +11 -0
  123. package/dist/esm/pdf-reader/type1-outline.js +63 -8
  124. package/dist/esm/pdf-reader/vector.js +71 -1
  125. package/dist/esm/svg/svg-text.d.ts +58 -0
  126. package/dist/esm/svg/svg-text.js +322 -0
  127. package/dist/esm/svg/svg-writer.js +135 -71
  128. package/dist/esm/word/doc/doc-reader.js +6 -2
  129. package/dist/esm/word/doc/doc-text.d.ts +6 -0
  130. package/dist/esm/word/doc/doc-text.js +19 -1
  131. package/dist/esm/word/document-parser.d.ts +2 -2
  132. package/dist/esm/word/document-parser.js +11 -1
  133. package/dist/esm/word/docx-reader.js +5 -3
  134. package/dist/esm/word/docx-writer.js +218 -23
  135. package/dist/esm/word/drawing-parser.d.ts +5 -3
  136. package/dist/esm/word/drawing-parser.js +49 -8
  137. package/dist/esm/word/font-embed.d.ts +30 -0
  138. package/dist/esm/word/font-embed.js +173 -0
  139. package/dist/esm/word/font-table.d.ts +10 -0
  140. package/dist/esm/word/font-table.js +13 -1
  141. package/dist/esm/word/index.js +1 -1
  142. package/dist/esm/word/numbering-parser.d.ts +3 -1
  143. package/dist/esm/word/numbering-parser.js +2 -1
  144. package/dist/esm/word/paragraph-properties.d.ts +7 -6
  145. package/dist/esm/word/paragraph-properties.js +14 -2
  146. package/dist/esm/word/run-properties.js +26 -0
  147. package/package.json +12 -3
@@ -4,18 +4,23 @@ import { FEATURES } from "../core/ir/features.js";
4
4
  import { isRightToLeft } from "./content.js";
5
5
  import { collectEmbeddedFonts } from "./embedded-fonts.js";
6
6
  import { collectFaceFamilies } from "./font.js";
7
+ import { displayOf, placeImages, placeRuns, placeVectors, textFrameOf, wordsTurnOf } from "./display.js";
8
+ import { BASELINE_AT, CARRIER_LINE_PT, GUESSED_MARGIN, NATURAL_LINE_EM, buildFlowDoc, dedupeLosses, figureBlock, floatOntoSheet, imageBlock, imageMember, labelMember, membersBox, paragraphFromRuns, positionedText, sectionFromPdfPages, sectionOnSheet, shapeBlock, spaceAfter, textSizeOf, tooSmallToRead, vectorMember, withMeasuredMargins } from "./flow-build.js";
9
+ import { faceOutlinesOf, kernedFaces, pageSpacing } from "./face-outlines.js";
7
10
  import { extractPageText } from "./text.js";
8
11
  import { collectPageVectors } from "./vector.js";
9
- import { displayOf, placeImages, placeRuns, placeVectors } from "./display.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";
11
12
  import { collectPageImages } from "./images.js";
12
13
  import { markDrawnRules } from "./text-rules.js";
14
+ import { joinLetters, pageFigures, tracedRun } from "./figures.js";
13
15
  import { regionsOf } from "./regions.js";
14
16
  import { punctuationOf } from "./glyph-shapes.js";
15
17
  import { matrixBlocks } from "./math-rows.js";
18
+ import { pageNumberingOf, runOf } from "./page-numbers.js";
16
19
  //#region src/pdf-reader/layout.ts
17
20
  /** The relationships the reconstruction files its running head and foot under. */
18
21
  var FOOTER_PART = "pdf-running-foot";
22
+ /** The part the running foot of a book's even pages is written to (see `sidesOf`). */
23
+ var EVEN_FOOTER_PART = "pdf-running-foot-even";
19
24
  var HEADER_PART = "pdf-running-head";
20
25
  /**
21
26
  * Heuristically reconstruct an untagged PDF into a {@link Reconstruction}
@@ -34,19 +39,60 @@ var HEADER_PART = "pdf-running-head";
34
39
  */
35
40
  function reconstructByLayout(file, mode = "flow") {
36
41
  const pages = file.pages();
37
- const shown = pages.map((page) => displayOf(page));
38
- const allRuns = pages.map((page, i) => placeRuns(extractPageText(file, page), shown[i]));
39
- const foot = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "foot");
40
- const head = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "head");
42
+ const sheets = pages.map((page) => displayOf(page));
43
+ const painted = /* @__PURE__ */ new Map();
44
+ const extracted = typewritten(pages.map((page) => extractPageText(file, page, painted)));
45
+ const spacing = pageSpacing(extracted);
46
+ const outlines = faceOutlinesOf(painted, spacing);
47
+ const faceSpaces = new Map([...outlines].flatMap(([name, face]) => {
48
+ const advance = face.glyphs.get(" ")?.advance;
49
+ return advance !== void 0 ? [[name, advance]] : [];
50
+ }));
51
+ const onSheets = extracted.map((runs, i) => placeRuns(runs, sheets[i]));
52
+ const turns = onSheets.map((runs) => wordsTurnOf(runs));
53
+ const shown = sheets.map((sheet, i) => mode !== "positional" && turns[i] === 270 ? textFrameOf(sheet) : sheet);
54
+ const allRuns = shown.map((d, i) => d.sheet ? placeRuns(extracted[i], d) : onSheets[i]);
55
+ const offSheet = (r, i) => r.y < 0 || r.y > shown[i].height;
56
+ const bandRuns = allRuns.map((runs, i) => shown[i].sheet ? [] : runs.filter((r) => !offSheet(r, i)));
57
+ const foot = mode === "positional" ? void 0 : runningFoot(bandRuns, shown, "foot");
58
+ const head = mode === "positional" ? void 0 : runningFoot(bandRuns, shown, "head");
59
+ const numberedBand = foot?.numbered === true ? foot : head?.numbered === true ? head : void 0;
60
+ const numbering = numberingOf(numberedBand);
41
61
  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;
62
+ const stamp = (r, i) => mode !== "positional" && (r.annotation === true || tooSmallToRead(r, textSize) || offSheet(r, i));
63
+ const stamps = allRuns.map((runs, i) => runs.filter((r) => stamp(r, i)));
64
+ const readRuns = 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, i))) : allRuns;
65
+ const art = pages.map((page, i) => {
66
+ const raw = collectPageImages(file, page);
67
+ const images = placeImages(raw.images, shown[i]);
68
+ const lifted = collectPageVectors(file, page, images.map((img) => ({
69
+ minX: img.x,
70
+ minY: img.y,
71
+ maxX: img.x + img.widthPt,
72
+ maxY: img.y + img.heightPt
73
+ })));
74
+ return {
75
+ images,
76
+ imageLosses: raw.losses,
77
+ vectors: placeVectors(lifted.vectors, shown[i]),
78
+ vectorLosses: lifted.losses
79
+ };
80
+ });
81
+ const figures = art.map((a, i) => mode === "positional" || shown[i].sheet !== void 0 ? [] : pageFigures(a.vectors, a.images, readRuns[i], shown[i]));
82
+ const pageRuns = figures.some((f) => f.length > 0) ? readRuns.map((runs, i) => {
83
+ const labels = new Set(figures[i].flatMap((f) => f.labels));
84
+ return labels.size > 0 ? runs.filter((r) => !labels.has(r)) : runs;
85
+ }) : readRuns;
45
86
  const pageMarks = pages.map(() => []);
46
87
  const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
47
88
  const resources = new ResourceStore();
48
89
  const losses = [];
49
- if (pageRuns.some((page) => page.some((r) => r.text.includes("�")))) losses.push({
90
+ if (mode !== "positional" && turns.some((t) => t === 90 || t === 180)) losses.push({
91
+ severity: "degraded",
92
+ feature: FEATURES.text,
93
+ detail: "a page shows its words running up the sheet or upside down; a document sets a section’s text across the sheet or down it, so they are set across it"
94
+ });
95
+ if (readRuns.some((page) => page.some((r) => r.text.includes("�")))) losses.push({
50
96
  severity: "dropped",
51
97
  feature: FEATURES.text,
52
98
  detail: "some glyphs map to no character — the font states no /ToUnicode and its program says nothing either, so that text is unrecoverable"
@@ -61,30 +107,43 @@ function reconstructByLayout(file, mode = "flow") {
61
107
  feature: FEATURES.text,
62
108
  detail: "text filled with a shading pattern is drawn in the middle colour of the gradient, not as the gradient"
63
109
  });
64
- const perPage = pages.map((_, i) => detectGutters(pageRuns[i], shown[i].width));
110
+ const gutterRuns = pageRuns.map((runs, i) => {
111
+ const listed = withoutListings(runs);
112
+ const prose = withoutTables(listed);
113
+ return prose !== listed && detectGutters(prose, shown[i].width).length > 0 ? prose : listed;
114
+ });
115
+ const perPage = pages.map((_, i) => detectGutters(gutterRuns[i], shown[i].width));
65
116
  const shared = commonGutters(perPage);
66
- const pageGutters = perPage.map((own, i) => own.length > 0 ? own : shared && fitsGutters(pageRuns[i], shared) ? shared : own);
117
+ const pageGutters = perPage.map((own, i) => own.length > 0 ? own : shared && fitsGutters(gutterRuns[i], shared) ? shared : own);
67
118
  const body = [];
68
119
  const sectionEnds = [];
69
120
  let sectionFrom = 0;
70
121
  let lastSize = "";
122
+ const sheetSizes = [];
71
123
  const leads = [];
72
124
  let curColumns = 1;
73
125
  let curSpace = 0;
74
126
  let pendingContinuous = false;
127
+ const columnFeet = [];
128
+ const columnTurns = [];
75
129
  pages.forEach((page, i) => {
76
130
  const runs = pageRuns[i];
77
131
  const display = shown[i];
132
+ const turned = display.sheet !== void 0;
78
133
  const gutters = pageGutters[i];
79
134
  const pageWidth = display.width;
80
135
  const stepped = stepsBetweenWords(runs);
81
136
  const blocks = [];
137
+ const shaded = /* @__PURE__ */ new Set();
82
138
  const textEdges = pageTextEdges(runs);
83
139
  const inColumns = proseColumns(runs, gutters, textEdges);
84
140
  const split = gutters.length > 0 && inColumns ? assignColumns(runs, gutters) : void 0;
141
+ const figs = figures[i];
142
+ const spansGutter = (f) => gutters.some((g) => f.minX < g.mid && f.maxX > g.mid);
143
+ const breaks = split ? [...split.breaks, ...figs.filter(spansGutter).map((f) => f.maxY)].sort((a, b) => b - a) : [];
85
144
  const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
86
- const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
87
- const addColumn = (allRuns, col) => {
145
+ const bandAt = (top) => bandOf(breaks, top, bandEpsilon);
146
+ const addColumn = (allRuns, col, columnFigures = []) => {
88
147
  const colRuns = allRuns.filter((r) => r.type3 !== true && (mode !== "positional" || r.invisible !== true));
89
148
  if (mode === "positional") {
90
149
  for (const [angle, turned] of byAngle(colRuns)) for (const runs of byPainter(turned)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
@@ -132,10 +191,55 @@ function reconstructByLayout(file, mode = "flow") {
132
191
  });
133
192
  }
134
193
  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;
194
+ const slabs = [];
195
+ let rest = textRuns;
196
+ for (const figure of columnFigures) {
197
+ slabs.push({
198
+ runs: rest.filter((r) => r.y > figure.maxY),
199
+ figure
200
+ });
201
+ rest = rest.filter((r) => r.y <= figure.maxY);
202
+ }
203
+ slabs.push({ runs: rest });
204
+ for (const slab of slabs) {
205
+ const regions = turned ? [{
206
+ kind: "flow",
207
+ runs: slab.runs
208
+ }] : regionsOf(slab.runs);
209
+ for (const region of regions) {
210
+ if (region.kind === "side") {
211
+ const made = sideBySide(region, measure, above);
212
+ if (made === void 0) continue;
213
+ blocks.push({
214
+ band: bandAt(made.top),
215
+ col,
216
+ top: made.top,
217
+ el: made.el
218
+ });
219
+ above = made.below;
220
+ continue;
221
+ }
222
+ const regionLines = groupIntoLines(region.runs, false, stepped, faceSpaces, columnRules(vectors)).filter((l) => l.text.length > 0);
223
+ const reach = lines.length < 3 && gutters.length === 0 ? pageWidth * (1 - GUESSED_MARGIN) : void 0;
224
+ const { paras, under } = onBoxes(groupIntoParagraphs(regionLines, measure, display.height, above, false, reach), vectors.filter(solidBox), measure);
225
+ for (const set of setParagraphs(paras, measure && {
226
+ left: measure.left,
227
+ right: Math.max(measure.right, textEdges?.right ?? measure.right)
228
+ }, pageWidth)) blocks.push({
229
+ band: bandAt(set.top),
230
+ col,
231
+ top: set.top,
232
+ el: set.el,
233
+ ...set.foot !== void 0 ? { foot: set.foot } : {}
234
+ });
235
+ const last = paras[paras.length - 1];
236
+ if (last !== void 0) above = {
237
+ y: last.bottom - under,
238
+ lineHeight: last.lineHeight
239
+ };
240
+ }
241
+ if (slab.figure !== void 0) {
242
+ const made = figureIn(slab.figure, measure, above, col === SPANNING_COLUMN);
139
243
  blocks.push({
140
244
  band: bandAt(made.top),
141
245
  col,
@@ -143,27 +247,64 @@ function reconstructByLayout(file, mode = "flow") {
143
247
  el: made.el
144
248
  });
145
249
  above = made.below;
146
- continue;
147
250
  }
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
251
  }
166
252
  };
253
+ /** A figure's parts in the order the page painted them, its words over its drawing. */
254
+ const figureMembers = (figure) => {
255
+ const drawn = [...joinLetters([...figure.vectors].sort((a, b) => compareOrder(a.orderKey, b.orderKey))).map((v) => ({
256
+ key: v.orderKey,
257
+ member: vectorMember(v)
258
+ })), ...figure.images.map((img) => ({
259
+ key: img.orderKey,
260
+ member: imageMember(img, resources)
261
+ }))].sort((a, b) => compareOrder(a.key, b.key));
262
+ const labels = [];
263
+ const written = figure.labels.filter((r) => !tracedRun(r));
264
+ for (const [angle, turnedRuns] of byAngle(written)) for (const layer of byPainter(turnedRuns)) for (const line of groupIntoLines(rotate(layer, -angle), true, stepped)) {
265
+ if (line.text.length === 0) continue;
266
+ const box = angle === 0 ? {
267
+ x: line.x,
268
+ y: line.y - line.fontSize * .25,
269
+ width: line.width + line.fontSize,
270
+ height: line.fontSize * 1.25
271
+ } : turnedBox(line, angle, pageWidth);
272
+ labels.push(labelMember(line.spans, box, rotation60kOf(angle)));
273
+ }
274
+ return [...drawn.map((d) => d.member), ...labels];
275
+ };
276
+ /**
277
+ * §20.5.2.17 — a figure as the paragraph it stands in (see `figureBlock`):
278
+ * spaced from the text over it as a paragraph is, and set in from its
279
+ * column's edge as far as the page set it.
280
+ *
281
+ * A figure across the columns is spaced from the text nearest over it in
282
+ * any column: what its own column holds over it is the line before the
283
+ * columns began.
284
+ */
285
+ const figureIn = (figure, measure, over, across = false) => {
286
+ const members = figureMembers(figure);
287
+ const box = membersBox(members);
288
+ const nearest = across ? runs.map((r) => r.y - r.fontSizePt * .25).filter((edge) => edge > box.top).reduce((low, edge) => Math.min(low ?? edge, edge), void 0) : void 0;
289
+ const above = nearest !== void 0 ? {
290
+ y: nearest,
291
+ lineHeight: 0
292
+ } : over;
293
+ const opened = above !== void 0 ? above.y - (1 - BASELINE_AT) * above.lineHeight - box.top : 0;
294
+ const indent = box.left - (measure?.left ?? box.left);
295
+ const properties = {
296
+ ...opened > SPACING_NOISE_PT ? { spacingBefore: pt(Math.min(opened, display.height / 3)) } : {},
297
+ ...Math.abs(indent) > SPACING_NOISE_PT ? { indentLeft: pt(indent) } : {}
298
+ };
299
+ return {
300
+ top: box.top,
301
+ below: {
302
+ y: box.bottom,
303
+ lineHeight: 0
304
+ },
305
+ el: figureBlock(members, properties)
306
+ };
307
+ };
167
308
  /**
168
309
  * A region's paragraphs as the elements they are set in: the lines that
169
310
  * stand on the same stops as a table (§17.4.38), the rest as paragraphs
@@ -171,7 +312,7 @@ function reconstructByLayout(file, mode = "flow") {
171
312
  */
172
313
  const far = /* @__PURE__ */ new WeakMap();
173
314
  const setParagraphs = (paras, tableMeasure, sheetWidth = 0) => {
174
- const asRows = tabbedRows(paras, tableMeasure);
315
+ const asRows = turned ? /* @__PURE__ */ new Map() : tabbedRows(paras, tableMeasure);
175
316
  const out = [];
176
317
  for (const [at, para] of paras.entries()) {
177
318
  const table = asRows.get(at);
@@ -183,6 +324,8 @@ function reconstructByLayout(file, mode = "flow") {
183
324
  continue;
184
325
  }
185
326
  const overflow = sheetWidth > 0 ? runOn(para, tableMeasure, sheetWidth) : 0;
327
+ const { shade } = para;
328
+ if (shade) shaded.add(shade.box);
186
329
  const el = paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
187
330
  ...para.stops !== void 0 && para.stops.length > 0 ? { tabs: para.stops.filter((x) => x > 0).map((x) => ({
188
331
  positionPt: pt(x),
@@ -194,12 +337,14 @@ function reconstructByLayout(file, mode = "flow") {
194
337
  spacingLineRule: "exact",
195
338
  ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
196
339
  ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {},
197
- ...overflow > 0 ? { indentRight: pt(-overflow) } : {}
340
+ ...overflow > 0 ? { indentRight: pt(-overflow) } : {},
341
+ ...shade ? shadeProperties(shade) : {}
198
342
  });
199
343
  if (sheetWidth > 0 && para.far !== void 0) far.set(el, para.far);
200
344
  out.push({
201
345
  top: para.top,
202
- el
346
+ el,
347
+ foot: para.bottom - (1 - BASELINE_AT) * para.lineHeight - (shade?.under ?? 0)
203
348
  });
204
349
  }
205
350
  return out;
@@ -215,7 +360,7 @@ function reconstructByLayout(file, mode = "flow") {
215
360
  const cells = region.cells.map((cell) => {
216
361
  return {
217
362
  cell,
218
- lines: groupIntoLines(cell.runs, false, stepped).filter((l) => l.text.length > 0)
363
+ lines: groupIntoLines(cell.runs, false, stepped, faceSpaces).filter((l) => l.text.length > 0)
219
364
  };
220
365
  });
221
366
  if (cells.some((c) => c.lines.length === 0)) return void 0;
@@ -231,7 +376,7 @@ function reconstructByLayout(file, mode = "flow") {
231
376
  left: cell.from,
232
377
  right: cell.to
233
378
  };
234
- const paras = groupIntoParagraphs(lines, own, display.height, edge, true);
379
+ const paras = groupIntoParagraphs(lines, own, display.height, edge, !isProse(lines, own));
235
380
  const last = paras[paras.length - 1];
236
381
  foot = Math.min(foot, last.bottom - (1 - BASELINE_AT) * last.lineHeight);
237
382
  const inset = cell.from - starts[k];
@@ -295,21 +440,13 @@ function reconstructByLayout(file, mode = "flow") {
295
440
  make: (z) => positionedText(line.spans, box, frame, z)
296
441
  });
297
442
  }
298
- const raw = collectPageImages(file, page);
299
- const imgs = {
300
- images: placeImages(raw.images, display),
301
- losses: raw.losses
302
- };
303
- losses.push(...imgs.losses);
304
- pageMarks[i] = imgs.images;
305
- const lifted = collectPageVectors(file, page, imgs.images.map((img) => ({
306
- minX: img.x,
307
- minY: img.y,
308
- maxX: img.x + img.widthPt,
309
- maxY: img.y + img.heightPt
310
- })));
311
- losses.push(...lifted.losses);
312
- const placedVectors = placeVectors(lifted.vectors, display);
443
+ const lifted = art[i];
444
+ losses.push(...lifted.imageLosses);
445
+ pageMarks[i] = lifted.images;
446
+ losses.push(...lifted.vectorLosses);
447
+ const figured = new Set(figures[i].flatMap((f) => [...f.vectors, ...f.images]));
448
+ const imgs = { images: figured.size > 0 ? lifted.images.filter((img) => !figured.has(img)) : lifted.images };
449
+ const placedVectors = figured.size > 0 ? lifted.vectors.filter((v) => !figured.has(v)) : lifted.vectors;
313
450
  const drawnRules = markDrawnRules(runs, placedVectors);
314
451
  const strayGlyphs = strayMarks(placedVectors, runs);
315
452
  const ruled = mode !== "positional" ? {
@@ -318,22 +455,34 @@ function reconstructByLayout(file, mode = "flow") {
318
455
  } : drawnRules;
319
456
  const vectors = placedVectors.filter((v) => !ruled.consumed.has(v)).filter((v) => !(mode !== "positional" && strayGlyphs.has(v)));
320
457
  const drawn = drawnWords(vectors);
321
- const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
458
+ const ruledIntoColumns = mode !== "positional" && !turned && looksRuled(ruled.runs, gutters, textEdges);
322
459
  const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
323
- if (asTable) for (const block of asTable) blocks.push({
324
- band: bandAt(block.top),
325
- col: 0,
326
- top: block.top,
327
- el: block.el
328
- });
329
- else if (split && !ruledIntoColumns) {
460
+ if (asTable) {
461
+ for (const block of asTable) blocks.push({
462
+ band: bandAt(block.top),
463
+ col: 0,
464
+ top: block.top,
465
+ el: block.el
466
+ });
467
+ for (const figure of figs) {
468
+ const made = figureIn(figure, void 0, void 0);
469
+ blocks.push({
470
+ band: bandAt(made.top),
471
+ col: 0,
472
+ top: made.top,
473
+ el: made.el
474
+ });
475
+ }
476
+ } else if (split && !ruledIntoColumns) {
330
477
  const columnFor = (r) => split.columnOf.get(r) ?? colOf(r.x);
478
+ const figureColumn = (f) => spansGutter(f) ? SPANNING_COLUMN : colOf((f.minX + f.maxX) / 2);
331
479
  const columns = Array.from({ length: gutters.length + 1 }, (_, n) => n);
332
- for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
333
- } else addColumn(ruled.runs, 0);
480
+ for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col, figs.filter((f) => figureColumn(f) === col));
481
+ spaceUnderSpans(blocks);
482
+ } else addColumn(ruled.runs, 0, figs);
334
483
  const under = mode !== "positional";
335
484
  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;
485
+ const givenAway = mode !== "positional" ? ruleBorders(vectors, blocks, display.width, colOf) : void 0;
337
486
  [
338
487
  ...imgs.images.map((img) => ({
339
488
  key: img.orderKey,
@@ -341,7 +490,7 @@ function reconstructByLayout(file, mode = "flow") {
341
490
  top: img.y + img.heightPt,
342
491
  make: (z) => imageBlock(img, resources, void 0, frame, z, under)
343
492
  })),
344
- ...drawn.filter((v) => givenAway?.has(v) !== true).map((v) => ({
493
+ ...drawn.filter((v) => givenAway?.has(v) !== true && !shaded.has(v)).map((v) => ({
345
494
  key: v.orderKey,
346
495
  col: colOf((v.minX + v.maxX) / 2),
347
496
  top: v.maxY,
@@ -357,8 +506,8 @@ function reconstructByLayout(file, mode = "flow") {
357
506
  });
358
507
  });
359
508
  blocks.sort((a, b) => a.band - b.band || columnOrder(a.col) - columnOrder(b.col) || b.top - a.top);
360
- const size = `${shown[i].width.toFixed(2)}x${shown[i].height.toFixed(2)}`;
361
- const opensSection = i > 0 && size !== lastSize;
509
+ const size = `${shown[i].width.toFixed(2)}x${shown[i].height.toFixed(2)}${turned ? " down" : ""}`;
510
+ const opensSection = i > 0 && (size !== lastSize || numbering?.runs.some((run) => run.from === i) === true);
362
511
  if (opensSection) {
363
512
  sectionEnds.push({
364
513
  at: body.length,
@@ -372,6 +521,8 @@ function reconstructByLayout(file, mode = "flow") {
372
521
  pendingContinuous = false;
373
522
  }
374
523
  lastSize = size;
524
+ sheetSizes[i] = size;
525
+ const breakAt = i > 0 && !opensSection ? body.length : void 0;
375
526
  if (i > 0 && !opensSection) body.push({
376
527
  kind: "paragraph",
377
528
  paragraph: {
@@ -395,40 +546,147 @@ function reconstructByLayout(file, mode = "flow") {
395
546
  });
396
547
  const columnsHere = ruledIntoColumns || !proseColumns(runs, gutters, textEdges) ? 1 : gutters.length + 1;
397
548
  const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
549
+ columnFeet[i] = columnsHere > 1 ? lastColumnFeet(blocks) : void 0;
398
550
  let led = false;
551
+ let begun = false;
552
+ const footOf = (band, col) => {
553
+ const column = blocks.filter((b) => b.band === band && b.col === col);
554
+ if (column.length === 0) return void 0;
555
+ return column.reduce((low, b) => b.top < low.top ? b : low).foot;
556
+ };
557
+ let prev;
558
+ const placedAt = /* @__PURE__ */ new Map();
399
559
  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";
560
+ if (columnsHere > 1 && prev !== void 0 && block.band === prev.band && prev.col !== SPANNING_COLUMN && block.col > prev.col) {
561
+ const ends = footOf(prev.band, prev.col);
562
+ const next = footOf(block.band, block.col);
563
+ const closed = blocks.some((b) => b.band === block.band && b.col === SPANNING_COLUMN);
564
+ if (ends !== void 0 && next !== void 0) {
565
+ const rest = blocks.slice(blocks.indexOf(block));
566
+ const skipped = rest.findIndex((b) => !floating(b.el));
567
+ const lead = skipped >= 0 ? leadingLine(rest[skipped].el) : void 0;
568
+ const opener = blocks.find((b) => b.band === block.band && b.col !== SPANNING_COLUMN && !floating(b.el));
569
+ const openerAt = opener !== void 0 ? placedAt.get(opener) : void 0;
570
+ const openerLine = opener !== void 0 ? leadingLine(opener.el) : void 0;
571
+ columnTurns.push({
572
+ at: body.length,
573
+ foot: ends,
574
+ next,
575
+ closed,
576
+ ...lead !== void 0 ? { head: {
577
+ at: body.length + skipped,
578
+ baseline: rest[skipped].top,
579
+ lineHeight: lead
580
+ } } : {},
581
+ ...opener !== void 0 && openerAt !== void 0 && openerLine !== void 0 ? { opens: {
582
+ at: openerAt,
583
+ baseline: opener.top,
584
+ lineHeight: openerLine
585
+ } } : {}
586
+ });
587
+ }
409
588
  }
589
+ prev = block;
410
590
  const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
411
591
  if (count !== curColumns) {
592
+ const opensPage = breakAt !== void 0 && !begun;
593
+ if (opensPage) {
594
+ const last = body[breakAt - 1];
595
+ const own = sectionEnds.every((e) => e.at < breakAt);
596
+ if (last?.kind === "paragraph" && own) body.pop();
597
+ else body[breakAt] = {
598
+ kind: "paragraph",
599
+ paragraph: {
600
+ properties: {
601
+ spacingLine: CARRIER_LINE_PT,
602
+ spacingLineRule: "exact"
603
+ },
604
+ runs: []
605
+ }
606
+ };
607
+ }
412
608
  sectionEnds.push({
413
609
  at: body.length,
414
610
  from: sectionFrom,
415
- to: i + 1,
611
+ to: opensPage ? i : i + 1,
416
612
  columns: curColumns,
417
613
  spacePt: curSpace,
418
614
  continuous: pendingContinuous
419
615
  });
420
616
  sectionFrom = i;
421
- pendingContinuous = true;
617
+ pendingContinuous = !opensPage;
422
618
  curColumns = count;
423
619
  curSpace = spacePt;
424
620
  }
621
+ if (!led && mode !== "positional") {
622
+ const lead = leadingLine(block.el);
623
+ if (lead !== void 0) leads.push({
624
+ page: i,
625
+ at: body.length,
626
+ baseline: block.top,
627
+ lineHeight: lead
628
+ });
629
+ led = lead !== void 0 || block.el.kind === "table" || block.el.kind === "paragraph";
630
+ }
631
+ placedAt.set(block, body.length);
425
632
  body.push(block.el);
633
+ begun = true;
426
634
  }
427
635
  });
636
+ const measured = (own, from, to) => withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to), shown[from]?.sheet ? void 0 : foot?.band);
637
+ const sheetRun = (from) => {
638
+ let start = from;
639
+ while (start > 0 && sheetSizes[start - 1] === sheetSizes[from]) start--;
640
+ let end = from + 1;
641
+ while (end < pages.length && sheetSizes[end] === sheetSizes[from]) end++;
642
+ return [start, end];
643
+ };
644
+ const setUps = /* @__PURE__ */ new Map();
428
645
  const setUp = (from, to) => {
429
- const own = sectionFromPdfPages(pages.slice(from, to));
430
- return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to), foot?.band);
646
+ const key = `${String(from)}:${String(to)}`;
647
+ if (setUps.has(key)) return setUps.get(key);
648
+ const own = sectionFromPdfPages(pages.slice(from, to), shown[from]);
649
+ let section = mode === "positional" ? own : measured(own, from, to);
650
+ const [start, end] = sheetRun(from);
651
+ if (mode !== "positional" && section?.margins && (start < from || end > to)) {
652
+ const sheet = measured(own, start, end)?.margins;
653
+ if (sheet) section = {
654
+ ...section,
655
+ margins: {
656
+ ...section.margins,
657
+ top: sheet.top,
658
+ bottom: sheet.bottom
659
+ }
660
+ };
661
+ }
662
+ setUps.set(key, section);
663
+ return section;
431
664
  };
665
+ const lastFeet = columnFeet[pages.length - 1];
666
+ const floor = setUp(sectionFrom, pages.length)?.margins?.bottom;
667
+ if (mode !== "positional" && curColumns > 1 && lastFeet !== void 0 && floor !== void 0 && balancedEnd(lastFeet, floor, medianFont)) {
668
+ sectionEnds.push({
669
+ at: body.length,
670
+ from: sectionFrom,
671
+ to: pages.length,
672
+ columns: curColumns,
673
+ spacePt: curSpace,
674
+ continuous: pendingContinuous
675
+ });
676
+ body.push({
677
+ kind: "paragraph",
678
+ paragraph: {
679
+ properties: {
680
+ spacingLine: CARRIER_LINE_PT,
681
+ spacingLineRule: "exact"
682
+ },
683
+ runs: []
684
+ }
685
+ });
686
+ curColumns = 1;
687
+ curSpace = 0;
688
+ pendingContinuous = true;
689
+ }
432
690
  sectionEnds.push({
433
691
  at: body.length,
434
692
  from: sectionFrom,
@@ -437,30 +695,16 @@ function reconstructByLayout(file, mode = "flow") {
437
695
  spacePt: curSpace,
438
696
  continuous: pendingContinuous
439
697
  });
440
- const sections = sectionEnds.length > 1 ? sectionEnds.flatMap((end) => {
441
- const base = setUp(end.from, end.to);
442
- if (!base) return [];
443
- return [{
444
- properties: {
445
- ...base,
446
- ...end.columns > 1 && mode !== "positional" ? { columns: {
447
- count: end.columns,
448
- spacePt: end.spacePt
449
- } } : {},
450
- ...end.continuous ? { sectionStart: "continuous" } : {}
451
- },
452
- endIndex: end.at
453
- }];
454
- }) : [];
455
698
  for (const lead of leads) {
456
699
  const end = sectionEnds.find((e) => e.at > lead.at);
457
700
  const top = (end ? setUp(end.from, end.to) : setUp(0, pages.length))?.margins?.top;
458
701
  const page = shown[lead.page];
459
702
  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;
703
+ if (top === void 0 || page === void 0) continue;
704
+ const border = el?.kind === "paragraph" ? el.paragraph.properties.borders?.top?.width ?? 0 : 0;
705
+ const before = page.height - top - lead.baseline - BASELINE_AT * lead.lineHeight - border;
462
706
  if (before <= SPACING_NOISE_PT) continue;
463
- body[lead.at] = {
707
+ if (el?.kind === "paragraph") body[lead.at] = {
464
708
  ...el,
465
709
  paragraph: {
466
710
  ...el.paragraph,
@@ -470,27 +714,127 @@ function reconstructByLayout(file, mode = "flow") {
470
714
  }
471
715
  }
472
716
  };
717
+ else if (el?.kind === "shape") body[lead.at] = {
718
+ ...el,
719
+ shape: {
720
+ ...el.shape,
721
+ paragraphProperties: {
722
+ ...el.shape.paragraphProperties,
723
+ spacingBefore: pt(before)
724
+ }
725
+ }
726
+ };
727
+ }
728
+ const line = medianFont * NATURAL_LINE_EM;
729
+ const breaks = [];
730
+ for (const turn of columnTurns) {
731
+ const end = sectionEnds.find((e) => e.at > turn.at);
732
+ const margins = (end ? setUp(end.from, end.to) : void 0)?.margins;
733
+ if (turn.closed) {
734
+ if (Math.abs(turn.foot - turn.next) <= line * BALANCED_LINES) continue;
735
+ } else if (margins?.bottom === void 0 || turn.foot - margins.bottom < line * SHORT_LINES) continue;
736
+ const { head: start, opens } = turn;
737
+ const opener = opens !== void 0 ? body[opens.at] : void 0;
738
+ const openerProps = opener?.kind === "paragraph" ? opener.paragraph.properties : opener?.kind === "shape" ? opener.shape.paragraphProperties : void 0;
739
+ const before = start !== void 0 && opens !== void 0 && openerProps !== void 0 ? (openerProps.spacingBefore ?? 0) + (openerProps.borders?.top?.width ?? 0) + (opens.baseline + BASELINE_AT * opens.lineHeight) - (start.baseline + BASELINE_AT * start.lineHeight) : void 0;
740
+ breaks.push({
741
+ at: turn.at,
742
+ ...start !== void 0 && before !== void 0 ? { head: {
743
+ at: start.at,
744
+ before
745
+ } } : {}
746
+ });
473
747
  }
474
- const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
475
- const edges0 = pageTextEdges(allRuns[0] ?? []);
476
- const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
477
- const headBand = head ? footerBand(head.band, stepped0, edges0, head.numbered) : [];
748
+ for (const brk of [...breaks].sort((x, y) => y.at - x.at)) {
749
+ const el = brk.head !== void 0 ? body[brk.head.at] : void 0;
750
+ if (brk.head !== void 0 && el?.kind === "paragraph") {
751
+ const border = el.paragraph.properties.borders?.top?.width ?? 0;
752
+ const before = brk.head.before - border;
753
+ const { spacingBefore: _, ...rest } = el.paragraph.properties;
754
+ body[brk.head.at] = {
755
+ ...el,
756
+ paragraph: {
757
+ ...el.paragraph,
758
+ properties: before > SPACING_NOISE_PT ? {
759
+ ...rest,
760
+ spacingBefore: pt(before)
761
+ } : rest
762
+ }
763
+ };
764
+ }
765
+ body.splice(brk.at, 0, {
766
+ kind: "paragraph",
767
+ paragraph: {
768
+ properties: {
769
+ spacingLine: CARRIER_LINE_PT,
770
+ spacingLineRule: "exact"
771
+ },
772
+ runs: [{
773
+ text: "\n",
774
+ properties: {},
775
+ columnBreak: true
776
+ }]
777
+ }
778
+ });
779
+ for (const e of sectionEnds) if (e.at > brk.at) e.at++;
780
+ }
781
+ const sections = sectionEnds.length > 1 ? sectionEnds.flatMap((end) => {
782
+ const base = sectionOnSheet(setUp(end.from, end.to), shown[end.from]);
783
+ if (!base) return [];
784
+ return [{
785
+ properties: {
786
+ ...base,
787
+ ...numberedFrom(numbering, end.from),
788
+ ...end.columns > 1 && mode !== "positional" ? { columns: {
789
+ count: end.columns,
790
+ spacePt: end.spacePt
791
+ } } : {},
792
+ ...end.continuous ? { sectionStart: "continuous" } : {}
793
+ },
794
+ endIndex: end.at
795
+ }];
796
+ }) : [];
797
+ let sectionStart = 0;
798
+ for (const end of sectionEnds) {
799
+ const sheet = shown[end.from]?.sheet;
800
+ if (sheet) for (let k = sectionStart; k < end.at; k++) body[k] = floatOntoSheet(body[k], sheet.width);
801
+ sectionStart = end.at;
802
+ }
803
+ const setOn = (runs, page, numbered, numeral) => footerBand(runs, stepsBetweenWords(allRuns[page] ?? []), pageTextEdges(allRuns[page] ?? []), numbered, numeral);
804
+ const firstOf = (of) => Math.max(0, of?.lift.findIndex((set) => set.size > 0) ?? 0);
805
+ const numeralOf = (of) => {
806
+ if (of === void 0 || of !== numberedBand || numbering === void 0) return void 0;
807
+ const first = of.lift.findIndex((set) => set.size > 0);
808
+ return first >= 0 ? numbering.numbers[first]?.text : void 0;
809
+ };
810
+ const sides = foot !== void 0 && foot === numberedBand && numbering !== void 0 ? sidesOf(foot, numbering, shown.map((s) => s.width / 2)) : void 0;
811
+ const band = foot ? sides ? setOn(sides.odd.runs, sides.odd.page, true, sides.odd.numeral) : setOn(foot.band, firstOf(foot), foot.numbered, numeralOf(foot)) : [];
812
+ const evenBand = sides ? setOn(sides.even.runs, sides.even.page, true, sides.even.numeral) : [];
813
+ const headBand = head ? setOn(head.band, firstOf(head), head.numbered, numeralOf(head)) : [];
478
814
  const withFooter = (properties) => properties ? {
479
815
  ...properties,
480
816
  ...band.length > 0 ? { footers: [{
481
817
  type: "default",
482
818
  relationshipId: FOOTER_PART
483
- }] } : {},
819
+ }, ...evenBand.length > 0 ? [{
820
+ type: "even",
821
+ relationshipId: EVEN_FOOTER_PART
822
+ }] : []] } : {},
823
+ ...evenBand.length > 0 ? { evenAndOddHeaders: true } : {},
484
824
  ...headBand.length > 0 ? { headers: [{
485
825
  type: "default",
486
826
  relationshipId: HEADER_PART
487
827
  }] } : {}
488
828
  } : properties;
489
829
  return {
490
- doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
830
+ doc: buildFlowDoc(body, resources, withFooter(numberedAs(sectionOnSheet(setUp(0, pages.length), shown[0]), numbering)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
491
831
  ...s,
492
832
  properties: withFooter(s.properties) ?? s.properties
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)),
833
+ })), band.length > 0 || headBand.length > 0 ? new Map([
834
+ ...band.length > 0 ? [[FOOTER_PART, band]] : [],
835
+ ...evenBand.length > 0 ? [[EVEN_FOOTER_PART, evenBand]] : [],
836
+ ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []
837
+ ]) : void 0, collectFaceFamilies(file, pages), outlines, kernedFaces(spacing)),
494
838
  losses: dedupeLosses(losses)
495
839
  };
496
840
  }
@@ -503,7 +847,8 @@ function detectGutters(runs, pageWidth) {
503
847
  });
504
848
  if (spans.length === 0) return [];
505
849
  const minX = Math.min(...spans.map((iv) => iv[0]));
506
- const span = Math.max(...spans.map((iv) => iv[1])) - minX;
850
+ const maxX = Math.max(...spans.map((iv) => iv[1]));
851
+ const span = maxX - minX;
507
852
  if (span < pageWidth * .5) return [];
508
853
  const sorted = [...spans].sort((a, b) => a[0] - b[0]);
509
854
  let curEnd = sorted[0][1];
@@ -544,6 +889,7 @@ function detectGutters(runs, pageWidth) {
544
889
  const after = row.filter(([l]) => l >= x);
545
890
  if (before.length === 0 || after.length === 0) continue;
546
891
  if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
892
+ else crossing++;
547
893
  }
548
894
  if (columned < MIN_COLUMNED_ROWS || crossing >= columned) continue;
549
895
  const last = voted[voted.length - 1];
@@ -559,9 +905,124 @@ function detectGutters(runs, pageWidth) {
559
905
  });
560
906
  }
561
907
  const near = (band) => empty.some((e) => band.mid > e.from - fontSize && band.mid < e.to + fontSize);
562
- return separating([...empty, ...voted.filter((v) => !near(v))].sort((a, b) => a.mid - b.mid), spans);
908
+ return withoutNarrowColumns(separating([...empty, ...voted.filter((v) => !near(v))].sort((a, b) => a.mid - b.mid), spans), empty, rows, [minX, maxX], fontSize);
909
+ }
910
+ /**
911
+ * The gutters left when no column beside a gap the page's lines run across is
912
+ * narrower than a page's column can be.
913
+ *
914
+ * A table's columns stand apart as a page's do, line after line, and a page
915
+ * set in columns of tables has gaps between its tables' columns as well as
916
+ * its own: canvas.pdf sets two columns of Name, Type and Default, and read at
917
+ * every gap its sheets came back in three and five columns, a word wide each,
918
+ * on seven pages where it has two. What gives such a gap away is that the
919
+ * page's other lines — the headings over each table, the prose between them —
920
+ * run across it, and that a column it leaves is a few ems wide: such a gap
921
+ * goes, the one with the less white beside the narrowest column first, until
922
+ * no column is left so narrow. A gap NO line crosses stays however narrow the
923
+ * columns beside it — a page that is one table from edge to edge is read by
924
+ * its columns, as a table — and so does a page's one gap.
925
+ *
926
+ * The gaps that stay are then measured afresh, as the white every line split
927
+ * there leaves. The vote answers wherever more lines stand apart than run
928
+ * across, which is where MOST of a column's lines end and not where its
929
+ * longest does: a gutter voted across a column of short cells reaches back
930
+ * into that column, and a section set with it stood its columns sixty-five
931
+ * points further apart than the page does. freeculture.pdf's index sets its
932
+ * entries ragged, two columns six to twenty points apart, and set seventy
933
+ * apart its columns came back a third narrower than the page's: the thirteen
934
+ * pages of the index ran to twenty-seven, and its longest entries ran on past
935
+ * the middle of the white and were read as lines across the page.
936
+ *
937
+ * @param gutters The gutters found, left to right.
938
+ * @param firm The gutters no line crosses, which stay.
939
+ * @param rows Each line's ink across the page, left to right.
940
+ * @param extent Where the page's ink begins and ends.
941
+ * @param fontSize The page's body size.
942
+ */
943
+ function withoutNarrowColumns(gutters, firm, rows, extent, fontSize) {
944
+ const kept = [...gutters];
945
+ const least = fontSize * NARROWEST_COLUMN_EM;
946
+ const white = (g) => g.to - g.from;
947
+ while (kept.length > 1) {
948
+ const edges = [
949
+ extent[0],
950
+ ...kept.flatMap((g) => [g.from, g.to]),
951
+ extent[1]
952
+ ];
953
+ let narrowest;
954
+ for (let k = 0; k <= kept.length; k++) {
955
+ const width = edges[2 * k + 1] - edges[2 * k];
956
+ if (width >= least || narrowest && narrowest.width <= width) continue;
957
+ const beside = [kept[k - 1], kept[k]].filter((g) => g !== void 0 && !firm.includes(g));
958
+ if (beside.length === 0) continue;
959
+ narrowest = {
960
+ width,
961
+ gap: beside.reduce((a, b) => white(b) < white(a) ? b : a)
962
+ };
963
+ }
964
+ if (!narrowest) break;
965
+ kept.splice(kept.indexOf(narrowest.gap), 1);
966
+ }
967
+ return kept.map((g) => {
968
+ if (firm.includes(g)) return g;
969
+ const starts = [];
970
+ const ends = [];
971
+ for (const row of rows) {
972
+ let widest;
973
+ let reach = row[0]?.[1] ?? -Infinity;
974
+ for (const [l, r] of row.slice(1)) {
975
+ const overlaps = l > g.from && reach < g.to;
976
+ if (l - reach >= fontSize * MIN_GUTTER_EM && overlaps) {
977
+ if (!widest || l - reach > widest[1] - widest[0]) widest = [reach, l];
978
+ }
979
+ reach = Math.max(reach, r);
980
+ }
981
+ if (!widest) continue;
982
+ starts.push(widest[0]);
983
+ ends.push(widest[1]);
984
+ }
985
+ if (starts.length === 0) return g;
986
+ const from = sharedEdge(starts) ?? Math.max(...starts);
987
+ const to = Math.min(...ends);
988
+ return to > from ? {
989
+ from,
990
+ to,
991
+ mid: (from + to) / 2
992
+ } : g;
993
+ });
563
994
  }
564
995
  /**
996
+ * The edge at least half of `ends` agree on, to {@link FLUSH_PT}: the furthest
997
+ * out of the agreeing ones, which every one of them clears — where no more
998
+ * than a line in twenty stops past it, set over the measure. Lines of one
999
+ * length agree on where they stop too, and the column's longer lines past
1000
+ * them are its measure.
1001
+ *
1002
+ * @param ends Where each line stops.
1003
+ * @returns The edge, or `undefined` where no half of them agree on one.
1004
+ */
1005
+ function sharedEdge(ends) {
1006
+ const sorted = [...ends].sort((a, b) => a - b);
1007
+ let best;
1008
+ let low = 0;
1009
+ for (let high = 0; high < sorted.length; high++) {
1010
+ while (sorted[high] - sorted[low] > FLUSH_PT) low++;
1011
+ if (!best || high - low > best[1] - best[0]) best = [low, high];
1012
+ }
1013
+ if (!best || (best[1] - best[0] + 1) * 2 < sorted.length) return void 0;
1014
+ if (sorted.length - 1 - best[1] > Math.max(1, Math.floor(sorted.length * OVERSET_SHARE))) return void 0;
1015
+ return sorted[best[1]];
1016
+ }
1017
+ /** How many of a column's lines, as a share of them, may be set over its measure. */
1018
+ var OVERSET_SHARE = .05;
1019
+ /**
1020
+ * How narrow, in ems of the page's body, a column of the page may be: narrower
1021
+ * is a table's. The columns canvas.pdf's tables leave are two to six ems wide,
1022
+ * and eight is four or five words of prose.
1023
+ */
1024
+ var NARROWEST_COLUMN_EM = 8;
1025
+ /**
565
1026
  * The candidate gutters that actually separate something, left to right.
566
1027
  *
567
1028
  * A gutter with nothing on one side of it is a margin, and two of them with a
@@ -815,6 +1276,13 @@ function assignColumns(runs, gutters) {
815
1276
  for (const run of row) columnOf.set(run, SPANNING_COLUMN);
816
1277
  breaks.push(Math.max(...row.map((r) => r.y)));
817
1278
  }
1279
+ const inRows = rows.filter((row) => row.some((r) => r.text.trim() !== ""));
1280
+ const across = (block) => block.some((row) => row.some((r) => columnOf.get(r) === SPANNING_COLUMN));
1281
+ const blocks = [...blocksOf(inRows, isCode, 1).filter(across), ...blocksOf(inRows, isTableRow, LEAST_TABLE_ROWS).filter((block) => across(block) || block.length >= SPANNING_TABLE_ROWS)];
1282
+ for (const block of blocks) for (const row of block) {
1283
+ if (row.some((r) => columnOf.get(r) !== SPANNING_COLUMN)) breaks.push(Math.max(...row.map((r) => r.y)));
1284
+ for (const run of row) columnOf.set(run, SPANNING_COLUMN);
1285
+ }
818
1286
  return {
819
1287
  columnOf,
820
1288
  breaks: breaks.sort((a, b) => b - a)
@@ -967,7 +1435,7 @@ function rotation60kOf(angleDeg) {
967
1435
  * 0.05, and thirty at 0.05 em or more. A twentieth of an em sits in that gap.
968
1436
  */
969
1437
  var BASELINE_STEP_EM = .05;
970
- function groupIntoLines(runs, split = false, stepped = false) {
1438
+ function groupIntoLines(runs, split = false, stepped = false, spaces, rules = []) {
971
1439
  const sorted = [...runs].sort((a, b) => b.y - a.y || a.x - b.x);
972
1440
  const clusters = [];
973
1441
  for (const run of sorted) {
@@ -975,6 +1443,7 @@ function groupIntoLines(runs, split = false, stepped = false) {
975
1443
  const tol = Math.max(1, (run.fontSizePt || 10) * .5);
976
1444
  if (last && Math.abs(last.y - run.y) <= tol) {
977
1445
  last.runs.push(run);
1446
+ if ((run.fontSizePt || 0) > last.fontSize) last.y = run.y;
978
1447
  last.fontSize = Math.max(last.fontSize, run.fontSizePt || 0);
979
1448
  } else clusters.push({
980
1449
  y: run.y,
@@ -983,11 +1452,11 @@ function groupIntoLines(runs, split = false, stepped = false) {
983
1452
  });
984
1453
  }
985
1454
  for (const c of clusters) c.runs.sort((a, b) => a.x - b.x);
986
- const stops = sharedStops(clusters.map((c) => c.runs));
1455
+ const stops = sharedStops(clusters.map((c) => c.runs), rules);
987
1456
  return clusters.flatMap((c) => {
988
1457
  const ordered = c.runs;
989
1458
  const fontSize = c.fontSize || 10;
990
- if (!split) return [lineOf(ordered, c.y, fontSize, stepped, stops)];
1459
+ if (!split) return [lineOf(ordered, c.y, fontSize, stepped, stops, spaces)];
991
1460
  const pieces = [[]];
992
1461
  for (const run of ordered) {
993
1462
  const prev = pieces[pieces.length - 1];
@@ -996,10 +1465,35 @@ function groupIntoLines(runs, split = false, stepped = false) {
996
1465
  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([]);
997
1466
  pieces[pieces.length - 1].push(run);
998
1467
  }
999
- return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize, stepped, stops));
1468
+ return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize, stepped, stops, spaces));
1000
1469
  });
1001
1470
  }
1002
1471
  /**
1472
+ * Whether a block's lines are PROSE: many of them, across a measure a sentence
1473
+ * is set to, and most running out to the edge that measure breaks them at, as
1474
+ * a column of text does — where a stack of labels and values, or an address,
1475
+ * is short lines of their own lengths.
1476
+ *
1477
+ * @param lines The block's lines, top first.
1478
+ * @param own The block's own left and right edges.
1479
+ * @returns True for a column of running text.
1480
+ */
1481
+ function isProse(lines, own) {
1482
+ if (lines.length < PROSE_LINES) return false;
1483
+ const size = median(lines.map((l) => l.fontSize)) || 10;
1484
+ if (own.right - own.left < size * PROSE_MEASURE_EM) return false;
1485
+ const asked = lines.slice(0, -1);
1486
+ return asked.filter((l) => l.x + l.width >= own.right - l.fontSize * FULL_LINE_EM).length >= asked.length * PROSE_FULL_SHARE;
1487
+ }
1488
+ /** How many lines a block needs before it can be told for prose. */
1489
+ var PROSE_LINES = 5;
1490
+ /** The narrowest measure, in ems, a column of prose is set to. */
1491
+ var PROSE_MEASURE_EM = 20;
1492
+ /** How near the block's edge, in ems, a line has to end to have run out to it. */
1493
+ var FULL_LINE_EM = 2;
1494
+ /** The share of a block's lines that run out to its edge in a column of prose. */
1495
+ var PROSE_FULL_SHARE = .6;
1496
+ /**
1003
1497
  * §17.3.1.38 — the stops a block of lines is set out on: where text resumes,
1004
1498
  * after a gap wider than a word space, at the same place on more than one line
1005
1499
  * — and on at least one of them after a gap only a tab makes.
@@ -1010,21 +1504,32 @@ function groupIntoLines(runs, split = false, stepped = false) {
1010
1504
  * other two came back with their values a word's width after the label
1011
1505
  * instead of in the column.
1012
1506
  *
1507
+ * …or a gap a rule is drawn down. A table ruled into columns needs no white
1508
+ * wider than a space between them, the rule says where one ends: comments.pdf's
1509
+ * Figure 9 sets its codes "xx1", "000" in a column all as wide as each other,
1510
+ * twelve points from the types beside them and a rule between, and the two
1511
+ * columns came back one, "xx1 number", with the rule struck through it.
1512
+ *
1013
1513
  * @param lines Each line's runs, left to right.
1514
+ * @param rules The rules drawn down the block (see {@link columnRules}).
1014
1515
  * @returns The x of every stop two lines share.
1015
1516
  */
1016
- function sharedStops(lines) {
1517
+ function sharedStops(lines, rules = []) {
1017
1518
  const seen = [];
1018
1519
  lines.forEach((runs, line) => {
1019
1520
  let end;
1020
1521
  for (const run of runs) {
1021
1522
  if (run.text.replaceAll("�", "").trim() === "") continue;
1022
1523
  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
- });
1524
+ if (end !== void 0 && run.x - end >= size * STOP_GAP_EM) {
1525
+ const from = end;
1526
+ const ruled = rules.some((r) => r.x > from && r.x < run.x && r.minY <= run.y + size * .5 && r.maxY >= run.y);
1527
+ seen.push({
1528
+ x: run.x,
1529
+ line,
1530
+ tab: ruled || run.x - end >= size * TAB_GAP_EM
1531
+ });
1532
+ }
1028
1533
  end = Math.max(end ?? run.endX, run.endX);
1029
1534
  }
1030
1535
  });
@@ -1037,6 +1542,23 @@ function sharedStops(lines) {
1037
1542
  }
1038
1543
  /** The least gap, in ems, that lands a run on a stop the lines around it share. */
1039
1544
  var STOP_GAP_EM = .5;
1545
+ /**
1546
+ * The rules a page draws down its lines: thin upright paths, a line's height
1547
+ * long at the least — the edges of a table's columns.
1548
+ *
1549
+ * @param vectors The page's paths.
1550
+ */
1551
+ function columnRules(vectors) {
1552
+ return vectors.filter((v) => v.glyph !== true && v.maxX - v.minX <= COLUMN_RULE_THIN_PT && v.maxY - v.minY >= COLUMN_RULE_LEAST_PT).map((v) => ({
1553
+ x: (v.minX + v.maxX) / 2,
1554
+ minY: v.minY,
1555
+ maxY: v.maxY
1556
+ }));
1557
+ }
1558
+ /** No thicker than this, a path drawn down the page is a rule. */
1559
+ var COLUMN_RULE_THIN_PT = 1;
1560
+ /** …and no shorter than this: a rule stands at least a line of small type tall. */
1561
+ var COLUMN_RULE_LEAST_PT = 5;
1040
1562
  /** How far from a shared stop a run may start and still stand on it. */
1041
1563
  var STOP_SLACK_PT = .75;
1042
1564
  /**
@@ -1070,9 +1592,154 @@ function inkSpan(runs) {
1070
1592
  width: Math.max(0, last.endX - trail - x)
1071
1593
  };
1072
1594
  }
1595
+ /**
1596
+ * A document's runs as its listings are read (see {@link isCode}).
1597
+ *
1598
+ * A typewriter face is a listing's only where the document is set in another:
1599
+ * a letter typed throughout, a screenplay, a page set in Courier for its even
1600
+ * widths, is PROSE in that face, and its lines run on and its double spaces
1601
+ * are spaces. Where most of what the document shows is typewritten, its runs
1602
+ * are read as any other face's.
1603
+ *
1604
+ * @param pages Each page's runs.
1605
+ * @returns The same runs, or copies without `fixedPitch` where the typewriter
1606
+ * face is the document's own.
1607
+ */
1608
+ function typewritten(pages) {
1609
+ let typed = 0;
1610
+ let all = 0;
1611
+ for (const runs of pages) for (const run of runs) {
1612
+ const letters = run.text.replace(/\s/gu, "").length;
1613
+ all += letters;
1614
+ if (run.fixedPitch === true) typed += letters;
1615
+ }
1616
+ if (typed === 0 || typed * 2 <= all) return pages;
1617
+ return pages.map((runs) => runs.map((run) => {
1618
+ if (run.fixedPitch !== true) return run;
1619
+ const { fixedPitch: _typed, ...prose } = run;
1620
+ return prose;
1621
+ }));
1622
+ }
1623
+ /**
1624
+ * Whether a line is CODE: every word of it set in a typewriter face (§9.8.2).
1625
+ *
1626
+ * A listing is set line by line with its indents and its comments lined up in
1627
+ * columns of the typewriter's cells, and re-set as prose it is no program:
1628
+ * comments.pdf's "v0 := ld state[748]" and "st sp[0], v0" ran together as
1629
+ * one line of a paragraph, and so did line 4 of Figure 1 and its line 5.
1630
+ */
1631
+ function isCode(runs) {
1632
+ const inked = runs.filter((r) => r.text.trim() !== "");
1633
+ return inked.length > 0 && inked.every((r) => r.fixedPitch === true);
1634
+ }
1635
+ /**
1636
+ * Whether the white between two runs of a typewriter face LINES UP what
1637
+ * follows it: wider than a cell and a half, where a word space is one cell.
1638
+ * A listing puts its comments in a column that way, and a space in their
1639
+ * place set each comment against its own line's code.
1640
+ */
1641
+ function aligned(before, after) {
1642
+ if (before.fixedPitch !== true || after.fixedPitch !== true) return false;
1643
+ const letters = [...before.text].length;
1644
+ if (letters === 0) return false;
1645
+ const cell = (before.endX - before.x) / letters;
1646
+ return cell > 0 && after.x - before.endX >= cell * ALIGNED_CELLS;
1647
+ }
1648
+ /** How many of a typewriter's cells of white line up what follows them. */
1649
+ var ALIGNED_CELLS = 1.5;
1650
+ /**
1651
+ * A page's runs less its code listings: the rows every word of which is set
1652
+ * in a typewriter face (see {@link isCode}).
1653
+ *
1654
+ * A listing says nothing about the page's columns. comments.pdf sets two
1655
+ * across the head of its third page, the code at the left and each comment
1656
+ * lined up at one x: the white between them — thirty-five lines of it at the
1657
+ * same place — was taken for the gutter of a page in two columns, while the
1658
+ * real one, under the listings, was crossed by every comment and not found.
1659
+ * The code came back as a column of prose and the comments as another.
1660
+ */
1661
+ function withoutListings(runs) {
1662
+ if (!runs.some((r) => r.fixedPitch === true)) return runs;
1663
+ const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1664
+ const listed = new Set(rowsOf(runs, fontSize).filter(isCode).flat());
1665
+ return listed.size > 0 ? runs.filter((r) => !listed.has(r)) : runs;
1666
+ }
1667
+ /**
1668
+ * A page's runs less its tables (see {@link isTableRow}): the rows of every
1669
+ * block of three and more table rows. The same runs where it has none.
1670
+ *
1671
+ * The white between a table's columns says nothing about the columns of the
1672
+ * text around it. comments.pdf sets Figure 13, a benchmark and nine figures a
1673
+ * row, twenty-six rows deep, over two columns of text: its nine gaps were
1674
+ * taken for the page's gutters, the text's own was crossed by its rows and
1675
+ * not found, and the whole page came back as one table, each line of the text
1676
+ * a row of it.
1677
+ */
1678
+ function withoutTables(runs) {
1679
+ const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.some((r) => r.text.trim() !== ""));
1680
+ const tabled = new Set(blocksOf(rows, isTableRow, LEAST_TABLE_ROWS).flat(2));
1681
+ return tabled.size > 0 ? runs.filter((r) => !tabled.has(r)) : runs;
1682
+ }
1683
+ /**
1684
+ * The rows of a page that stand in runs of at least `least` for which `holds`
1685
+ * is true, each run of them a block.
1686
+ */
1687
+ function blocksOf(rows, holds, least) {
1688
+ const out = [];
1689
+ for (let from = 0; from < rows.length;) {
1690
+ let to = from;
1691
+ while (to < rows.length && holds(rows[to])) to++;
1692
+ if (to - from >= least) out.push(rows.slice(from, to));
1693
+ from = Math.max(to, from + 1);
1694
+ }
1695
+ return out;
1696
+ }
1697
+ /**
1698
+ * Whether a row is a TABLE's: four cells or more — its words as far as each
1699
+ * gap wider than an em — and none of them as long as a line of prose. A row
1700
+ * of a page in columns is two long pieces; comments.pdf's Figure 13 is ten
1701
+ * short ones a row, a benchmark and nine figures, twenty-six rows deep.
1702
+ */
1703
+ function isTableRow(runs) {
1704
+ const cells = cellsOf(runs);
1705
+ if (cells.length < TABLE_CELLS) return false;
1706
+ const size = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1707
+ return cells.every((c) => c.to - c.from <= size * WIDEST_CELL_EM);
1708
+ }
1709
+ /** A row's ink as its cells: its words, as far as each gap wider than an em. */
1710
+ function cellsOf(runs) {
1711
+ const inks = runs.map((run) => ({
1712
+ run,
1713
+ ink: runInk(run)
1714
+ })).filter((r) => r.ink !== void 0).sort((a, b) => a.ink[0] - b.ink[0]);
1715
+ const cells = [];
1716
+ for (const { run, ink } of inks) {
1717
+ const last = cells[cells.length - 1];
1718
+ if (last && ink[0] - last.to < (run.fontSizePt || 10) * CELL_GAP_EM) last.to = Math.max(last.to, ink[1]);
1719
+ else cells.push({
1720
+ from: ink[0],
1721
+ to: ink[1]
1722
+ });
1723
+ }
1724
+ return cells;
1725
+ }
1726
+ /** How many cells make a row a table's. */
1727
+ var TABLE_CELLS = 4;
1728
+ /** How many such rows make a table. */
1729
+ var LEAST_TABLE_ROWS = 3;
1730
+ /**
1731
+ * …and how many make one across the page's columns with no row of it
1732
+ * reaching over the gutter on its own: more than two columns' rows fall
1733
+ * together by chance.
1734
+ */
1735
+ var SPANNING_TABLE_ROWS = 6;
1736
+ /** The white between two cells of a table row, in ems: wider than a word space ever is. */
1737
+ var CELL_GAP_EM = 1;
1738
+ /** The longest a table's cell runs, in ems; a line of a column of prose runs further. */
1739
+ var WIDEST_CELL_EM = 12;
1073
1740
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
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);
1741
+ function lineOf(runs, y, fontSize, stepped, shared = [], spaces) {
1742
+ const { spans: ordered, stops, pieces } = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)), shared, spaces);
1076
1743
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
1077
1744
  const ink = inkSpan(runs);
1078
1745
  return {
@@ -1085,6 +1752,7 @@ function lineOf(runs, y, fontSize, stepped, shared = []) {
1085
1752
  stops,
1086
1753
  pieces
1087
1754
  } : {},
1755
+ ...isCode(runs) ? { code: true } : {},
1088
1756
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
1089
1757
  spans
1090
1758
  };
@@ -1138,10 +1806,25 @@ var TAB_GAP_EM = 2.5;
1138
1806
  * {@link stepsBetweenWords}). The tight one still clears the gaps a producer
1139
1807
  * leaves INSIDE a word when it splits one for kerning, which measure eight
1140
1808
  * hundredths of an em at their widest across this corpus.
1809
+ *
1810
+ * …and a page that draws its spaces may step some of them all the same: a
1811
+ * gap as wide as most of the face's own space is one, set by a step instead
1812
+ * of a glyph. freeculture.pdf's index draws the space in "academic journals"
1813
+ * and steps the one after "journals," — 1.71 points against a space of 2.03,
1814
+ * short of a quarter em — and the entry came back "journals,262,280–82".
1141
1815
  */
1142
1816
  function spaceGap(prev, fontSize, stepped) {
1143
- return (prev.fontSizePt || fontSize) * (stepped ? STEPPED_SPACE_EM : DRAWN_SPACE_EM);
1817
+ const size = prev.fontSizePt || fontSize;
1818
+ if (stepped) return size * STEPPED_SPACE_EM;
1819
+ const space = prev.spaceWidthPt;
1820
+ const face = space !== void 0 && space >= size * LEAST_FACE_SPACE_EM && space <= size * MOST_FACE_SPACE_EM ? space * STEPPED_SPACE_SHARE : Infinity;
1821
+ return Math.min(size * DRAWN_SPACE_EM, face);
1144
1822
  }
1823
+ /** How much of the face's own space a step has to be to stand for one. */
1824
+ var STEPPED_SPACE_SHARE = .75;
1825
+ /** The narrowest and widest space, in ems, a face is taken at its word for. */
1826
+ var LEAST_FACE_SPACE_EM = .15;
1827
+ var MOST_FACE_SPACE_EM = .5;
1145
1828
  /**
1146
1829
  * §17.3.1.25 — one character of a LEADER, the dotted rule that carries the eye
1147
1830
  * across a table of contents.
@@ -1156,6 +1839,17 @@ function spaceGap(prev, fontSize, stepped) {
1156
1839
  function isLeader(text) {
1157
1840
  return text.length === 1 && LEADER_CHARS.has(text);
1158
1841
  }
1842
+ /**
1843
+ * Whether `run` carries on the leader `prev` is a character of: the same
1844
+ * character, a step no wider than a word space on from it. A dash a column
1845
+ * further on is a cell's: comments.pdf's Figure 13 marks three empty figures
1846
+ * "-", a column apart, and joined as one leader they came back "---", one
1847
+ * cell standing where three had.
1848
+ */
1849
+ function continuesLeader(prev, run, fontSize) {
1850
+ const dots = prev.text.endsWith(".") && run.text.startsWith(".");
1851
+ return (isLeader(prev.text) && prev.text === run.text || dots) && run.x - prev.endX < (run.fontSizePt || fontSize) * CELL_GAP_EM;
1852
+ }
1159
1853
  var LEADER_CHARS = new Set([
1160
1854
  ".",
1161
1855
  "·",
@@ -1180,34 +1874,47 @@ function stepsBetweenWords(runs) {
1180
1874
  if (runs.length < 8) return false;
1181
1875
  return runs.filter((r) => /\s/u.test(r.text)).length / runs.length < .05;
1182
1876
  }
1183
- function lineSpans(runs, fontSize, stepped, shared = []) {
1877
+ function lineSpans(runs, fontSize, stepped, shared = [], spaces) {
1184
1878
  const spans = [];
1185
1879
  const stops = [];
1186
1880
  const pieces = [{
1187
1881
  from: Infinity,
1188
1882
  to: -Infinity
1189
1883
  }];
1884
+ const table = isTableRow(runs);
1885
+ const code = isCode(runs);
1190
1886
  const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
1191
1887
  const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
1192
1888
  let prev;
1193
1889
  let inked;
1194
1890
  for (const [i, run] of runs.entries()) {
1195
- if (run.text.trim() === "" && run.endX - run.x <= 0 && inked !== void 0) {
1891
+ if (run.text.trim() === "" && inked !== void 0) {
1196
1892
  const next = runs.slice(i + 1).find((r) => r.text.trim() !== "");
1197
- if (next !== void 0 && next.x - inked < spaceGap(next, fontSize, true)) continue;
1893
+ if ((run.endX - run.x <= 0 || next !== void 0 && next.x < (run.x + run.endX) / 2) && next !== void 0 && next.x - inked < spaceGap(next, fontSize, true)) continue;
1198
1894
  }
1199
1895
  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)) {
1896
+ if (!code && prev !== void 0 && prev.text.trim() === "" && run.text.trim() !== "" && inked !== void 0 && run.x - inked >= (run.fontSizePt || fontSize) * STOP_GAP_EM && shared.some((x) => Math.abs(x - run.x) <= STOP_SLACK_PT)) {
1897
+ while (spans.length > 0 && spans[spans.length - 1].text.trim() === "") {
1898
+ if (spans[spans.length - 1].text === " ") break;
1899
+ spans.pop();
1900
+ }
1901
+ spans.push({ text: " " });
1902
+ stops.push(run.x);
1903
+ pieces.push({
1904
+ from: Infinity,
1905
+ to: -Infinity
1906
+ });
1907
+ } else if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !continuesLeader(prev, run, fontSize)) {
1201
1908
  const size = run.fontSizePt || fontSize;
1202
1909
  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) {
1910
+ if (run.x - prev.endX >= size * TAB_GAP_EM || onStop || aligned(prev, run) || table && run.x - prev.endX >= size * CELL_GAP_EM) {
1204
1911
  spans.push({ text: " " });
1205
1912
  stops.push(run.x);
1206
1913
  pieces.push({
1207
1914
  from: Infinity,
1208
1915
  to: -Infinity
1209
1916
  });
1210
- } else spans.push(spaceAfter(spans[spans.length - 1]));
1917
+ } else if (!/\s$/u.test(prev.text) && !/^\s/u.test(run.text) || run.x - prev.endX > (prev.fontSizePt || fontSize) * DRAWN_SPACE_EM) spans.push(fittedSpace(spaceAfter(spans[spans.length - 1], run), prev, run, spaces));
1211
1918
  }
1212
1919
  spans.push({
1213
1920
  text: run.text.replaceAll("�", ""),
@@ -1239,6 +1946,57 @@ function lineSpans(runs, fontSize, stepped, shared = []) {
1239
1946
  };
1240
1947
  }
1241
1948
  /**
1949
+ * §17.3.2.43 — a word space set as narrow as the page set it.
1950
+ *
1951
+ * TeX justifies a line by stretching its word spaces or SHRINKING them, and a
1952
+ * line it shrank holds more than the same words with the face's own spaces:
1953
+ * re-set with those, its last word no longer fits and goes to the next line,
1954
+ * and the paragraph grows by a line. comments.pdf sets its 9pt Times with
1955
+ * word spaces of 1.70 to 3.44 points against the face's 2.24, and every column
1956
+ * of it ran one to four lines long. A space the page set narrower than the
1957
+ * face's is set at that share of the face's width (`w:w`, whole percent
1958
+ * rounded down, so the line still fits). A wider one is left alone: a line TeX
1959
+ * stretched fits with the face's spaces too.
1960
+ *
1961
+ * The share of the glyph and not a spacing after it: LibreOffice lays no
1962
+ * character spacing (§17.3.2.35) after the last character of a run, and a
1963
+ * run that is one space has nothing else — Word and LibreOffice both narrow
1964
+ * the glyph itself.
1965
+ *
1966
+ * Only in a face the document carries, whose space is known: a substitute's
1967
+ * could be anything.
1968
+ *
1969
+ * @param space The space as the line would have it.
1970
+ * @param before The run the space follows.
1971
+ * @param after The run it precedes.
1972
+ * @param spaces The space each face the document carries is set with.
1973
+ * @returns The space, narrowed where the page set it narrower.
1974
+ */
1975
+ function fittedSpace(space, before, after, spaces) {
1976
+ if (spaces === void 0 || /\s$/u.test(before.text) || /^\s/u.test(after.text)) return space;
1977
+ const advance = before.fontName !== void 0 ? spaces.get(before.fontName) : void 0;
1978
+ const size = space.sizePt ?? before.fontSizePt;
1979
+ if (advance === void 0 || !(size > 0)) return space;
1980
+ const own = advance / 1e3 * size;
1981
+ const gap = after.x - before.endX;
1982
+ if (gap > own - FIT_SLACK_PT) return space;
1983
+ const share = Math.floor(gap / own * 100) / 100;
1984
+ return {
1985
+ ...space,
1986
+ widthScale: Math.max(share, 1 - MOST_SHRINK)
1987
+ };
1988
+ }
1989
+ /** A space this much narrower than the face's is the page's rounding, not a shrink. */
1990
+ var FIT_SLACK_PT = .02;
1991
+ /**
1992
+ * The most of a space a line may take back. TeX shrinks a Times space by a
1993
+ * quarter of itself and a Computer Modern one by a third — but the space after
1994
+ * a word in type is written in the type's face, and comments.pdf sets its
1995
+ * `primes` in 9pt Courier-wide cmtt9 and the space after it in Times: 2.25
1996
+ * points of a 4.72-point space. A gap narrower still is not a word space.
1997
+ */
1998
+ var MOST_SHRINK = .6;
1999
+ /**
1242
2000
  * §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
1243
2001
  * is.
1244
2002
  *
@@ -1308,19 +2066,25 @@ var RULE_CHARS = 3;
1308
2066
  function groupIntoParagraphs(lines, column, pageHeight = 0, before, eachLine = false, reach) {
1309
2067
  const groups = [];
1310
2068
  const gaps = [];
2069
+ const flushEnds = column ? lines.filter((l) => Math.abs(column.right - (l.x + l.width)) <= JUSTIFIED_SLACK_PT).length : 0;
2070
+ const setJustified = column !== void 0 && lines.length >= JUSTIFIED_COLUMN_LINES && flushEnds >= lines.length * JUSTIFIED_COLUMN_SHARE;
2071
+ const hangsAt = column !== void 0 ? hangingEdge(lines, column) : void 0;
1311
2072
  let prev;
2073
+ let beforePrev;
1312
2074
  for (const line of lines) {
1313
2075
  const gap = prev !== void 0 ? prev.y - line.y : 0;
1314
2076
  const opened = prev !== void 0 && gap > line.fontSize * 1.5;
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)) {
2077
+ const goesOn = hangsAt !== void 0 && line.x >= hangsAt;
2078
+ if (groups.length === 0 || eachLine || opened || ruleOfCharacters(line) || line.code === true || hangsAt !== void 0 && line.x < hangsAt || line.tabbed === true || prev !== void 0 && opensItem(prev, line) || prev !== void 0 && (ruleOfCharacters(prev) || prev.code === true || !goesOn && (endedParagraph(prev, line, column) || reach !== void 0 && roomForWord(prev, line, reach) || setJustified && endedJustified(beforePrev, prev, line, column) || column !== void 0 && endedCentred(prev, line, column)) || carriesLeader(prev) || prev.tabbed === true)) {
1316
2079
  groups.push([]);
1317
2080
  gaps.push(prev === void 0 ? before !== void 0 ? before.y - line.y : 0 : gap);
1318
2081
  }
1319
2082
  groups[groups.length - 1].push(line);
2083
+ beforePrev = prev;
1320
2084
  prev = line;
1321
2085
  }
1322
2086
  const starts = lines.map((l) => l.x).sort((a, b) => a - b);
1323
- const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
2087
+ const columnLeft = column?.left ?? starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
1324
2088
  const heights = [];
1325
2089
  return groups.map((g, i) => {
1326
2090
  const first = g[0];
@@ -1345,10 +2109,10 @@ function groupIntoParagraphs(lines, column, pageHeight = 0, before, eachLine = f
1345
2109
  top: first.y,
1346
2110
  bottom: g[g.length - 1].y,
1347
2111
  ...stops.length > 0 ? {
1348
- stops: stops.map((x) => x - (column?.left ?? columnLeft)),
2112
+ stops: stops.map((x) => x - columnLeft),
1349
2113
  pieces: (first.pieces ?? []).map((p) => ({
1350
- from: p.from - (column?.left ?? columnLeft),
1351
- to: p.to - (column?.left ?? columnLeft)
2114
+ from: p.from - columnLeft,
2115
+ to: p.to - columnLeft
1352
2116
  }))
1353
2117
  } : {},
1354
2118
  ...spacingBefore !== void 0 ? { spacingBefore } : {},
@@ -1356,12 +2120,87 @@ function groupIntoParagraphs(lines, column, pageHeight = 0, before, eachLine = f
1356
2120
  ...aligned,
1357
2121
  ...indentOf(g, columnLeft, aligned.alignment),
1358
2122
  lineCount: g.length,
2123
+ left: Math.min(...g.map((l) => l.x)),
1359
2124
  right: Math.max(...g.map((l) => l.x + l.width)),
1360
2125
  ...opened > most ? { far: g } : {}
1361
2126
  };
1362
2127
  });
1363
2128
  }
1364
2129
  /**
2130
+ * Where a column's paragraphs go on, where they HANG: the first line of each
2131
+ * at the column's edge — or at a level set in from it — and the lines after it
2132
+ * set in further, as an index sets its entries and a bibliography its
2133
+ * references. freeculture.pdf's index sets "democracy:" at the edge, "digital
2134
+ * sharing within, 184" and the other entries under it a level in, and the
2135
+ * lines that carry an entry on — "41–42, 43, 44–45" — at a level further in
2136
+ * again; read by the measure alone, its short entries ran together, and a line
2137
+ * back at the edge after one set in was taken for the second line of a
2138
+ * paragraph whose first is set in.
2139
+ *
2140
+ * The level a paragraph goes on at is reached from full lines, the ones the
2141
+ * entry ran to the measure and turned from, and many of its lines are short:
2142
+ * they end their entries. The level a paragraph's own first line is set in to
2143
+ * is reached from short lines — the paragraph before ended there — or runs to
2144
+ * the measure itself.
2145
+ *
2146
+ * @param lines The column's lines, top to bottom.
2147
+ * @param column The column they are set in.
2148
+ * @returns How far in a line goes on its paragraph; a line nearer the edge
2149
+ * opens one. `undefined` where the column's paragraphs do not hang.
2150
+ */
2151
+ function hangingEdge(lines, column) {
2152
+ const width = column.right - column.left;
2153
+ if (!(width > 0) || lines.length < HANGING_LINES) return void 0;
2154
+ const size = median(lines.map((l) => l.fontSize)) || 10;
2155
+ const edge = Math.min(...lines.map((l) => l.x));
2156
+ const short = (l) => column.right - (l.x + l.width) > width * .25;
2157
+ const levels = [];
2158
+ for (let k = 1; k < lines.length; k++) {
2159
+ const prev = lines[k - 1];
2160
+ const line = lines[k];
2161
+ const setIn = line.x - edge;
2162
+ if (setIn < size * .5 || setIn > size * HANGING_DEEPEST_EM) continue;
2163
+ let level = levels.find((v) => Math.abs(v.x - line.x) <= size * HANGING_SLACK_EM);
2164
+ if (level === void 0) {
2165
+ level = {
2166
+ x: line.x,
2167
+ lines: 0,
2168
+ afterFull: 0,
2169
+ short: 0
2170
+ };
2171
+ levels.push(level);
2172
+ }
2173
+ level.lines++;
2174
+ if (!short(prev)) level.afterFull++;
2175
+ if (short(line)) level.short++;
2176
+ }
2177
+ const goesOn = levels.filter((v) => v.lines >= HANGING_LEAST && v.afterFull >= v.lines * HANGING_FULL_SHARE && v.short >= v.lines * HANGING_SHORT_SHARE);
2178
+ if (goesOn.length === 0) return void 0;
2179
+ let pairs = 0;
2180
+ let fullPairs = 0;
2181
+ for (let k = 1; k < lines.length; k++) {
2182
+ if (lines[k - 1].x - edge >= size * .5 || lines[k].x - edge >= size * .5) continue;
2183
+ pairs++;
2184
+ if (!short(lines[k - 1])) fullPairs++;
2185
+ }
2186
+ if (fullPairs > Math.max(1, pairs * HANGING_RUN_ON_SHARE)) return void 0;
2187
+ return Math.min(...goesOn.map((v) => v.x)) - size * HANGING_SLACK_EM;
2188
+ }
2189
+ /** How many lines a column needs before its paragraphs can be seen to hang. */
2190
+ var HANGING_LINES = 8;
2191
+ /** …and how many lines a level needs before a paragraph can be seen to go on at it. */
2192
+ var HANGING_LEAST = 2;
2193
+ /** How far off a level, in ems, a line may start and still stand at it. */
2194
+ var HANGING_SLACK_EM = .25;
2195
+ /** How far in from the edge, in ems, a level may stand. */
2196
+ var HANGING_DEEPEST_EM = 4;
2197
+ /** How many of a level's lines, as a share of them, follow full lines where paragraphs go on at it. */
2198
+ var HANGING_FULL_SHARE = .8;
2199
+ /** …and how many of them are short, ending their paragraphs. */
2200
+ var HANGING_SHORT_SHARE = .3;
2201
+ /** How many lines at the edge, as a share of those after another, may follow a full one. */
2202
+ var HANGING_RUN_ON_SHARE = .6;
2203
+ /**
1365
2204
  * Whether the first word of `next` would have fit on `prev` short of `reach` —
1366
2205
  * so the page did not break `prev` for want of room, and ended it.
1367
2206
  *
@@ -1375,6 +2214,66 @@ function roomForWord(prev, next, reach) {
1375
2214
  const width = next.width * word.length / text.length;
1376
2215
  return reach - (prev.x + prev.width) > width + prev.fontSize * WORD_SPACE_EM;
1377
2216
  }
2217
+ /**
2218
+ * Whether a centred line ended its paragraph: the line under it is centred too,
2219
+ * and its first word would have fit on this one. A centred paragraph that wraps
2220
+ * fills its lines to the measure but the last, as any paragraph does; lines a
2221
+ * page centres one by one do not. comments.pdf centres its authors'
2222
+ * affiliations a line apiece — "{gal,brendan,…}@mozilla.com", and under it
2223
+ * "Adobe Corporation" — and run together they came back re-wrapped a line
2224
+ * shorter, the columns under them risen into the white.
2225
+ */
2226
+ function endedCentred(prev, next, column) {
2227
+ const width = column.right - column.left;
2228
+ if (!(width > 0)) return false;
2229
+ const centred = (l) => {
2230
+ const lead = l.x - column.left;
2231
+ const trail = column.right - (l.x + l.width);
2232
+ return Math.abs(lead - trail) <= width * CENTRED_SLACK && Math.min(lead, trail) > 0;
2233
+ };
2234
+ if (!centred(prev) || !centred(next)) return false;
2235
+ const inset = (l) => Math.min(l.x - column.left, column.right - (l.x + l.width));
2236
+ if (Math.max(inset(prev), inset(next)) < width * CENTRED_INSET) return false;
2237
+ const text = next.text.trimStart();
2238
+ const word = text.split(/\s/u)[0] ?? "";
2239
+ if (word.length === 0) return false;
2240
+ const wordWidth = next.width * word.length / text.length;
2241
+ return width - prev.width > wordWidth + prev.fontSize * WORD_SPACE_EM;
2242
+ }
2243
+ /** How far off the middle of the measure, as a share of it, a centred line may stand. */
2244
+ var CENTRED_SLACK = .06;
2245
+ /**
2246
+ * How far in from both edges, as a share of the measure, a centred line
2247
+ * stands: further than a full line stands in from a column whose edges are
2248
+ * its bullets' and an overfull line's (comments.pdf's, a thirtieth at most),
2249
+ * and no further than a block of names needs — the paper's second line of
2250
+ * authors stands in by a thirteenth, and asked for a tenth, the line under it
2251
+ * came back run into it.
2252
+ */
2253
+ var CENTRED_INSET = .05;
2254
+ /**
2255
+ * Whether a line of a column set against both edges ended its paragraph: it
2256
+ * stops short of the measure, and the line after it is indented or its first
2257
+ * word would have fit on it.
2258
+ *
2259
+ * Not where a line beside it stops at the same place: that is the edge of a
2260
+ * narrower block, not the end of the text. A quotation set in from both sides
2261
+ * stops short of the measure on every line, and freeculture.pdf's came back a
2262
+ * paragraph a line.
2263
+ */
2264
+ function endedJustified(before, prev, next, column) {
2265
+ const end = prev.x + prev.width;
2266
+ if (column.right - end <= prev.fontSize * WORD_SPACE_EM) return false;
2267
+ const edge = (l) => l !== void 0 && Math.abs(l.x + l.width - end) <= JUSTIFIED_SLACK_PT;
2268
+ if (edge(before) || edge(next)) return false;
2269
+ return next.x - prev.x >= prev.fontSize * INDENT_EM || roomForWord(prev, next, column.right);
2270
+ }
2271
+ /** How many lines a column needs to show it is set against both edges. */
2272
+ var JUSTIFIED_COLUMN_LINES = 8;
2273
+ /** …and how many of them run to the measure, at the least. */
2274
+ var JUSTIFIED_COLUMN_SHARE = .6;
2275
+ /** How far short of the measure a line of it may end and still run to it: the page's rounding. */
2276
+ var JUSTIFIED_SLACK_PT = 1;
1378
2277
  /** A word space, in ems: what stands between a line's end and the word put after it. */
1379
2278
  var WORD_SPACE_EM = .3;
1380
2279
  /**
@@ -1382,6 +2281,7 @@ var WORD_SPACE_EM = .3;
1382
2281
  * line of text set to one (see {@link groupIntoParagraphs}).
1383
2282
  */
1384
2283
  function leadingLine(el) {
2284
+ if (el.kind === "shape" && el.shape.float === void 0) return 0;
1385
2285
  if (el.kind !== "paragraph" || el.paragraph.runs.length === 0) return void 0;
1386
2286
  const { spacingLine, spacingLineRule } = el.paragraph.properties;
1387
2287
  return spacingLineRule === "exact" && spacingLine !== void 0 && spacingLine > 0 ? spacingLine : void 0;
@@ -1403,6 +2303,11 @@ var SPACING_NOISE_PT = .25;
1403
2303
  * "typical two-column docu ment incorporating tables, figures and mathemat
1404
2304
  * ics" — the soft hyphens dropped by the page and a space in their place.
1405
2305
  *
2306
+ * A dash closed up to the word before it binds that word to the one after,
2307
+ * as the hyphen does: freeculture.pdf's index breaks "167–68" after its dash,
2308
+ * and the entry came back "167– 68". A dash with a space before it stands
2309
+ * apart, and the break is a space.
2310
+ *
1406
2311
  * @param lines The paragraph's lines, in order.
1407
2312
  */
1408
2313
  function joinLines(lines) {
@@ -1412,12 +2317,13 @@ function joinLines(lines) {
1412
2317
  const prev = out[out.length - 1];
1413
2318
  const ends = prev?.text ?? "";
1414
2319
  const soft = ends.endsWith(SOFT_HYPHEN);
1415
- const hard = HYPHENS.has(ends.slice(-1));
2320
+ const before = ends.length > 1 ? ends.slice(-2, -1) : out[out.length - 2]?.text.slice(-1) ?? "";
2321
+ const hard = HYPHENS.has(ends.slice(-1)) || DASHES.has(ends.slice(-1)) && /\S/u.test(before);
1416
2322
  if (soft && prev) out[out.length - 1] = {
1417
2323
  ...prev,
1418
2324
  text: ends.slice(0, -1)
1419
2325
  };
1420
- else if (!hard && !/\s$/u.test(ends) && !/^\s/u.test(line.spans[0]?.text ?? "")) out.push(spaceAfter(prev));
2326
+ else if (!hard && !/\s$/u.test(ends) && !/^\s/u.test(line.spans[0]?.text ?? "")) out.push(spaceAfter(prev, line.spans[0]));
1421
2327
  }
1422
2328
  out.push(...line.spans);
1423
2329
  });
@@ -1431,11 +2337,36 @@ var HYPHENS = new Set([
1431
2337
  "‐",
1432
2338
  "‑"
1433
2339
  ]);
2340
+ /** The dashes, en and em, that belong to the words either side where closed up to them. */
2341
+ var DASHES = new Set(["–", "—"]);
1434
2342
  /** How many lines' worth of indent still reads as a first line, not a placement. */
1435
2343
  var INDENT_LINES = 3;
1436
2344
  /** Where in the run of line starts the column's own left edge is looked for. */
1437
2345
  var COLUMN_LEFT_QUANTILE = .15;
1438
2346
  /**
2347
+ * Whether a line opens a list item: it begins with a bullet set apart from the
2348
+ * words it marks, or with a label — "[19]", "3." — standing out left of the
2349
+ * line over it. Where the item before it ends on a line that runs to the
2350
+ * measure, or nearly, nothing else says it ended: comments.pdf's "• We explain
2351
+ * how to speculatively generate…" came back run into the item over it, its
2352
+ * bullet in the middle of a line, and its reference "[19] M. Zaleski…" into
2353
+ * "[18]". A label that does not stand out is a line of prose that begins
2354
+ * with one — a citation, a figure.
2355
+ *
2356
+ * @param prev The line over it.
2357
+ * @param line A line of the column.
2358
+ */
2359
+ function opensItem(prev, line) {
2360
+ if (BULLET_LEAD.test(line.text)) return true;
2361
+ return LABEL_LEAD.test(line.text) && prev.x - line.x >= line.fontSize * HANGING_EM;
2362
+ }
2363
+ /** A bullet and the white after it, at the head of a line. */
2364
+ var BULLET_LEAD = /^[\u2022\u2023\u2043\u2219\u25aa\u25ab\u25cb\u25cf\u25e6\u25a0\u25a1]\s/u;
2365
+ /** A reference's or a step's label, "[19]" or "3.", and the white after it. */
2366
+ var LABEL_LEAD = /^(?:\[\d{1,4}\]|\d{1,3}\.)\s/u;
2367
+ /** How far, in ems, a label stands out left of the line over it to hang. */
2368
+ var HANGING_EM = 1;
2369
+ /**
1439
2370
  * §17.3.1.12 `w:ind` — how far a paragraph is set in from its column, and where
1440
2371
  * its first line begins.
1441
2372
  *
@@ -1646,9 +2577,9 @@ function runningFoot(pageRuns, shown, where) {
1646
2577
  const feet = pageRuns.map((runs, i) => edgeLine(runs.filter((r) => !tooSmallToRead(r, textSize)), shown[i]?.height ?? 0, where));
1647
2578
  const found = feet.filter((f) => f !== void 0);
1648
2579
  if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
1649
- const ys = found.map((f) => f.y);
1650
- const mid = median(ys);
1651
- if (ys.some((y) => Math.abs(y - mid) > FOOT_DRIFT)) return void 0;
2580
+ const inner = (f) => where === "foot" ? Math.max(...f.runs.map((r) => r.y)) : Math.min(...f.runs.map((r) => r.y));
2581
+ const mid = median(found.map(inner));
2582
+ if (found.some((f) => (where === "foot" ? inner(f) - mid : mid - inner(f)) > FOOT_DRIFT)) return;
1652
2583
  const texts = found.map((f) => f.runs.map((r) => r.text).join("").trim());
1653
2584
  return {
1654
2585
  lift: feet.map((f) => new Set(f?.runs ?? [])),
@@ -1656,7 +2587,107 @@ function runningFoot(pageRuns, shown, where) {
1656
2587
  numbered: new Set(texts).size > 1
1657
2588
  };
1658
2589
  }
2590
+ /**
2591
+ * §17.10.1, §17.15.1.36 — the running feet of a book's two sides, where they
2592
+ * differ. A book signs its left-hand pages one way and its right-hand ones
2593
+ * another, its folio at the outer edge of each: freeculture.pdf sets "6 FREE
2594
+ * CULTURE" at the left of its even pages with the URL it is published at
2595
+ * under it, and "INTRODUCTION 5" at the right of its odd ones. Read as one
2596
+ * foot, every page was signed the way its first was.
2597
+ *
2598
+ * The side is the page number's, as Word takes it, printing the even foot on
2599
+ * the pages it numbers even; the sides differ where their numbers stand on
2600
+ * opposite halves of the page. A side's foot says what most of that side's
2601
+ * feet say, and its number: the chapter a page is in is not named alike in
2602
+ * any two chapters, and set once it would name every page after the first.
2603
+ *
2604
+ * @param foot The running foot, a page's runs to each page.
2605
+ * @param numbering The numbers its pages print.
2606
+ * @param middle The middle of each page, across.
2607
+ * @returns Each side's runs and the numeral they print, or `undefined` where
2608
+ * the two sides are signed alike.
2609
+ */
2610
+ function sidesOf(foot, numbering, middle) {
2611
+ const bySide = [[], []];
2612
+ foot.lift.forEach((set, i) => {
2613
+ const number = numbering.numbers[i];
2614
+ if (set.size === 0 || number === void 0) return;
2615
+ const runs = [...set];
2616
+ const numeral = runs.find((r) => r.text.trim() === number.text);
2617
+ if (numeral === void 0) return;
2618
+ bySide[number.value % 2].push({
2619
+ runs,
2620
+ numeral,
2621
+ left: (numeral.x + numeral.endX) / 2 < (middle[i] ?? 0),
2622
+ page: i
2623
+ });
2624
+ });
2625
+ const [even, odd] = bySide;
2626
+ if (even.length < LEAST_SIDE_FEET || odd.length < LEAST_SIDE_FEET) return void 0;
2627
+ const leftOf = (side) => side.filter((f) => f.left).length * 2 > side.length;
2628
+ if (leftOf(even) === leftOf(odd)) return void 0;
2629
+ const wordsOf = (runs) => runs.flatMap((r) => r.text.split(/\s+/u)).filter((w) => w !== "");
2630
+ const sideOf = (side) => {
2631
+ const counts = /* @__PURE__ */ new Map();
2632
+ for (const f of side) for (const w of new Set(wordsOf(f.runs.filter((r) => r !== f.numeral)))) counts.set(w, (counts.get(w) ?? 0) + 1);
2633
+ const said = (r) => wordsOf([r]).every((w) => (counts.get(w) ?? 0) >= side.length * SIDE_SHARE);
2634
+ const kept = (f) => f.runs.filter((r) => r === f.numeral || said(r));
2635
+ const best = side.filter((f) => f.left === leftOf(side)).reduce((a, b) => kept(b).length > kept(a).length ? b : a);
2636
+ return {
2637
+ runs: kept(best),
2638
+ numeral: best.numeral.text.trim(),
2639
+ page: best.page
2640
+ };
2641
+ };
2642
+ return {
2643
+ even: sideOf(even),
2644
+ odd: sideOf(odd)
2645
+ };
2646
+ }
2647
+ /** How many feet each side must show before a book is taken to sign its sides apart. */
2648
+ var LEAST_SIDE_FEET = 2;
2649
+ /** How many of a side's feet, as a share of them, must say a word before its foot does. */
2650
+ var SIDE_SHARE = .6;
1659
2651
  /** How many of a document's pages must carry the foot before it is running. */
2652
+ /**
2653
+ * §17.6.12 — how a document's pages are numbered, read off the band that
2654
+ * prints their numbers (see ./page-numbers).
2655
+ *
2656
+ * @param band The running head or foot whose numbers change from page to page.
2657
+ * @returns The numbering, or undefined where no band numbers the pages.
2658
+ */
2659
+ function numberingOf(band) {
2660
+ if (!band) return void 0;
2661
+ return pageNumberingOf(band.lift.map((set) => set.size > 0 ? bandText([...set]) : void 0));
2662
+ }
2663
+ /** A band's text on one page, with a word space wherever the page shows one. */
2664
+ function bandText(runs) {
2665
+ return groupIntoLines(runs, false, stepsBetweenWords(runs)).map((line) => line.text).join(" ");
2666
+ }
2667
+ /**
2668
+ * The numbering a section opening at `page` states: the numerals of the
2669
+ * sequence the page is counted in, and the number the sequence starts at where
2670
+ * it starts there.
2671
+ *
2672
+ * @param numbering The document's numbering.
2673
+ * @param page The section's first page.
2674
+ * @returns The section's `pageNumberFormat` and `pageNumberStart`, as needed.
2675
+ */
2676
+ function numberedFrom(numbering, page) {
2677
+ const run = numbering ? runOf(numbering.runs, page) : void 0;
2678
+ if (!run) return {};
2679
+ return {
2680
+ ...run.format !== "decimal" ? { pageNumberFormat: run.format } : {},
2681
+ ...run.from === page && (page > 0 || run.start !== 1) ? { pageNumberStart: run.start } : {}
2682
+ };
2683
+ }
2684
+ /** A document of one section, numbered from its first page. */
2685
+ function numberedAs(section, numbering) {
2686
+ return section ? {
2687
+ ...section,
2688
+ ...numberedFrom(numbering, 0)
2689
+ } : section;
2690
+ }
1660
2691
  var FOOT_SHARE = .6;
1661
2692
  /** How far, in points, a running foot may drift from page to page. */
1662
2693
  var FOOT_DRIFT = 4;
@@ -1729,11 +2760,12 @@ var FOOT_CHARS = 90;
1729
2760
  * the number of the page it is drawn on, which is what lets ONE band serve
1730
2761
  * every page. The run is cut around it and the middle becomes the field.
1731
2762
  *
1732
- * @param run The footer's run.
2763
+ * @param run The footer's run.
2764
+ * @param numeral The page's number as this band prints it, where it is known.
1733
2765
  * @returns The run, or the two or three it is cut into.
1734
2766
  */
1735
- function pageNumbered(run) {
1736
- const found = /(^|\s)(\d{1,4})(\s|$)/u.exec(run.text);
2767
+ function pageNumbered(run, numeral) {
2768
+ const found = (numeral !== void 0 ? new RegExp(`(^|\\s)(${numeral})(\\s|$)`, "u") : /(^|\s)(\d{1,4})(\s|$)/u).exec(run.text);
1737
2769
  if (!found || run.field !== void 0) return [run];
1738
2770
  const at = found.index + found[1].length;
1739
2771
  const number = found[2];
@@ -1789,8 +2821,9 @@ function pageNumbered(run) {
1789
2821
  * @param stepped Whether the page steps between its words.
1790
2822
  * @param measure The measure it was set across, for its alignment.
1791
2823
  * @param numbered Whether a number in it is the page's own.
2824
+ * @param numeral That number as this band prints it, where it is known.
1792
2825
  */
1793
- function footerBand(runs, stepped, measure, numbered) {
2826
+ function footerBand(runs, stepped, measure, numbered, numeral) {
1794
2827
  const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1795
2828
  return rowsOf(runs, fontSize).map((row) => bandLine(row, fontSize, stepped, measure)).filter((line) => line !== void 0).map(({ spans, properties }) => {
1796
2829
  const el = paragraphFromRuns(spans, void 0, properties);
@@ -1799,7 +2832,7 @@ function footerBand(runs, stepped, measure, numbered) {
1799
2832
  kind: "paragraph",
1800
2833
  paragraph: {
1801
2834
  ...el.paragraph,
1802
- runs: numbered ? el.paragraph.runs.flatMap((run) => pageNumbered(run)) : el.paragraph.runs
2835
+ runs: numbered ? el.paragraph.runs.flatMap((run) => pageNumbered(run, numeral)) : el.paragraph.runs
1803
2836
  }
1804
2837
  };
1805
2838
  });
@@ -1835,7 +2868,7 @@ function bandLine(row, fontSize, stepped, measure) {
1835
2868
  const width = measure ? measure.right - measure.left : 0;
1836
2869
  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;
1837
2870
  return {
1838
- spans: lines.flatMap((line, i) => i === 0 ? line.spans : [spaceAfter(lines[i - 1].spans.at(-1)), ...line.spans]),
2871
+ spans: lines.flatMap((line, i) => i === 0 ? line.spans : [spaceAfter(lines[i - 1].spans.at(-1), line.spans[0]), ...line.spans]),
1839
2872
  properties: alignment ? { alignment } : {}
1840
2873
  };
1841
2874
  }
@@ -1957,9 +2990,11 @@ function proseColumns(runs, gutters, edges) {
1957
2990
  for (const x of xs) most = Math.max(most, xs.filter((y) => Math.abs(y - x) <= FLUSH_PT).length);
1958
2991
  return most;
1959
2992
  };
1960
- return edgesIn.every((lines) => {
2993
+ return edgesIn.every((lines, i) => {
1961
2994
  if (lines.length < MIN_PROSE_LINES) return false;
1962
- return agreeing(lines.map((l) => l.right)) <= agreeing(lines.map((l) => l.left));
2995
+ const [lo, hi] = regions[i];
2996
+ const full = lines.filter((l) => l.right - l.left >= (hi - lo) * FULL_LINE_SHARE).length;
2997
+ return agreeing(lines.map((l) => l.right)) <= agreeing(lines.map((l) => l.left)) || full >= lines.length * FILLED_SHARE;
1963
2998
  });
1964
2999
  }
1965
3000
  /**
@@ -2116,71 +3151,163 @@ function tabbedRows(paras, measure) {
2116
3151
  to: Math.max(...cells.map((c) => c.to))
2117
3152
  };
2118
3153
  });
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);
3154
+ const above = i > 0 && !out.has(i - 1) ? paras[i - 1] : void 0;
3155
+ const close = above !== void 0 && above.top - rows[0].top <= rows[0].lineHeight * HEADING_LINES;
3156
+ const columnsFor = (heading) => {
3157
+ const flush = inks.map((ink, k) => {
3158
+ if (k === 0 || ink === void 0) return false;
3159
+ const tos = rows.map((r) => r.pieces[k].to);
3160
+ const froms = rows.map((r) => r.pieces[k].from);
3161
+ const even = Math.max(...tos) - Math.min(...tos);
3162
+ if (even > FLUSH_SLACK_PT) return false;
3163
+ if (Math.max(...froms) - Math.min(...froms) > even + FLUSH_SLACK_PT) return true;
3164
+ const head = heading?.over[k];
3165
+ return head !== void 0 && Math.abs(head.to - Math.max(...tos)) <= FLUSH_SLACK_PT && Math.min(...froms) - head.from > FLUSH_SLACK_PT;
3166
+ });
3167
+ const holds = inks.map((ink, k) => {
3168
+ const head = heading?.over[k];
3169
+ return ink !== void 0 && head !== void 0 ? {
3170
+ from: Math.min(ink.from, head.from),
3171
+ to: Math.max(ink.to, head.to)
3172
+ } : ink;
3173
+ });
3174
+ const edges = [
3175
+ 0,
3176
+ ...bounds,
3177
+ width
3178
+ ];
3179
+ for (let k = 1; k < bounds.length + 1; k++) {
3180
+ const before = holds[k - 1];
3181
+ const own = holds[k];
3182
+ if (!flush[k] || before === void 0 || own === void 0) continue;
3183
+ const mid = (before.to + own.from) / 2;
3184
+ if (mid > edges[k - 1] && mid < own.from) edges[k] = mid;
3185
+ }
3186
+ const lead = holds[0]?.from ?? 0;
3187
+ if (lead > FLUSH_SLACK_PT && lead < edges[1]) edges[0] = lead;
3188
+ const flushRight = flush.map((right, k) => right ? Math.max(0, edges[k + 1] - inks[k].to) : void 0);
3189
+ return {
3190
+ holds,
3191
+ edges,
3192
+ flushRight,
3193
+ held: heading?.over.every((head, k) => head === void 0 || edges[k + 1] - edges[k] - (flushRight[k] ?? 0) >= head.to - head.from - FLUSH_SLACK_PT) ?? true
3194
+ };
3195
+ };
3196
+ const offered = above && close ? headingOver(above, inks) : void 0;
3197
+ const withHeading = offered ? columnsFor(offered) : void 0;
3198
+ const heading = withHeading?.held === true ? offered : void 0;
3199
+ const { holds, edges, flushRight } = heading && withHeading ? withHeading : columnsFor(void 0);
2139
3200
  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))
3201
+ const tableRows = above && heading ? [{
3202
+ ...above,
3203
+ spans: heading.spans
3204
+ }, ...rows] : rows;
3205
+ const table = {
3206
+ properties: {
3207
+ defaultCellMargins: {
3208
+ left: pt(0),
3209
+ right: pt(0)
2151
3210
  },
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
- }
3211
+ layout: "fixed",
3212
+ widthType: "dxa",
3213
+ widthPt: pt(grid.reduce((sum, w) => sum + w, 0)),
3214
+ ...edges[0] > 0 ? { indentPt: pt(edges[0]) } : {}
3215
+ },
3216
+ grid,
3217
+ rows: tableRows.map((row, r) => {
3218
+ const prev = tableRows[r - 1];
3219
+ const pitch = prev ? prev.top - row.top - (1 - BASELINE_AT) * prev.lineHeight - BASELINE_AT * row.lineHeight : 0;
3220
+ const opening = r === 0 ? row.spacingBefore : pitch > 0 ? pitch : void 0;
3221
+ return {
3222
+ properties: {},
3223
+ cells: splitAtTabs(row.spans).map((cell, k) => {
3224
+ const inset = flushRight[k];
3225
+ const own = grid[k];
3226
+ return {
3227
+ properties: own !== void 0 ? { width: own } : {},
3228
+ content: [paragraphFromRuns(cell, void 0, {
3229
+ ...opening !== void 0 ? { spacingBefore: pt(opening) } : {},
3230
+ spacingLine: pt(row.lineHeight),
3231
+ spacingLineRule: "exact",
3232
+ ...inset !== void 0 ? {
3233
+ alignment: "right",
3234
+ indentRight: pt(inset)
3235
+ } : {}
3236
+ })]
3237
+ };
3238
+ })
3239
+ };
3240
+ })
3241
+ };
3242
+ const ink = holds.filter((h) => h !== void 0);
3243
+ tableReads.set(table, {
3244
+ rows: tableRows.map((row) => ({
3245
+ top: row.top,
3246
+ lineHeight: row.lineHeight
3247
+ })),
3248
+ from: measure.left + Math.min(...ink.map((h) => h.from)),
3249
+ to: measure.left + Math.max(...ink.map((h) => h.to)),
3250
+ left: measure.left
3251
+ });
3252
+ out.set(heading ? i - 1 : i, {
3253
+ kind: "table",
3254
+ table
2178
3255
  });
3256
+ if (heading) out.set(i, null);
2179
3257
  for (let k = i + 1; k < to; k++) out.set(k, null);
2180
3258
  i = to;
2181
3259
  }
2182
3260
  return out;
2183
3261
  }
3262
+ /**
3263
+ * A line's cells as a heading row over a table's columns: each cell over the
3264
+ * column whose figures it overlaps most, left to right, and an empty cell over
3265
+ * a column it leaves bare. `undefined` where the line is no such row.
3266
+ *
3267
+ * @param line The line above the table, with the cells it was set out in.
3268
+ * @param inks The ink of each of the table's columns.
3269
+ * @returns The line's spans with a tab between every two columns, and the ink
3270
+ * of the heading over each column.
3271
+ */
3272
+ function headingOver(line, inks) {
3273
+ const cells = splitAtTabs(line.spans);
3274
+ const pieces = line.pieces ?? [];
3275
+ if (cells.length < 2 || pieces.length !== cells.length || cells.length > inks.length || cells.length * 2 < inks.length) return;
3276
+ const columns = [];
3277
+ for (const piece of pieces) {
3278
+ let best = -1;
3279
+ let most = 0;
3280
+ inks.forEach((ink, k) => {
3281
+ if (ink === void 0) return;
3282
+ const overlap = Math.min(piece.to, ink.to) - Math.max(piece.from, ink.from);
3283
+ if (overlap > most) {
3284
+ most = overlap;
3285
+ best = k;
3286
+ }
3287
+ });
3288
+ if (best <= (columns[columns.length - 1] ?? -1)) return void 0;
3289
+ columns.push(best);
3290
+ }
3291
+ return {
3292
+ spans: inks.flatMap((_, k) => {
3293
+ const own = columns.indexOf(k);
3294
+ return [...k > 0 ? [{ text: " " }] : [], ...own >= 0 ? cells[own] : []];
3295
+ }),
3296
+ over: inks.map((_, k) => {
3297
+ const own = columns.indexOf(k);
3298
+ return own >= 0 ? pieces[own] : void 0;
3299
+ })
3300
+ };
3301
+ }
3302
+ /**
3303
+ * Where a table read off the page stood on it (see `tabbedRows`): each row's
3304
+ * baseline and the box its line stands in, top to bottom, and how far across
3305
+ * the page its words reach — which is what a rule drawn between two of its
3306
+ * rows is measured against (see `ruleBorders`).
3307
+ */
3308
+ var tableReads = /* @__PURE__ */ new WeakMap();
3309
+ /** How many of its rows' lines a heading may stand over a table and head it. */
3310
+ var HEADING_LINES = 3;
2184
3311
  /** How far apart the ends of a column's cells may stand and still be flush. */
2185
3312
  var FLUSH_SLACK_PT = 2;
2186
3313
  /** How many stops a line must stand on before a run of them is a table. */
@@ -2209,21 +3336,47 @@ function splitAtTabs(spans) {
2209
3336
  * the rule introduces; where nothing follows closely enough, the block above
2210
3337
  * takes it as a bottom one.
2211
3338
  *
3339
+ * A rule drawn down one column is a border of a block in that column, and one
3340
+ * drawn across the columns of a block across them: comments.pdf rules off
3341
+ * Figure 9's caption in the right column, and taken by the nearest line under
3342
+ * it on the page, the rule came back over "Every time the trace recorder
3343
+ * emits…" in the left column, and none over the caption.
3344
+ *
2212
3345
  * @param vectors The page's painted paths.
2213
3346
  * @param blocks The blocks read off the page so far, which the rule joins.
2214
3347
  * @param width The page's width, which a rule is long relative to.
3348
+ * @param colOf The column an x across the page stands in.
2215
3349
  * @returns The rules that became borders, and so must not be drawn again.
2216
3350
  */
2217
- function ruleBorders(vectors, blocks, width) {
3351
+ function ruleBorders(vectors, blocks, width, colOf = () => 0) {
2218
3352
  const given = /* @__PURE__ */ new Set();
2219
3353
  const paragraphs = blocks.filter((b) => b.el.kind === "paragraph");
2220
- if (paragraphs.length === 0) return given;
3354
+ const tables = blocks.filter((b) => b.el.kind === "table");
3355
+ if (paragraphs.length === 0 && tables.length === 0) return given;
2221
3356
  for (const band of collinear(vectors.filter((v) => flatBar(v)))) {
2222
3357
  const v = band.pieces[0];
2223
3358
  if (band.to - band.from < width * RULE_SHARE) continue;
2224
3359
  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];
3360
+ const border = {
3361
+ style: "single",
3362
+ width: pt(Math.max(v.lineWidth ?? v.maxY - v.minY, RULE_MIN_PT)),
3363
+ colorHex: v.strokeHex ?? v.fillHex ?? "000000"
3364
+ };
3365
+ const seat = seatOf(tables, band, y);
3366
+ if (seat !== void 0 && seat.block.el.kind === "table") {
3367
+ seat.block.el = {
3368
+ kind: "table",
3369
+ table: ruledRow(seat.block.el.table, seat.row, border, band)
3370
+ };
3371
+ for (const piece of band.pieces) given.add(piece);
3372
+ continue;
3373
+ }
3374
+ if (paragraphs.length === 0) continue;
3375
+ const first = colOf(band.from + RULE_INSET_PT);
3376
+ const col = first === colOf(band.to - RULE_INSET_PT) ? first : SPANNING_COLUMN;
3377
+ const own = paragraphs.filter((b) => b.col === col);
3378
+ const below = own.filter((b) => b.top < y).sort((a, b) => b.top - a.top)[0];
3379
+ const above = own.filter((b) => b.top >= y).sort((a, b) => a.top - b.top)[0];
2227
3380
  const side = below !== void 0 && y - below.top <= RULE_REACH_PT ? {
2228
3381
  block: below,
2229
3382
  edge: "top"
@@ -2232,11 +3385,6 @@ function ruleBorders(vectors, blocks, width) {
2232
3385
  edge: "bottom"
2233
3386
  } : void 0;
2234
3387
  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
3388
  const { paragraph } = side.block.el;
2241
3389
  const before = paragraph.properties.spacingBefore;
2242
3390
  const room = side.edge === "top" && before !== void 0 ? { spacingBefore: pt(Math.max(0, before - border.width)) } : {};
@@ -2259,8 +3407,347 @@ function ruleBorders(vectors, blocks, width) {
2259
3407
  }
2260
3408
  return given;
2261
3409
  }
3410
+ /**
3411
+ * §17.3.1.33 — the white a block across the page leaves over the columns
3412
+ * under it, as the space after its last paragraph. Each column reads its own
3413
+ * lines from nothing (see `addColumn`), so the white went nowhere, and
3414
+ * comments.pdf's Figure 13 caption came down onto the text under it. As the
3415
+ * space before each column's first paragraph Word and LibreOffice drop it at
3416
+ * the head of the second column, which then stands higher than the first.
3417
+ *
3418
+ * @param blocks A page's blocks, as read; each block across the page with
3419
+ * columns under it is given its white.
3420
+ */
3421
+ function spaceUnderSpans(blocks) {
3422
+ for (const span of blocks) {
3423
+ if (span.col !== SPANNING_COLUMN || span.foot === void 0 || span.el.kind !== "paragraph") continue;
3424
+ const next = blocks.filter((b) => b.col === SPANNING_COLUMN && b.top < span.top).reduce((high, b) => Math.max(high, b.top), -Infinity);
3425
+ const heads = blocks.filter((b) => b.col !== SPANNING_COLUMN && b.top < span.top && b.top > next).map((b) => b.top + BASELINE_AT * (leadingLine(b.el) ?? 0));
3426
+ if (heads.length === 0) continue;
3427
+ const white = span.foot - Math.max(...heads);
3428
+ if (white <= SPACING_NOISE_PT) continue;
3429
+ const { paragraph } = span.el;
3430
+ span.el = {
3431
+ kind: "paragraph",
3432
+ paragraph: {
3433
+ ...paragraph,
3434
+ properties: {
3435
+ ...paragraph.properties,
3436
+ spacingAfter: pt(white)
3437
+ }
3438
+ }
3439
+ };
3440
+ }
3441
+ }
3442
+ /**
3443
+ * Where each column of a page's last stretch of columns — under the last block
3444
+ * across the page — ends: the foot of its lowest paragraph, up the page.
3445
+ *
3446
+ * @param blocks The page's blocks.
3447
+ * @returns The feet, one a column, or nothing where fewer than two columns hold text.
3448
+ */
3449
+ function lastColumnFeet(blocks) {
3450
+ const across = blocks.filter((b) => b.col === SPANNING_COLUMN).reduce((low, b) => Math.min(low, b.top), Infinity);
3451
+ const feet = /* @__PURE__ */ new Map();
3452
+ for (const b of blocks) {
3453
+ if (b.col === SPANNING_COLUMN || b.foot === void 0 || b.top >= across) continue;
3454
+ feet.set(b.col, Math.min(feet.get(b.col) ?? Infinity, b.foot));
3455
+ }
3456
+ return feet.size >= 2 ? [...feet.values()] : void 0;
3457
+ }
3458
+ /**
3459
+ * Whether a page's last columns are set BALANCED: they end within a couple of
3460
+ * lines of each other, and well above the foot of the text.
3461
+ *
3462
+ * @param feet Where each column ends, up the page.
3463
+ * @param floor Where the text ends at its lowest, up the page.
3464
+ * @param size The body's type size.
3465
+ */
3466
+ function balancedEnd(feet, floor, size) {
3467
+ const line = size * NATURAL_LINE_EM;
3468
+ const deepest = Math.min(...feet);
3469
+ return Math.max(...feet) - deepest <= line * BALANCED_LINES && deepest - floor >= line * SHORT_LINES;
3470
+ }
3471
+ /** How many lines apart the ends of balanced columns may stand. */
3472
+ var BALANCED_LINES = 4;
3473
+ /** How many lines short of the foot a page's columns stop to be set short. */
3474
+ var SHORT_LINES = 3;
3475
+ /** Whether an element is a drawing anchored to the page, which takes no room in the flow. */
3476
+ function floating(el) {
3477
+ return el?.kind === "shape" && el.shape.float !== void 0 || el?.kind === "image" && el.image.float !== void 0;
3478
+ }
3479
+ /**
3480
+ * A table with a rule over one of its rows: the top edge of each of the row's
3481
+ * cells (§17.4.39), the rule taking its room out of the white the row keeps
3482
+ * over it as a paragraph's does (see `ruleBorders`).
3483
+ */
3484
+ function ruledRow(table, at, border, rule) {
3485
+ const ruled = {
3486
+ ...table,
3487
+ rows: table.rows.map((row, r) => r !== at ? row : {
3488
+ ...row,
3489
+ cells: row.cells.map((cell) => ({
3490
+ properties: {
3491
+ ...cell.properties,
3492
+ borders: {
3493
+ ...cell.properties.borders,
3494
+ top: border
3495
+ }
3496
+ },
3497
+ content: cell.content.map((block, k) => {
3498
+ const before = block.kind === "paragraph" && k === 0 ? block.paragraph.properties.spacingBefore : void 0;
3499
+ return before === void 0 || block.kind !== "paragraph" ? block : {
3500
+ ...block,
3501
+ paragraph: {
3502
+ ...block.paragraph,
3503
+ properties: {
3504
+ ...block.paragraph.properties,
3505
+ spacingBefore: pt(Math.max(0, before - border.width))
3506
+ }
3507
+ }
3508
+ };
3509
+ })
3510
+ }))
3511
+ })
3512
+ };
3513
+ const read = tableReads.get(table);
3514
+ if (!read) return ruled;
3515
+ const fitted = fittedToRule(ruled, read, rule);
3516
+ tableReads.set(fitted, read);
3517
+ return fitted;
3518
+ }
3519
+ /**
3520
+ * §17.4.63 — a table as wide as the rule drawn across it.
3521
+ *
3522
+ * A table read off a page begins where its words do, and its last column runs
3523
+ * to the measure; a rule drawn across it is its own edge, standing a little
3524
+ * past its words on either side. comments.pdf rules Figure 13 from six points
3525
+ * left of its names to six right of its last figures, and written as the
3526
+ * edge of the row under its headings, the rule ran on twenty points past the
3527
+ * table, to the margin. The table now spans what the rule does: its first
3528
+ * column reaches out to where the rule begins, its words held where they
3529
+ * stood, and its last column ends where the rule ends.
3530
+ *
3531
+ * Only a rule that reaches past the words on both sides is the table's edge.
3532
+ *
3533
+ * @param table The table, its rule written as a row's border.
3534
+ * @param read Where it stood on the page.
3535
+ * @param rule The rule, across the page.
3536
+ */
3537
+ function fittedToRule(table, read, rule) {
3538
+ const last = table.grid.length - 1;
3539
+ if (last < 0 || rule.from > read.from || rule.to < read.to) return table;
3540
+ const left = read.left + (table.properties.indentPt ?? 0);
3541
+ const right = left + table.grid.reduce((sum, w) => sum + w, 0);
3542
+ const out = left - rule.from;
3543
+ const inward = right - rule.to;
3544
+ if (Math.abs(out) < FIT_NOISE_PT && Math.abs(inward) < FIT_NOISE_PT) return table;
3545
+ const grid = table.grid.map((w, k) => {
3546
+ const wider = k === 0 ? w + out : w;
3547
+ return pt(Math.max(1, k === last ? wider - inward : wider));
3548
+ });
3549
+ /** A cell's paragraphs, its words held where the page set them. */
3550
+ const held = (content, k) => content.map((block) => {
3551
+ if (block.kind !== "paragraph") return block;
3552
+ const { indentLeft, indentRight } = block.paragraph.properties;
3553
+ const properties = {
3554
+ ...block.paragraph.properties,
3555
+ ...k === 0 && Math.abs(out) >= FIT_NOISE_PT ? { indentLeft: pt(Math.max(0, (indentLeft ?? 0) + out)) } : {},
3556
+ ...k === last && Math.abs(inward) >= FIT_NOISE_PT && indentRight !== void 0 ? { indentRight: pt(Math.max(0, indentRight - inward)) } : {}
3557
+ };
3558
+ return {
3559
+ ...block,
3560
+ paragraph: {
3561
+ ...block.paragraph,
3562
+ properties
3563
+ }
3564
+ };
3565
+ });
3566
+ return {
3567
+ ...table,
3568
+ properties: {
3569
+ ...table.properties,
3570
+ widthPt: pt(grid.reduce((sum, w) => sum + w, 0)),
3571
+ indentPt: pt((table.properties.indentPt ?? 0) - out)
3572
+ },
3573
+ grid,
3574
+ rows: table.rows.map((row) => ({
3575
+ ...row,
3576
+ cells: row.cells.map((cell, k) => k !== 0 && k !== last ? cell : {
3577
+ properties: {
3578
+ ...cell.properties,
3579
+ width: grid[k]
3580
+ },
3581
+ content: held(cell.content, k)
3582
+ })
3583
+ }))
3584
+ };
3585
+ }
3586
+ /** A fit smaller than this moves nothing a reader sees. */
3587
+ var FIT_NOISE_PT = .5;
3588
+ /**
3589
+ * The table row a rule is drawn on the top edge of: a row after the first,
3590
+ * the rule standing where its line's box meets the box of the line above it,
3591
+ * and reaching across the table's words. `undefined` where the rule is no
3592
+ * such row's.
3593
+ *
3594
+ * @param tables The page's tables, as blocks.
3595
+ * @param band The rule, across the page.
3596
+ * @param y Its height on the page.
3597
+ */
3598
+ function seatOf(tables, band, y) {
3599
+ for (const block of tables) {
3600
+ const read = block.el.kind === "table" ? tableReads.get(block.el.table) : void 0;
3601
+ if (!read) continue;
3602
+ if (band.from > read.from + RULE_SEAT_PT || band.to < read.to - RULE_SEAT_PT) continue;
3603
+ for (let r = 1; r < read.rows.length; r++) {
3604
+ const row = read.rows[r];
3605
+ const prev = read.rows[r - 1];
3606
+ const edge = row.top + BASELINE_AT * row.lineHeight;
3607
+ const over = prev.top - (1 - BASELINE_AT) * prev.lineHeight;
3608
+ if (y > row.top && y < prev.top && y - edge <= RULE_SEAT_PT && over - y <= RULE_SEAT_PT) return {
3609
+ block,
3610
+ row: r
3611
+ };
3612
+ }
3613
+ }
3614
+ }
3615
+ /**
3616
+ * How far off the edge between two rows a rule may stand and still be that
3617
+ * edge — a point or two either way, which the white a row keeps does not
3618
+ * notice.
3619
+ */
3620
+ var RULE_SEAT_PT = 3;
2262
3621
  /** How far from a paragraph a rule may stand and still belong to it. */
2263
3622
  var RULE_REACH_PT = 14;
3623
+ /** How far in from its ends a rule is looked at to say which column it stands in. */
3624
+ var RULE_INSET_PT = 2;
3625
+ /**
3626
+ * §17.3.1.31 — the paragraphs a page sets on a box it fills, each given the box
3627
+ * as its shading.
3628
+ *
3629
+ * canvas.pdf heads each section with its name in white on a dark bar, and each
3630
+ * table with "Attributes" or "Methods" on a grey one. Drawn where the page drew
3631
+ * them, the bars stayed on the sheet while the words were set again: headings
3632
+ * slid off their bars, white on white, and the bars struck through the tables
3633
+ * under them. As a paragraph's shading the bar goes where the paragraph goes.
3634
+ *
3635
+ * A paragraph's shading fills its line's box across its indents, and the box
3636
+ * a page draws stands out over and under the line: that is a border of its
3637
+ * own colour over the line, and one under it (§17.3.1.24), as thick as the box
3638
+ * stands out. The room the borders take comes out of the white the page left
3639
+ * over the paragraph and under it — and where there is none to take, the box
3640
+ * reaches no further than the line's box does.
3641
+ *
3642
+ * A box is a paragraph's only where it holds the paragraph's one line and no
3643
+ * other, stands out by less than a line over it and under it, and is no wider
3644
+ * than the column: a panel drawn behind a block of text is drawn where the
3645
+ * page drew it. And only a box of a colour: white is the paper's, and
3646
+ * bug1815476.pdf backs a whole form with white boxes as wide as the sheet —
3647
+ * taken for shading, "A N E X O" stood off centre by as much as the box
3648
+ * reached past the column.
3649
+ *
3650
+ * @param paras A region's paragraphs, top to bottom.
3651
+ * @param boxes The page's filled boxes (see {@link solidBox}).
3652
+ * @param measure The column the paragraphs are set in.
3653
+ * @returns The paragraphs, and how far under the last one its box reaches.
3654
+ */
3655
+ function onBoxes(paras, boxes, measure) {
3656
+ const out = paras.map((para) => ({ ...para }));
3657
+ if (boxes.length === 0 || measure === void 0) return {
3658
+ paras: out,
3659
+ under: 0
3660
+ };
3661
+ let under = 0;
3662
+ out.forEach((para, k) => {
3663
+ if (para.lineCount !== 1 || (para.stops?.length ?? 0) > 0) return;
3664
+ const lineTop = para.top + BASELINE_AT * para.lineHeight;
3665
+ const lineFoot = para.bottom - (1 - BASELINE_AT) * para.lineHeight;
3666
+ const reach = para.fontSize * BAR_REACH_EM;
3667
+ const box = boxes.find((b) => b.minX >= measure.left - reach && b.maxX <= measure.right + reach && b.minX <= para.left + FLUSH_PT && b.maxX >= para.right - FLUSH_PT && b.maxY >= lineTop - FLUSH_PT && b.maxY - lineTop <= para.lineHeight && b.minY <= lineFoot + FLUSH_PT && lineFoot - b.minY <= para.lineHeight && paras.every((o, n) => n === k || o.bottom > b.maxY || o.top < b.minY));
3668
+ if (box === void 0) return;
3669
+ const next = out[k + 1];
3670
+ const over = Math.min(Math.max(0, box.maxY - lineTop), para.spacingBefore ?? 0, MOST_BORDER_PT);
3671
+ const below = Math.min(Math.max(0, lineFoot - box.minY), next !== void 0 ? next.spacingBefore ?? 0 : Infinity, MOST_BORDER_PT);
3672
+ const inset = para.alignment === void 0 ? Math.max(0, para.left - box.minX) : 0;
3673
+ const shade = {
3674
+ box,
3675
+ over: over > SPACING_NOISE_PT ? over : 0,
3676
+ under: below > SPACING_NOISE_PT ? below : 0,
3677
+ indentLeft: box.minX - measure.left,
3678
+ indentFirstLine: inset,
3679
+ indentRight: measure.right - box.maxX
3680
+ };
3681
+ para.shade = shade;
3682
+ if (para.spacingBefore !== void 0) para.spacingBefore -= shade.over;
3683
+ if (next?.spacingBefore !== void 0) next.spacingBefore -= shade.under;
3684
+ if (k === out.length - 1) under = shade.under;
3685
+ });
3686
+ return {
3687
+ paras: out,
3688
+ under
3689
+ };
3690
+ }
3691
+ /**
3692
+ * The paragraph properties a {@link Shade} sets: the box's colour behind the
3693
+ * line, a border of it as thick as the box stands out over the line and under
3694
+ * it, and the indents that put its edges where the page drew them.
3695
+ */
3696
+ function shadeProperties(shade) {
3697
+ const colorHex = shade.box.fillHex ?? "FFFFFF";
3698
+ const edge = (width) => width > 0 ? {
3699
+ style: "single",
3700
+ width: pt(width),
3701
+ colorHex
3702
+ } : void 0;
3703
+ const top = edge(shade.over);
3704
+ const bottom = edge(shade.under);
3705
+ const noted = (value) => Math.abs(value) > SPACING_NOISE_PT ? pt(value) : void 0;
3706
+ const indentLeft = noted(shade.indentLeft);
3707
+ const indentFirstLine = noted(shade.indentFirstLine);
3708
+ const indentRight = noted(shade.indentRight);
3709
+ return {
3710
+ shading: { colorHex },
3711
+ ...top || bottom ? { borders: {
3712
+ ...top ? { top } : {},
3713
+ ...bottom ? { bottom } : {}
3714
+ } } : {},
3715
+ ...indentLeft !== void 0 ? { indentLeft } : {},
3716
+ ...indentFirstLine !== void 0 ? { indentFirstLine } : {},
3717
+ ...indentRight !== void 0 ? { indentRight } : {}
3718
+ };
3719
+ }
3720
+ /**
3721
+ * §17.3.4 — the thickest border a single line may be, twelve points: a box
3722
+ * standing out further than that from its line is not a bar behind it.
3723
+ */
3724
+ var MOST_BORDER_PT = 12;
3725
+ /** How far past its column, in ems of its line, a bar behind a line may reach. */
3726
+ var BAR_REACH_EM = 2;
3727
+ /** How light, in each of its channels, a fill is the paper's rather than a colour. */
3728
+ var PAPER_LEVEL = 250;
3729
+ /**
3730
+ * Whether a painted path is a box filled in one opaque colour, square to the
3731
+ * page: a bar a heading is set on, a panel. A shape, a gradient, a stroked
3732
+ * frame or a glyph is none, and nor is a box of the paper's own white.
3733
+ *
3734
+ * @param v The painted path.
3735
+ */
3736
+ function solidBox(v) {
3737
+ if (v.fillHex === void 0 || v.gradient !== void 0 || v.glyph === true) return false;
3738
+ if ([
3739
+ 0,
3740
+ 2,
3741
+ 4
3742
+ ].map((k) => parseInt(v.fillHex.slice(k, k + 2), 16)).every((c) => c >= PAPER_LEVEL)) return false;
3743
+ if ((v.alpha ?? 1) < 1 || v.darkens === true) return false;
3744
+ if (v.strokeHex !== void 0 && v.strokeHex !== v.fillHex) return false;
3745
+ if (v.segs.filter((s) => s.op === "move").length !== 1) return false;
3746
+ const points = v.segs.filter((s) => s.op !== "close");
3747
+ if (points.length < 4 || points.length > 5) return false;
3748
+ const at = (value, edge) => Math.abs(value - edge) <= FLUSH_PT;
3749
+ return points.every((s) => s.op !== "cubic" && (at(s.x, v.minX) || at(s.x, v.maxX)) && (at(s.y, v.minY) || at(s.y, v.maxY)));
3750
+ }
2264
3751
  /** The thinnest a border may be drawn and still be seen. */
2265
3752
  var RULE_MIN_PT = .5;
2266
3753
  /**
@@ -2322,6 +3809,8 @@ var RULE_RATIO = 20;
2322
3809
  var FLUSH_PT = 1;
2323
3810
  /** And how many lines it takes before a region is a column at all. */
2324
3811
  var MIN_PROSE_LINES = 6;
3812
+ /** How many of a column's lines fill it when it is justified prose. */
3813
+ var FILLED_SHARE = .5;
2325
3814
  /**
2326
3815
  * §17.4.38 — the page's rows as the TABLE they are.
2327
3816
  *
@@ -2513,4 +4002,4 @@ var TABLE_ALIGNED_SHARE = .6;
2513
4002
  */
2514
4003
  var TABLE_CELL_FILL = .65;
2515
4004
  //#endregion
2516
- export { endedParagraph, reconstructByLayout };
4005
+ export { FOOTER_PART, HEADER_PART, endedParagraph, footerBand, numberedFrom, numberingOf, pageTextEdges, reconstructByLayout, runningFoot, stepsBetweenWords };