reamkit 1.27.0 → 1.29.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.
- package/README.md +6 -5
- package/dist/esm/core/converter/ream.d.ts +8 -9
- package/dist/esm/core/converter/ream.js +0 -1
- package/dist/esm/core/document-model/types.d.ts +5 -3
- package/dist/esm/core/font/index.d.ts +1 -0
- package/dist/esm/core/font/ligatures.d.ts +17 -0
- package/dist/esm/core/font/ligatures.js +49 -0
- package/dist/esm/core/font/ttf-parser.d.ts +2 -1
- package/dist/esm/core/font/ttf-parser.js +11 -2
- package/dist/esm/core/fonts/remote-fonts.d.ts +1 -1
- package/dist/esm/core/fonts/remote-fonts.js +47 -2
- package/dist/esm/core/fonts/scripts.js +10 -5
- package/dist/esm/excel/header-footer.js +53 -8
- package/dist/esm/layout/styled-layout.js +53 -3
- package/dist/esm/pdf/cid-font.js +35 -8
- package/dist/esm/pdf-reader/annot-draw.js +114 -1
- package/dist/esm/pdf-reader/annots.d.ts +0 -14
- package/dist/esm/pdf-reader/annots.js +13 -1
- package/dist/esm/pdf-reader/ccitt.d.ts +2 -3
- package/dist/esm/pdf-reader/ccitt.js +32 -4
- package/dist/esm/pdf-reader/cff-outline.d.ts +36 -0
- package/dist/esm/pdf-reader/cff-outline.js +1122 -0
- package/dist/esm/pdf-reader/cmap.js +16 -6
- package/dist/esm/pdf-reader/content.d.ts +91 -0
- package/dist/esm/pdf-reader/content.js +156 -60
- package/dist/esm/pdf-reader/dingbats.d.ts +11 -0
- package/dist/esm/pdf-reader/dingbats.js +1033 -0
- package/dist/esm/pdf-reader/display.js +41 -1
- package/dist/esm/pdf-reader/document.d.ts +8 -0
- package/dist/esm/pdf-reader/document.js +26 -9
- package/dist/esm/pdf-reader/embedded-fonts.d.ts +11 -0
- package/dist/esm/pdf-reader/embedded-fonts.js +14 -1
- package/dist/esm/pdf-reader/encodings.d.ts +25 -0
- package/dist/esm/pdf-reader/encodings.js +110 -0
- package/dist/esm/pdf-reader/flow-build.d.ts +15 -4
- package/dist/esm/pdf-reader/flow-build.js +24 -11
- package/dist/esm/pdf-reader/font.js +329 -8
- package/dist/esm/pdf-reader/glyf-outline.d.ts +43 -0
- package/dist/esm/pdf-reader/glyf-outline.js +351 -0
- package/dist/esm/pdf-reader/glyph-names.js +20 -0
- package/dist/esm/pdf-reader/icc.d.ts +10 -0
- package/dist/esm/pdf-reader/icc.js +210 -0
- package/dist/esm/pdf-reader/image-decode.d.ts +4 -3
- package/dist/esm/pdf-reader/image-decode.js +85 -64
- package/dist/esm/pdf-reader/images.d.ts +6 -0
- package/dist/esm/pdf-reader/images.js +50 -7
- package/dist/esm/pdf-reader/jbig2.js +104 -19
- package/dist/esm/pdf-reader/layout.js +1531 -55
- package/dist/esm/pdf-reader/math-rows.d.ts +23 -0
- package/dist/esm/pdf-reader/math-rows.js +198 -0
- package/dist/esm/pdf-reader/predefined-cmap.d.ts +21 -0
- package/dist/esm/pdf-reader/predefined-cmap.js +102 -0
- package/dist/esm/pdf-reader/reader.d.ts +6 -9
- package/dist/esm/pdf-reader/reader.js +34 -21
- package/dist/esm/pdf-reader/shading.d.ts +81 -2
- package/dist/esm/pdf-reader/shading.js +191 -25
- package/dist/esm/pdf-reader/stream-filters.d.ts +6 -0
- package/dist/esm/pdf-reader/stream-filters.js +67 -0
- package/dist/esm/pdf-reader/tagged.js +1 -1
- package/dist/esm/pdf-reader/text-rules.js +83 -10
- package/dist/esm/pdf-reader/text.js +82 -3
- package/dist/esm/pdf-reader/type1-outline.d.ts +21 -0
- package/dist/esm/pdf-reader/type1-outline.js +576 -0
- package/dist/esm/pdf-reader/vector.d.ts +2 -0
- package/dist/esm/pdf-reader/vector.js +44 -4
- package/dist/esm/word/document-parser.js +4 -1
- package/dist/esm/word/docx-writer.js +28 -11
- package/package.json +1 -1
|
@@ -9,6 +9,11 @@ import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
|
|
|
9
9
|
import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock, withMeasuredMargins } from "./flow-build.js";
|
|
10
10
|
import { collectPageImages } from "./images.js";
|
|
11
11
|
import { markDrawnRules } from "./text-rules.js";
|
|
12
|
+
import { matrixBlocks } from "./math-rows.js";
|
|
13
|
+
//#region src/pdf-reader/layout.ts
|
|
14
|
+
/** The relationships the reconstruction files its running head and foot under. */
|
|
15
|
+
var FOOTER_PART = "pdf-running-foot";
|
|
16
|
+
var HEADER_PART = "pdf-running-head";
|
|
12
17
|
/**
|
|
13
18
|
* Heuristically reconstruct an untagged PDF into a {@link Reconstruction}
|
|
14
19
|
* (E-PDF EP4). With no structure tree there is only positioned content, so
|
|
@@ -27,7 +32,11 @@ import { markDrawnRules } from "./text-rules.js";
|
|
|
27
32
|
function reconstructByLayout(file, mode = "flow") {
|
|
28
33
|
const pages = file.pages();
|
|
29
34
|
const shown = pages.map((page) => displayOf(page));
|
|
30
|
-
const
|
|
35
|
+
const allRuns = pages.map((page, i) => placeRuns(extractPageText(file, page), shown[i]));
|
|
36
|
+
const foot = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "foot");
|
|
37
|
+
const head = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "head");
|
|
38
|
+
const pageRuns = foot || head ? allRuns.map((runs, i) => runs.filter((r) => foot?.lift[i]?.has(r) !== true && head?.lift[i]?.has(r) !== true)) : allRuns;
|
|
39
|
+
const pageMarks = pages.map(() => []);
|
|
31
40
|
const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
|
|
32
41
|
const resources = new ResourceStore();
|
|
33
42
|
const losses = [];
|
|
@@ -41,16 +50,35 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
41
50
|
feature: FEATURES.text,
|
|
42
51
|
detail: "text filled with a tiling pattern is drawn as a flat tint of the pattern’s colour, not as the pattern"
|
|
43
52
|
});
|
|
53
|
+
if (pageRuns.some((page) => page.some((r) => r.gradientFill === true))) losses.push({
|
|
54
|
+
severity: "degraded",
|
|
55
|
+
feature: FEATURES.text,
|
|
56
|
+
detail: "text filled with a shading pattern is drawn in the middle colour of the gradient, not as the gradient"
|
|
57
|
+
});
|
|
58
|
+
const perPage = pages.map((_, i) => detectGutters(pageRuns[i], shown[i].width));
|
|
59
|
+
const shared = commonGutters(perPage);
|
|
60
|
+
const pageGutters = perPage.map((own, i) => own.length > 0 ? own : shared && fitsGutters(pageRuns[i], shared) ? shared : own);
|
|
44
61
|
const body = [];
|
|
62
|
+
const sectionEnds = [];
|
|
63
|
+
let sectionFrom = 0;
|
|
64
|
+
let lastSize = "";
|
|
65
|
+
let curColumns = 1;
|
|
66
|
+
let curSpace = 0;
|
|
67
|
+
let pendingContinuous = false;
|
|
45
68
|
pages.forEach((page, i) => {
|
|
46
69
|
const runs = pageRuns[i];
|
|
47
70
|
const display = shown[i];
|
|
71
|
+
const gutters = pageGutters[i];
|
|
48
72
|
const pageWidth = display.width;
|
|
49
|
-
const gutter = detectGutter(runs, pageWidth);
|
|
50
73
|
const stepped = stepsBetweenWords(runs);
|
|
51
74
|
const blocks = [];
|
|
75
|
+
const textEdges = pageTextEdges(runs);
|
|
76
|
+
const inColumns = proseColumns(runs, gutters, textEdges);
|
|
77
|
+
const split = gutters.length > 0 && inColumns ? assignColumns(runs, gutters) : void 0;
|
|
78
|
+
const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
|
|
79
|
+
const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
|
|
52
80
|
const addColumn = (allRuns, col) => {
|
|
53
|
-
const colRuns =
|
|
81
|
+
const colRuns = allRuns.filter((r) => r.type3 !== true && (mode !== "positional" || r.invisible !== true));
|
|
54
82
|
if (mode === "positional") {
|
|
55
83
|
for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
|
|
56
84
|
if (line.text.length === 0) continue;
|
|
@@ -64,22 +92,72 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
64
92
|
}
|
|
65
93
|
return;
|
|
66
94
|
}
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
for (const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
const columnSize = median(colRuns.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
96
|
+
const maths = matrixBlocks(colRuns, columnSize);
|
|
97
|
+
const inMath = new Set(maths.flatMap((m) => [...m.used]));
|
|
98
|
+
const lines = groupIntoLines(inMath.size > 0 ? colRuns.filter((r) => !inMath.has(r)) : colRuns, false, stepped).filter((l) => l.text.length > 0);
|
|
99
|
+
const measure = measureOf(col, gutters, textEdges) ?? measureOfLines(lines);
|
|
100
|
+
for (const found of maths) {
|
|
101
|
+
const { alignment } = alignmentOf([{
|
|
102
|
+
y: found.top,
|
|
103
|
+
fontSize: columnSize,
|
|
104
|
+
text: "",
|
|
105
|
+
spans: [],
|
|
106
|
+
x: found.x,
|
|
107
|
+
width: found.width
|
|
108
|
+
}], measure);
|
|
109
|
+
blocks.push({
|
|
110
|
+
band: bandAt(found.top),
|
|
111
|
+
col,
|
|
112
|
+
top: found.top,
|
|
113
|
+
el: {
|
|
114
|
+
kind: "paragraph",
|
|
115
|
+
paragraph: {
|
|
116
|
+
properties: alignment ? { alignment } : {},
|
|
117
|
+
runs: [{
|
|
118
|
+
text: "",
|
|
119
|
+
properties: {},
|
|
120
|
+
math: found.math
|
|
121
|
+
}]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const paras = groupIntoParagraphs(lines, measure, display.height);
|
|
127
|
+
const asRows = tabbedRows(paras, measure && {
|
|
128
|
+
left: measure.left,
|
|
129
|
+
right: Math.max(measure.right, textEdges?.right ?? measure.right)
|
|
79
130
|
});
|
|
131
|
+
for (const [at, para] of paras.entries()) {
|
|
132
|
+
const table = asRows.get(at);
|
|
133
|
+
if (table !== void 0) {
|
|
134
|
+
if (table !== null) blocks.push({
|
|
135
|
+
band: bandAt(para.top),
|
|
136
|
+
col,
|
|
137
|
+
top: para.top,
|
|
138
|
+
el: table
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
blocks.push({
|
|
143
|
+
band: bandAt(para.top),
|
|
144
|
+
col,
|
|
145
|
+
top: para.top,
|
|
146
|
+
el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
|
|
147
|
+
...para.stops !== void 0 && para.stops.length > 0 ? { tabs: para.stops.filter((x) => x > 0).map((x) => ({
|
|
148
|
+
positionPt: pt(x),
|
|
149
|
+
alignment: "left"
|
|
150
|
+
})) } : {},
|
|
151
|
+
...para.alignment !== void 0 ? { alignment: para.alignment } : {},
|
|
152
|
+
...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
|
|
153
|
+
...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
|
|
154
|
+
...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
}
|
|
80
158
|
};
|
|
81
159
|
const placed = [];
|
|
82
|
-
const colOf = (centerX) =>
|
|
160
|
+
const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
|
|
83
161
|
const frame = {
|
|
84
162
|
left: 0,
|
|
85
163
|
top: display.height
|
|
@@ -90,6 +168,7 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
90
168
|
losses: raw.losses
|
|
91
169
|
};
|
|
92
170
|
losses.push(...imgs.losses);
|
|
171
|
+
pageMarks[i] = imgs.images;
|
|
93
172
|
const lifted = collectPageVectors(file, page, imgs.images.map((img) => ({
|
|
94
173
|
minX: img.x,
|
|
95
174
|
minY: img.y,
|
|
@@ -99,12 +178,23 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
99
178
|
losses.push(...lifted.losses);
|
|
100
179
|
const placedVectors = placeVectors(lifted.vectors, display);
|
|
101
180
|
const ruled = markDrawnRules(runs, placedVectors);
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
181
|
+
const strayGlyphs = strayMarks(placedVectors, runs);
|
|
182
|
+
const vectors = placedVectors.filter((v) => !ruled.consumed.has(v)).filter((v) => !(mode !== "positional" && strayGlyphs.has(v)));
|
|
183
|
+
const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
|
|
184
|
+
const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
|
|
185
|
+
if (asTable) for (const block of asTable) blocks.push({
|
|
186
|
+
band: bandAt(block.top),
|
|
187
|
+
col: 0,
|
|
188
|
+
top: block.top,
|
|
189
|
+
el: block.el
|
|
190
|
+
});
|
|
191
|
+
else if (split && !ruledIntoColumns) {
|
|
192
|
+
const columnFor = (r) => split.columnOf.get(r) ?? colOf(r.x);
|
|
193
|
+
const columns = Array.from({ length: gutters.length + 1 }, (_, n) => n);
|
|
194
|
+
for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
|
|
106
195
|
} else addColumn(ruled.runs, 0);
|
|
107
196
|
const under = mode !== "positional";
|
|
197
|
+
const givenAway = mode !== "positional" ? ruleBorders(vectors, blocks, display.width) : void 0;
|
|
108
198
|
[
|
|
109
199
|
...imgs.images.map((img) => ({
|
|
110
200
|
key: img.orderKey,
|
|
@@ -112,7 +202,7 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
112
202
|
top: img.y + img.heightPt,
|
|
113
203
|
make: (z) => imageBlock(img, resources, void 0, frame, z, under)
|
|
114
204
|
})),
|
|
115
|
-
...vectors.map((v) => ({
|
|
205
|
+
...vectors.filter((v) => givenAway?.has(v) !== true).map((v) => ({
|
|
116
206
|
key: v.orderKey,
|
|
117
207
|
col: colOf((v.minX + v.maxX) / 2),
|
|
118
208
|
top: v.maxY,
|
|
@@ -121,13 +211,29 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
121
211
|
...placed
|
|
122
212
|
].sort((a, b) => compareOrder(a.key, b.key)).forEach((mark, z) => {
|
|
123
213
|
blocks.push({
|
|
214
|
+
band: bandAt(mark.top),
|
|
124
215
|
col: mark.col,
|
|
125
216
|
top: mark.top,
|
|
126
217
|
el: mark.make(z)
|
|
127
218
|
});
|
|
128
219
|
});
|
|
129
|
-
blocks.sort((a, b) => a.col - b.col || b.top - a.top);
|
|
130
|
-
|
|
220
|
+
blocks.sort((a, b) => a.band - b.band || columnOrder(a.col) - columnOrder(b.col) || b.top - a.top);
|
|
221
|
+
const size = `${shown[i].width.toFixed(2)}x${shown[i].height.toFixed(2)}`;
|
|
222
|
+
const opensSection = i > 0 && size !== lastSize;
|
|
223
|
+
if (opensSection) {
|
|
224
|
+
sectionEnds.push({
|
|
225
|
+
at: body.length,
|
|
226
|
+
from: sectionFrom,
|
|
227
|
+
to: i,
|
|
228
|
+
columns: curColumns,
|
|
229
|
+
spacePt: curSpace,
|
|
230
|
+
continuous: pendingContinuous
|
|
231
|
+
});
|
|
232
|
+
sectionFrom = i;
|
|
233
|
+
pendingContinuous = false;
|
|
234
|
+
}
|
|
235
|
+
lastSize = size;
|
|
236
|
+
if (i > 0 && !opensSection && blocks.length > 0) body.push({
|
|
131
237
|
kind: "paragraph",
|
|
132
238
|
paragraph: {
|
|
133
239
|
properties: {
|
|
@@ -138,36 +244,434 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
138
244
|
runs: []
|
|
139
245
|
}
|
|
140
246
|
});
|
|
141
|
-
|
|
247
|
+
const columnsHere = ruledIntoColumns || !proseColumns(runs, gutters, textEdges) ? 1 : gutters.length + 1;
|
|
248
|
+
const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
|
|
249
|
+
for (const block of blocks) {
|
|
250
|
+
const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
|
|
251
|
+
if (count !== curColumns) {
|
|
252
|
+
sectionEnds.push({
|
|
253
|
+
at: body.length,
|
|
254
|
+
from: sectionFrom,
|
|
255
|
+
to: i + 1,
|
|
256
|
+
columns: curColumns,
|
|
257
|
+
spacePt: curSpace,
|
|
258
|
+
continuous: pendingContinuous
|
|
259
|
+
});
|
|
260
|
+
sectionFrom = i;
|
|
261
|
+
pendingContinuous = true;
|
|
262
|
+
curColumns = count;
|
|
263
|
+
curSpace = spacePt;
|
|
264
|
+
}
|
|
265
|
+
body.push(block.el);
|
|
266
|
+
}
|
|
142
267
|
});
|
|
143
|
-
const
|
|
268
|
+
const setUp = (from, to) => {
|
|
269
|
+
const own = sectionFromPdfPages(pages.slice(from, to));
|
|
270
|
+
return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to));
|
|
271
|
+
};
|
|
272
|
+
sectionEnds.push({
|
|
273
|
+
at: body.length,
|
|
274
|
+
from: sectionFrom,
|
|
275
|
+
to: pages.length,
|
|
276
|
+
columns: curColumns,
|
|
277
|
+
spacePt: curSpace,
|
|
278
|
+
continuous: pendingContinuous
|
|
279
|
+
});
|
|
280
|
+
const sections = sectionEnds.length > 1 ? sectionEnds.flatMap((end) => {
|
|
281
|
+
const base = setUp(end.from, end.to);
|
|
282
|
+
if (!base) return [];
|
|
283
|
+
return [{
|
|
284
|
+
properties: {
|
|
285
|
+
...base,
|
|
286
|
+
...end.columns > 1 && mode !== "positional" ? { columns: {
|
|
287
|
+
count: end.columns,
|
|
288
|
+
spacePt: end.spacePt
|
|
289
|
+
} } : {},
|
|
290
|
+
...end.continuous ? { sectionStart: "continuous" } : {}
|
|
291
|
+
},
|
|
292
|
+
endIndex: end.at
|
|
293
|
+
}];
|
|
294
|
+
}) : [];
|
|
295
|
+
const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
|
|
296
|
+
const edges0 = pageTextEdges(allRuns[0] ?? []);
|
|
297
|
+
const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
|
|
298
|
+
const headBand = head ? footerBand(head.band, stepped0, edges0, head.numbered) : [];
|
|
299
|
+
const withFooter = (properties) => properties ? {
|
|
300
|
+
...properties,
|
|
301
|
+
...band.length > 0 ? { footers: [{
|
|
302
|
+
type: "default",
|
|
303
|
+
relationshipId: FOOTER_PART
|
|
304
|
+
}] } : {},
|
|
305
|
+
...headBand.length > 0 ? { headers: [{
|
|
306
|
+
type: "default",
|
|
307
|
+
relationshipId: HEADER_PART
|
|
308
|
+
}] } : {}
|
|
309
|
+
} : properties;
|
|
144
310
|
return {
|
|
145
|
-
doc: buildFlowDoc(body, resources,
|
|
311
|
+
doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
|
|
312
|
+
...s,
|
|
313
|
+
properties: withFooter(s.properties) ?? s.properties
|
|
314
|
+
})), band.length > 0 || headBand.length > 0 ? new Map([...band.length > 0 ? [[FOOTER_PART, band]] : [], ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []]) : void 0),
|
|
146
315
|
losses: dedupeLosses(losses)
|
|
147
316
|
};
|
|
148
317
|
}
|
|
149
|
-
function
|
|
150
|
-
if (runs.length < 30 || pageWidth <= 0) return
|
|
318
|
+
function detectGutters(runs, pageWidth) {
|
|
319
|
+
if (runs.length < 30 || pageWidth <= 0) return [];
|
|
151
320
|
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
321
|
+
const spans = runs.flatMap((r) => {
|
|
322
|
+
const ink = runInk(r);
|
|
323
|
+
return ink ? [ink] : [];
|
|
324
|
+
});
|
|
325
|
+
if (spans.length === 0) return [];
|
|
326
|
+
const minX = Math.min(...spans.map((iv) => iv[0]));
|
|
327
|
+
const span = Math.max(...spans.map((iv) => iv[1])) - minX;
|
|
328
|
+
if (span < pageWidth * .5) return [];
|
|
329
|
+
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
|
|
330
|
+
let curEnd = sorted[0][1];
|
|
331
|
+
const bands = [];
|
|
332
|
+
for (const [l, r] of sorted) {
|
|
333
|
+
if (l - curEnd >= fontSize * 3) bands.push({
|
|
334
|
+
mid: (curEnd + l) / 2,
|
|
335
|
+
from: curEnd,
|
|
336
|
+
to: l
|
|
337
|
+
});
|
|
164
338
|
if (r > curEnd) curEnd = r;
|
|
165
339
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
340
|
+
const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
|
|
341
|
+
const ink = runInk(r);
|
|
342
|
+
return ink ? [ink] : [];
|
|
343
|
+
}).sort((a, b) => a[0] - b[0]));
|
|
344
|
+
const straddling = (band) => {
|
|
345
|
+
let n = 0;
|
|
346
|
+
for (const row of rows) {
|
|
347
|
+
const before = row.filter(([, r]) => r <= band.from);
|
|
348
|
+
const after = row.filter(([l]) => l >= band.to);
|
|
349
|
+
if (before.length === 0 || after.length === 0) continue;
|
|
350
|
+
if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) n++;
|
|
351
|
+
}
|
|
352
|
+
return n;
|
|
353
|
+
};
|
|
354
|
+
const empty = bands.filter((b) => straddling(b) >= MIN_EMPTY_BAND_ROWS);
|
|
355
|
+
const voted = [];
|
|
356
|
+
for (const x of sample(minX + span * .1, minX + span * .9, 1)) {
|
|
357
|
+
let columned = 0;
|
|
358
|
+
let crossing = 0;
|
|
359
|
+
for (const row of rows) {
|
|
360
|
+
if (row.some(([l, r]) => l < x && r > x)) {
|
|
361
|
+
crossing++;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const before = row.filter(([, r]) => r <= x);
|
|
365
|
+
const after = row.filter(([l]) => l >= x);
|
|
366
|
+
if (before.length === 0 || after.length === 0) continue;
|
|
367
|
+
if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
|
|
368
|
+
}
|
|
369
|
+
if (columned < MIN_COLUMNED_ROWS || crossing >= columned) continue;
|
|
370
|
+
const last = voted[voted.length - 1];
|
|
371
|
+
if (last && x - last.to <= 1.5) voted[voted.length - 1] = {
|
|
372
|
+
...last,
|
|
373
|
+
to: x,
|
|
374
|
+
mid: (last.from + x) / 2
|
|
375
|
+
};
|
|
376
|
+
else voted.push({
|
|
377
|
+
mid: x,
|
|
378
|
+
from: x,
|
|
379
|
+
to: x
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const near = (band) => empty.some((e) => band.mid > e.from - fontSize && band.mid < e.to + fontSize);
|
|
383
|
+
return separating([...empty, ...voted.filter((v) => !near(v))].sort((a, b) => a.mid - b.mid), spans);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The candidate gutters that actually separate something, left to right.
|
|
387
|
+
*
|
|
388
|
+
* A gutter with nothing on one side of it is a margin, and two of them with a
|
|
389
|
+
* word between are one column and a stray. Each band is kept only where the
|
|
390
|
+
* region since the last kept one holds a real share of the page's runs — and
|
|
391
|
+
* the last one only if something follows it.
|
|
392
|
+
*
|
|
393
|
+
* @param bands The candidates, in order.
|
|
394
|
+
* @param spans Every run's horizontal extent.
|
|
395
|
+
* @returns The gutters worth splitting on.
|
|
396
|
+
*/
|
|
397
|
+
function separating(bands, spans) {
|
|
398
|
+
const need = spans.length * MIN_COLUMN_SHARE;
|
|
399
|
+
const out = [];
|
|
400
|
+
let from = -Infinity;
|
|
401
|
+
for (const band of bands) {
|
|
402
|
+
if (spans.filter(([l, r]) => (l + r) / 2 > from && (l + r) / 2 < band.mid).length < need) continue;
|
|
403
|
+
out.push(band);
|
|
404
|
+
from = band.mid;
|
|
405
|
+
}
|
|
406
|
+
const tail = spans.filter(([l, r]) => (l + r) / 2 > from).length;
|
|
407
|
+
if (out.length > 0 && tail < need) out.pop();
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
/** How much of a page's text the narrowest column holds before it is a column. */
|
|
411
|
+
var MIN_COLUMN_SHARE = .08;
|
|
412
|
+
/**
|
|
413
|
+
* The gutters MOST of the document's pages agree on.
|
|
414
|
+
*
|
|
415
|
+
* A document is set one way: §17.6.4 puts the column setup on the section, and
|
|
416
|
+
* a paper does not change it from sheet to sheet. So a page that says nothing
|
|
417
|
+
* on its own — one carrying a figure across both columns, with too few clean
|
|
418
|
+
* lines left for the vote — can be asked the question the rest of the document
|
|
419
|
+
* already answered.
|
|
420
|
+
*
|
|
421
|
+
* @param perPage Each page's own answer, in page order.
|
|
422
|
+
* @returns The answer given by more pages than any other, or `undefined` where
|
|
423
|
+
* no two pages agree.
|
|
424
|
+
*/
|
|
425
|
+
function commonGutters(perPage) {
|
|
426
|
+
const seen = /* @__PURE__ */ new Map();
|
|
427
|
+
for (const gutters of perPage) {
|
|
428
|
+
if (gutters.length === 0) continue;
|
|
429
|
+
const key = gutters.map((g) => Math.round(g.mid / 2)).join(",");
|
|
430
|
+
const had = seen.get(key);
|
|
431
|
+
if (had) had.pages++;
|
|
432
|
+
else seen.set(key, {
|
|
433
|
+
gutters,
|
|
434
|
+
pages: 1
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
let best;
|
|
438
|
+
for (const entry of seen.values()) if (!best || entry.pages > best.pages) best = entry;
|
|
439
|
+
return best?.gutters;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Whether a page's own lines agree with gutters the document states.
|
|
443
|
+
*
|
|
444
|
+
* The document's answer is a suggestion, not a licence: a sheet whose lines run
|
|
445
|
+
* straight through the gutter is set in one column whatever its neighbours do.
|
|
446
|
+
* The bar is lower than the vote's own — the evidence from the other pages is
|
|
447
|
+
* already in — but it is still the page that decides.
|
|
448
|
+
*
|
|
449
|
+
* @param runs The page's runs.
|
|
450
|
+
* @param gutters The document's gutters.
|
|
451
|
+
*/
|
|
452
|
+
function fitsGutters(runs, gutters) {
|
|
453
|
+
if (runs.length < 30) return false;
|
|
454
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
455
|
+
const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
|
|
456
|
+
const ink = runInk(r);
|
|
457
|
+
return ink ? [ink] : [];
|
|
458
|
+
}).sort((a, b) => a[0] - b[0]));
|
|
459
|
+
for (const gutter of gutters) {
|
|
460
|
+
let columned = 0;
|
|
461
|
+
let crossing = 0;
|
|
462
|
+
for (const row of rows) {
|
|
463
|
+
if (row.some(([l, r]) => l < gutter.mid && r > gutter.mid)) {
|
|
464
|
+
crossing++;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const before = row.filter(([, r]) => r <= gutter.mid);
|
|
468
|
+
const after = row.filter(([l]) => l >= gutter.mid);
|
|
469
|
+
if (before.length === 0 || after.length === 0) continue;
|
|
470
|
+
if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
|
|
471
|
+
}
|
|
472
|
+
if (crossing === 0) continue;
|
|
473
|
+
if (columned < MIN_SHARED_ROWS || crossing >= columned) return false;
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
/** How many lines a page must split at a gutter the DOCUMENT already states. */
|
|
478
|
+
var MIN_SHARED_ROWS = 3;
|
|
479
|
+
/** How many lines have to be split at the same place before the page is in columns. */
|
|
480
|
+
var MIN_COLUMNED_ROWS = 12;
|
|
481
|
+
/**
|
|
482
|
+
* How many lines a band NO run crosses must separate before it is a gutter
|
|
483
|
+
* rather than an empty half of a page. Lower than the vote's bar, because a
|
|
484
|
+
* band nothing crosses is stronger evidence than a band most lines avoid — but
|
|
485
|
+
* not zero, which is what an invoice's blank left half offers.
|
|
486
|
+
*/
|
|
487
|
+
var MIN_EMPTY_BAND_ROWS = 8;
|
|
488
|
+
/**
|
|
489
|
+
* The page's runs grouped by baseline.
|
|
490
|
+
*
|
|
491
|
+
* Swept in order rather than bucketed: a superscript sits a few points above
|
|
492
|
+
* the baseline it belongs to, and a bucket boundary between the two would leave
|
|
493
|
+
* it a line of its own — bug1885505.pdf's author block came back with the
|
|
494
|
+
* asterisks and daggers standing alone on five separate lines.
|
|
495
|
+
*/
|
|
496
|
+
function rowsOf(runs, fontSize) {
|
|
497
|
+
const tolerance = Math.max(fontSize * .6, 1);
|
|
498
|
+
const rows = [];
|
|
499
|
+
let row = [];
|
|
500
|
+
let rowY = Number.POSITIVE_INFINITY;
|
|
501
|
+
for (const run of [...runs].sort((a, b) => b.y - a.y)) {
|
|
502
|
+
if (row.length > 0 && rowY - run.y > tolerance) {
|
|
503
|
+
rows.push(row);
|
|
504
|
+
row = [];
|
|
505
|
+
}
|
|
506
|
+
if (row.length === 0) rowY = run.y;
|
|
507
|
+
row.push(run);
|
|
508
|
+
}
|
|
509
|
+
if (row.length > 0) rows.push(row);
|
|
510
|
+
return rows;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* A run's INK — where its letters are, which a trailing space is not.
|
|
514
|
+
*
|
|
515
|
+
* A run carries the advance it stepped, and the space that ends a line of a
|
|
516
|
+
* column is part of it: chrome-text-selection-markedContent.pdf sets two
|
|
517
|
+
* columns 21 points apart and its left column's lines end ", " — five of those
|
|
518
|
+
* points — so every measurement of the gutter came back a third short and the
|
|
519
|
+
* page was read straight across.
|
|
520
|
+
*
|
|
521
|
+
* @param run The run.
|
|
522
|
+
* @returns Its ink, or `undefined` for a run that is nothing but space.
|
|
523
|
+
*/
|
|
524
|
+
function runInk(run) {
|
|
525
|
+
const from = Math.min(run.x, run.endX);
|
|
526
|
+
const to = Math.max(run.endX, run.x + 1);
|
|
527
|
+
const chars = [...run.text];
|
|
528
|
+
if (chars.length === 0) return [from, to];
|
|
529
|
+
let head = 0;
|
|
530
|
+
while (head < chars.length && SPACE.test(chars[head])) head++;
|
|
531
|
+
if (head === chars.length) return void 0;
|
|
532
|
+
let tail = 0;
|
|
533
|
+
while (tail < chars.length - head && SPACE.test(chars[chars.length - 1 - tail])) tail++;
|
|
534
|
+
if (head === 0 && tail === 0) return [from, to];
|
|
535
|
+
const step = (to - from) / chars.length;
|
|
536
|
+
return [from + head * step, to - tail * step];
|
|
537
|
+
}
|
|
538
|
+
var SPACE = /\s/u;
|
|
539
|
+
/** Where the page's text starts and ends, ignoring what is only a space. */
|
|
540
|
+
function pageTextEdges(runs) {
|
|
541
|
+
let left = Number.POSITIVE_INFINITY;
|
|
542
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
543
|
+
for (const run of runs) {
|
|
544
|
+
const ink = runInk(run);
|
|
545
|
+
if (!ink) continue;
|
|
546
|
+
left = Math.min(left, ink[0]);
|
|
547
|
+
right = Math.max(right, ink[1]);
|
|
548
|
+
}
|
|
549
|
+
return right > left ? {
|
|
550
|
+
left,
|
|
551
|
+
right
|
|
552
|
+
} : void 0;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The measure a column is set across: from the gutter on its left to the one
|
|
556
|
+
* on its right, and to the page's own text edge where there is none.
|
|
557
|
+
*
|
|
558
|
+
* A line that spans the page (see {@link SPANNING_COLUMN}) is set across all of
|
|
559
|
+
* them, which is what makes a centred title centred.
|
|
560
|
+
*
|
|
561
|
+
* @param col The column, or {@link SPANNING_COLUMN}.
|
|
562
|
+
* @param gutters The page's gutters, left to right.
|
|
563
|
+
* @param edges Where the page's text starts and ends.
|
|
564
|
+
*/
|
|
565
|
+
function measureOf(col, gutters, edges) {
|
|
566
|
+
if (!edges) return void 0;
|
|
567
|
+
if (col === SPANNING_COLUMN || gutters.length === 0) return edges;
|
|
568
|
+
return {
|
|
569
|
+
left: col === 0 ? edges.left : gutters[col - 1]?.to ?? edges.left,
|
|
570
|
+
right: col >= gutters.length ? edges.right : gutters[col]?.from ?? edges.right
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/** The measure the lines themselves reach across, where the page states none. */
|
|
574
|
+
function measureOfLines(lines) {
|
|
575
|
+
if (lines.length === 0) return void 0;
|
|
576
|
+
return {
|
|
577
|
+
left: Math.min(...lines.map((l) => l.x)),
|
|
578
|
+
right: Math.max(...lines.map((l) => l.x + l.width))
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/** The x's to measure at, from `a` to `b` inclusive. */
|
|
582
|
+
function sample(a, b, step) {
|
|
583
|
+
const out = [];
|
|
584
|
+
for (let x = a; x <= b; x += Math.max(step, .5)) out.push(x);
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* EP17 — which column each run belongs to, and where the page's bands break.
|
|
589
|
+
*
|
|
590
|
+
* The decision is made a LINE at a time, not a run at a time: a title is drawn
|
|
591
|
+
* as several runs and only one of them may reach across the gutter, so judging
|
|
592
|
+
* each on its own would leave the rest of the title standing in a column.
|
|
593
|
+
*
|
|
594
|
+
* @param runs The page's runs.
|
|
595
|
+
* @param gutter The band from {@link detectGutter}.
|
|
596
|
+
* @returns The column of each run (0, 1, or {@link SPANNING_COLUMN}) and the
|
|
597
|
+
* baselines of the full-width lines, which separate the bands.
|
|
598
|
+
*/
|
|
599
|
+
function assignColumns(runs, gutters) {
|
|
600
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
601
|
+
const rows = rowsOf(runs, fontSize);
|
|
602
|
+
const columnOf = /* @__PURE__ */ new Map();
|
|
603
|
+
const breaks = [];
|
|
604
|
+
const columnAt = (x) => gutters.filter((g) => x >= g.mid).length;
|
|
605
|
+
for (const row of rows) {
|
|
606
|
+
const inked = row.flatMap((run) => {
|
|
607
|
+
const ink = runInk(run);
|
|
608
|
+
return ink ? [{
|
|
609
|
+
run,
|
|
610
|
+
from: ink[0],
|
|
611
|
+
to: ink[1]
|
|
612
|
+
}] : [];
|
|
613
|
+
}).sort((a, b) => a.from - b.from);
|
|
614
|
+
if (inked.length === 0) continue;
|
|
615
|
+
const leftmost = inked[0].from;
|
|
616
|
+
const rightmost = Math.max(...inked.map((r) => r.to));
|
|
617
|
+
const straddled = gutters.filter((g) => leftmost < g.mid && rightmost > g.mid);
|
|
618
|
+
if (straddled.length === 0) {
|
|
619
|
+
const col = columnAt(leftmost);
|
|
620
|
+
for (const run of row) columnOf.set(run, col);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const gapAt = (mid) => {
|
|
624
|
+
let cur = inked[0].to;
|
|
625
|
+
let gap = 0;
|
|
626
|
+
for (const { from, to } of inked.slice(1)) {
|
|
627
|
+
if (from > cur && cur <= mid && from >= mid) gap = from - cur;
|
|
628
|
+
cur = Math.max(cur, to);
|
|
629
|
+
}
|
|
630
|
+
return gap;
|
|
631
|
+
};
|
|
632
|
+
if (straddled.every((g) => gapAt(g.mid) >= fontSize * MIN_GUTTER_EM)) {
|
|
633
|
+
for (const { run, from } of inked) columnOf.set(run, columnAt(from));
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
for (const run of row) columnOf.set(run, SPANNING_COLUMN);
|
|
637
|
+
breaks.push(Math.max(...row.map((r) => r.y)));
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
columnOf,
|
|
641
|
+
breaks: breaks.sort((a, b) => b - a)
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* A line that spans the page belongs to no column: it stands between the bands
|
|
646
|
+
* it separates.
|
|
647
|
+
*
|
|
648
|
+
* Which side of them? A spanning line is what BREAKS a band, and a band runs
|
|
649
|
+
* from one break to the next, so the line is always at the FOOT of its own —
|
|
650
|
+
* the columns of that band are the ones above it. Read ahead of them,
|
|
651
|
+
* bug1997343.pdf's page number came out between the date and the abstract.
|
|
652
|
+
*/
|
|
653
|
+
var SPANNING_COLUMN = -1;
|
|
654
|
+
/** Where a column reads in its band: the spanning line at the foot of it. */
|
|
655
|
+
function columnOrder(col) {
|
|
656
|
+
return col === SPANNING_COLUMN ? Number.MAX_SAFE_INTEGER : col;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* How wide, in ems, the gap over the middle has to be for a line to be two
|
|
660
|
+
* lines. A word space is a quarter of an em (see `SPACE_GAP_EM`) and a
|
|
661
|
+
* justified one no more than half; a gutter is an em and more.
|
|
662
|
+
*
|
|
663
|
+
* It stood at one and a half, which is wider than some magazines set:
|
|
664
|
+
* chrome-text-selection-markedContent.pdf puts fourteen and a half points
|
|
665
|
+
* between columns of eleven-point type — 1.31 em — and every line of it was
|
|
666
|
+
* read straight across, the left column's sentence running into the right
|
|
667
|
+
* column's.
|
|
668
|
+
*/
|
|
669
|
+
var MIN_GUTTER_EM = 1;
|
|
670
|
+
/** Which band a mark at this height belongs to — how many breaks stand above it. */
|
|
671
|
+
function bandOf(breaks, top, epsilon) {
|
|
672
|
+
let n = 0;
|
|
673
|
+
for (const y of breaks) if (y > top + epsilon) n++;
|
|
674
|
+
return n;
|
|
171
675
|
}
|
|
172
676
|
/**
|
|
173
677
|
* The gap, in ems, past which the placed reader cuts a line rather than write a
|
|
@@ -321,7 +825,7 @@ function inkSpan(runs) {
|
|
|
321
825
|
}
|
|
322
826
|
/** One run of runs, left to right on a shared baseline, as a {@link Line}. */
|
|
323
827
|
function lineOf(runs, y, fontSize, stepped) {
|
|
324
|
-
const ordered = lineSpans(runs, fontSize, stepped);
|
|
828
|
+
const { spans: ordered, stops } = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
|
|
325
829
|
const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
|
|
326
830
|
const ink = inkSpan(runs);
|
|
327
831
|
return {
|
|
@@ -329,11 +833,47 @@ function lineOf(runs, y, fontSize, stepped) {
|
|
|
329
833
|
width: ink.width,
|
|
330
834
|
y,
|
|
331
835
|
fontSize,
|
|
836
|
+
...tabbed(runs, fontSize) ? { tabbed: true } : {},
|
|
837
|
+
...stops.length > 0 ? { stops } : {},
|
|
332
838
|
text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
|
|
333
839
|
spans
|
|
334
840
|
};
|
|
335
841
|
}
|
|
336
842
|
/**
|
|
843
|
+
* Whether a TAB stands inside the line — a gap no word space could be.
|
|
844
|
+
*
|
|
845
|
+
* A line with one is a line the page SET OUT, not a line of prose: a contents
|
|
846
|
+
* entry with its page number at the measure, a two-column list, a label and its
|
|
847
|
+
* value. It does not run on into the line below it, and read as prose it does:
|
|
848
|
+
* bug1997343.pdf's contents came back as "2 Document structures 1 2.1
|
|
849
|
+
* Mathematics ............. 1", two entries in one line, where the file sets
|
|
850
|
+
* one to a line.
|
|
851
|
+
*
|
|
852
|
+
* A leader says the same thing (see {@link carriesLeader}) but only where the
|
|
853
|
+
* entry is dotted; a top-level entry is spaced, and nothing else marks it.
|
|
854
|
+
*
|
|
855
|
+
* @param runs The line's runs.
|
|
856
|
+
* @param fontSize The line's size.
|
|
857
|
+
*/
|
|
858
|
+
function tabbed(runs, fontSize) {
|
|
859
|
+
const inked = runs.flatMap((r) => {
|
|
860
|
+
const ink = runInk(r);
|
|
861
|
+
return ink ? [ink] : [];
|
|
862
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
863
|
+
let cur = Number.NEGATIVE_INFINITY;
|
|
864
|
+
for (const [from, to] of inked) {
|
|
865
|
+
if (cur > Number.NEGATIVE_INFINITY && from - cur >= fontSize * TAB_GAP_EM) return true;
|
|
866
|
+
cur = Math.max(cur, to);
|
|
867
|
+
}
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* How wide a gap has to be, in ems, before a word space could not have stood
|
|
872
|
+
* there. Justification stretches a space to about half an em; two and a half is
|
|
873
|
+
* a jump nothing but a tab makes.
|
|
874
|
+
*/
|
|
875
|
+
var TAB_GAP_EM = 2.5;
|
|
876
|
+
/**
|
|
337
877
|
* The gap between two runs that means a WORD SPACE stood there.
|
|
338
878
|
*
|
|
339
879
|
* A page that draws its own spaces has already said where its words divide, and
|
|
@@ -352,6 +892,28 @@ function lineOf(runs, y, fontSize, stepped) {
|
|
|
352
892
|
function spaceGap(prev, fontSize, stepped) {
|
|
353
893
|
return (prev.fontSizePt || fontSize) * (stepped ? STEPPED_SPACE_EM : DRAWN_SPACE_EM);
|
|
354
894
|
}
|
|
895
|
+
/**
|
|
896
|
+
* §17.3.1.25 — one character of a LEADER, the dotted rule that carries the eye
|
|
897
|
+
* across a table of contents.
|
|
898
|
+
*
|
|
899
|
+
* A leader is drawn one character at a time with a step about as wide as a word
|
|
900
|
+
* space, so every threshold that tells a space from a kern says "space" between
|
|
901
|
+
* every dot. bug886717.pdf's contents came back as
|
|
902
|
+
* "Abstract . . . . . . . . . . . . 3", four times as long as the page sets it,
|
|
903
|
+
* and its forty entries spilled onto a second page. What tells a leader from
|
|
904
|
+
* words is that it is the SAME character over and over.
|
|
905
|
+
*/
|
|
906
|
+
function isLeader(text) {
|
|
907
|
+
return text.length === 1 && LEADER_CHARS.has(text);
|
|
908
|
+
}
|
|
909
|
+
var LEADER_CHARS = new Set([
|
|
910
|
+
".",
|
|
911
|
+
"·",
|
|
912
|
+
"_",
|
|
913
|
+
"-",
|
|
914
|
+
"‐",
|
|
915
|
+
"–"
|
|
916
|
+
]);
|
|
355
917
|
/** A page that writes its own spaces: only a wide gap means anything more. */
|
|
356
918
|
var DRAWN_SPACE_EM = .25;
|
|
357
919
|
/** A page that writes none: the step between its words is all there is. */
|
|
@@ -370,9 +932,15 @@ function stepsBetweenWords(runs) {
|
|
|
370
932
|
}
|
|
371
933
|
function lineSpans(runs, fontSize, stepped) {
|
|
372
934
|
const spans = [];
|
|
935
|
+
const stops = [];
|
|
936
|
+
const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
|
|
937
|
+
const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
|
|
373
938
|
let prev;
|
|
374
939
|
for (const run of runs) {
|
|
375
|
-
if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped))
|
|
940
|
+
if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) if (run.x - prev.endX >= (run.fontSizePt || fontSize) * TAB_GAP_EM) {
|
|
941
|
+
spans.push({ text: " " });
|
|
942
|
+
stops.push(run.x);
|
|
943
|
+
} else spans.push({ text: " " });
|
|
376
944
|
spans.push({
|
|
377
945
|
text: run.text.replaceAll("�", ""),
|
|
378
946
|
sizePt: run.fontSizePt,
|
|
@@ -384,13 +952,84 @@ function lineSpans(runs, fontSize, stepped) {
|
|
|
384
952
|
} } : {},
|
|
385
953
|
...run.bold ? { bold: true } : {},
|
|
386
954
|
...run.italic ? { italic: true } : {},
|
|
955
|
+
...script(run, baseline, fontSize),
|
|
387
956
|
...run.markup !== void 0 ? { markup: run.markup } : {},
|
|
388
957
|
...run.href !== void 0 ? { href: run.href } : {}
|
|
389
958
|
});
|
|
390
959
|
prev = run;
|
|
391
960
|
}
|
|
392
|
-
return
|
|
961
|
+
return {
|
|
962
|
+
spans,
|
|
963
|
+
stops
|
|
964
|
+
};
|
|
393
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
|
|
968
|
+
* is.
|
|
969
|
+
*
|
|
970
|
+
* A PDF states no such property: an exponent is a smaller face set a little
|
|
971
|
+
* higher, and an index a smaller face set a little lower. Read flat, the whole
|
|
972
|
+
* of mathematics comes back on one line — bug1997343.pdf sets `n^p = n mod p`
|
|
973
|
+
* and we read "np", and every prime on the page landed beside its letter
|
|
974
|
+
* instead of over it.
|
|
975
|
+
*
|
|
976
|
+
* The size the span keeps is the LINE's, not the mark's: a document states the
|
|
977
|
+
* nominal size and the layout shrinks a script, so the drawn seven points under
|
|
978
|
+
* a superscript would come out at five.
|
|
979
|
+
*
|
|
980
|
+
* @param run The run.
|
|
981
|
+
* @param baseline The line's own baseline.
|
|
982
|
+
* @param fontSize The line's size.
|
|
983
|
+
*/
|
|
984
|
+
function script(run, baseline, fontSize) {
|
|
985
|
+
if ((run.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE) return {};
|
|
986
|
+
const rise = run.y - baseline;
|
|
987
|
+
if (rise > fontSize * SCRIPT_RISE) return {
|
|
988
|
+
script: "superscript",
|
|
989
|
+
sizePt: fontSize
|
|
990
|
+
};
|
|
991
|
+
if (rise < -fontSize * SCRIPT_DROP) return {
|
|
992
|
+
script: "subscript",
|
|
993
|
+
sizePt: fontSize
|
|
994
|
+
};
|
|
995
|
+
return {};
|
|
996
|
+
}
|
|
997
|
+
/** How much smaller than its line a run must be set to be a script of it. */
|
|
998
|
+
var SCRIPT_SIZE = .85;
|
|
999
|
+
/** How far above the baseline a superscript stands, and below it a subscript. */
|
|
1000
|
+
var SCRIPT_RISE = .15;
|
|
1001
|
+
var SCRIPT_DROP = .08;
|
|
1002
|
+
/**
|
|
1003
|
+
* §17.3.1.25 — whether a line carries a LEADER, which makes it an entry in a
|
|
1004
|
+
* directory rather than a line of prose.
|
|
1005
|
+
*
|
|
1006
|
+
* A contents line runs the full measure — the dots are there to make it — so
|
|
1007
|
+
* the ragged-edge test that ends every other paragraph never fires on one, and
|
|
1008
|
+
* bug886717.pdf's forty entries came back as a single reflowing paragraph.
|
|
1009
|
+
*/
|
|
1010
|
+
function carriesLeader(line) {
|
|
1011
|
+
return LEADER_RUN.test(line.text);
|
|
1012
|
+
}
|
|
1013
|
+
/** Three of the same leader character in a row is a leader and not punctuation. */
|
|
1014
|
+
var LEADER_RUN = /([.\u00b7_])\1{2,}/u;
|
|
1015
|
+
/**
|
|
1016
|
+
* Whether the line is a RULE drawn out of characters — a row of hyphens, dots
|
|
1017
|
+
* or underscores and nothing else.
|
|
1018
|
+
*
|
|
1019
|
+
* A page with no rule to draw types one. It is not a sentence, it does not run
|
|
1020
|
+
* on into the line under it and the line over it does not run on into it.
|
|
1021
|
+
*
|
|
1022
|
+
* @param line The line.
|
|
1023
|
+
* @returns Whether it is a rule rather than words.
|
|
1024
|
+
*/
|
|
1025
|
+
function ruleOfCharacters(line) {
|
|
1026
|
+
const text = line.text.trim();
|
|
1027
|
+
if (text.length < RULE_CHARS) return false;
|
|
1028
|
+
for (const ch of text) if (!LEADER_CHARS.has(ch)) return false;
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
/** How many of them it takes before a row of marks is a rule. */
|
|
1032
|
+
var RULE_CHARS = 3;
|
|
394
1033
|
function groupIntoParagraphs(lines, column, pageHeight = 0) {
|
|
395
1034
|
const groups = [];
|
|
396
1035
|
const gaps = [];
|
|
@@ -398,29 +1037,120 @@ function groupIntoParagraphs(lines, column, pageHeight = 0) {
|
|
|
398
1037
|
for (const line of lines) {
|
|
399
1038
|
const gap = prev !== void 0 ? prev.y - line.y : 0;
|
|
400
1039
|
const opened = prev !== void 0 && gap > line.fontSize * 1.5;
|
|
401
|
-
if (groups.length === 0 || opened || prev !== void 0 && endedParagraph(prev, line, column)) {
|
|
1040
|
+
if (groups.length === 0 || opened || ruleOfCharacters(line) || prev !== void 0 && (ruleOfCharacters(prev) || endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
|
|
402
1041
|
groups.push([]);
|
|
403
1042
|
gaps.push(prev === void 0 ? 0 : gap);
|
|
404
1043
|
}
|
|
405
1044
|
groups[groups.length - 1].push(line);
|
|
406
1045
|
prev = line;
|
|
407
1046
|
}
|
|
1047
|
+
const starts = lines.map((l) => l.x).sort((a, b) => a - b);
|
|
1048
|
+
const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
|
|
408
1049
|
return groups.map((g, i) => {
|
|
409
1050
|
const first = g[0];
|
|
410
1051
|
const fontSize = Math.max(...g.map((l) => l.fontSize));
|
|
411
1052
|
const opened = (gaps[i] ?? 0) - fontSize * 1.2;
|
|
412
1053
|
const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
|
|
413
1054
|
const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
|
|
1055
|
+
const stops = g.length === 1 ? first.stops ?? [] : [];
|
|
414
1056
|
return {
|
|
415
|
-
spans: g
|
|
1057
|
+
spans: joinLines(g),
|
|
416
1058
|
fontSize,
|
|
417
1059
|
top: first.y,
|
|
1060
|
+
...stops.length > 0 ? { stops: stops.map((x) => x - (column?.left ?? columnLeft)) } : {},
|
|
418
1061
|
...spacingBefore !== void 0 ? { spacingBefore } : {},
|
|
419
|
-
...alignmentOf(g, column)
|
|
1062
|
+
...alignmentOf(g, column),
|
|
1063
|
+
...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
|
|
420
1064
|
};
|
|
421
1065
|
});
|
|
422
1066
|
}
|
|
423
1067
|
/**
|
|
1068
|
+
* A paragraph's lines as one run of spans, joined the way the page broke them.
|
|
1069
|
+
*
|
|
1070
|
+
* A line that ends in a hyphen was broken THERE, and the break is not part of
|
|
1071
|
+
* the text: re-set at another measure the word has to come back together. Which
|
|
1072
|
+
* hyphen decides what is left of it — U+00AD is the discretionary one, put in
|
|
1073
|
+
* to mark a place a word MAY break, and it goes with the break; a plain hyphen
|
|
1074
|
+
* belongs to the word ("two-" and "column" are "two-column") and stays.
|
|
1075
|
+
*
|
|
1076
|
+
* Read as prose with a space between every line, bug1997343.pdf came back
|
|
1077
|
+
* "typical two-column docu ment incorporating tables, figures and mathemat
|
|
1078
|
+
* ics" — the soft hyphens dropped by the page and a space in their place.
|
|
1079
|
+
*
|
|
1080
|
+
* @param lines The paragraph's lines, in order.
|
|
1081
|
+
*/
|
|
1082
|
+
function joinLines(lines) {
|
|
1083
|
+
const out = [];
|
|
1084
|
+
lines.forEach((line, i) => {
|
|
1085
|
+
if (i > 0) {
|
|
1086
|
+
const prev = out[out.length - 1];
|
|
1087
|
+
const ends = prev?.text ?? "";
|
|
1088
|
+
const soft = ends.endsWith(SOFT_HYPHEN);
|
|
1089
|
+
const hard = HYPHENS.has(ends.slice(-1));
|
|
1090
|
+
if (soft && prev) out[out.length - 1] = {
|
|
1091
|
+
...prev,
|
|
1092
|
+
text: ends.slice(0, -1)
|
|
1093
|
+
};
|
|
1094
|
+
else if (!hard) out.push({ text: " " });
|
|
1095
|
+
}
|
|
1096
|
+
out.push(...line.spans);
|
|
1097
|
+
});
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
1100
|
+
/** §17.3.3.29 — the hyphen that is a PLACE a word may break, not a hyphen. */
|
|
1101
|
+
var SOFT_HYPHEN = "";
|
|
1102
|
+
/** The hyphens that belong to the word they end. */
|
|
1103
|
+
var HYPHENS = new Set([
|
|
1104
|
+
"-",
|
|
1105
|
+
"‐",
|
|
1106
|
+
"‑"
|
|
1107
|
+
]);
|
|
1108
|
+
/** How many lines' worth of indent still reads as a first line, not a placement. */
|
|
1109
|
+
var INDENT_LINES = 3;
|
|
1110
|
+
/** Where in the run of line starts the column's own left edge is looked for. */
|
|
1111
|
+
var COLUMN_LEFT_QUANTILE = .15;
|
|
1112
|
+
/**
|
|
1113
|
+
* §17.3.1.12 `w:ind` — how far a paragraph is set in from its column, and where
|
|
1114
|
+
* its first line begins.
|
|
1115
|
+
*
|
|
1116
|
+
* A PDF states neither: every line is placed absolutely, and read flat every
|
|
1117
|
+
* paragraph came back against the left edge. That is most of what a list looks
|
|
1118
|
+
* like — bug1997343.pdf sets "• They may be unordered bullet lists" ten points
|
|
1119
|
+
* in and its nested "1. lists may also be nested" twenty more, and we set all
|
|
1120
|
+
* of them flush left — and it is the whole of a first-line indent, which is how
|
|
1121
|
+
* most of the world's prose marks a new paragraph.
|
|
1122
|
+
*
|
|
1123
|
+
* The first line is measured against the REST of the paragraph, which is what
|
|
1124
|
+
* `indentFirstLine` means: positive is a first line set in (a new paragraph),
|
|
1125
|
+
* negative a hanging one (a list, its marker standing out to the left).
|
|
1126
|
+
*
|
|
1127
|
+
* A paragraph that is centred or set to the right is placed, not indented, and
|
|
1128
|
+
* keeps neither.
|
|
1129
|
+
*
|
|
1130
|
+
* @param lines The paragraph's lines.
|
|
1131
|
+
* @param columnLeft Where the column's own text begins.
|
|
1132
|
+
* @param alignment What {@link alignmentOf} made of it.
|
|
1133
|
+
*/
|
|
1134
|
+
function indentOf(lines, columnLeft, alignment) {
|
|
1135
|
+
if (alignment !== void 0 || lines.length === 0) return {};
|
|
1136
|
+
const size = Math.max(...lines.map((l) => l.fontSize)) || 10;
|
|
1137
|
+
const rest = lines.slice(1);
|
|
1138
|
+
const body = rest.length > 0 ? Math.min(...rest.map((l) => l.x)) : lines[0].x;
|
|
1139
|
+
const left = body - columnLeft;
|
|
1140
|
+
const first = lines[0].x - body;
|
|
1141
|
+
const enough = size * INDENT_EM;
|
|
1142
|
+
return {
|
|
1143
|
+
...Math.abs(left) >= enough ? { indentLeft: left } : {},
|
|
1144
|
+
...Math.abs(first) >= enough ? { indentFirstLine: first } : {}
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* How far, in ems, a paragraph has to be set in before it is indented rather
|
|
1149
|
+
* than merely started. Half an em clears the rounding a producer leaves at the
|
|
1150
|
+
* head of a line and is well under the smallest indent anybody sets.
|
|
1151
|
+
*/
|
|
1152
|
+
var INDENT_EM = .5;
|
|
1153
|
+
/**
|
|
424
1154
|
* Whether a line ENDED a paragraph, rather than wrapping into the next.
|
|
425
1155
|
*
|
|
426
1156
|
* Leading alone cannot tell the two apart: five labels stacked at 15pt with a
|
|
@@ -444,7 +1174,9 @@ function endedParagraph(prev, next, column) {
|
|
|
444
1174
|
if (!column) return false;
|
|
445
1175
|
const width = column.right - column.left;
|
|
446
1176
|
if (!(width > 0)) return false;
|
|
447
|
-
|
|
1177
|
+
const step = Math.max(prev.fontSize, 4);
|
|
1178
|
+
const shift = next.x - prev.x;
|
|
1179
|
+
if (shift < -step || shift > step * INDENT_LINES) return false;
|
|
448
1180
|
return column.right - (prev.x + prev.width) > width * .25;
|
|
449
1181
|
}
|
|
450
1182
|
/**
|
|
@@ -457,7 +1189,7 @@ function endedParagraph(prev, next, column) {
|
|
|
457
1189
|
* is left alone — a justified paragraph and a ragged-right one look the same
|
|
458
1190
|
* from here, and guessing between them would re-set the body of every document.
|
|
459
1191
|
*/
|
|
460
|
-
function alignmentOf(lines, column) {
|
|
1192
|
+
function alignmentOf(lines, column, least) {
|
|
461
1193
|
if (!column || lines.length === 0) return {};
|
|
462
1194
|
const width = column.right - column.left;
|
|
463
1195
|
if (!(width > 0)) return {};
|
|
@@ -465,10 +1197,11 @@ function alignmentOf(lines, column) {
|
|
|
465
1197
|
lead: l.x - column.left,
|
|
466
1198
|
trail: column.right - (l.x + l.width)
|
|
467
1199
|
}));
|
|
468
|
-
const meaningful = width * .1;
|
|
1200
|
+
const meaningful = Math.min(width * .1, least ?? width);
|
|
469
1201
|
const even = width * .06;
|
|
470
1202
|
if (insets.every((i) => Math.abs(i.lead - i.trail) <= even) && Math.max(...insets.map((i) => Math.min(i.lead, i.trail))) >= meaningful) return { alignment: "center" };
|
|
471
|
-
|
|
1203
|
+
const leads = insets.map((i) => i.lead);
|
|
1204
|
+
if (insets.every((i) => i.trail <= even) && Math.max(...leads) >= meaningful && Math.max(...leads) - Math.min(...leads) > even) return { alignment: "right" };
|
|
472
1205
|
return {};
|
|
473
1206
|
}
|
|
474
1207
|
function headingLevel(fontSize, medianFont) {
|
|
@@ -490,5 +1223,748 @@ function compareOrder(a, b) {
|
|
|
490
1223
|
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
491
1224
|
return a.length - b.length;
|
|
492
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* §17.6.13 — the running foot a document repeats at the bottom of its pages.
|
|
1228
|
+
*
|
|
1229
|
+
* A page number, a title, a URL: it stands below the text block, in the margin,
|
|
1230
|
+
* and it is not part of what the page SAYS. Read as body it goes wherever the
|
|
1231
|
+
* reflow puts it — bug1997343.pdf's "1" came out on a sheet of its own between
|
|
1232
|
+
* the two the paper has, and TAMReview.pdf's "Sprouts — http://…" landed in the
|
|
1233
|
+
* middle of the abstract.
|
|
1234
|
+
*
|
|
1235
|
+
* What makes it a running foot is that it RUNS: the same place, page after
|
|
1236
|
+
* page, cut off from the text above it by more than a line of white. One page
|
|
1237
|
+
* proves nothing, so two are asked for.
|
|
1238
|
+
*
|
|
1239
|
+
* @param pageRuns Each page's runs, placed.
|
|
1240
|
+
* @param shown Each page's shown geometry.
|
|
1241
|
+
* @returns The runs to lift off each page and the band to put them in, or
|
|
1242
|
+
* `undefined` where the document repeats nothing.
|
|
1243
|
+
*/
|
|
1244
|
+
function runningFoot(pageRuns, shown, where) {
|
|
1245
|
+
const feet = pageRuns.map((runs, i) => edgeLine(runs, shown[i]?.height ?? 0, where));
|
|
1246
|
+
const found = feet.filter((f) => f !== void 0);
|
|
1247
|
+
if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
|
|
1248
|
+
const ys = found.map((f) => f.y);
|
|
1249
|
+
const mid = median(ys);
|
|
1250
|
+
if (ys.some((y) => Math.abs(y - mid) > FOOT_DRIFT)) return void 0;
|
|
1251
|
+
const texts = found.map((f) => f.runs.map((r) => r.text).join("").trim());
|
|
1252
|
+
return {
|
|
1253
|
+
lift: feet.map((f) => new Set(f?.runs ?? [])),
|
|
1254
|
+
band: found[0].runs,
|
|
1255
|
+
numbered: new Set(texts).size > 1
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
/** How many of a document's pages must carry the foot before it is running. */
|
|
1259
|
+
var FOOT_SHARE = .6;
|
|
1260
|
+
/** How far, in points, a running foot may drift from page to page. */
|
|
1261
|
+
var FOOT_DRIFT = 4;
|
|
1262
|
+
/**
|
|
1263
|
+
* The lines at the very bottom of a page, where they stand ALONE below the text
|
|
1264
|
+
* block.
|
|
1265
|
+
*
|
|
1266
|
+
* Alone means the white above them is more than the page's own leading — a last
|
|
1267
|
+
* paragraph is a line's gap from the one before it, a running foot is several.
|
|
1268
|
+
*
|
|
1269
|
+
* A foot need not be ONE line. ZapfDingbats.pdf signs each page twice, the
|
|
1270
|
+
* publisher's line and the suite's title on one baseline and the build stamp
|
|
1271
|
+
* thirty points below it, and taking only the bottom line left the other in the
|
|
1272
|
+
* body: after a table that fills the sheet it had nowhere to go but a page of
|
|
1273
|
+
* its own, and a two-page document came out as four.
|
|
1274
|
+
*/
|
|
1275
|
+
function edgeLine(runs, pageHeight, where) {
|
|
1276
|
+
if (runs.length < 4 || pageHeight <= 0) return void 0;
|
|
1277
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
1278
|
+
const rows = rowsOf(runs, fontSize);
|
|
1279
|
+
if (rows.length === 0) return void 0;
|
|
1280
|
+
const at = (i) => where === "foot" ? rows[rows.length - 1 - i] : rows[i];
|
|
1281
|
+
const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
|
|
1282
|
+
const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
|
|
1283
|
+
const y = edgeY(at(0));
|
|
1284
|
+
if (rows.length < 4) {
|
|
1285
|
+
const inked = rows.filter((row) => row.some((r) => r.text.trim() !== ""));
|
|
1286
|
+
const inBand = inked.length > 0 && inked.every((row) => where === "foot" ? edgeY(row) <= pageHeight * FOOT_BAND : edgeY(row) >= pageHeight * (1 - FOOT_BAND));
|
|
1287
|
+
const all = inked.flat();
|
|
1288
|
+
const text = all.map((r) => r.text).join("").trim();
|
|
1289
|
+
return inBand && text.length > 0 && text.length <= FOOT_CHARS * inked.length ? {
|
|
1290
|
+
y: edgeY(inked[inked.length - 1] ?? []),
|
|
1291
|
+
runs: all
|
|
1292
|
+
} : void 0;
|
|
1293
|
+
}
|
|
1294
|
+
let found;
|
|
1295
|
+
let group = [];
|
|
1296
|
+
const most = where === "foot" ? FOOT_ROWS : 1;
|
|
1297
|
+
for (let i = 0; i < Math.min(most, rows.length - 2); i++) {
|
|
1298
|
+
const row = at(i);
|
|
1299
|
+
const edge = edgeY(row);
|
|
1300
|
+
if (where === "foot" ? edge > pageHeight * FOOT_BAND : edge < pageHeight * (1 - FOOT_BAND)) break;
|
|
1301
|
+
group = [...group, ...row];
|
|
1302
|
+
const next = at(i + 1);
|
|
1303
|
+
if ((where === "foot" ? inside(next) - edgeY(row) : edgeY(row) - inside(next)) < fontSize * FOOT_GAP_EM) continue;
|
|
1304
|
+
const text = group.map((r) => r.text).join("").trim();
|
|
1305
|
+
if (text.length > 0 && text.length <= FOOT_CHARS * (i + 1)) found = group;
|
|
1306
|
+
}
|
|
1307
|
+
return found ? {
|
|
1308
|
+
y,
|
|
1309
|
+
runs: found
|
|
1310
|
+
} : void 0;
|
|
1311
|
+
}
|
|
1312
|
+
/** How much white, in ems, stands between the text block and a running foot. */
|
|
1313
|
+
var FOOT_GAP_EM = 2;
|
|
1314
|
+
/** How many lines a running foot may hold before it is a paragraph. */
|
|
1315
|
+
var FOOT_ROWS = 3;
|
|
1316
|
+
/** How far up the sheet a running foot may sit. */
|
|
1317
|
+
var FOOT_BAND = .12;
|
|
1318
|
+
/** A running foot is a line, not a paragraph. */
|
|
1319
|
+
var FOOT_CHARS = 90;
|
|
1320
|
+
/**
|
|
1321
|
+
* §17.16.5.35 — the number in a run, made the field it stands for.
|
|
1322
|
+
*
|
|
1323
|
+
* A foot reads "1" or "Chapter 3 — 47", and the number is not the text: it is
|
|
1324
|
+
* the number of the page it is drawn on, which is what lets ONE band serve
|
|
1325
|
+
* every page. The run is cut around it and the middle becomes the field.
|
|
1326
|
+
*
|
|
1327
|
+
* @param run The footer's run.
|
|
1328
|
+
* @returns The run, or the two or three it is cut into.
|
|
1329
|
+
*/
|
|
1330
|
+
function pageNumbered(run) {
|
|
1331
|
+
const found = /(^|\s)(\d{1,4})(\s|$)/u.exec(run.text);
|
|
1332
|
+
if (!found || run.field !== void 0) return [run];
|
|
1333
|
+
const at = found.index + found[1].length;
|
|
1334
|
+
const number = found[2];
|
|
1335
|
+
const before = run.text.slice(0, at);
|
|
1336
|
+
const after = run.text.slice(at + number.length);
|
|
1337
|
+
const total = /(^|\s)(\d{1,4})(\s|$)/u.exec(after);
|
|
1338
|
+
const tail = total === null ? after === "" ? [] : [{
|
|
1339
|
+
...run,
|
|
1340
|
+
text: after
|
|
1341
|
+
}] : [
|
|
1342
|
+
...after.slice(0, total.index + total[1].length) === "" ? [] : [{
|
|
1343
|
+
...run,
|
|
1344
|
+
text: after.slice(0, total.index + total[1].length)
|
|
1345
|
+
}],
|
|
1346
|
+
{
|
|
1347
|
+
...run,
|
|
1348
|
+
text: total[2],
|
|
1349
|
+
field: "NUMPAGES"
|
|
1350
|
+
},
|
|
1351
|
+
...after.slice(total.index + total[1].length + total[2].length) === "" ? [] : [{
|
|
1352
|
+
...run,
|
|
1353
|
+
text: after.slice(total.index + total[1].length + total[2].length)
|
|
1354
|
+
}]
|
|
1355
|
+
];
|
|
1356
|
+
return [
|
|
1357
|
+
...before === "" ? [] : [{
|
|
1358
|
+
...run,
|
|
1359
|
+
text: before
|
|
1360
|
+
}],
|
|
1361
|
+
{
|
|
1362
|
+
...run,
|
|
1363
|
+
text: number,
|
|
1364
|
+
field: "PAGE"
|
|
1365
|
+
},
|
|
1366
|
+
...tail
|
|
1367
|
+
];
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* The band itself: the foot's own line, with the number in it made a field.
|
|
1371
|
+
*
|
|
1372
|
+
* §17.16.5.35 — a page number is not the text "1"; it is the number of the page
|
|
1373
|
+
* it is drawn on, which is why the same band serves every page.
|
|
1374
|
+
*
|
|
1375
|
+
* A foot is written in REGIONS, the way a spreadsheet's is: something at the
|
|
1376
|
+
* left of the sheet, something at the middle, something against the far edge.
|
|
1377
|
+
* ZapfDingbats.pdf signs each page "© RenderX 2000" at the left and "XSL
|
|
1378
|
+
* Formatting Objects Test Suite" at the right, and the two hundred points
|
|
1379
|
+
* between them came back as one word space, the two texts crowding each other
|
|
1380
|
+
* at the left. They stand on TAB STOPS instead (§17.3.1.38), which is what the
|
|
1381
|
+
* two hundred points are.
|
|
1382
|
+
*
|
|
1383
|
+
* @param runs The foot's runs.
|
|
1384
|
+
* @param stepped Whether the page steps between its words.
|
|
1385
|
+
* @param measure The measure it was set across, for its alignment.
|
|
1386
|
+
* @param numbered Whether a number in it is the page's own.
|
|
1387
|
+
*/
|
|
1388
|
+
function footerBand(runs, stepped, measure, numbered) {
|
|
1389
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
1390
|
+
return rowsOf(runs, fontSize).map((row) => bandLine(row, fontSize, stepped, measure)).filter((line) => line !== void 0).map(({ spans, properties }) => {
|
|
1391
|
+
const el = paragraphFromRuns(spans, void 0, properties);
|
|
1392
|
+
if (el.kind !== "paragraph") return el;
|
|
1393
|
+
return {
|
|
1394
|
+
kind: "paragraph",
|
|
1395
|
+
paragraph: {
|
|
1396
|
+
...el.paragraph,
|
|
1397
|
+
runs: numbered ? el.paragraph.runs.flatMap((run) => pageNumbered(run)) : el.paragraph.runs
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* One line of a running head or foot: its spans, and the placement that puts
|
|
1404
|
+
* them where the page had them.
|
|
1405
|
+
*
|
|
1406
|
+
* A wide gap inside such a line is not a word space, it is the space BETWEEN
|
|
1407
|
+
* REGIONS — and each region after the first stands on a tab stop, at the middle
|
|
1408
|
+
* of the band or against its far edge, whichever it was written at.
|
|
1409
|
+
*
|
|
1410
|
+
* @param row The line's runs.
|
|
1411
|
+
* @param fontSize The band's size, for the runs that state none.
|
|
1412
|
+
* @param stepped Whether the page steps between its words.
|
|
1413
|
+
* @param measure The measure the band was set across.
|
|
1414
|
+
* @returns The line, or `undefined` where it holds no text.
|
|
1415
|
+
*/
|
|
1416
|
+
function bandLine(row, fontSize, stepped, measure) {
|
|
1417
|
+
const ordered = [...row].sort((a, b) => a.x - b.x);
|
|
1418
|
+
const pieces = [[]];
|
|
1419
|
+
for (const run of ordered) {
|
|
1420
|
+
const last = pieces[pieces.length - 1];
|
|
1421
|
+
const prev = last[last.length - 1];
|
|
1422
|
+
if (prev && run.x - prev.endX > (run.fontSizePt || fontSize) * BAND_REGION_EM) pieces.push([]);
|
|
1423
|
+
pieces[pieces.length - 1].push(run);
|
|
1424
|
+
}
|
|
1425
|
+
const y = Math.max(...ordered.map((r) => r.y));
|
|
1426
|
+
const lines = pieces.filter((piece) => piece.length > 0).map((piece) => lineOf(piece, y, Math.max(...piece.map((r) => r.fontSizePt || fontSize)), stepped)).filter((line) => line.text.length > 0);
|
|
1427
|
+
if (lines.length === 0) return void 0;
|
|
1428
|
+
if (lines.length === 1 || lines.length > BAND_REGIONS || !measure) {
|
|
1429
|
+
const { alignment } = alignmentOf(lines.slice(0, 1), measure);
|
|
1430
|
+
return {
|
|
1431
|
+
spans: lines.flatMap((line, i) => i === 0 ? line.spans : [{ text: " " }, ...line.spans]),
|
|
1432
|
+
properties: alignment ? { alignment } : {}
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
const width = measure.right - measure.left;
|
|
1436
|
+
const spans = [...lines[0].spans];
|
|
1437
|
+
const tabs = [];
|
|
1438
|
+
for (const line of lines.slice(1)) {
|
|
1439
|
+
const flush = measure.right - (line.x + line.width) <= width * BAND_RIGHT_SHARE;
|
|
1440
|
+
tabs.push({
|
|
1441
|
+
positionPt: pt(0),
|
|
1442
|
+
relativeTo: flush ? "right" : "center",
|
|
1443
|
+
alignment: flush ? "right" : "center"
|
|
1444
|
+
});
|
|
1445
|
+
spans.push({ text: " " }, ...line.spans);
|
|
1446
|
+
}
|
|
1447
|
+
return {
|
|
1448
|
+
spans,
|
|
1449
|
+
properties: { tabs }
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
/** A gap this wide, in ems, stands BETWEEN the regions of a head or foot. */
|
|
1453
|
+
var BAND_REGION_EM = 4;
|
|
1454
|
+
/** How many regions a band line may hold — left, centre, right. */
|
|
1455
|
+
var BAND_REGIONS = 3;
|
|
1456
|
+
/** How near the far edge a region has to end to be set against it. */
|
|
1457
|
+
var BAND_RIGHT_SHARE = .05;
|
|
1458
|
+
/**
|
|
1459
|
+
* Whether the page is RULED into columns rather than SET in them.
|
|
1460
|
+
*
|
|
1461
|
+
* A page of two columns and a page of two columns of a table look alike from
|
|
1462
|
+
* here: both have gutters, and both put their lines on one baseline grid — a
|
|
1463
|
+
* paper's columns are set on the same grid as a matter of course, so "the rows
|
|
1464
|
+
* line up" says nothing. What separates them is the CELL: a line of prose fills
|
|
1465
|
+
* its measure and a cell does not. Across this corpus a paper's lines cover 84
|
|
1466
|
+
* to 95 per cent of their column, and ZapfDingbats.pdf's cells cover 45.
|
|
1467
|
+
*
|
|
1468
|
+
* Read by column, its five hundred entries came back one column at a time with
|
|
1469
|
+
* every row torn into three — "1 a17", "[x2711]", "2 a18" — over five pages.
|
|
1470
|
+
* Read by ROW, which is what a ruled page says, each entry is a line of its own.
|
|
1471
|
+
*
|
|
1472
|
+
* @param runs The page's runs.
|
|
1473
|
+
* @param gutters The page's gutters.
|
|
1474
|
+
* @param edges Where the page's text starts and ends.
|
|
1475
|
+
*/
|
|
1476
|
+
function looksRuled(runs, gutters, edges) {
|
|
1477
|
+
if (gutters.length < MIN_RULED_GUTTERS || !edges) return false;
|
|
1478
|
+
const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
|
|
1479
|
+
if (rows.length < MIN_TABLE_ROWS) return false;
|
|
1480
|
+
const bounds = columnBounds(runs, gutters, edges);
|
|
1481
|
+
const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
|
|
1482
|
+
const regionOf = (x) => {
|
|
1483
|
+
for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
|
|
1484
|
+
return 0;
|
|
1485
|
+
};
|
|
1486
|
+
const fills = [];
|
|
1487
|
+
let aligned = 0;
|
|
1488
|
+
for (const row of rows) {
|
|
1489
|
+
const byRegion = regions.map(() => []);
|
|
1490
|
+
for (const run of row) byRegion[regionOf(run.x)].push(run);
|
|
1491
|
+
byRegion.forEach((inRegion, i) => {
|
|
1492
|
+
if (inRegion.length === 0) return;
|
|
1493
|
+
const from = Math.min(...inRegion.map((r) => r.x));
|
|
1494
|
+
const to = Math.max(...inRegion.map((r) => r.endX));
|
|
1495
|
+
const width = regions[i][1] - regions[i][0];
|
|
1496
|
+
if (width > 0) fills.push((to - from) / width);
|
|
1497
|
+
});
|
|
1498
|
+
if (byRegion.every((inRegion) => inRegion.length > 0)) aligned++;
|
|
1499
|
+
}
|
|
1500
|
+
if (aligned < rows.length * TABLE_ALIGNED_SHARE) return false;
|
|
1501
|
+
fills.sort((a, b) => a - b);
|
|
1502
|
+
return (fills[Math.floor(fills.length / 2)] ?? 1) <= TABLE_CELL_FILL;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Whether the regions the gutters cut the page into are COLUMNS OF PROSE — the
|
|
1506
|
+
* only thing worth re-setting a document in columns for.
|
|
1507
|
+
*
|
|
1508
|
+
* A gutter is evidence about the page's white space, and white space is not
|
|
1509
|
+
* enough: an invoice sets its labels along the left and its amounts against the
|
|
1510
|
+
* right margin, and a dozen lines then break at the same x. Read as two columns
|
|
1511
|
+
* the amounts became a column of their own and every label was indented past
|
|
1512
|
+
* the strip it was given — "Total excluding tax" came back one letter per line.
|
|
1513
|
+
*
|
|
1514
|
+
* What tells them apart is the same thing that tells a cell from a line of
|
|
1515
|
+
* prose: a column of prose FILLS its measure, over and over, because that is
|
|
1516
|
+
* what wrapping does. A column of values never fills anything — its lines are
|
|
1517
|
+
* short and it is their right edges that line up, not their left. So every
|
|
1518
|
+
* region must hold lines of its own and they must reach across it.
|
|
1519
|
+
*
|
|
1520
|
+
* @param runs The page's runs.
|
|
1521
|
+
* @param gutters The page's gutters.
|
|
1522
|
+
* @param edges Where the page's text starts and ends.
|
|
1523
|
+
* @returns Whether the page is set in columns of prose.
|
|
1524
|
+
*/
|
|
1525
|
+
function proseColumns(runs, gutters, edges) {
|
|
1526
|
+
if (gutters.length === 0) return false;
|
|
1527
|
+
if (!edges) return true;
|
|
1528
|
+
const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
|
|
1529
|
+
if (rows.length === 0) return true;
|
|
1530
|
+
const bounds = columnBounds(runs, gutters, edges);
|
|
1531
|
+
const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
|
|
1532
|
+
const regionOf = (x) => {
|
|
1533
|
+
for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
|
|
1534
|
+
return 0;
|
|
1535
|
+
};
|
|
1536
|
+
const edgesIn = regions.map(() => []);
|
|
1537
|
+
for (const row of rows) {
|
|
1538
|
+
const byRegion = regions.map(() => []);
|
|
1539
|
+
for (const run of row) byRegion[regionOf(run.x)].push(run);
|
|
1540
|
+
byRegion.forEach((inRegion, i) => {
|
|
1541
|
+
if (inRegion.length === 0) return;
|
|
1542
|
+
edgesIn[i].push({
|
|
1543
|
+
left: Math.min(...inRegion.map((r) => r.x)),
|
|
1544
|
+
right: Math.max(...inRegion.map((r) => r.endX))
|
|
1545
|
+
});
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
const agreeing = (xs) => {
|
|
1549
|
+
let most = 0;
|
|
1550
|
+
for (const x of xs) most = Math.max(most, xs.filter((y) => Math.abs(y - x) <= FLUSH_PT).length);
|
|
1551
|
+
return most;
|
|
1552
|
+
};
|
|
1553
|
+
return edgesIn.every((lines) => {
|
|
1554
|
+
if (lines.length < MIN_PROSE_LINES) return false;
|
|
1555
|
+
return agreeing(lines.map((l) => l.right)) <= agreeing(lines.map((l) => l.left));
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* The drawn glyphs that are STRAY marks in a line of ordinary text.
|
|
1560
|
+
*
|
|
1561
|
+
* §9.6.6 — a glyph the file states no character for is drawn, because nothing
|
|
1562
|
+
* downstream can write it. That is the whole content of some pages: a subset
|
|
1563
|
+
* that names its glyphs `g18`, a program with no `cmap`, a font of pictures.
|
|
1564
|
+
* On such a page the drawings ARE the words and they stay.
|
|
1565
|
+
*
|
|
1566
|
+
* A single mark among readable words is the other case, and Stripe's invoices
|
|
1567
|
+
* are full of them — a `/ToUnicode` stating U+0000 for every piece of
|
|
1568
|
+
* punctuation it sets. The flowing reading has nowhere to put a shape inside a
|
|
1569
|
+
* line, so the colon of "Kazakhstan VAT: 86-1696045" was placed as a floating
|
|
1570
|
+
* drawing and landed a word away, on a line of its own, pushing the text
|
|
1571
|
+
* around it aside. Dropped, the line keeps its words and the loss report still
|
|
1572
|
+
* says a character was unrecoverable.
|
|
1573
|
+
*
|
|
1574
|
+
* The line decides: where nearly everything on it is drawn, the drawings are
|
|
1575
|
+
* the text — bug1151216.pdf sets three lines of prices that way. Where one or
|
|
1576
|
+
* two marks stand among several readable runs, they are punctuation.
|
|
1577
|
+
*
|
|
1578
|
+
* @param vectors The page's painted paths.
|
|
1579
|
+
* @param runs The page's runs.
|
|
1580
|
+
* @returns The drawn glyphs to leave out of a flowing reading.
|
|
1581
|
+
*/
|
|
1582
|
+
function strayMarks(vectors, runs) {
|
|
1583
|
+
const glyphs = vectors.filter((v) => v.glyph === true);
|
|
1584
|
+
const out = /* @__PURE__ */ new Set();
|
|
1585
|
+
if (glyphs.length === 0) return out;
|
|
1586
|
+
const readable = runs.filter((r) => r.text.trim() !== "" && !r.text.includes("�"));
|
|
1587
|
+
for (const v of glyphs) {
|
|
1588
|
+
const mid = (v.minY + v.maxY) / 2;
|
|
1589
|
+
const size = v.maxY - v.minY || 10;
|
|
1590
|
+
const words = readable.filter((r) => Math.abs(r.y - mid) <= size);
|
|
1591
|
+
const before = words.some((r) => r.endX <= v.minX + size);
|
|
1592
|
+
const after = words.some((r) => r.x >= v.maxX - size);
|
|
1593
|
+
if (!before && !after) continue;
|
|
1594
|
+
if (glyphs.filter((g) => Math.abs((g.minY + g.maxY) / 2 - mid) <= size).length <= MOST_STRAY_MARKS && words.length >= LEAST_READABLE_RUNS) out.add(v);
|
|
1595
|
+
}
|
|
1596
|
+
return out;
|
|
1597
|
+
}
|
|
1598
|
+
/** How few drawn glyphs a line may hold before they are its text, not marks in it. */
|
|
1599
|
+
var MOST_STRAY_MARKS = 2;
|
|
1600
|
+
/** …and how many readable runs must stand on that line for them to be strays. */
|
|
1601
|
+
var LEAST_READABLE_RUNS = 4;
|
|
1602
|
+
/**
|
|
1603
|
+
* §17.4.38 — the consecutive lines a page set out on the SAME stops, as the
|
|
1604
|
+
* TABLE they are.
|
|
1605
|
+
*
|
|
1606
|
+
* A line broken at a gap no word space explains is a line the page laid out
|
|
1607
|
+
* (see {@link tabbed}); several of them one under another, broken in the same
|
|
1608
|
+
* places, is a table's head and its rows. An invoice's item table is exactly
|
|
1609
|
+
* that, and so is the payment history under it.
|
|
1610
|
+
*
|
|
1611
|
+
* Two stops at least, which is three columns: ONE stop is a contents entry with
|
|
1612
|
+
* its page number at the measure, and a list of those is a list.
|
|
1613
|
+
*
|
|
1614
|
+
* The stops do not have to agree to the point — a heading sits over its column
|
|
1615
|
+
* and a figure is set against the far side of it — so they are matched within
|
|
1616
|
+
* an em, and the column is put where the rows agree it is.
|
|
1617
|
+
*
|
|
1618
|
+
* @param paras The column's paragraphs, in order.
|
|
1619
|
+
* @param measure The measure they are set across.
|
|
1620
|
+
* @returns For each paragraph that belongs to a table: the table at its first
|
|
1621
|
+
* row, and `null` at the rows after it (their content is inside it).
|
|
1622
|
+
*/
|
|
1623
|
+
function tabbedRows(paras, measure) {
|
|
1624
|
+
const out = /* @__PURE__ */ new Map();
|
|
1625
|
+
if (!measure) return out;
|
|
1626
|
+
for (let i = 0; i < paras.length;) {
|
|
1627
|
+
const stops = paras[i]?.stops ?? [];
|
|
1628
|
+
if (stops.length < LEAST_TABLE_STOPS) {
|
|
1629
|
+
i++;
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
const near = (a, b) => a.length === b.length && a.every((x, k) => Math.abs(x - b[k]) <= Math.max(paras[i].fontSize, TABLE_STOP_SLACK_PT));
|
|
1633
|
+
let to = i + 1;
|
|
1634
|
+
while (to < paras.length && near(stops, paras[to]?.stops ?? [])) to++;
|
|
1635
|
+
if (to - i < 2) {
|
|
1636
|
+
i++;
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
const rows = paras.slice(i, to);
|
|
1640
|
+
const bounds = stops.map((_, k) => Math.min(...rows.map((r) => r.stops[k])));
|
|
1641
|
+
const width = measure.right - measure.left;
|
|
1642
|
+
const edges = [
|
|
1643
|
+
0,
|
|
1644
|
+
...bounds,
|
|
1645
|
+
width
|
|
1646
|
+
];
|
|
1647
|
+
out.set(i, {
|
|
1648
|
+
kind: "table",
|
|
1649
|
+
table: {
|
|
1650
|
+
properties: {
|
|
1651
|
+
defaultCellMargins: {
|
|
1652
|
+
left: pt(0),
|
|
1653
|
+
right: pt(0)
|
|
1654
|
+
},
|
|
1655
|
+
layout: "fixed"
|
|
1656
|
+
},
|
|
1657
|
+
grid: edges.slice(0, -1).map((from, k) => pt(Math.max(edges[k + 1] - from, 1))),
|
|
1658
|
+
rows: rows.map((row, r) => {
|
|
1659
|
+
const prev = rows[r - 1];
|
|
1660
|
+
const pitch = prev ? prev.top - row.top - row.fontSize * 1.2 : 0;
|
|
1661
|
+
const opening = r === 0 ? row.spacingBefore : pitch > row.fontSize * .3 ? pitch : void 0;
|
|
1662
|
+
return {
|
|
1663
|
+
properties: {},
|
|
1664
|
+
cells: splitAtTabs(row.spans).map((cell) => ({
|
|
1665
|
+
properties: {},
|
|
1666
|
+
content: [paragraphFromRuns(cell, void 0, opening !== void 0 ? { spacingBefore: pt(opening) } : {})]
|
|
1667
|
+
}))
|
|
1668
|
+
};
|
|
1669
|
+
})
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
for (let k = i + 1; k < to; k++) out.set(k, null);
|
|
1673
|
+
i = to;
|
|
1674
|
+
}
|
|
1675
|
+
return out;
|
|
1676
|
+
}
|
|
1677
|
+
/** How many stops a line must stand on before a run of them is a table. */
|
|
1678
|
+
var LEAST_TABLE_STOPS = 2;
|
|
1679
|
+
/**
|
|
1680
|
+
* How far two rows' stops may stand apart and still be one column.
|
|
1681
|
+
*
|
|
1682
|
+
* A heading is set over its column and a figure against the far side of it: an
|
|
1683
|
+
* invoice's "Qty" stands eight points left of the "1" under it, and the heading
|
|
1684
|
+
* is set two sizes smaller, so its own em is not a wide enough tolerance.
|
|
1685
|
+
*/
|
|
1686
|
+
var TABLE_STOP_SLACK_PT = 10;
|
|
1687
|
+
/** One line's spans cut into its cells, at the tabs the page set out. */
|
|
1688
|
+
function splitAtTabs(spans) {
|
|
1689
|
+
const cells = [[]];
|
|
1690
|
+
for (const span of spans) if (span.text === " ") cells.push([]);
|
|
1691
|
+
else cells[cells.length - 1].push(span);
|
|
1692
|
+
return cells;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Give each separator rule to the paragraph it separates, as that paragraph's
|
|
1696
|
+
* own border (§17.3.1.24), and say which rules were given away.
|
|
1697
|
+
*
|
|
1698
|
+
* A rule sits in the white between two blocks: under a table's headings, over a
|
|
1699
|
+
* total. The block BELOW it takes it as a top border, because that is the block
|
|
1700
|
+
* the rule introduces; where nothing follows closely enough, the block above
|
|
1701
|
+
* takes it as a bottom one.
|
|
1702
|
+
*
|
|
1703
|
+
* @param vectors The page's painted paths.
|
|
1704
|
+
* @param blocks The blocks read off the page so far, which the rule joins.
|
|
1705
|
+
* @param width The page's width, which a rule is long relative to.
|
|
1706
|
+
* @returns The rules that became borders, and so must not be drawn again.
|
|
1707
|
+
*/
|
|
1708
|
+
function ruleBorders(vectors, blocks, width) {
|
|
1709
|
+
const given = /* @__PURE__ */ new Set();
|
|
1710
|
+
const paragraphs = blocks.filter((b) => b.el.kind === "paragraph");
|
|
1711
|
+
if (paragraphs.length === 0) return given;
|
|
1712
|
+
for (const v of vectors) {
|
|
1713
|
+
if (!separatorRule(v, width)) continue;
|
|
1714
|
+
const y = (v.minY + v.maxY) / 2;
|
|
1715
|
+
const below = paragraphs.filter((b) => b.top < y).sort((a, b) => b.top - a.top)[0];
|
|
1716
|
+
const above = paragraphs.filter((b) => b.top >= y).sort((a, b) => a.top - b.top)[0];
|
|
1717
|
+
const side = below !== void 0 && y - below.top <= RULE_REACH_PT ? {
|
|
1718
|
+
block: below,
|
|
1719
|
+
edge: "top"
|
|
1720
|
+
} : above !== void 0 && above.top - y <= RULE_REACH_PT ? {
|
|
1721
|
+
block: above,
|
|
1722
|
+
edge: "bottom"
|
|
1723
|
+
} : void 0;
|
|
1724
|
+
if (!side || side.block.el.kind !== "paragraph") continue;
|
|
1725
|
+
const border = {
|
|
1726
|
+
style: "single",
|
|
1727
|
+
width: pt(Math.max(v.lineWidth ?? v.maxY - v.minY, RULE_MIN_PT)),
|
|
1728
|
+
colorHex: v.strokeHex ?? v.fillHex ?? "000000"
|
|
1729
|
+
};
|
|
1730
|
+
const { paragraph } = side.block.el;
|
|
1731
|
+
side.block.el = {
|
|
1732
|
+
kind: "paragraph",
|
|
1733
|
+
paragraph: {
|
|
1734
|
+
...paragraph,
|
|
1735
|
+
properties: {
|
|
1736
|
+
...paragraph.properties,
|
|
1737
|
+
borders: {
|
|
1738
|
+
...paragraph.properties.borders,
|
|
1739
|
+
[side.edge]: border
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
given.add(v);
|
|
1745
|
+
}
|
|
1746
|
+
return given;
|
|
1747
|
+
}
|
|
1748
|
+
/** How far from a paragraph a rule may stand and still belong to it. */
|
|
1749
|
+
var RULE_REACH_PT = 14;
|
|
1750
|
+
/** The thinnest a border may be drawn and still be seen. */
|
|
1751
|
+
var RULE_MIN_PT = .5;
|
|
1752
|
+
/**
|
|
1753
|
+
* Whether a painted path is a RULE — a line drawn to separate one block from
|
|
1754
|
+
* the next, rather than a piece of the page's artwork.
|
|
1755
|
+
*
|
|
1756
|
+
* Flat and long: a hairline the width of a column under a table's headings, or
|
|
1757
|
+
* the line a total is written over. Everything else — boxes, panels, drawings —
|
|
1758
|
+
* keeps the place on the page it was drawn at.
|
|
1759
|
+
*
|
|
1760
|
+
* @param v The painted path.
|
|
1761
|
+
* @param width The page's width, which the rule is long RELATIVE to.
|
|
1762
|
+
* @returns Whether it separates blocks rather than drawing something.
|
|
1763
|
+
*/
|
|
1764
|
+
function separatorRule(v, width) {
|
|
1765
|
+
const w = v.maxX - v.minX;
|
|
1766
|
+
const h = v.maxY - v.minY;
|
|
1767
|
+
return h <= RULE_THICK_PT && w >= width * RULE_SHARE && w > h * RULE_RATIO;
|
|
1768
|
+
}
|
|
1769
|
+
/** How thick a mark may be and still be a rule. */
|
|
1770
|
+
var RULE_THICK_PT = 2;
|
|
1771
|
+
/** How much of the sheet it must run across. */
|
|
1772
|
+
var RULE_SHARE = .2;
|
|
1773
|
+
/** …and how much longer than it is thick. */
|
|
1774
|
+
var RULE_RATIO = 20;
|
|
1775
|
+
/** How close two edges stand before they count as the same one. */
|
|
1776
|
+
var FLUSH_PT = 1;
|
|
1777
|
+
/** And how many lines it takes before a region is a column at all. */
|
|
1778
|
+
var MIN_PROSE_LINES = 6;
|
|
1779
|
+
/**
|
|
1780
|
+
* §17.4.38 — the page's rows as the TABLE they are.
|
|
1781
|
+
*
|
|
1782
|
+
* The regions between the gutters are the columns and the rows are the rows;
|
|
1783
|
+
* a cell is what one row leaves in one region, and an empty cell is empty. The
|
|
1784
|
+
* grid is measured, so the columns come out where the page put them.
|
|
1785
|
+
*
|
|
1786
|
+
* The lines the page hangs ABOVE its ruling come back as themselves. A line
|
|
1787
|
+
* that crosses every column is not a row of the table — ZapfDingbats.pdf heads
|
|
1788
|
+
* each sheet with two red lines of provenance that run wider than the frame
|
|
1789
|
+
* drawn under them, and squeezed into a cell they wrapped and cost the sheet a
|
|
1790
|
+
* row.
|
|
1791
|
+
*
|
|
1792
|
+
* @param runs The page's runs.
|
|
1793
|
+
* @param gutters The page's gutters.
|
|
1794
|
+
* @param edges Where the page's text starts and ends.
|
|
1795
|
+
* @param stepped Whether the page steps between its words.
|
|
1796
|
+
* @returns The blocks, the table among them, each with where it stands.
|
|
1797
|
+
*/
|
|
1798
|
+
function tableFrom(runs, gutters, edges, stepped) {
|
|
1799
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
1800
|
+
const all = rowsOf(runs, fontSize).filter((row) => row.length > 0);
|
|
1801
|
+
if (all.length === 0) return void 0;
|
|
1802
|
+
const bounds = columnBounds(runs, gutters, edges);
|
|
1803
|
+
const regionsAt = (from) => {
|
|
1804
|
+
const regions = from.slice(0, -1).map((lo, i) => [lo, from[i + 1]]);
|
|
1805
|
+
return {
|
|
1806
|
+
regions,
|
|
1807
|
+
of: (x) => {
|
|
1808
|
+
for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
|
|
1809
|
+
return 0;
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
};
|
|
1813
|
+
const first = regionsAt(bounds);
|
|
1814
|
+
const oneWideLine = (row) => {
|
|
1815
|
+
const to = spanEnd(row, 0, first.regions.length, first.of);
|
|
1816
|
+
return to > 0 && row.every((run) => first.of(run.x) <= to);
|
|
1817
|
+
};
|
|
1818
|
+
let start = 0;
|
|
1819
|
+
while (start < all.length - 1 && oneWideLine(all[start])) start++;
|
|
1820
|
+
const above = all.slice(0, start);
|
|
1821
|
+
const rows = all.slice(start);
|
|
1822
|
+
const own = rows.filter((row) => spanEnd(row, 0, first.regions.length, first.of) === 0).flatMap((row) => row.filter((run) => first.of(run.x) === 0)).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
|
|
1823
|
+
const left = own.length > 0 ? Math.min(...own) : bounds[0];
|
|
1824
|
+
const { regions, of: regionOf } = regionsAt([left, ...bounds.slice(1)]);
|
|
1825
|
+
const flush = regions.map((region, i) => {
|
|
1826
|
+
const starts = [];
|
|
1827
|
+
for (const row of rows) {
|
|
1828
|
+
const at = row.filter((run) => regionOf(run.x) === i).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
|
|
1829
|
+
if (at.length > 0) starts.push(Math.min(...at));
|
|
1830
|
+
}
|
|
1831
|
+
if (starts.length === 0) return region[0];
|
|
1832
|
+
starts.sort((a, b) => a - b);
|
|
1833
|
+
return starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? region[0];
|
|
1834
|
+
});
|
|
1835
|
+
const table = {
|
|
1836
|
+
properties: {
|
|
1837
|
+
defaultCellMargins: {
|
|
1838
|
+
left: pt(0),
|
|
1839
|
+
right: pt(0)
|
|
1840
|
+
},
|
|
1841
|
+
layout: "fixed",
|
|
1842
|
+
...left > edges.left ? { indentPt: pt(left - edges.left) } : {}
|
|
1843
|
+
},
|
|
1844
|
+
grid: regions.map((r) => pt(Math.max(r[1] - r[0], 1))),
|
|
1845
|
+
rows: rows.map((row, r) => {
|
|
1846
|
+
const byRegion = regions.map(() => []);
|
|
1847
|
+
for (const run of row) byRegion[regionOf(run.x)].push(run);
|
|
1848
|
+
const y = Math.max(...row.map((r2) => r2.y));
|
|
1849
|
+
const cells = [];
|
|
1850
|
+
for (let i = 0; i < regions.length;) {
|
|
1851
|
+
const to = spanEnd(row, i, regions.length, regionOf);
|
|
1852
|
+
const inSpan = byRegion.slice(i, to + 1).flat();
|
|
1853
|
+
const width = to - i + 1;
|
|
1854
|
+
const size = inSpan.length > 0 ? Math.max(...inSpan.map((r) => r.fontSizePt || fontSize)) : fontSize;
|
|
1855
|
+
const line = inSpan.length > 0 ? lineOf(inSpan, y, size, stepped) : void 0;
|
|
1856
|
+
const placed = line && line.x - flush[i] >= size / 4 ? alignmentOf([line], {
|
|
1857
|
+
left: regions[i][0],
|
|
1858
|
+
right: regions[to][1]
|
|
1859
|
+
}, size / 2) : {};
|
|
1860
|
+
cells.push({
|
|
1861
|
+
properties: width > 1 ? { colSpan: width } : {},
|
|
1862
|
+
content: line === void 0 ? [{
|
|
1863
|
+
kind: "paragraph",
|
|
1864
|
+
paragraph: {
|
|
1865
|
+
properties: {},
|
|
1866
|
+
runs: []
|
|
1867
|
+
}
|
|
1868
|
+
}] : [paragraphFromRuns(line.spans, void 0, placed.alignment ? { alignment: placed.alignment } : {})]
|
|
1869
|
+
});
|
|
1870
|
+
i = to + 1;
|
|
1871
|
+
}
|
|
1872
|
+
const next = rows[r + 1];
|
|
1873
|
+
const pitch = next ? y - Math.max(...next.map((r2) => r2.y)) : 0;
|
|
1874
|
+
return {
|
|
1875
|
+
properties: pitch > 0 ? {
|
|
1876
|
+
height: pt(pitch),
|
|
1877
|
+
heightRule: "atLeast"
|
|
1878
|
+
} : {},
|
|
1879
|
+
cells
|
|
1880
|
+
};
|
|
1881
|
+
})
|
|
1882
|
+
};
|
|
1883
|
+
return [...above.map((row) => {
|
|
1884
|
+
const y = Math.max(...row.map((r) => r.y));
|
|
1885
|
+
return {
|
|
1886
|
+
el: paragraphFromRuns(lineOf(row, y, Math.max(...row.map((r) => r.fontSizePt || fontSize)), stepped).spans),
|
|
1887
|
+
top: y
|
|
1888
|
+
};
|
|
1889
|
+
}), {
|
|
1890
|
+
el: {
|
|
1891
|
+
kind: "table",
|
|
1892
|
+
table
|
|
1893
|
+
},
|
|
1894
|
+
top: Math.max(...rows[0].map((r) => r.y))
|
|
1895
|
+
}];
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Where one column of a ruled page ends and the next begins.
|
|
1899
|
+
*
|
|
1900
|
+
* The gutter is a BAND, and its middle is only a guess at the line inside it: a
|
|
1901
|
+
* gutter is where the fewest lines cross, not where none do (see
|
|
1902
|
+
* `detectGutters`), so a page whose prose overhangs the first column by a few
|
|
1903
|
+
* points has that prose crossing the middle. Cut there, ZapfDingbats.pdf's
|
|
1904
|
+
* heading and its lead paragraph either wrapped inside a cell too narrow for
|
|
1905
|
+
* them or swallowed the glyph standing beside them. The boundary is put past
|
|
1906
|
+
* the crossing ink instead, as far as the band allows — where the columns
|
|
1907
|
+
* really do divide.
|
|
1908
|
+
*
|
|
1909
|
+
* @param runs The page's runs.
|
|
1910
|
+
* @param gutters The page's gutters.
|
|
1911
|
+
* @param edges Where the page's text starts and ends.
|
|
1912
|
+
* @returns The column boundaries, left edge first and right edge last.
|
|
1913
|
+
*/
|
|
1914
|
+
function columnBounds(runs, gutters, edges) {
|
|
1915
|
+
const inks = runs.map((r) => runInk(r)).filter((ink) => ink !== void 0);
|
|
1916
|
+
const clearance = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 4;
|
|
1917
|
+
return [
|
|
1918
|
+
edges.left,
|
|
1919
|
+
...gutters.map((g) => {
|
|
1920
|
+
let at = g.mid;
|
|
1921
|
+
for (const [from, to] of inks) if (from < g.mid && to > at && to <= g.to) at = Math.min(to + clearance, g.to);
|
|
1922
|
+
return at;
|
|
1923
|
+
}),
|
|
1924
|
+
edges.right
|
|
1925
|
+
];
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* The last column a cell starting at `from` covers — the one where no run
|
|
1929
|
+
* reaches any further right.
|
|
1930
|
+
*
|
|
1931
|
+
* A run whose INK ends past a column boundary was drawn as one line across
|
|
1932
|
+
* both, so both belong to one cell; and a cell widened that way may pick up a
|
|
1933
|
+
* run that crosses the next boundary in turn.
|
|
1934
|
+
*
|
|
1935
|
+
* @param row The row's runs.
|
|
1936
|
+
* @param from The column the cell starts in.
|
|
1937
|
+
* @param count How many columns the table has.
|
|
1938
|
+
* @param regionOf Which column an x falls in.
|
|
1939
|
+
*/
|
|
1940
|
+
function spanEnd(row, from, count, regionOf) {
|
|
1941
|
+
let to = from;
|
|
1942
|
+
for (let grew = true; grew && to < count - 1;) {
|
|
1943
|
+
grew = false;
|
|
1944
|
+
for (const run of row) {
|
|
1945
|
+
const ink = runInk(run);
|
|
1946
|
+
if (ink === void 0) continue;
|
|
1947
|
+
const start = regionOf(ink[0]);
|
|
1948
|
+
if (start < from || start > to) continue;
|
|
1949
|
+
const end = regionOf(ink[1] - 1);
|
|
1950
|
+
if (end > to) {
|
|
1951
|
+
to = Math.min(end, count - 1);
|
|
1952
|
+
grew = true;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return to;
|
|
1957
|
+
}
|
|
1958
|
+
/** Below this many gutters a page in columns is read as columns. */
|
|
1959
|
+
var MIN_RULED_GUTTERS = 2;
|
|
1960
|
+
/** Below this many rows a page is not ruled into anything. */
|
|
1961
|
+
var MIN_TABLE_ROWS = 6;
|
|
1962
|
+
/** How many of a table's rows must carry a cell in every one of its columns. */
|
|
1963
|
+
var TABLE_ALIGNED_SHARE = .6;
|
|
1964
|
+
/**
|
|
1965
|
+
* How much of its column a CELL covers, at the median. A line of prose fills
|
|
1966
|
+
* its measure — 84 to 95 per cent across this corpus — and a cell does not.
|
|
1967
|
+
*/
|
|
1968
|
+
var TABLE_CELL_FILL = .65;
|
|
493
1969
|
//#endregion
|
|
494
1970
|
export { endedParagraph, reconstructByLayout };
|