reamkit 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/dist/esm/core/converter/ream.d.ts +0 -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.d.ts +65 -0
- package/dist/esm/pdf-reader/annot-draw.js +487 -0
- package/dist/esm/pdf-reader/annots.d.ts +0 -12
- package/dist/esm/pdf-reader/annots.js +30 -4
- package/dist/esm/pdf-reader/ccitt.d.ts +20 -3
- package/dist/esm/pdf-reader/ccitt.js +102 -6
- 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/cie-color.d.ts +33 -0
- package/dist/esm/pdf-reader/cie-color.js +112 -0
- package/dist/esm/pdf-reader/cmap.js +5 -2
- package/dist/esm/pdf-reader/content.d.ts +123 -3
- package/dist/esm/pdf-reader/content.js +232 -54
- 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.d.ts +1 -1
- package/dist/esm/pdf-reader/display.js +61 -6
- package/dist/esm/pdf-reader/document.d.ts +6 -0
- package/dist/esm/pdf-reader/document.js +55 -10
- package/dist/esm/pdf-reader/embedded-fonts.d.ts +23 -3
- package/dist/esm/pdf-reader/embedded-fonts.js +36 -4
- 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 +19 -5
- package/dist/esm/pdf-reader/flow-build.js +111 -11
- package/dist/esm/pdf-reader/font.js +403 -21
- package/dist/esm/pdf-reader/function.d.ts +16 -0
- package/dist/esm/pdf-reader/function.js +414 -0
- 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 +9 -4
- package/dist/esm/pdf-reader/image-decode.js +274 -72
- package/dist/esm/pdf-reader/images.d.ts +18 -0
- package/dist/esm/pdf-reader/images.js +154 -11
- package/dist/esm/pdf-reader/jbig2.d.ts +23 -0
- package/dist/esm/pdf-reader/jbig2.js +126 -32
- package/dist/esm/pdf-reader/layout.d.ts +32 -0
- package/dist/esm/pdf-reader/layout.js +1316 -64
- package/dist/esm/pdf-reader/lexer.d.ts +2 -0
- package/dist/esm/pdf-reader/lexer.js +4 -0
- 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/optional-content.d.ts +36 -0
- package/dist/esm/pdf-reader/optional-content.js +93 -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 -6
- package/dist/esm/pdf-reader/reader.js +102 -16
- package/dist/esm/pdf-reader/shading.d.ts +139 -8
- package/dist/esm/pdf-reader/shading.js +309 -39
- package/dist/esm/pdf-reader/standard-metrics.d.ts +8 -0
- package/dist/esm/pdf-reader/standard-metrics.js +18 -0
- package/dist/esm/pdf-reader/standard-widths.d.ts +20 -0
- package/dist/esm/pdf-reader/standard-widths.js +62 -0
- 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 +204 -32
- package/dist/esm/pdf-reader/text-rules.d.ts +16 -0
- package/dist/esm/pdf-reader/text-rules.js +185 -0
- package/dist/esm/pdf-reader/text.js +157 -4
- 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 +5 -0
- package/dist/esm/pdf-reader/vector.js +79 -27
- package/dist/esm/word/document-parser.js +4 -1
- package/dist/esm/word/docx-writer.js +74 -11
- package/package.json +1 -1
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { pt } from "../core/ir/units.js";
|
|
2
2
|
import { ResourceStore } from "../core/ir/resources.js";
|
|
3
3
|
import { FEATURES } from "../core/ir/features.js";
|
|
4
|
-
import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
|
|
5
|
-
import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock } from "./flow-build.js";
|
|
6
|
-
import { collectEmbeddedFonts } from "./embedded-fonts.js";
|
|
7
4
|
import { isRightToLeft } from "./content.js";
|
|
8
|
-
import {
|
|
5
|
+
import { collectEmbeddedFonts } from "./embedded-fonts.js";
|
|
9
6
|
import { extractPageText } from "./text.js";
|
|
10
7
|
import { collectPageVectors } from "./vector.js";
|
|
8
|
+
import { displayOf, placeImages, placeRuns, placeVectors } from "./display.js";
|
|
9
|
+
import { buildFlowDoc, dedupeLosses, imageBlock, paragraphFromRuns, positionedText, sectionFromPdfPages, shapeBlock, withMeasuredMargins } from "./flow-build.js";
|
|
10
|
+
import { collectPageImages } from "./images.js";
|
|
11
|
+
import { markDrawnRules } from "./text-rules.js";
|
|
12
|
+
import { matrixBlocks } from "./math-rows.js";
|
|
11
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,26 +32,53 @@ import { collectPageVectors } from "./vector.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;
|
|
31
39
|
const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
|
|
32
40
|
const resources = new ResourceStore();
|
|
33
41
|
const losses = [];
|
|
42
|
+
if (pageRuns.some((page) => page.some((r) => r.text.includes("�")))) losses.push({
|
|
43
|
+
severity: "dropped",
|
|
44
|
+
feature: FEATURES.text,
|
|
45
|
+
detail: "some glyphs map to no character — the font states no /ToUnicode and its program says nothing either, so that text is unrecoverable"
|
|
46
|
+
});
|
|
34
47
|
if (pageRuns.some((page) => page.some((r) => r.fillPatternName !== void 0))) losses.push({
|
|
35
48
|
severity: "degraded",
|
|
36
49
|
feature: FEATURES.text,
|
|
37
50
|
detail: "text filled with a tiling pattern is drawn as a flat tint of the pattern’s colour, not as the pattern"
|
|
38
51
|
});
|
|
52
|
+
if (pageRuns.some((page) => page.some((r) => r.gradientFill === true))) losses.push({
|
|
53
|
+
severity: "degraded",
|
|
54
|
+
feature: FEATURES.text,
|
|
55
|
+
detail: "text filled with a shading pattern is drawn in the middle colour of the gradient, not as the gradient"
|
|
56
|
+
});
|
|
57
|
+
const perPage = pages.map((_, i) => detectGutters(pageRuns[i], shown[i].width));
|
|
58
|
+
const shared = commonGutters(perPage);
|
|
59
|
+
const pageGutters = perPage.map((own, i) => own.length > 0 ? own : shared && fitsGutters(pageRuns[i], shared) ? shared : own);
|
|
39
60
|
const body = [];
|
|
61
|
+
const sectionEnds = [];
|
|
62
|
+
let sectionFrom = 0;
|
|
63
|
+
let lastSize = "";
|
|
64
|
+
let curColumns = 1;
|
|
65
|
+
let curSpace = 0;
|
|
66
|
+
let pendingContinuous = false;
|
|
40
67
|
pages.forEach((page, i) => {
|
|
41
68
|
const runs = pageRuns[i];
|
|
42
69
|
const display = shown[i];
|
|
70
|
+
const gutters = pageGutters[i];
|
|
43
71
|
const pageWidth = display.width;
|
|
44
|
-
const
|
|
72
|
+
const stepped = stepsBetweenWords(runs);
|
|
45
73
|
const blocks = [];
|
|
74
|
+
const split = gutters.length > 0 ? assignColumns(runs, gutters) : void 0;
|
|
75
|
+
const textEdges = pageTextEdges(runs);
|
|
76
|
+
const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
|
|
77
|
+
const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
|
|
46
78
|
const addColumn = (allRuns, col) => {
|
|
47
79
|
const colRuns = mode === "positional" ? allRuns.filter((r) => r.type3 !== true && r.invisible !== true) : allRuns;
|
|
48
80
|
if (mode === "positional") {
|
|
49
|
-
for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true)) {
|
|
81
|
+
for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
|
|
50
82
|
if (line.text.length === 0) continue;
|
|
51
83
|
const box = turnedBox(line, angle, pageWidth);
|
|
52
84
|
placed.push({
|
|
@@ -58,19 +90,51 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
58
90
|
}
|
|
59
91
|
return;
|
|
60
92
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
93
|
+
const columnSize = median(colRuns.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
94
|
+
const maths = matrixBlocks(colRuns, columnSize);
|
|
95
|
+
const inMath = new Set(maths.flatMap((m) => [...m.used]));
|
|
96
|
+
const lines = groupIntoLines(inMath.size > 0 ? colRuns.filter((r) => !inMath.has(r)) : colRuns, false, stepped).filter((l) => l.text.length > 0);
|
|
97
|
+
const measure = measureOf(col, gutters, textEdges) ?? measureOfLines(lines);
|
|
98
|
+
for (const found of maths) {
|
|
99
|
+
const { alignment } = alignmentOf([{
|
|
100
|
+
y: found.top,
|
|
101
|
+
fontSize: columnSize,
|
|
102
|
+
text: "",
|
|
103
|
+
spans: [],
|
|
104
|
+
x: found.x,
|
|
105
|
+
width: found.width
|
|
106
|
+
}], measure);
|
|
107
|
+
blocks.push({
|
|
108
|
+
band: bandAt(found.top),
|
|
109
|
+
col,
|
|
110
|
+
top: found.top,
|
|
111
|
+
el: {
|
|
112
|
+
kind: "paragraph",
|
|
113
|
+
paragraph: {
|
|
114
|
+
properties: alignment ? { alignment } : {},
|
|
115
|
+
runs: [{
|
|
116
|
+
text: "",
|
|
117
|
+
properties: {},
|
|
118
|
+
math: found.math
|
|
119
|
+
}]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
for (const para of groupIntoParagraphs(lines, measure, display.height)) blocks.push({
|
|
125
|
+
band: bandAt(para.top),
|
|
63
126
|
col,
|
|
64
127
|
top: para.top,
|
|
65
|
-
el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont)
|
|
128
|
+
el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
|
|
129
|
+
...para.alignment !== void 0 ? { alignment: para.alignment } : {},
|
|
130
|
+
...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
|
|
131
|
+
...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
|
|
132
|
+
...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
|
|
133
|
+
})
|
|
66
134
|
});
|
|
67
135
|
};
|
|
68
136
|
const placed = [];
|
|
69
|
-
|
|
70
|
-
addColumn(runs.filter((r) => r.x < gutter), 0);
|
|
71
|
-
addColumn(runs.filter((r) => r.x >= gutter), 1);
|
|
72
|
-
} else addColumn(runs, 0);
|
|
73
|
-
const colOf = (centerX) => gutter !== void 0 && centerX >= gutter ? 1 : 0;
|
|
137
|
+
const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
|
|
74
138
|
const frame = {
|
|
75
139
|
left: 0,
|
|
76
140
|
top: display.height
|
|
@@ -88,30 +152,62 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
88
152
|
maxY: img.y + img.heightPt
|
|
89
153
|
})));
|
|
90
154
|
losses.push(...lifted.losses);
|
|
91
|
-
const
|
|
155
|
+
const placedVectors = placeVectors(lifted.vectors, display);
|
|
156
|
+
const ruled = markDrawnRules(runs, placedVectors);
|
|
157
|
+
const vectors = placedVectors.filter((v) => !ruled.consumed.has(v));
|
|
158
|
+
const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
|
|
159
|
+
const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
|
|
160
|
+
if (asTable) for (const block of asTable) blocks.push({
|
|
161
|
+
band: bandAt(block.top),
|
|
162
|
+
col: 0,
|
|
163
|
+
top: block.top,
|
|
164
|
+
el: block.el
|
|
165
|
+
});
|
|
166
|
+
else if (split && !ruledIntoColumns) {
|
|
167
|
+
const columnFor = (r) => split.columnOf.get(r) ?? colOf(r.x);
|
|
168
|
+
const columns = Array.from({ length: gutters.length + 1 }, (_, n) => n);
|
|
169
|
+
for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
|
|
170
|
+
} else addColumn(ruled.runs, 0);
|
|
171
|
+
const under = mode !== "positional";
|
|
92
172
|
[
|
|
93
173
|
...imgs.images.map((img) => ({
|
|
94
174
|
key: img.orderKey,
|
|
95
175
|
col: colOf(img.x + img.widthPt / 2),
|
|
96
176
|
top: img.y + img.heightPt,
|
|
97
|
-
make: (z) => imageBlock(img, resources, void 0, frame, z)
|
|
177
|
+
make: (z) => imageBlock(img, resources, void 0, frame, z, under)
|
|
98
178
|
})),
|
|
99
179
|
...vectors.map((v) => ({
|
|
100
180
|
key: v.orderKey,
|
|
101
181
|
col: colOf((v.minX + v.maxX) / 2),
|
|
102
182
|
top: v.maxY,
|
|
103
|
-
make: (z) => shapeBlock(v, frame, z)
|
|
183
|
+
make: (z) => shapeBlock(v, frame, z, under)
|
|
104
184
|
})),
|
|
105
185
|
...placed
|
|
106
186
|
].sort((a, b) => compareOrder(a.key, b.key)).forEach((mark, z) => {
|
|
107
187
|
blocks.push({
|
|
188
|
+
band: bandAt(mark.top),
|
|
108
189
|
col: mark.col,
|
|
109
190
|
top: mark.top,
|
|
110
191
|
el: mark.make(z)
|
|
111
192
|
});
|
|
112
193
|
});
|
|
113
|
-
blocks.sort((a, b) => a.col - b.col || b.top - a.top);
|
|
114
|
-
|
|
194
|
+
blocks.sort((a, b) => a.band - b.band || columnOrder(a.col) - columnOrder(b.col) || b.top - a.top);
|
|
195
|
+
const size = `${shown[i].width.toFixed(2)}x${shown[i].height.toFixed(2)}`;
|
|
196
|
+
const opensSection = i > 0 && size !== lastSize;
|
|
197
|
+
if (opensSection) {
|
|
198
|
+
sectionEnds.push({
|
|
199
|
+
at: body.length,
|
|
200
|
+
from: sectionFrom,
|
|
201
|
+
to: i,
|
|
202
|
+
columns: curColumns,
|
|
203
|
+
spacePt: curSpace,
|
|
204
|
+
continuous: pendingContinuous
|
|
205
|
+
});
|
|
206
|
+
sectionFrom = i;
|
|
207
|
+
pendingContinuous = false;
|
|
208
|
+
}
|
|
209
|
+
lastSize = size;
|
|
210
|
+
if (i > 0 && !opensSection && blocks.length > 0) body.push({
|
|
115
211
|
kind: "paragraph",
|
|
116
212
|
paragraph: {
|
|
117
213
|
properties: {
|
|
@@ -122,35 +218,416 @@ function reconstructByLayout(file, mode = "flow") {
|
|
|
122
218
|
runs: []
|
|
123
219
|
}
|
|
124
220
|
});
|
|
125
|
-
|
|
221
|
+
const columnsHere = ruledIntoColumns ? 1 : gutters.length + 1;
|
|
222
|
+
const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
|
|
223
|
+
for (const block of blocks) {
|
|
224
|
+
const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
|
|
225
|
+
if (count !== curColumns) {
|
|
226
|
+
sectionEnds.push({
|
|
227
|
+
at: body.length,
|
|
228
|
+
from: sectionFrom,
|
|
229
|
+
to: i + 1,
|
|
230
|
+
columns: curColumns,
|
|
231
|
+
spacePt: curSpace,
|
|
232
|
+
continuous: pendingContinuous
|
|
233
|
+
});
|
|
234
|
+
sectionFrom = i;
|
|
235
|
+
pendingContinuous = true;
|
|
236
|
+
curColumns = count;
|
|
237
|
+
curSpace = spacePt;
|
|
238
|
+
}
|
|
239
|
+
body.push(block.el);
|
|
240
|
+
}
|
|
126
241
|
});
|
|
242
|
+
const setUp = (from, to) => {
|
|
243
|
+
const own = sectionFromPdfPages(pages.slice(from, to));
|
|
244
|
+
return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to));
|
|
245
|
+
};
|
|
246
|
+
sectionEnds.push({
|
|
247
|
+
at: body.length,
|
|
248
|
+
from: sectionFrom,
|
|
249
|
+
to: pages.length,
|
|
250
|
+
columns: curColumns,
|
|
251
|
+
spacePt: curSpace,
|
|
252
|
+
continuous: pendingContinuous
|
|
253
|
+
});
|
|
254
|
+
const sections = sectionEnds.length > 1 ? sectionEnds.flatMap((end) => {
|
|
255
|
+
const base = setUp(end.from, end.to);
|
|
256
|
+
if (!base) return [];
|
|
257
|
+
return [{
|
|
258
|
+
properties: {
|
|
259
|
+
...base,
|
|
260
|
+
...end.columns > 1 && mode !== "positional" ? { columns: {
|
|
261
|
+
count: end.columns,
|
|
262
|
+
spacePt: end.spacePt
|
|
263
|
+
} } : {},
|
|
264
|
+
...end.continuous ? { sectionStart: "continuous" } : {}
|
|
265
|
+
},
|
|
266
|
+
endIndex: end.at
|
|
267
|
+
}];
|
|
268
|
+
}) : [];
|
|
269
|
+
const stepped0 = stepsBetweenWords(allRuns[0] ?? []);
|
|
270
|
+
const edges0 = pageTextEdges(allRuns[0] ?? []);
|
|
271
|
+
const band = foot ? footerBand(foot.band, stepped0, edges0, foot.numbered) : [];
|
|
272
|
+
const headBand = head ? footerBand(head.band, stepped0, edges0, head.numbered) : [];
|
|
273
|
+
const withFooter = (properties) => properties ? {
|
|
274
|
+
...properties,
|
|
275
|
+
...band.length > 0 ? { footers: [{
|
|
276
|
+
type: "default",
|
|
277
|
+
relationshipId: FOOTER_PART
|
|
278
|
+
}] } : {},
|
|
279
|
+
...headBand.length > 0 ? { headers: [{
|
|
280
|
+
type: "default",
|
|
281
|
+
relationshipId: HEADER_PART
|
|
282
|
+
}] } : {}
|
|
283
|
+
} : properties;
|
|
127
284
|
return {
|
|
128
|
-
doc: buildFlowDoc(body, resources,
|
|
285
|
+
doc: buildFlowDoc(body, resources, withFooter(setUp(0, pages.length)), collectEmbeddedFonts(file, pages, losses), sections.map((s) => ({
|
|
286
|
+
...s,
|
|
287
|
+
properties: withFooter(s.properties) ?? s.properties
|
|
288
|
+
})), band.length > 0 || headBand.length > 0 ? new Map([...band.length > 0 ? [[FOOTER_PART, band]] : [], ...headBand.length > 0 ? [[HEADER_PART, headBand]] : []]) : void 0),
|
|
129
289
|
losses: dedupeLosses(losses)
|
|
130
290
|
};
|
|
131
291
|
}
|
|
132
|
-
function
|
|
133
|
-
if (runs.length < 30 || pageWidth <= 0) return
|
|
292
|
+
function detectGutters(runs, pageWidth) {
|
|
293
|
+
if (runs.length < 30 || pageWidth <= 0) return [];
|
|
134
294
|
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
295
|
+
const spans = runs.flatMap((r) => {
|
|
296
|
+
const ink = runInk(r);
|
|
297
|
+
return ink ? [ink] : [];
|
|
298
|
+
});
|
|
299
|
+
if (spans.length === 0) return [];
|
|
300
|
+
const minX = Math.min(...spans.map((iv) => iv[0]));
|
|
301
|
+
const span = Math.max(...spans.map((iv) => iv[1])) - minX;
|
|
302
|
+
if (span < pageWidth * .5) return [];
|
|
303
|
+
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
|
|
304
|
+
let curEnd = sorted[0][1];
|
|
305
|
+
const empty = [];
|
|
306
|
+
for (const [l, r] of sorted) {
|
|
307
|
+
if (l - curEnd >= fontSize * 3) empty.push({
|
|
308
|
+
mid: (curEnd + l) / 2,
|
|
309
|
+
from: curEnd,
|
|
310
|
+
to: l
|
|
311
|
+
});
|
|
147
312
|
if (r > curEnd) curEnd = r;
|
|
148
313
|
}
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
314
|
+
const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
|
|
315
|
+
const ink = runInk(r);
|
|
316
|
+
return ink ? [ink] : [];
|
|
317
|
+
}).sort((a, b) => a[0] - b[0]));
|
|
318
|
+
const voted = [];
|
|
319
|
+
for (const x of sample(minX + span * .1, minX + span * .9, 1)) {
|
|
320
|
+
let columned = 0;
|
|
321
|
+
let crossing = 0;
|
|
322
|
+
for (const row of rows) {
|
|
323
|
+
if (row.some(([l, r]) => l < x && r > x)) {
|
|
324
|
+
crossing++;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const before = row.filter(([, r]) => r <= x);
|
|
328
|
+
const after = row.filter(([l]) => l >= x);
|
|
329
|
+
if (before.length === 0 || after.length === 0) continue;
|
|
330
|
+
if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
|
|
331
|
+
}
|
|
332
|
+
if (columned < MIN_COLUMNED_ROWS || crossing >= columned) continue;
|
|
333
|
+
const last = voted[voted.length - 1];
|
|
334
|
+
if (last && x - last.to <= 1.5) voted[voted.length - 1] = {
|
|
335
|
+
...last,
|
|
336
|
+
to: x,
|
|
337
|
+
mid: (last.from + x) / 2
|
|
338
|
+
};
|
|
339
|
+
else voted.push({
|
|
340
|
+
mid: x,
|
|
341
|
+
from: x,
|
|
342
|
+
to: x
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
const near = (band) => empty.some((e) => band.mid > e.from - fontSize && band.mid < e.to + fontSize);
|
|
346
|
+
return separating([...empty, ...voted.filter((v) => !near(v))].sort((a, b) => a.mid - b.mid), spans);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The candidate gutters that actually separate something, left to right.
|
|
350
|
+
*
|
|
351
|
+
* A gutter with nothing on one side of it is a margin, and two of them with a
|
|
352
|
+
* word between are one column and a stray. Each band is kept only where the
|
|
353
|
+
* region since the last kept one holds a real share of the page's runs — and
|
|
354
|
+
* the last one only if something follows it.
|
|
355
|
+
*
|
|
356
|
+
* @param bands The candidates, in order.
|
|
357
|
+
* @param spans Every run's horizontal extent.
|
|
358
|
+
* @returns The gutters worth splitting on.
|
|
359
|
+
*/
|
|
360
|
+
function separating(bands, spans) {
|
|
361
|
+
const need = spans.length * MIN_COLUMN_SHARE;
|
|
362
|
+
const out = [];
|
|
363
|
+
let from = -Infinity;
|
|
364
|
+
for (const band of bands) {
|
|
365
|
+
if (spans.filter(([l, r]) => (l + r) / 2 > from && (l + r) / 2 < band.mid).length < need) continue;
|
|
366
|
+
out.push(band);
|
|
367
|
+
from = band.mid;
|
|
368
|
+
}
|
|
369
|
+
const tail = spans.filter(([l, r]) => (l + r) / 2 > from).length;
|
|
370
|
+
if (out.length > 0 && tail < need) out.pop();
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
/** How much of a page's text the narrowest column holds before it is a column. */
|
|
374
|
+
var MIN_COLUMN_SHARE = .08;
|
|
375
|
+
/**
|
|
376
|
+
* The gutters MOST of the document's pages agree on.
|
|
377
|
+
*
|
|
378
|
+
* A document is set one way: §17.6.4 puts the column setup on the section, and
|
|
379
|
+
* a paper does not change it from sheet to sheet. So a page that says nothing
|
|
380
|
+
* on its own — one carrying a figure across both columns, with too few clean
|
|
381
|
+
* lines left for the vote — can be asked the question the rest of the document
|
|
382
|
+
* already answered.
|
|
383
|
+
*
|
|
384
|
+
* @param perPage Each page's own answer, in page order.
|
|
385
|
+
* @returns The answer given by more pages than any other, or `undefined` where
|
|
386
|
+
* no two pages agree.
|
|
387
|
+
*/
|
|
388
|
+
function commonGutters(perPage) {
|
|
389
|
+
const seen = /* @__PURE__ */ new Map();
|
|
390
|
+
for (const gutters of perPage) {
|
|
391
|
+
if (gutters.length === 0) continue;
|
|
392
|
+
const key = gutters.map((g) => Math.round(g.mid / 2)).join(",");
|
|
393
|
+
const had = seen.get(key);
|
|
394
|
+
if (had) had.pages++;
|
|
395
|
+
else seen.set(key, {
|
|
396
|
+
gutters,
|
|
397
|
+
pages: 1
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
let best;
|
|
401
|
+
for (const entry of seen.values()) if (!best || entry.pages > best.pages) best = entry;
|
|
402
|
+
return best?.gutters;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Whether a page's own lines agree with gutters the document states.
|
|
406
|
+
*
|
|
407
|
+
* The document's answer is a suggestion, not a licence: a sheet whose lines run
|
|
408
|
+
* straight through the gutter is set in one column whatever its neighbours do.
|
|
409
|
+
* The bar is lower than the vote's own — the evidence from the other pages is
|
|
410
|
+
* already in — but it is still the page that decides.
|
|
411
|
+
*
|
|
412
|
+
* @param runs The page's runs.
|
|
413
|
+
* @param gutters The document's gutters.
|
|
414
|
+
*/
|
|
415
|
+
function fitsGutters(runs, gutters) {
|
|
416
|
+
if (runs.length < 30) return false;
|
|
417
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
418
|
+
const rows = rowsOf(runs, fontSize).map((row) => row.flatMap((r) => {
|
|
419
|
+
const ink = runInk(r);
|
|
420
|
+
return ink ? [ink] : [];
|
|
421
|
+
}).sort((a, b) => a[0] - b[0]));
|
|
422
|
+
for (const gutter of gutters) {
|
|
423
|
+
let columned = 0;
|
|
424
|
+
let crossing = 0;
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
if (row.some(([l, r]) => l < gutter.mid && r > gutter.mid)) {
|
|
427
|
+
crossing++;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const before = row.filter(([, r]) => r <= gutter.mid);
|
|
431
|
+
const after = row.filter(([l]) => l >= gutter.mid);
|
|
432
|
+
if (before.length === 0 || after.length === 0) continue;
|
|
433
|
+
if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) columned++;
|
|
434
|
+
}
|
|
435
|
+
if (crossing === 0) continue;
|
|
436
|
+
if (columned < MIN_SHARED_ROWS || crossing >= columned) return false;
|
|
437
|
+
}
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
/** How many lines a page must split at a gutter the DOCUMENT already states. */
|
|
441
|
+
var MIN_SHARED_ROWS = 3;
|
|
442
|
+
/** How many lines have to be split at the same place before the page is in columns. */
|
|
443
|
+
var MIN_COLUMNED_ROWS = 12;
|
|
444
|
+
/**
|
|
445
|
+
* The page's runs grouped by baseline.
|
|
446
|
+
*
|
|
447
|
+
* Swept in order rather than bucketed: a superscript sits a few points above
|
|
448
|
+
* the baseline it belongs to, and a bucket boundary between the two would leave
|
|
449
|
+
* it a line of its own — bug1885505.pdf's author block came back with the
|
|
450
|
+
* asterisks and daggers standing alone on five separate lines.
|
|
451
|
+
*/
|
|
452
|
+
function rowsOf(runs, fontSize) {
|
|
453
|
+
const tolerance = Math.max(fontSize * .6, 1);
|
|
454
|
+
const rows = [];
|
|
455
|
+
let row = [];
|
|
456
|
+
let rowY = Number.POSITIVE_INFINITY;
|
|
457
|
+
for (const run of [...runs].sort((a, b) => b.y - a.y)) {
|
|
458
|
+
if (row.length > 0 && rowY - run.y > tolerance) {
|
|
459
|
+
rows.push(row);
|
|
460
|
+
row = [];
|
|
461
|
+
}
|
|
462
|
+
if (row.length === 0) rowY = run.y;
|
|
463
|
+
row.push(run);
|
|
464
|
+
}
|
|
465
|
+
if (row.length > 0) rows.push(row);
|
|
466
|
+
return rows;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* A run's INK — where its letters are, which a trailing space is not.
|
|
470
|
+
*
|
|
471
|
+
* A run carries the advance it stepped, and the space that ends a line of a
|
|
472
|
+
* column is part of it: chrome-text-selection-markedContent.pdf sets two
|
|
473
|
+
* columns 21 points apart and its left column's lines end ", " — five of those
|
|
474
|
+
* points — so every measurement of the gutter came back a third short and the
|
|
475
|
+
* page was read straight across.
|
|
476
|
+
*
|
|
477
|
+
* @param run The run.
|
|
478
|
+
* @returns Its ink, or `undefined` for a run that is nothing but space.
|
|
479
|
+
*/
|
|
480
|
+
function runInk(run) {
|
|
481
|
+
const from = Math.min(run.x, run.endX);
|
|
482
|
+
const to = Math.max(run.endX, run.x + 1);
|
|
483
|
+
const chars = [...run.text];
|
|
484
|
+
if (chars.length === 0) return [from, to];
|
|
485
|
+
let head = 0;
|
|
486
|
+
while (head < chars.length && SPACE.test(chars[head])) head++;
|
|
487
|
+
if (head === chars.length) return void 0;
|
|
488
|
+
let tail = 0;
|
|
489
|
+
while (tail < chars.length - head && SPACE.test(chars[chars.length - 1 - tail])) tail++;
|
|
490
|
+
if (head === 0 && tail === 0) return [from, to];
|
|
491
|
+
const step = (to - from) / chars.length;
|
|
492
|
+
return [from + head * step, to - tail * step];
|
|
493
|
+
}
|
|
494
|
+
var SPACE = /\s/u;
|
|
495
|
+
/** Where the page's text starts and ends, ignoring what is only a space. */
|
|
496
|
+
function pageTextEdges(runs) {
|
|
497
|
+
let left = Number.POSITIVE_INFINITY;
|
|
498
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
499
|
+
for (const run of runs) {
|
|
500
|
+
const ink = runInk(run);
|
|
501
|
+
if (!ink) continue;
|
|
502
|
+
left = Math.min(left, ink[0]);
|
|
503
|
+
right = Math.max(right, ink[1]);
|
|
504
|
+
}
|
|
505
|
+
return right > left ? {
|
|
506
|
+
left,
|
|
507
|
+
right
|
|
508
|
+
} : void 0;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The measure a column is set across: from the gutter on its left to the one
|
|
512
|
+
* on its right, and to the page's own text edge where there is none.
|
|
513
|
+
*
|
|
514
|
+
* A line that spans the page (see {@link SPANNING_COLUMN}) is set across all of
|
|
515
|
+
* them, which is what makes a centred title centred.
|
|
516
|
+
*
|
|
517
|
+
* @param col The column, or {@link SPANNING_COLUMN}.
|
|
518
|
+
* @param gutters The page's gutters, left to right.
|
|
519
|
+
* @param edges Where the page's text starts and ends.
|
|
520
|
+
*/
|
|
521
|
+
function measureOf(col, gutters, edges) {
|
|
522
|
+
if (!edges) return void 0;
|
|
523
|
+
if (col === SPANNING_COLUMN || gutters.length === 0) return edges;
|
|
524
|
+
return {
|
|
525
|
+
left: col === 0 ? edges.left : gutters[col - 1]?.to ?? edges.left,
|
|
526
|
+
right: col >= gutters.length ? edges.right : gutters[col]?.from ?? edges.right
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/** The measure the lines themselves reach across, where the page states none. */
|
|
530
|
+
function measureOfLines(lines) {
|
|
531
|
+
if (lines.length === 0) return void 0;
|
|
532
|
+
return {
|
|
533
|
+
left: Math.min(...lines.map((l) => l.x)),
|
|
534
|
+
right: Math.max(...lines.map((l) => l.x + l.width))
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/** The x's to measure at, from `a` to `b` inclusive. */
|
|
538
|
+
function sample(a, b, step) {
|
|
539
|
+
const out = [];
|
|
540
|
+
for (let x = a; x <= b; x += Math.max(step, .5)) out.push(x);
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* EP17 — which column each run belongs to, and where the page's bands break.
|
|
545
|
+
*
|
|
546
|
+
* The decision is made a LINE at a time, not a run at a time: a title is drawn
|
|
547
|
+
* as several runs and only one of them may reach across the gutter, so judging
|
|
548
|
+
* each on its own would leave the rest of the title standing in a column.
|
|
549
|
+
*
|
|
550
|
+
* @param runs The page's runs.
|
|
551
|
+
* @param gutter The band from {@link detectGutter}.
|
|
552
|
+
* @returns The column of each run (0, 1, or {@link SPANNING_COLUMN}) and the
|
|
553
|
+
* baselines of the full-width lines, which separate the bands.
|
|
554
|
+
*/
|
|
555
|
+
function assignColumns(runs, gutters) {
|
|
556
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
557
|
+
const rows = rowsOf(runs, fontSize);
|
|
558
|
+
const columnOf = /* @__PURE__ */ new Map();
|
|
559
|
+
const breaks = [];
|
|
560
|
+
const columnAt = (x) => gutters.filter((g) => x >= g.mid).length;
|
|
561
|
+
for (const row of rows) {
|
|
562
|
+
const inked = row.flatMap((run) => {
|
|
563
|
+
const ink = runInk(run);
|
|
564
|
+
return ink ? [{
|
|
565
|
+
run,
|
|
566
|
+
from: ink[0],
|
|
567
|
+
to: ink[1]
|
|
568
|
+
}] : [];
|
|
569
|
+
}).sort((a, b) => a.from - b.from);
|
|
570
|
+
if (inked.length === 0) continue;
|
|
571
|
+
const leftmost = inked[0].from;
|
|
572
|
+
const rightmost = Math.max(...inked.map((r) => r.to));
|
|
573
|
+
const straddled = gutters.filter((g) => leftmost < g.mid && rightmost > g.mid);
|
|
574
|
+
if (straddled.length === 0) {
|
|
575
|
+
const col = columnAt(leftmost);
|
|
576
|
+
for (const run of row) columnOf.set(run, col);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const gapAt = (mid) => {
|
|
580
|
+
let cur = inked[0].to;
|
|
581
|
+
let gap = 0;
|
|
582
|
+
for (const { from, to } of inked.slice(1)) {
|
|
583
|
+
if (from > cur && cur <= mid && from >= mid) gap = from - cur;
|
|
584
|
+
cur = Math.max(cur, to);
|
|
585
|
+
}
|
|
586
|
+
return gap;
|
|
587
|
+
};
|
|
588
|
+
if (straddled.every((g) => gapAt(g.mid) >= fontSize * MIN_GUTTER_EM)) {
|
|
589
|
+
for (const { run, from } of inked) columnOf.set(run, columnAt(from));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
for (const run of row) columnOf.set(run, SPANNING_COLUMN);
|
|
593
|
+
breaks.push(Math.max(...row.map((r) => r.y)));
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
columnOf,
|
|
597
|
+
breaks: breaks.sort((a, b) => b - a)
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* A line that spans the page belongs to no column: it stands between the bands
|
|
602
|
+
* it separates.
|
|
603
|
+
*
|
|
604
|
+
* Which side of them? A spanning line is what BREAKS a band, and a band runs
|
|
605
|
+
* from one break to the next, so the line is always at the FOOT of its own —
|
|
606
|
+
* the columns of that band are the ones above it. Read ahead of them,
|
|
607
|
+
* bug1997343.pdf's page number came out between the date and the abstract.
|
|
608
|
+
*/
|
|
609
|
+
var SPANNING_COLUMN = -1;
|
|
610
|
+
/** Where a column reads in its band: the spanning line at the foot of it. */
|
|
611
|
+
function columnOrder(col) {
|
|
612
|
+
return col === SPANNING_COLUMN ? Number.MAX_SAFE_INTEGER : col;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* How wide, in ems, the gap over the middle has to be for a line to be two
|
|
616
|
+
* lines. A word space is a quarter of an em (see `SPACE_GAP_EM`) and a
|
|
617
|
+
* justified one no more than half; a gutter is an em and more.
|
|
618
|
+
*
|
|
619
|
+
* It stood at one and a half, which is wider than some magazines set:
|
|
620
|
+
* chrome-text-selection-markedContent.pdf puts fourteen and a half points
|
|
621
|
+
* between columns of eleven-point type — 1.31 em — and every line of it was
|
|
622
|
+
* read straight across, the left column's sentence running into the right
|
|
623
|
+
* column's.
|
|
624
|
+
*/
|
|
625
|
+
var MIN_GUTTER_EM = 1;
|
|
626
|
+
/** Which band a mark at this height belongs to — how many breaks stand above it. */
|
|
627
|
+
function bandOf(breaks, top, epsilon) {
|
|
628
|
+
let n = 0;
|
|
629
|
+
for (const y of breaks) if (y > top + epsilon) n++;
|
|
630
|
+
return n;
|
|
154
631
|
}
|
|
155
632
|
/**
|
|
156
633
|
* The gap, in ems, past which the placed reader cuts a line rather than write a
|
|
@@ -241,7 +718,7 @@ function rotation60kOf(angleDeg) {
|
|
|
241
718
|
* 0.05, and thirty at 0.05 em or more. A twentieth of an em sits in that gap.
|
|
242
719
|
*/
|
|
243
720
|
var BASELINE_STEP_EM = .05;
|
|
244
|
-
function groupIntoLines(runs, split = false) {
|
|
721
|
+
function groupIntoLines(runs, split = false, stepped = false) {
|
|
245
722
|
const sorted = [...runs].sort((a, b) => b.y - a.y || a.x - b.x);
|
|
246
723
|
const clusters = [];
|
|
247
724
|
for (const run of sorted) {
|
|
@@ -259,7 +736,7 @@ function groupIntoLines(runs, split = false) {
|
|
|
259
736
|
return clusters.flatMap((c) => {
|
|
260
737
|
const ordered = c.runs.sort((a, b) => a.x - b.x);
|
|
261
738
|
const fontSize = c.fontSize || 10;
|
|
262
|
-
if (!split) return [lineOf(ordered, c.y, fontSize)];
|
|
739
|
+
if (!split) return [lineOf(ordered, c.y, fontSize, stepped)];
|
|
263
740
|
const pieces = [[]];
|
|
264
741
|
for (const run of ordered) {
|
|
265
742
|
const prev = pieces[pieces.length - 1];
|
|
@@ -268,30 +745,155 @@ function groupIntoLines(runs, split = false) {
|
|
|
268
745
|
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([]);
|
|
269
746
|
pieces[pieces.length - 1].push(run);
|
|
270
747
|
}
|
|
271
|
-
return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize));
|
|
748
|
+
return pieces.map((piece) => lineOf(piece, piece[0].y, Math.max(...piece.map((r) => r.fontSizePt || 0)) || fontSize, stepped));
|
|
272
749
|
});
|
|
273
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Where a line's INK is, which is not how far its pen travelled.
|
|
753
|
+
*
|
|
754
|
+
* A run of spaces advances the pen and marks nothing. basicapi.pdf sets its
|
|
755
|
+
* page number as thirty-one spaces and "page 1 / 3" in ONE run, reaching 635pt
|
|
756
|
+
* across a 595pt sheet — and the measure taken off that line was wide enough
|
|
757
|
+
* that the centred title in the same column no longer looked centred.
|
|
758
|
+
*
|
|
759
|
+
* The blanks are deducted at the face's own space width, which the run carries
|
|
760
|
+
* (§9.4.4); where the face states none, at a quarter of the size.
|
|
761
|
+
*/
|
|
762
|
+
function inkSpan(runs) {
|
|
763
|
+
const marked = runs.filter((r) => r.text.trim().length > 0);
|
|
764
|
+
if (marked.length === 0) {
|
|
765
|
+
const x = runs[0].x;
|
|
766
|
+
return {
|
|
767
|
+
x,
|
|
768
|
+
width: runs[runs.length - 1].endX - x
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const first = marked[0];
|
|
772
|
+
const last = marked[marked.length - 1];
|
|
773
|
+
const space = (r) => r.spaceWidthPt !== void 0 && r.spaceWidthPt > 0 ? r.spaceWidthPt : (r.fontSizePt || 10) * .25;
|
|
774
|
+
const lead = (/^\s*/u.exec(first.text)?.[0].length ?? 0) * space(first);
|
|
775
|
+
const trail = (/\s*$/u.exec(last.text)?.[0].length ?? 0) * space(last);
|
|
776
|
+
const x = first.x + lead;
|
|
777
|
+
return {
|
|
778
|
+
x,
|
|
779
|
+
width: Math.max(0, last.endX - trail - x)
|
|
780
|
+
};
|
|
781
|
+
}
|
|
274
782
|
/** One run of runs, left to right on a shared baseline, as a {@link Line}. */
|
|
275
|
-
function lineOf(runs, y, fontSize) {
|
|
276
|
-
const ordered = lineSpans(runs, fontSize);
|
|
783
|
+
function lineOf(runs, y, fontSize, stepped) {
|
|
784
|
+
const ordered = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
|
|
277
785
|
const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
|
|
278
|
-
const
|
|
786
|
+
const ink = inkSpan(runs);
|
|
279
787
|
return {
|
|
280
|
-
x,
|
|
281
|
-
width:
|
|
788
|
+
x: ink.x,
|
|
789
|
+
width: ink.width,
|
|
282
790
|
y,
|
|
283
791
|
fontSize,
|
|
792
|
+
...tabbed(runs, fontSize) ? { tabbed: true } : {},
|
|
284
793
|
text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
|
|
285
794
|
spans
|
|
286
795
|
};
|
|
287
796
|
}
|
|
288
|
-
|
|
797
|
+
/**
|
|
798
|
+
* Whether a TAB stands inside the line — a gap no word space could be.
|
|
799
|
+
*
|
|
800
|
+
* A line with one is a line the page SET OUT, not a line of prose: a contents
|
|
801
|
+
* entry with its page number at the measure, a two-column list, a label and its
|
|
802
|
+
* value. It does not run on into the line below it, and read as prose it does:
|
|
803
|
+
* bug1997343.pdf's contents came back as "2 Document structures 1 2.1
|
|
804
|
+
* Mathematics ............. 1", two entries in one line, where the file sets
|
|
805
|
+
* one to a line.
|
|
806
|
+
*
|
|
807
|
+
* A leader says the same thing (see {@link carriesLeader}) but only where the
|
|
808
|
+
* entry is dotted; a top-level entry is spaced, and nothing else marks it.
|
|
809
|
+
*
|
|
810
|
+
* @param runs The line's runs.
|
|
811
|
+
* @param fontSize The line's size.
|
|
812
|
+
*/
|
|
813
|
+
function tabbed(runs, fontSize) {
|
|
814
|
+
const inked = runs.flatMap((r) => {
|
|
815
|
+
const ink = runInk(r);
|
|
816
|
+
return ink ? [ink] : [];
|
|
817
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
818
|
+
let cur = Number.NEGATIVE_INFINITY;
|
|
819
|
+
for (const [from, to] of inked) {
|
|
820
|
+
if (cur > Number.NEGATIVE_INFINITY && from - cur >= fontSize * TAB_GAP_EM) return true;
|
|
821
|
+
cur = Math.max(cur, to);
|
|
822
|
+
}
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* How wide a gap has to be, in ems, before a word space could not have stood
|
|
827
|
+
* there. Justification stretches a space to about half an em; two and a half is
|
|
828
|
+
* a jump nothing but a tab makes.
|
|
829
|
+
*/
|
|
830
|
+
var TAB_GAP_EM = 2.5;
|
|
831
|
+
/**
|
|
832
|
+
* The gap between two runs that means a WORD SPACE stood there.
|
|
833
|
+
*
|
|
834
|
+
* A page that draws its own spaces has already said where its words divide, and
|
|
835
|
+
* a gap between two of its runs is a COLUMN or a placement — 160F-2019.pdf is
|
|
836
|
+
* ruled into fields a quarter-inch apart and a generous threshold keeps them
|
|
837
|
+
* apart. A page that draws none has said nothing, and every word boundary on it
|
|
838
|
+
* is a gap: bigboundingbox.pdf steps 0.226 em between words and never writes a
|
|
839
|
+
* space, so at a quarter em its every line ran together — "OrangeDemoInc.",
|
|
840
|
+
* "Whenpayingbycheck,pleasecompletethispaymentadvice".
|
|
841
|
+
*
|
|
842
|
+
* The two want different thresholds, and the page says which it is (see
|
|
843
|
+
* {@link stepsBetweenWords}). The tight one still clears the gaps a producer
|
|
844
|
+
* leaves INSIDE a word when it splits one for kerning, which measure eight
|
|
845
|
+
* hundredths of an em at their widest across this corpus.
|
|
846
|
+
*/
|
|
847
|
+
function spaceGap(prev, fontSize, stepped) {
|
|
848
|
+
return (prev.fontSizePt || fontSize) * (stepped ? STEPPED_SPACE_EM : DRAWN_SPACE_EM);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* §17.3.1.25 — one character of a LEADER, the dotted rule that carries the eye
|
|
852
|
+
* across a table of contents.
|
|
853
|
+
*
|
|
854
|
+
* A leader is drawn one character at a time with a step about as wide as a word
|
|
855
|
+
* space, so every threshold that tells a space from a kern says "space" between
|
|
856
|
+
* every dot. bug886717.pdf's contents came back as
|
|
857
|
+
* "Abstract . . . . . . . . . . . . 3", four times as long as the page sets it,
|
|
858
|
+
* and its forty entries spilled onto a second page. What tells a leader from
|
|
859
|
+
* words is that it is the SAME character over and over.
|
|
860
|
+
*/
|
|
861
|
+
function isLeader(text) {
|
|
862
|
+
return text.length === 1 && LEADER_CHARS.has(text);
|
|
863
|
+
}
|
|
864
|
+
var LEADER_CHARS = new Set([
|
|
865
|
+
".",
|
|
866
|
+
"·",
|
|
867
|
+
"_",
|
|
868
|
+
"-",
|
|
869
|
+
"‐",
|
|
870
|
+
"–"
|
|
871
|
+
]);
|
|
872
|
+
/** A page that writes its own spaces: only a wide gap means anything more. */
|
|
873
|
+
var DRAWN_SPACE_EM = .25;
|
|
874
|
+
/** A page that writes none: the step between its words is all there is. */
|
|
875
|
+
var STEPPED_SPACE_EM = .12;
|
|
876
|
+
/**
|
|
877
|
+
* Whether this page STEPS between its words rather than writing spaces.
|
|
878
|
+
*
|
|
879
|
+
* Counted rather than guessed: bigboundingbox.pdf writes a space in one run in
|
|
880
|
+
* a hundred, TAMReview.pdf in a third of them, and no page does a little of
|
|
881
|
+
* both. A page with almost no text says nothing either way and keeps the
|
|
882
|
+
* cautious reading.
|
|
883
|
+
*/
|
|
884
|
+
function stepsBetweenWords(runs) {
|
|
885
|
+
if (runs.length < 8) return false;
|
|
886
|
+
return runs.filter((r) => /\s/u.test(r.text)).length / runs.length < .05;
|
|
887
|
+
}
|
|
888
|
+
function lineSpans(runs, fontSize, stepped) {
|
|
289
889
|
const spans = [];
|
|
290
|
-
|
|
890
|
+
const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
|
|
891
|
+
const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
|
|
892
|
+
let prev;
|
|
291
893
|
for (const run of runs) {
|
|
292
|
-
if (
|
|
894
|
+
if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) spans.push({ text: " " });
|
|
293
895
|
spans.push({
|
|
294
|
-
text: run.text,
|
|
896
|
+
text: run.text.replaceAll("�", ""),
|
|
295
897
|
sizePt: run.fontSizePt,
|
|
296
898
|
...run.colorHex !== "000000" ? { colorHex: run.colorHex } : {},
|
|
297
899
|
...run.fontName !== void 0 ? { fontName: run.fontName } : {},
|
|
@@ -301,26 +903,234 @@ function lineSpans(runs, fontSize) {
|
|
|
301
903
|
} } : {},
|
|
302
904
|
...run.bold ? { bold: true } : {},
|
|
303
905
|
...run.italic ? { italic: true } : {},
|
|
906
|
+
...script(run, baseline, fontSize),
|
|
907
|
+
...run.markup !== void 0 ? { markup: run.markup } : {},
|
|
304
908
|
...run.href !== void 0 ? { href: run.href } : {}
|
|
305
909
|
});
|
|
306
|
-
|
|
910
|
+
prev = run;
|
|
307
911
|
}
|
|
308
912
|
return spans;
|
|
309
913
|
}
|
|
310
|
-
|
|
914
|
+
/**
|
|
915
|
+
* §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
|
|
916
|
+
* is.
|
|
917
|
+
*
|
|
918
|
+
* A PDF states no such property: an exponent is a smaller face set a little
|
|
919
|
+
* higher, and an index a smaller face set a little lower. Read flat, the whole
|
|
920
|
+
* of mathematics comes back on one line — bug1997343.pdf sets `n^p = n mod p`
|
|
921
|
+
* and we read "np", and every prime on the page landed beside its letter
|
|
922
|
+
* instead of over it.
|
|
923
|
+
*
|
|
924
|
+
* The size the span keeps is the LINE's, not the mark's: a document states the
|
|
925
|
+
* nominal size and the layout shrinks a script, so the drawn seven points under
|
|
926
|
+
* a superscript would come out at five.
|
|
927
|
+
*
|
|
928
|
+
* @param run The run.
|
|
929
|
+
* @param baseline The line's own baseline.
|
|
930
|
+
* @param fontSize The line's size.
|
|
931
|
+
*/
|
|
932
|
+
function script(run, baseline, fontSize) {
|
|
933
|
+
if ((run.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE) return {};
|
|
934
|
+
const rise = run.y - baseline;
|
|
935
|
+
if (rise > fontSize * SCRIPT_RISE) return {
|
|
936
|
+
script: "superscript",
|
|
937
|
+
sizePt: fontSize
|
|
938
|
+
};
|
|
939
|
+
if (rise < -fontSize * SCRIPT_DROP) return {
|
|
940
|
+
script: "subscript",
|
|
941
|
+
sizePt: fontSize
|
|
942
|
+
};
|
|
943
|
+
return {};
|
|
944
|
+
}
|
|
945
|
+
/** How much smaller than its line a run must be set to be a script of it. */
|
|
946
|
+
var SCRIPT_SIZE = .85;
|
|
947
|
+
/** How far above the baseline a superscript stands, and below it a subscript. */
|
|
948
|
+
var SCRIPT_RISE = .15;
|
|
949
|
+
var SCRIPT_DROP = .08;
|
|
950
|
+
/**
|
|
951
|
+
* §17.3.1.25 — whether a line carries a LEADER, which makes it an entry in a
|
|
952
|
+
* directory rather than a line of prose.
|
|
953
|
+
*
|
|
954
|
+
* A contents line runs the full measure — the dots are there to make it — so
|
|
955
|
+
* the ragged-edge test that ends every other paragraph never fires on one, and
|
|
956
|
+
* bug886717.pdf's forty entries came back as a single reflowing paragraph.
|
|
957
|
+
*/
|
|
958
|
+
function carriesLeader(line) {
|
|
959
|
+
return LEADER_RUN.test(line.text);
|
|
960
|
+
}
|
|
961
|
+
/** Three of the same leader character in a row is a leader and not punctuation. */
|
|
962
|
+
var LEADER_RUN = /([.\u00b7_])\1{2,}/u;
|
|
963
|
+
function groupIntoParagraphs(lines, column, pageHeight = 0) {
|
|
311
964
|
const groups = [];
|
|
312
|
-
|
|
965
|
+
const gaps = [];
|
|
966
|
+
let prev;
|
|
313
967
|
for (const line of lines) {
|
|
314
|
-
const gap =
|
|
315
|
-
|
|
968
|
+
const gap = prev !== void 0 ? prev.y - line.y : 0;
|
|
969
|
+
const opened = prev !== void 0 && gap > line.fontSize * 1.5;
|
|
970
|
+
if (groups.length === 0 || opened || prev !== void 0 && (endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
|
|
971
|
+
groups.push([]);
|
|
972
|
+
gaps.push(prev === void 0 ? 0 : gap);
|
|
973
|
+
}
|
|
316
974
|
groups[groups.length - 1].push(line);
|
|
317
|
-
|
|
975
|
+
prev = line;
|
|
318
976
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
977
|
+
const starts = lines.map((l) => l.x).sort((a, b) => a - b);
|
|
978
|
+
const columnLeft = starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? 0;
|
|
979
|
+
return groups.map((g, i) => {
|
|
980
|
+
const first = g[0];
|
|
981
|
+
const fontSize = Math.max(...g.map((l) => l.fontSize));
|
|
982
|
+
const opened = (gaps[i] ?? 0) - fontSize * 1.2;
|
|
983
|
+
const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
|
|
984
|
+
const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
|
|
985
|
+
return {
|
|
986
|
+
spans: joinLines(g),
|
|
987
|
+
fontSize,
|
|
988
|
+
top: first.y,
|
|
989
|
+
...spacingBefore !== void 0 ? { spacingBefore } : {},
|
|
990
|
+
...alignmentOf(g, column),
|
|
991
|
+
...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
|
|
992
|
+
};
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* A paragraph's lines as one run of spans, joined the way the page broke them.
|
|
997
|
+
*
|
|
998
|
+
* A line that ends in a hyphen was broken THERE, and the break is not part of
|
|
999
|
+
* the text: re-set at another measure the word has to come back together. Which
|
|
1000
|
+
* hyphen decides what is left of it — U+00AD is the discretionary one, put in
|
|
1001
|
+
* to mark a place a word MAY break, and it goes with the break; a plain hyphen
|
|
1002
|
+
* belongs to the word ("two-" and "column" are "two-column") and stays.
|
|
1003
|
+
*
|
|
1004
|
+
* Read as prose with a space between every line, bug1997343.pdf came back
|
|
1005
|
+
* "typical two-column docu ment incorporating tables, figures and mathemat
|
|
1006
|
+
* ics" — the soft hyphens dropped by the page and a space in their place.
|
|
1007
|
+
*
|
|
1008
|
+
* @param lines The paragraph's lines, in order.
|
|
1009
|
+
*/
|
|
1010
|
+
function joinLines(lines) {
|
|
1011
|
+
const out = [];
|
|
1012
|
+
lines.forEach((line, i) => {
|
|
1013
|
+
if (i > 0) {
|
|
1014
|
+
const prev = out[out.length - 1];
|
|
1015
|
+
const ends = prev?.text ?? "";
|
|
1016
|
+
const soft = ends.endsWith(SOFT_HYPHEN);
|
|
1017
|
+
const hard = HYPHENS.has(ends.slice(-1));
|
|
1018
|
+
if (soft && prev) out[out.length - 1] = {
|
|
1019
|
+
...prev,
|
|
1020
|
+
text: ends.slice(0, -1)
|
|
1021
|
+
};
|
|
1022
|
+
else if (!hard) out.push({ text: " " });
|
|
1023
|
+
}
|
|
1024
|
+
out.push(...line.spans);
|
|
1025
|
+
});
|
|
1026
|
+
return out;
|
|
1027
|
+
}
|
|
1028
|
+
/** §17.3.3.29 — the hyphen that is a PLACE a word may break, not a hyphen. */
|
|
1029
|
+
var SOFT_HYPHEN = "";
|
|
1030
|
+
/** The hyphens that belong to the word they end. */
|
|
1031
|
+
var HYPHENS = new Set([
|
|
1032
|
+
"-",
|
|
1033
|
+
"‐",
|
|
1034
|
+
"‑"
|
|
1035
|
+
]);
|
|
1036
|
+
/** How many lines' worth of indent still reads as a first line, not a placement. */
|
|
1037
|
+
var INDENT_LINES = 3;
|
|
1038
|
+
/** Where in the run of line starts the column's own left edge is looked for. */
|
|
1039
|
+
var COLUMN_LEFT_QUANTILE = .15;
|
|
1040
|
+
/**
|
|
1041
|
+
* §17.3.1.12 `w:ind` — how far a paragraph is set in from its column, and where
|
|
1042
|
+
* its first line begins.
|
|
1043
|
+
*
|
|
1044
|
+
* A PDF states neither: every line is placed absolutely, and read flat every
|
|
1045
|
+
* paragraph came back against the left edge. That is most of what a list looks
|
|
1046
|
+
* like — bug1997343.pdf sets "• They may be unordered bullet lists" ten points
|
|
1047
|
+
* in and its nested "1. lists may also be nested" twenty more, and we set all
|
|
1048
|
+
* of them flush left — and it is the whole of a first-line indent, which is how
|
|
1049
|
+
* most of the world's prose marks a new paragraph.
|
|
1050
|
+
*
|
|
1051
|
+
* The first line is measured against the REST of the paragraph, which is what
|
|
1052
|
+
* `indentFirstLine` means: positive is a first line set in (a new paragraph),
|
|
1053
|
+
* negative a hanging one (a list, its marker standing out to the left).
|
|
1054
|
+
*
|
|
1055
|
+
* A paragraph that is centred or set to the right is placed, not indented, and
|
|
1056
|
+
* keeps neither.
|
|
1057
|
+
*
|
|
1058
|
+
* @param lines The paragraph's lines.
|
|
1059
|
+
* @param columnLeft Where the column's own text begins.
|
|
1060
|
+
* @param alignment What {@link alignmentOf} made of it.
|
|
1061
|
+
*/
|
|
1062
|
+
function indentOf(lines, columnLeft, alignment) {
|
|
1063
|
+
if (alignment !== void 0 || lines.length === 0) return {};
|
|
1064
|
+
const size = Math.max(...lines.map((l) => l.fontSize)) || 10;
|
|
1065
|
+
const rest = lines.slice(1);
|
|
1066
|
+
const body = rest.length > 0 ? Math.min(...rest.map((l) => l.x)) : lines[0].x;
|
|
1067
|
+
const left = body - columnLeft;
|
|
1068
|
+
const first = lines[0].x - body;
|
|
1069
|
+
const enough = size * INDENT_EM;
|
|
1070
|
+
return {
|
|
1071
|
+
...Math.abs(left) >= enough ? { indentLeft: left } : {},
|
|
1072
|
+
...Math.abs(first) >= enough ? { indentFirstLine: first } : {}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* How far, in ems, a paragraph has to be set in before it is indented rather
|
|
1077
|
+
* than merely started. Half an em clears the rounding a producer leaves at the
|
|
1078
|
+
* head of a line and is well under the smallest indent anybody sets.
|
|
1079
|
+
*/
|
|
1080
|
+
var INDENT_EM = .5;
|
|
1081
|
+
/**
|
|
1082
|
+
* Whether a line ENDED a paragraph, rather than wrapping into the next.
|
|
1083
|
+
*
|
|
1084
|
+
* Leading alone cannot tell the two apart: five labels stacked at 15pt with a
|
|
1085
|
+
* 12pt face look exactly like five wrapped lines, and alphatrans.pdf's five are
|
|
1086
|
+
* read as one paragraph and re-wrapped into two. But a wrapping engine pulls
|
|
1087
|
+
* the next word UP — so a line that stops well short of the measure stopped
|
|
1088
|
+
* because its author stopped it, and the line after it begins something new.
|
|
1089
|
+
* The same rule separates two paragraphs set with no extra space between them,
|
|
1090
|
+
* which used to run together for the same reason.
|
|
1091
|
+
*
|
|
1092
|
+
* Only where both lines start at the same edge. Where they do not, the block is
|
|
1093
|
+
* placed rather than set — a centred title's every line is short of the measure
|
|
1094
|
+
* and none of them ends anything.
|
|
1095
|
+
*
|
|
1096
|
+
* @param prev The line before: where it starts, how wide it is, its face.
|
|
1097
|
+
* @param next The line after — only where it starts matters.
|
|
1098
|
+
* @param column The measure both were set in, when it is known.
|
|
1099
|
+
* @returns Whether the first line ended a paragraph.
|
|
1100
|
+
*/
|
|
1101
|
+
function endedParagraph(prev, next, column) {
|
|
1102
|
+
if (!column) return false;
|
|
1103
|
+
const width = column.right - column.left;
|
|
1104
|
+
if (!(width > 0)) return false;
|
|
1105
|
+
const step = Math.max(prev.fontSize, 4);
|
|
1106
|
+
const shift = next.x - prev.x;
|
|
1107
|
+
if (shift < -step || shift > step * INDENT_LINES) return false;
|
|
1108
|
+
return column.right - (prev.x + prev.width) > width * .25;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* §17.3.1.13 — where a paragraph sits across its column, which is the only
|
|
1112
|
+
* witness a PDF leaves of how it was set: every line is placed absolutely and
|
|
1113
|
+
* nothing says "centred".
|
|
1114
|
+
*
|
|
1115
|
+
* A paragraph whose lines are inset by about as much on each side is centred; a
|
|
1116
|
+
* one-line paragraph pushed to the right edge is right-aligned. Everything else
|
|
1117
|
+
* is left alone — a justified paragraph and a ragged-right one look the same
|
|
1118
|
+
* from here, and guessing between them would re-set the body of every document.
|
|
1119
|
+
*/
|
|
1120
|
+
function alignmentOf(lines, column, least) {
|
|
1121
|
+
if (!column || lines.length === 0) return {};
|
|
1122
|
+
const width = column.right - column.left;
|
|
1123
|
+
if (!(width > 0)) return {};
|
|
1124
|
+
const insets = lines.map((l) => ({
|
|
1125
|
+
lead: l.x - column.left,
|
|
1126
|
+
trail: column.right - (l.x + l.width)
|
|
323
1127
|
}));
|
|
1128
|
+
const meaningful = Math.min(width * .1, least ?? width);
|
|
1129
|
+
const even = width * .06;
|
|
1130
|
+
if (insets.every((i) => Math.abs(i.lead - i.trail) <= even) && Math.max(...insets.map((i) => Math.min(i.lead, i.trail))) >= meaningful) return { alignment: "center" };
|
|
1131
|
+
const leads = insets.map((i) => i.lead);
|
|
1132
|
+
if (insets.every((i) => i.trail <= even) && Math.max(...leads) >= meaningful && Math.max(...leads) - Math.min(...leads) > even) return { alignment: "right" };
|
|
1133
|
+
return {};
|
|
324
1134
|
}
|
|
325
1135
|
function headingLevel(fontSize, medianFont) {
|
|
326
1136
|
if (fontSize >= medianFont * 1.5) return 0;
|
|
@@ -341,5 +1151,447 @@ function compareOrder(a, b) {
|
|
|
341
1151
|
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
342
1152
|
return a.length - b.length;
|
|
343
1153
|
}
|
|
1154
|
+
/**
|
|
1155
|
+
* §17.6.13 — the running foot a document repeats at the bottom of its pages.
|
|
1156
|
+
*
|
|
1157
|
+
* A page number, a title, a URL: it stands below the text block, in the margin,
|
|
1158
|
+
* and it is not part of what the page SAYS. Read as body it goes wherever the
|
|
1159
|
+
* reflow puts it — bug1997343.pdf's "1" came out on a sheet of its own between
|
|
1160
|
+
* the two the paper has, and TAMReview.pdf's "Sprouts — http://…" landed in the
|
|
1161
|
+
* middle of the abstract.
|
|
1162
|
+
*
|
|
1163
|
+
* What makes it a running foot is that it RUNS: the same place, page after
|
|
1164
|
+
* page, cut off from the text above it by more than a line of white. One page
|
|
1165
|
+
* proves nothing, so two are asked for.
|
|
1166
|
+
*
|
|
1167
|
+
* @param pageRuns Each page's runs, placed.
|
|
1168
|
+
* @param shown Each page's shown geometry.
|
|
1169
|
+
* @returns The runs to lift off each page and the band to put them in, or
|
|
1170
|
+
* `undefined` where the document repeats nothing.
|
|
1171
|
+
*/
|
|
1172
|
+
function runningFoot(pageRuns, shown, where) {
|
|
1173
|
+
const feet = pageRuns.map((runs, i) => edgeLine(runs, shown[i]?.height ?? 0, where));
|
|
1174
|
+
const found = feet.filter((f) => f !== void 0);
|
|
1175
|
+
if (found.length < 2 || found.length < pageRuns.length * FOOT_SHARE) return void 0;
|
|
1176
|
+
const ys = found.map((f) => f.y);
|
|
1177
|
+
const mid = median(ys);
|
|
1178
|
+
if (ys.some((y) => Math.abs(y - mid) > FOOT_DRIFT)) return void 0;
|
|
1179
|
+
const texts = found.map((f) => f.runs.map((r) => r.text).join("").trim());
|
|
1180
|
+
return {
|
|
1181
|
+
lift: feet.map((f) => new Set(f?.runs ?? [])),
|
|
1182
|
+
band: found[0].runs,
|
|
1183
|
+
numbered: new Set(texts).size > 1
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
/** How many of a document's pages must carry the foot before it is running. */
|
|
1187
|
+
var FOOT_SHARE = .6;
|
|
1188
|
+
/** How far, in points, a running foot may drift from page to page. */
|
|
1189
|
+
var FOOT_DRIFT = 4;
|
|
1190
|
+
/**
|
|
1191
|
+
* The lines at the very bottom of a page, where they stand ALONE below the text
|
|
1192
|
+
* block.
|
|
1193
|
+
*
|
|
1194
|
+
* Alone means the white above them is more than the page's own leading — a last
|
|
1195
|
+
* paragraph is a line's gap from the one before it, a running foot is several.
|
|
1196
|
+
*
|
|
1197
|
+
* A foot need not be ONE line. ZapfDingbats.pdf signs each page twice, the
|
|
1198
|
+
* publisher's line and the suite's title on one baseline and the build stamp
|
|
1199
|
+
* thirty points below it, and taking only the bottom line left the other in the
|
|
1200
|
+
* body: after a table that fills the sheet it had nowhere to go but a page of
|
|
1201
|
+
* its own, and a two-page document came out as four.
|
|
1202
|
+
*/
|
|
1203
|
+
function edgeLine(runs, pageHeight, where) {
|
|
1204
|
+
if (runs.length < 4 || pageHeight <= 0) return void 0;
|
|
1205
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
1206
|
+
const rows = rowsOf(runs, fontSize);
|
|
1207
|
+
if (rows.length < 4) return void 0;
|
|
1208
|
+
const at = (i) => where === "foot" ? rows[rows.length - 1 - i] : rows[i];
|
|
1209
|
+
const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
|
|
1210
|
+
const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
|
|
1211
|
+
const y = edgeY(at(0));
|
|
1212
|
+
let found;
|
|
1213
|
+
let group = [];
|
|
1214
|
+
const most = where === "foot" ? FOOT_ROWS : 1;
|
|
1215
|
+
for (let i = 0; i < Math.min(most, rows.length - 2); i++) {
|
|
1216
|
+
const row = at(i);
|
|
1217
|
+
const edge = edgeY(row);
|
|
1218
|
+
if (where === "foot" ? edge > pageHeight * FOOT_BAND : edge < pageHeight * (1 - FOOT_BAND)) break;
|
|
1219
|
+
group = [...group, ...row];
|
|
1220
|
+
const next = at(i + 1);
|
|
1221
|
+
if ((where === "foot" ? inside(next) - edgeY(row) : edgeY(row) - inside(next)) < fontSize * FOOT_GAP_EM) continue;
|
|
1222
|
+
const text = group.map((r) => r.text).join("").trim();
|
|
1223
|
+
if (text.length > 0 && text.length <= FOOT_CHARS * (i + 1)) found = group;
|
|
1224
|
+
}
|
|
1225
|
+
return found ? {
|
|
1226
|
+
y,
|
|
1227
|
+
runs: found
|
|
1228
|
+
} : void 0;
|
|
1229
|
+
}
|
|
1230
|
+
/** How much white, in ems, stands between the text block and a running foot. */
|
|
1231
|
+
var FOOT_GAP_EM = 2;
|
|
1232
|
+
/** How many lines a running foot may hold before it is a paragraph. */
|
|
1233
|
+
var FOOT_ROWS = 3;
|
|
1234
|
+
/** How far up the sheet a running foot may sit. */
|
|
1235
|
+
var FOOT_BAND = .12;
|
|
1236
|
+
/** A running foot is a line, not a paragraph. */
|
|
1237
|
+
var FOOT_CHARS = 90;
|
|
1238
|
+
/**
|
|
1239
|
+
* §17.16.5.35 — the number in a run, made the field it stands for.
|
|
1240
|
+
*
|
|
1241
|
+
* A foot reads "1" or "Chapter 3 — 47", and the number is not the text: it is
|
|
1242
|
+
* the number of the page it is drawn on, which is what lets ONE band serve
|
|
1243
|
+
* every page. The run is cut around it and the middle becomes the field.
|
|
1244
|
+
*
|
|
1245
|
+
* @param run The footer's run.
|
|
1246
|
+
* @returns The run, or the two or three it is cut into.
|
|
1247
|
+
*/
|
|
1248
|
+
function pageNumbered(run) {
|
|
1249
|
+
const found = /(^|\s)(\d{1,4})(\s|$)/u.exec(run.text);
|
|
1250
|
+
if (!found || run.field !== void 0) return [run];
|
|
1251
|
+
const at = found.index + found[1].length;
|
|
1252
|
+
const number = found[2];
|
|
1253
|
+
const before = run.text.slice(0, at);
|
|
1254
|
+
const after = run.text.slice(at + number.length);
|
|
1255
|
+
return [
|
|
1256
|
+
...before === "" ? [] : [{
|
|
1257
|
+
...run,
|
|
1258
|
+
text: before
|
|
1259
|
+
}],
|
|
1260
|
+
{
|
|
1261
|
+
...run,
|
|
1262
|
+
text: number,
|
|
1263
|
+
field: "PAGE"
|
|
1264
|
+
},
|
|
1265
|
+
...after === "" ? [] : [{
|
|
1266
|
+
...run,
|
|
1267
|
+
text: after
|
|
1268
|
+
}]
|
|
1269
|
+
];
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* The band itself: the foot's own line, with the number in it made a field.
|
|
1273
|
+
*
|
|
1274
|
+
* §17.16.5.35 — a page number is not the text "1"; it is the number of the page
|
|
1275
|
+
* it is drawn on, which is why the same band serves every page.
|
|
1276
|
+
*
|
|
1277
|
+
* A foot is written in REGIONS, the way a spreadsheet's is: something at the
|
|
1278
|
+
* left of the sheet, something at the middle, something against the far edge.
|
|
1279
|
+
* ZapfDingbats.pdf signs each page "© RenderX 2000" at the left and "XSL
|
|
1280
|
+
* Formatting Objects Test Suite" at the right, and the two hundred points
|
|
1281
|
+
* between them came back as one word space, the two texts crowding each other
|
|
1282
|
+
* at the left. They stand on TAB STOPS instead (§17.3.1.38), which is what the
|
|
1283
|
+
* two hundred points are.
|
|
1284
|
+
*
|
|
1285
|
+
* @param runs The foot's runs.
|
|
1286
|
+
* @param stepped Whether the page steps between its words.
|
|
1287
|
+
* @param measure The measure it was set across, for its alignment.
|
|
1288
|
+
* @param numbered Whether a number in it is the page's own.
|
|
1289
|
+
*/
|
|
1290
|
+
function footerBand(runs, stepped, measure, numbered) {
|
|
1291
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
1292
|
+
return rowsOf(runs, fontSize).map((row) => bandLine(row, fontSize, stepped, measure)).filter((line) => line !== void 0).map(({ spans, properties }) => {
|
|
1293
|
+
const el = paragraphFromRuns(spans, void 0, properties);
|
|
1294
|
+
if (el.kind !== "paragraph") return el;
|
|
1295
|
+
return {
|
|
1296
|
+
kind: "paragraph",
|
|
1297
|
+
paragraph: {
|
|
1298
|
+
...el.paragraph,
|
|
1299
|
+
runs: numbered ? el.paragraph.runs.flatMap((run) => pageNumbered(run)) : el.paragraph.runs
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* One line of a running head or foot: its spans, and the placement that puts
|
|
1306
|
+
* them where the page had them.
|
|
1307
|
+
*
|
|
1308
|
+
* A wide gap inside such a line is not a word space, it is the space BETWEEN
|
|
1309
|
+
* REGIONS — and each region after the first stands on a tab stop, at the middle
|
|
1310
|
+
* of the band or against its far edge, whichever it was written at.
|
|
1311
|
+
*
|
|
1312
|
+
* @param row The line's runs.
|
|
1313
|
+
* @param fontSize The band's size, for the runs that state none.
|
|
1314
|
+
* @param stepped Whether the page steps between its words.
|
|
1315
|
+
* @param measure The measure the band was set across.
|
|
1316
|
+
* @returns The line, or `undefined` where it holds no text.
|
|
1317
|
+
*/
|
|
1318
|
+
function bandLine(row, fontSize, stepped, measure) {
|
|
1319
|
+
const ordered = [...row].sort((a, b) => a.x - b.x);
|
|
1320
|
+
const pieces = [[]];
|
|
1321
|
+
for (const run of ordered) {
|
|
1322
|
+
const last = pieces[pieces.length - 1];
|
|
1323
|
+
const prev = last[last.length - 1];
|
|
1324
|
+
if (prev && run.x - prev.endX > (run.fontSizePt || fontSize) * BAND_REGION_EM) pieces.push([]);
|
|
1325
|
+
pieces[pieces.length - 1].push(run);
|
|
1326
|
+
}
|
|
1327
|
+
const y = Math.max(...ordered.map((r) => r.y));
|
|
1328
|
+
const lines = pieces.filter((piece) => piece.length > 0).map((piece) => lineOf(piece, y, Math.max(...piece.map((r) => r.fontSizePt || fontSize)), stepped)).filter((line) => line.text.length > 0);
|
|
1329
|
+
if (lines.length === 0) return void 0;
|
|
1330
|
+
if (lines.length === 1 || lines.length > BAND_REGIONS || !measure) {
|
|
1331
|
+
const { alignment } = alignmentOf(lines.slice(0, 1), measure);
|
|
1332
|
+
return {
|
|
1333
|
+
spans: lines.flatMap((line, i) => i === 0 ? line.spans : [{ text: " " }, ...line.spans]),
|
|
1334
|
+
properties: alignment ? { alignment } : {}
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
const width = measure.right - measure.left;
|
|
1338
|
+
const spans = [...lines[0].spans];
|
|
1339
|
+
const tabs = [];
|
|
1340
|
+
for (const line of lines.slice(1)) {
|
|
1341
|
+
const flush = measure.right - (line.x + line.width) <= width * BAND_RIGHT_SHARE;
|
|
1342
|
+
tabs.push({
|
|
1343
|
+
positionPt: pt(0),
|
|
1344
|
+
relativeTo: flush ? "right" : "center",
|
|
1345
|
+
alignment: flush ? "right" : "center"
|
|
1346
|
+
});
|
|
1347
|
+
spans.push({ text: " " }, ...line.spans);
|
|
1348
|
+
}
|
|
1349
|
+
return {
|
|
1350
|
+
spans,
|
|
1351
|
+
properties: { tabs }
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
/** A gap this wide, in ems, stands BETWEEN the regions of a head or foot. */
|
|
1355
|
+
var BAND_REGION_EM = 4;
|
|
1356
|
+
/** How many regions a band line may hold — left, centre, right. */
|
|
1357
|
+
var BAND_REGIONS = 3;
|
|
1358
|
+
/** How near the far edge a region has to end to be set against it. */
|
|
1359
|
+
var BAND_RIGHT_SHARE = .05;
|
|
1360
|
+
/**
|
|
1361
|
+
* Whether the page is RULED into columns rather than SET in them.
|
|
1362
|
+
*
|
|
1363
|
+
* A page of two columns and a page of two columns of a table look alike from
|
|
1364
|
+
* here: both have gutters, and both put their lines on one baseline grid — a
|
|
1365
|
+
* paper's columns are set on the same grid as a matter of course, so "the rows
|
|
1366
|
+
* line up" says nothing. What separates them is the CELL: a line of prose fills
|
|
1367
|
+
* its measure and a cell does not. Across this corpus a paper's lines cover 84
|
|
1368
|
+
* to 95 per cent of their column, and ZapfDingbats.pdf's cells cover 45.
|
|
1369
|
+
*
|
|
1370
|
+
* Read by column, its five hundred entries came back one column at a time with
|
|
1371
|
+
* every row torn into three — "1 a17", "[x2711]", "2 a18" — over five pages.
|
|
1372
|
+
* Read by ROW, which is what a ruled page says, each entry is a line of its own.
|
|
1373
|
+
*
|
|
1374
|
+
* @param runs The page's runs.
|
|
1375
|
+
* @param gutters The page's gutters.
|
|
1376
|
+
* @param edges Where the page's text starts and ends.
|
|
1377
|
+
*/
|
|
1378
|
+
function looksRuled(runs, gutters, edges) {
|
|
1379
|
+
if (gutters.length < MIN_RULED_GUTTERS || !edges) return false;
|
|
1380
|
+
const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
|
|
1381
|
+
if (rows.length < MIN_TABLE_ROWS) return false;
|
|
1382
|
+
const bounds = columnBounds(runs, gutters, edges);
|
|
1383
|
+
const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
|
|
1384
|
+
const regionOf = (x) => {
|
|
1385
|
+
for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
|
|
1386
|
+
return 0;
|
|
1387
|
+
};
|
|
1388
|
+
const fills = [];
|
|
1389
|
+
let aligned = 0;
|
|
1390
|
+
for (const row of rows) {
|
|
1391
|
+
const byRegion = regions.map(() => []);
|
|
1392
|
+
for (const run of row) byRegion[regionOf(run.x)].push(run);
|
|
1393
|
+
byRegion.forEach((inRegion, i) => {
|
|
1394
|
+
if (inRegion.length === 0) return;
|
|
1395
|
+
const from = Math.min(...inRegion.map((r) => r.x));
|
|
1396
|
+
const to = Math.max(...inRegion.map((r) => r.endX));
|
|
1397
|
+
const width = regions[i][1] - regions[i][0];
|
|
1398
|
+
if (width > 0) fills.push((to - from) / width);
|
|
1399
|
+
});
|
|
1400
|
+
if (byRegion.every((inRegion) => inRegion.length > 0)) aligned++;
|
|
1401
|
+
}
|
|
1402
|
+
if (aligned < rows.length * TABLE_ALIGNED_SHARE) return false;
|
|
1403
|
+
fills.sort((a, b) => a - b);
|
|
1404
|
+
return (fills[Math.floor(fills.length / 2)] ?? 1) <= TABLE_CELL_FILL;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* §17.4.38 — the page's rows as the TABLE they are.
|
|
1408
|
+
*
|
|
1409
|
+
* The regions between the gutters are the columns and the rows are the rows;
|
|
1410
|
+
* a cell is what one row leaves in one region, and an empty cell is empty. The
|
|
1411
|
+
* grid is measured, so the columns come out where the page put them.
|
|
1412
|
+
*
|
|
1413
|
+
* The lines the page hangs ABOVE its ruling come back as themselves. A line
|
|
1414
|
+
* that crosses every column is not a row of the table — ZapfDingbats.pdf heads
|
|
1415
|
+
* each sheet with two red lines of provenance that run wider than the frame
|
|
1416
|
+
* drawn under them, and squeezed into a cell they wrapped and cost the sheet a
|
|
1417
|
+
* row.
|
|
1418
|
+
*
|
|
1419
|
+
* @param runs The page's runs.
|
|
1420
|
+
* @param gutters The page's gutters.
|
|
1421
|
+
* @param edges Where the page's text starts and ends.
|
|
1422
|
+
* @param stepped Whether the page steps between its words.
|
|
1423
|
+
* @returns The blocks, the table among them, each with where it stands.
|
|
1424
|
+
*/
|
|
1425
|
+
function tableFrom(runs, gutters, edges, stepped) {
|
|
1426
|
+
const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
|
|
1427
|
+
const all = rowsOf(runs, fontSize).filter((row) => row.length > 0);
|
|
1428
|
+
if (all.length === 0) return void 0;
|
|
1429
|
+
const bounds = columnBounds(runs, gutters, edges);
|
|
1430
|
+
const regionsAt = (from) => {
|
|
1431
|
+
const regions = from.slice(0, -1).map((lo, i) => [lo, from[i + 1]]);
|
|
1432
|
+
return {
|
|
1433
|
+
regions,
|
|
1434
|
+
of: (x) => {
|
|
1435
|
+
for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
|
|
1436
|
+
return 0;
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
};
|
|
1440
|
+
const first = regionsAt(bounds);
|
|
1441
|
+
const oneWideLine = (row) => {
|
|
1442
|
+
const to = spanEnd(row, 0, first.regions.length, first.of);
|
|
1443
|
+
return to > 0 && row.every((run) => first.of(run.x) <= to);
|
|
1444
|
+
};
|
|
1445
|
+
let start = 0;
|
|
1446
|
+
while (start < all.length - 1 && oneWideLine(all[start])) start++;
|
|
1447
|
+
const above = all.slice(0, start);
|
|
1448
|
+
const rows = all.slice(start);
|
|
1449
|
+
const own = rows.filter((row) => spanEnd(row, 0, first.regions.length, first.of) === 0).flatMap((row) => row.filter((run) => first.of(run.x) === 0)).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
|
|
1450
|
+
const left = own.length > 0 ? Math.min(...own) : bounds[0];
|
|
1451
|
+
const { regions, of: regionOf } = regionsAt([left, ...bounds.slice(1)]);
|
|
1452
|
+
const flush = regions.map((region, i) => {
|
|
1453
|
+
const starts = [];
|
|
1454
|
+
for (const row of rows) {
|
|
1455
|
+
const at = row.filter((run) => regionOf(run.x) === i).map((run) => runInk(run)?.[0]).filter((x) => x !== void 0);
|
|
1456
|
+
if (at.length > 0) starts.push(Math.min(...at));
|
|
1457
|
+
}
|
|
1458
|
+
if (starts.length === 0) return region[0];
|
|
1459
|
+
starts.sort((a, b) => a - b);
|
|
1460
|
+
return starts[Math.floor(starts.length * COLUMN_LEFT_QUANTILE)] ?? region[0];
|
|
1461
|
+
});
|
|
1462
|
+
const table = {
|
|
1463
|
+
properties: {
|
|
1464
|
+
defaultCellMargins: {
|
|
1465
|
+
left: pt(0),
|
|
1466
|
+
right: pt(0)
|
|
1467
|
+
},
|
|
1468
|
+
layout: "fixed",
|
|
1469
|
+
...left > edges.left ? { indentPt: pt(left - edges.left) } : {}
|
|
1470
|
+
},
|
|
1471
|
+
grid: regions.map((r) => pt(Math.max(r[1] - r[0], 1))),
|
|
1472
|
+
rows: rows.map((row, r) => {
|
|
1473
|
+
const byRegion = regions.map(() => []);
|
|
1474
|
+
for (const run of row) byRegion[regionOf(run.x)].push(run);
|
|
1475
|
+
const y = Math.max(...row.map((r2) => r2.y));
|
|
1476
|
+
const cells = [];
|
|
1477
|
+
for (let i = 0; i < regions.length;) {
|
|
1478
|
+
const to = spanEnd(row, i, regions.length, regionOf);
|
|
1479
|
+
const inSpan = byRegion.slice(i, to + 1).flat();
|
|
1480
|
+
const width = to - i + 1;
|
|
1481
|
+
const size = inSpan.length > 0 ? Math.max(...inSpan.map((r) => r.fontSizePt || fontSize)) : fontSize;
|
|
1482
|
+
const line = inSpan.length > 0 ? lineOf(inSpan, y, size, stepped) : void 0;
|
|
1483
|
+
const placed = line && line.x - flush[i] >= size / 4 ? alignmentOf([line], {
|
|
1484
|
+
left: regions[i][0],
|
|
1485
|
+
right: regions[to][1]
|
|
1486
|
+
}, size / 2) : {};
|
|
1487
|
+
cells.push({
|
|
1488
|
+
properties: width > 1 ? { colSpan: width } : {},
|
|
1489
|
+
content: line === void 0 ? [{
|
|
1490
|
+
kind: "paragraph",
|
|
1491
|
+
paragraph: {
|
|
1492
|
+
properties: {},
|
|
1493
|
+
runs: []
|
|
1494
|
+
}
|
|
1495
|
+
}] : [paragraphFromRuns(line.spans, void 0, placed.alignment ? { alignment: placed.alignment } : {})]
|
|
1496
|
+
});
|
|
1497
|
+
i = to + 1;
|
|
1498
|
+
}
|
|
1499
|
+
const next = rows[r + 1];
|
|
1500
|
+
const pitch = next ? y - Math.max(...next.map((r2) => r2.y)) : 0;
|
|
1501
|
+
return {
|
|
1502
|
+
properties: pitch > 0 ? {
|
|
1503
|
+
height: pt(pitch),
|
|
1504
|
+
heightRule: "atLeast"
|
|
1505
|
+
} : {},
|
|
1506
|
+
cells
|
|
1507
|
+
};
|
|
1508
|
+
})
|
|
1509
|
+
};
|
|
1510
|
+
return [...above.map((row) => {
|
|
1511
|
+
const y = Math.max(...row.map((r) => r.y));
|
|
1512
|
+
return {
|
|
1513
|
+
el: paragraphFromRuns(lineOf(row, y, Math.max(...row.map((r) => r.fontSizePt || fontSize)), stepped).spans),
|
|
1514
|
+
top: y
|
|
1515
|
+
};
|
|
1516
|
+
}), {
|
|
1517
|
+
el: {
|
|
1518
|
+
kind: "table",
|
|
1519
|
+
table
|
|
1520
|
+
},
|
|
1521
|
+
top: Math.max(...rows[0].map((r) => r.y))
|
|
1522
|
+
}];
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Where one column of a ruled page ends and the next begins.
|
|
1526
|
+
*
|
|
1527
|
+
* The gutter is a BAND, and its middle is only a guess at the line inside it: a
|
|
1528
|
+
* gutter is where the fewest lines cross, not where none do (see
|
|
1529
|
+
* `detectGutters`), so a page whose prose overhangs the first column by a few
|
|
1530
|
+
* points has that prose crossing the middle. Cut there, ZapfDingbats.pdf's
|
|
1531
|
+
* heading and its lead paragraph either wrapped inside a cell too narrow for
|
|
1532
|
+
* them or swallowed the glyph standing beside them. The boundary is put past
|
|
1533
|
+
* the crossing ink instead, as far as the band allows — where the columns
|
|
1534
|
+
* really do divide.
|
|
1535
|
+
*
|
|
1536
|
+
* @param runs The page's runs.
|
|
1537
|
+
* @param gutters The page's gutters.
|
|
1538
|
+
* @param edges Where the page's text starts and ends.
|
|
1539
|
+
* @returns The column boundaries, left edge first and right edge last.
|
|
1540
|
+
*/
|
|
1541
|
+
function columnBounds(runs, gutters, edges) {
|
|
1542
|
+
const inks = runs.map((r) => runInk(r)).filter((ink) => ink !== void 0);
|
|
1543
|
+
const clearance = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 4;
|
|
1544
|
+
return [
|
|
1545
|
+
edges.left,
|
|
1546
|
+
...gutters.map((g) => {
|
|
1547
|
+
let at = g.mid;
|
|
1548
|
+
for (const [from, to] of inks) if (from < g.mid && to > at && to <= g.to) at = Math.min(to + clearance, g.to);
|
|
1549
|
+
return at;
|
|
1550
|
+
}),
|
|
1551
|
+
edges.right
|
|
1552
|
+
];
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* The last column a cell starting at `from` covers — the one where no run
|
|
1556
|
+
* reaches any further right.
|
|
1557
|
+
*
|
|
1558
|
+
* A run whose INK ends past a column boundary was drawn as one line across
|
|
1559
|
+
* both, so both belong to one cell; and a cell widened that way may pick up a
|
|
1560
|
+
* run that crosses the next boundary in turn.
|
|
1561
|
+
*
|
|
1562
|
+
* @param row The row's runs.
|
|
1563
|
+
* @param from The column the cell starts in.
|
|
1564
|
+
* @param count How many columns the table has.
|
|
1565
|
+
* @param regionOf Which column an x falls in.
|
|
1566
|
+
*/
|
|
1567
|
+
function spanEnd(row, from, count, regionOf) {
|
|
1568
|
+
let to = from;
|
|
1569
|
+
for (let grew = true; grew && to < count - 1;) {
|
|
1570
|
+
grew = false;
|
|
1571
|
+
for (const run of row) {
|
|
1572
|
+
const ink = runInk(run);
|
|
1573
|
+
if (ink === void 0) continue;
|
|
1574
|
+
const start = regionOf(ink[0]);
|
|
1575
|
+
if (start < from || start > to) continue;
|
|
1576
|
+
const end = regionOf(ink[1] - 1);
|
|
1577
|
+
if (end > to) {
|
|
1578
|
+
to = Math.min(end, count - 1);
|
|
1579
|
+
grew = true;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return to;
|
|
1584
|
+
}
|
|
1585
|
+
/** Below this many gutters a page in columns is read as columns. */
|
|
1586
|
+
var MIN_RULED_GUTTERS = 2;
|
|
1587
|
+
/** Below this many rows a page is not ruled into anything. */
|
|
1588
|
+
var MIN_TABLE_ROWS = 6;
|
|
1589
|
+
/** How many of a table's rows must carry a cell in every one of its columns. */
|
|
1590
|
+
var TABLE_ALIGNED_SHARE = .6;
|
|
1591
|
+
/**
|
|
1592
|
+
* How much of its column a CELL covers, at the median. A line of prose fills
|
|
1593
|
+
* its measure — 84 to 95 per cent across this corpus — and a cell does not.
|
|
1594
|
+
*/
|
|
1595
|
+
var TABLE_CELL_FILL = .65;
|
|
344
1596
|
//#endregion
|
|
345
|
-
export { reconstructByLayout };
|
|
1597
|
+
export { endedParagraph, reconstructByLayout };
|