Repository navigation
Expand file tree
/
Copy pathtextlayer.ts
More file actions
237 lines (217 loc) · 9.26 KB
/
Copy pathtextlayer.ts
File metadata and controls
237 lines (217 loc) · 9.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
/**
* Read a page out of the PDF's own text layer, for the pages the model did not
* read at all.
*
* This is a fallback, never a preference. The model is what produces structure —
* headings, tables, formulas, reading order across columns — and the text layer
* has none of that: it is a bag of positioned strings, and reconstructing
* paragraphs from it is guesswork that gets two-column layouts wrong often
* enough that it would be a downgrade almost everywhere.
*
* Almost. A title page whose lower half is one large figure can come back from
* the model as nothing but `<picture>` blocks — the title, the authors and the
* whole abstract simply absent, with no truncation to warn about because
* generation ended normally. That page's text sits complete and in reading order
* in the text layer we already extract for every page and have never once read.
* Recovering it imperfectly is strictly better than the alternative, which is
* that the abstract is not in the note.
*
* So the caller applies this only where the model produced no prose at all, and
* says in the note where the text came from.
*/
import type { BBox, TextToken } from "./types.js";
/**
* Horizontal gap that means a word boundary, as a fraction of line height.
*
* PDF text is emitted in runs, and where a run breaks is a typesetting detail,
* not a linguistic one: small caps split "KIMI" into "K" and "IMI" with no gap
* at all, while ordinary inter-word space is a visible fraction of the font
* size. Measuring the gap is the only way to tell those apart — joining every
* run with a space yields "K IMI K3", and joining with nothing yields
* "KIMIK3:OPENFRONTIER".
*/
const WORD_GAP = 0.22;
/** Vertical movement that starts a new line, as a fraction of line height. */
const LINE_BREAK = 0.55;
/** Vertical gap between lines that starts a new paragraph, ditto. */
const PARAGRAPH_GAP = 0.75;
/**
* How far a line must be inset from a block, in line heights, before its being
* centred over that block is worth asking about.
*
* One-and-a-half ems clears a first-line indent and the ragged edge of
* unjustified text. It is only a floor — on its own it is nowhere near enough,
* see `detached`.
*/
const BLOCK_INSET = 1.5;
/**
* How unequal the two insets may be and still read as centred, as a fraction of
* the larger one.
*
* This is the load-bearing half of the test. Measured over 75 pages of two
* papers, an inset floor alone re-broke 56 of them: a hanging-indented
* bibliography entry is inset on the left by its indent and on the right by
* wherever the citation happens to stop, and that satisfies any floor you pick.
* What it is not is symmetric — 0.024 against 0.224 of the page — where a
* centred label is symmetric by construction, 0.277 against 0.277.
*/
const CENTRE_TOLERANCE = 0.4;
/**
* How much taller or shorter than the block a line must be set, as a ratio, on
* top of being centred over it.
*
* Centring alone still cannot tell a label from the second line of a centred
* *title*, which is shorter than the first and therefore symmetric about it —
* on the second paper in the sample it split the title in two. A label is set
* differently from what it labels; a title's own lines are set alike. Ten
* percent is below any real step in a document's type scale (`ABSTRACT` here is
* 20% up on the body) and above the wobble that an inline superscript adds to a
* line's measured height.
*/
const SIZE_STEP = 1.1;
/** How much text a page must have before it is worth calling this a rescue. */
export const MIN_RESCUE_CHARS = 200;
interface Line {
text: string;
top: number;
bottom: number;
height: number;
left: number;
right: number;
}
/**
* Tokens (in the order pdf.js produced them) to paragraphs.
*
* Content-stream order is used as reading order rather than re-sorting by
* position. For the PDFs this rescues — LaTeX output — the two agree, and where
* they disagree the content stream is more often right than a sort that has to
* guess at columns.
*/
export function textLayerParagraphs(tokens: TextToken[], exclude: BBox[] = []): string {
const lines = groupIntoLines(exclude.length ? tokens.filter((t) => !inside(t, exclude)) : tokens);
if (!lines.length) return "";
const paragraphs: string[] = [];
let current: string[] = [];
let previous: Line | null = null;
for (const line of lines) {
const gap = previous ? line.top - previous.bottom : 0;
const broke =
previous !== null &&
(gap > PARAGRAPH_GAP * line.height || detached(previous, line) || detached(line, previous));
if (broke && current.length) {
paragraphs.push(current.join(" "));
current = [];
}
current.push(line.text);
previous = line;
}
if (current.length) paragraphs.push(current.join(" "));
return paragraphs
.map((p) => p.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n\n");
}
/**
* Does `line` stand apart from the block `block` belongs to, rather than being
* one of its lines?
*
* Vertical spacing alone cannot see a centred section label. A paper's
* `ABSTRACT` sits *closer* to the paragraph beneath it than a paragraph break
* would be — measured on the page this was written for, 0.0081 of the page
* against a 0.00945 threshold — so the gap rule keeps them together and the
* label is glued onto the first sentence: "ABSTRACT We introduce Kimi K3, a
* 2.8T parameter…". The break is visibly there on the page; it is just not in
* the leading.
*
* What distinguishes the two is horizontal, and specifically that the label is
* *centred*: inset from the block on both sides, by the same amount. A line of
* a block is not. Ordinary prose agrees on its margins to within a rounding
* error; a justified paragraph's last line still starts flush left; a
* first-line indent moves one edge by about an em and leaves the other alone;
* and a hanging-indented bibliography entry, which is inset on both sides and
* would fool a test that only asked for that, is inset by wildly different
* amounts. Symmetry is the part that separates a label from all of them.
*
* Applied in both directions by the caller, so a centred line separates from
* what is above it as well as from what is below. The comparison is against the
* adjacent line rather than a page-level margin, which means a label whose
* neighbour is itself short can go unnoticed; that costs a break we would have
* liked, never one we would not.
*
* This reads a break that is on the page; it does not infer a heading from one.
* The rescued text still has no headings, which is what the note tells the
* reader it has.
*/
function detached(line: Line, block: Line): boolean {
const em = Math.max(line.height, block.height);
const leftInset = line.left - block.left;
const rightInset = block.right - line.right;
if (leftInset <= BLOCK_INSET * em || rightInset <= BLOCK_INSET * em) return false;
const larger = Math.max(leftInset, rightInset);
if (Math.abs(leftInset - rightInset) > CENTRE_TOLERANCE * larger) return false;
return em / Math.min(line.height, block.height) >= SIZE_STEP;
}
/**
* Is this token inside something the model already showed the reader?
*
* The pictures are the *only* thing a rescued page yields, and they are worth
* reading as an instruction: the model put a box around that region and the note
* carries a crop of it. A chart's axis labels and series names are text in the
* layer and noise in a paragraph, so the region the crop covers is skipped. The
* caller passes the boxes it already cropped, so this can never disagree with
* what the note shows.
*/
function inside(token: TextToken, boxes: BBox[]): boolean {
const x = (token.bbox.l + token.bbox.r) / 2;
const y = (token.bbox.t + token.bbox.b) / 2;
return boxes.some((b) => x >= b.l && x <= b.r && y >= b.t && y <= b.b);
}
function groupIntoLines(tokens: TextToken[]): Line[] {
const lines: Line[] = [];
let text = "";
let top = 0;
let bottom = 0;
let height = 0;
// `right` doubles as the word-gap cursor below, so it tracks the rightmost
// edge seen so far either way; `left` exists only for the line's extent.
let left = 0;
let right = 0;
let open = false;
const flush = (): void => {
const t = text.replace(/\s+/g, " ").trim();
if (t) lines.push({ text: t, top, bottom, height: height || 0.01, left, right });
open = false;
text = "";
};
for (const token of tokens) {
if (!token.str || !token.str.trim()) continue;
const { l, t, r, b } = token.bbox;
const h = Math.max(b - t, 0.001);
const centre = (t + b) / 2;
if (open) {
const sameLine = Math.abs(centre - (top + bottom) / 2) <= LINE_BREAK * Math.max(h, height);
if (sameLine) {
// Hyphenation is left alone: whether a trailing "-" is a line break or
// part of the word is not decidable here, and joining wrongly damages
// text that is otherwise correct.
text += r > right && l - right > WORD_GAP * h ? ` ${token.str}` : token.str;
top = Math.min(top, t);
bottom = Math.max(bottom, b);
height = Math.max(height, h);
left = Math.min(left, l);
right = Math.max(right, r);
continue;
}
flush();
}
open = true;
text = token.str;
top = t;
bottom = b;
height = h;
left = l;
right = r;
}
if (open) flush();
return lines;
}