@doccloak/core 0.12.1 → 0.13.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/CHANGELOG.md +333 -0
- package/NOTICE +5 -0
- package/README.md +131 -9
- package/dist/dom/errors.d.ts +1 -1
- package/dist/dom/ocr.d.ts +33 -7
- package/dist/dom/ocr.js +98 -4
- package/dist/dom/office.d.ts +20 -6
- package/dist/dom/office.js +44 -11
- package/dist/engine.js +4 -5
- package/dist/env.d.ts +3 -3
- package/dist/pdf/cmap.d.ts +83 -0
- package/dist/pdf/cmap.js +659 -0
- package/dist/pdf/encodings.d.ts +49 -0
- package/dist/pdf/encodings.js +181 -0
- package/dist/pdf/extract.d.ts +106 -0
- package/dist/pdf/extract.js +1064 -0
- package/dist/pdf/fallback-fonts.d.ts +146 -0
- package/dist/pdf/fallback-fonts.js +312 -0
- package/dist/pdf/fit.d.ts +35 -0
- package/dist/pdf/fit.js +60 -0
- package/dist/pdf/fonts.d.ts +82 -0
- package/dist/pdf/fonts.js +465 -0
- package/dist/pdf/glyphlist.d.ts +37 -0
- package/dist/pdf/glyphlist.js +485 -0
- package/dist/pdf/index.d.ts +21 -0
- package/dist/pdf/index.js +7 -0
- package/dist/pdf/lexer.d.ts +61 -0
- package/dist/pdf/lexer.js +554 -0
- package/dist/pdf/objects.d.ts +57 -0
- package/dist/pdf/objects.js +217 -0
- package/dist/pdf/oracle.d.ts +149 -0
- package/dist/pdf/oracle.js +384 -0
- package/dist/pdf/raster.d.ts +45 -0
- package/dist/pdf/raster.js +89 -0
- package/dist/pdf/read.d.ts +36 -0
- package/dist/pdf/read.js +65 -0
- package/dist/pdf/serializer.d.ts +21 -0
- package/dist/pdf/serializer.js +106 -0
- package/dist/pdf/standard-metrics.d.ts +27 -0
- package/dist/pdf/standard-metrics.js +72 -0
- package/dist/pdf/surgery.d.ts +131 -0
- package/dist/pdf/surgery.js +847 -0
- package/dist/pdf/textstate.d.ts +77 -0
- package/dist/pdf/textstate.js +280 -0
- package/dist/pdf/types.d.ts +191 -0
- package/dist/pdf/types.js +11 -0
- package/dist/pdf/verify.d.ts +33 -0
- package/dist/pdf/verify.js +446 -0
- package/dist/pdf/write.d.ts +52 -0
- package/dist/pdf/write.js +644 -0
- package/dist/pipeline.d.ts +15 -0
- package/dist/pipeline.js +123 -8
- package/dist/providers/bardsai.d.ts +1 -1
- package/dist/providers/bardsai.js +5 -1
- package/dist/providers/gliner-base.d.ts +1 -1
- package/dist/providers/gliner-base.js +5 -1
- package/dist/providers/gliner.d.ts +1 -1
- package/dist/providers/gliner.js +5 -1
- package/dist/regex/rules.data.js +13 -8
- package/dist/types.d.ts +17 -2
- package/dist/types.js +17 -0
- package/dist/worker-protocol.d.ts +11 -0
- package/dist/worker-protocol.js +41 -8
- package/fonts/liberation/LICENSE +102 -0
- package/fonts/liberation/LiberationMono-Bold.ttf +0 -0
- package/fonts/liberation/LiberationMono-BoldItalic.ttf +0 -0
- package/fonts/liberation/LiberationMono-Italic.ttf +0 -0
- package/fonts/liberation/LiberationMono-Regular.ttf +0 -0
- package/fonts/liberation/LiberationSans-Bold.ttf +0 -0
- package/fonts/liberation/LiberationSans-BoldItalic.ttf +0 -0
- package/fonts/liberation/LiberationSans-Italic.ttf +0 -0
- package/fonts/liberation/LiberationSans-Regular.ttf +0 -0
- package/fonts/liberation/LiberationSerif-Bold.ttf +0 -0
- package/fonts/liberation/LiberationSerif-BoldItalic.ttf +0 -0
- package/fonts/liberation/LiberationSerif-Italic.ttf +0 -0
- package/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
- package/package.json +11 -2
- package/rules/pl.json +7 -3
- package/rules/universal.json +6 -5
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - content-stream surgery (T213).
|
|
3
|
+
*
|
|
4
|
+
* Turns flat-text replacements into operator-level edits and rewrites the
|
|
5
|
+
* affected content streams:
|
|
6
|
+
*
|
|
7
|
+
* - the glyphs of a redacted span are cut out of their text-showing operator
|
|
8
|
+
* (string operands are split at code boundaries; kerning numbers between
|
|
9
|
+
* kept glyphs are preserved, numbers adjacent to removed glyphs are dropped);
|
|
10
|
+
* - the placeholder is written as a new show in the original font when that
|
|
11
|
+
* font can encode it, else in the matched fallback face, sized by fit.ts;
|
|
12
|
+
* - every show after an edited one in the same positioning scope is
|
|
13
|
+
* re-anchored with an absolute Tm computed from the ORIGINAL geometry, so
|
|
14
|
+
* the implicit advance chain cannot shift (and the residual positions of
|
|
15
|
+
* surviving glyphs cannot encode what was removed);
|
|
16
|
+
* - when the placeholder is wider than the gap even after shrinking, the rest
|
|
17
|
+
* of the line (in the same stream) moves right by the overflow;
|
|
18
|
+
* - the line matrix is restored before the next relative positioning
|
|
19
|
+
* operator, so later lines land exactly where they did.
|
|
20
|
+
*
|
|
21
|
+
* Operators that are not touched are copied byte-for-byte.
|
|
22
|
+
*/
|
|
23
|
+
import { TEXT_POSITION_OPS, TEXT_SHOW_OPS, array, hexString, makeOp, name, num } from "./lexer.js";
|
|
24
|
+
import { serializeOps } from "./serializer.js";
|
|
25
|
+
import { StateMachine, initialState, mul, translate } from "./textstate.js";
|
|
26
|
+
import { loadFont } from "./fonts.js";
|
|
27
|
+
import { getDict } from "./objects.js";
|
|
28
|
+
import { fitPlaceholder } from "./fit.js";
|
|
29
|
+
/** Key of a run inside a plan: stream key + operator index. */
|
|
30
|
+
export function runKey(run) {
|
|
31
|
+
return `${run.streamKey}#${run.opIndex}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Adjacent text on the same line as a gap: within this many em of the previous run's end it still
|
|
35
|
+
* moves; a tab stop or table column sits further away and never moves. (A line that STARTS with
|
|
36
|
+
* the tail of a wrapped value is prose, and closes up as a whole: see planEdits.)
|
|
37
|
+
*/
|
|
38
|
+
const ADJACENCY_EM = 0.75;
|
|
39
|
+
/** On a line that starts with the tail of a wrapped value, justified word gaps still chain; a tab or a far column does not. */
|
|
40
|
+
const CONTINUATION_ADJACENCY_EM = 3;
|
|
41
|
+
/** Kept between a pushed run and the next column to its right (em). */
|
|
42
|
+
const COLUMN_MARGIN_EM = 0.25;
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
/* 1. Replacements -> segments */
|
|
45
|
+
/* ------------------------------------------------------------------ */
|
|
46
|
+
/** Glyph text range in the flat text: [offset, offset + unicode.length). */
|
|
47
|
+
function glyphRange(run, i) {
|
|
48
|
+
const off = run.textOffsets[i];
|
|
49
|
+
return [off, off + run.glyphs[i].unicode.length];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Map flat-text replacements to per-run glyph segments. Runs must be in
|
|
53
|
+
* flat-text order (as the extractor produced them). Oracle runs (raster-only
|
|
54
|
+
* pages) are returned as segments too; the caller routes them to the
|
|
55
|
+
* rasterizer.
|
|
56
|
+
*/
|
|
57
|
+
export function segmentsFor(runs, replacements) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const sorted = [...replacements].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
60
|
+
let runIdx = 0;
|
|
61
|
+
sorted.forEach((r, ri) => {
|
|
62
|
+
// Runs are ordered by textStart; skip runs that end before this replacement.
|
|
63
|
+
while (runIdx < runs.length && runs[runIdx].textEnd <= r.start)
|
|
64
|
+
runIdx++;
|
|
65
|
+
for (let k = runIdx; k < runs.length && runs[k].textStart < r.end; k++) {
|
|
66
|
+
const run = runs[k];
|
|
67
|
+
let from = -1;
|
|
68
|
+
let to = -1;
|
|
69
|
+
for (let i = 0; i < run.glyphs.length; i++) {
|
|
70
|
+
const [a, b] = glyphRange(run, i);
|
|
71
|
+
const covered = b > a ? a < r.end && b > r.start : (from >= 0 && a >= r.start && a < r.end);
|
|
72
|
+
if (covered) {
|
|
73
|
+
if (from < 0)
|
|
74
|
+
from = i;
|
|
75
|
+
to = i + 1;
|
|
76
|
+
}
|
|
77
|
+
else if (from >= 0 && b > a && a >= r.end) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (from >= 0)
|
|
82
|
+
out.push({ run, from, to, replacementIndex: ri });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/* ------------------------------------------------------------------ */
|
|
88
|
+
/* 2. Geometry helpers */
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/** User units per unscaled text-space unit along the baseline of a run. */
|
|
91
|
+
export function unitScale(run) {
|
|
92
|
+
const em = Math.hypot(run.emX[0], run.emX[1]);
|
|
93
|
+
const denom = run.local.fontSize * run.local.hscale;
|
|
94
|
+
return denom !== 0 ? em / denom : 1;
|
|
95
|
+
}
|
|
96
|
+
/** Position of a glyph origin along/across the baseline of a reference run, in user units. */
|
|
97
|
+
/** Origin-independent projection of (x, y) on the baseline direction of `ref`, in user units. */
|
|
98
|
+
function alongAbs(ref, x, y) {
|
|
99
|
+
const ex = ref.emX[0];
|
|
100
|
+
const ey = ref.emX[1];
|
|
101
|
+
const len = Math.hypot(ex, ey) || 1;
|
|
102
|
+
return (x * ex + y * ey) / len;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* T225: the farthest text end on the page along the baseline direction of `ref` (absolute
|
|
106
|
+
* projection): a proxy for the right margin. Text after a placeholder may move up to here, so a
|
|
107
|
+
* left-aligned line with room keeps the placeholder at full size while a line that already
|
|
108
|
+
* reaches the margin (right-aligned, justified) still shrinks. Cached per direction.
|
|
109
|
+
*/
|
|
110
|
+
function pageTextEdge(page, ref) {
|
|
111
|
+
const refLen = Math.hypot(ref.emX[0], ref.emX[1]) || 1;
|
|
112
|
+
const key = `${Math.round((ref.emX[0] / refLen) * 1000)},${Math.round((ref.emX[1] / refLen) * 1000)}`;
|
|
113
|
+
const cached = page.edges.get(key);
|
|
114
|
+
if (cached !== undefined)
|
|
115
|
+
return cached;
|
|
116
|
+
let edge = -Infinity;
|
|
117
|
+
let minAcross = Infinity;
|
|
118
|
+
let maxAcross = -Infinity;
|
|
119
|
+
for (const r of page.all) {
|
|
120
|
+
const len = Math.hypot(r.emX[0], r.emX[1]) || 1;
|
|
121
|
+
const cos = (r.emX[0] * ref.emX[0] + r.emX[1] * ref.emX[1]) / (len * refLen);
|
|
122
|
+
if (cos < 0.996)
|
|
123
|
+
continue;
|
|
124
|
+
const [ex, ey] = glyphEnd(r, r.glyphs.length - 1);
|
|
125
|
+
edge = Math.max(edge, alongAbs(ref, ex, ey));
|
|
126
|
+
const g = r.glyphs[0];
|
|
127
|
+
const across = (g.x * -ref.emX[1] + g.y * ref.emX[0]) / refLen;
|
|
128
|
+
minAcross = Math.min(minAcross, across);
|
|
129
|
+
maxAcross = Math.max(maxAcross, across);
|
|
130
|
+
}
|
|
131
|
+
page.edges.set(key, edge);
|
|
132
|
+
const size = Math.hypot(ref.emY[0], ref.emY[1]) || ref.glyphs[0]?.fontSize || 1;
|
|
133
|
+
page.edges.set(`${key}|lines`, maxAcross - minAcross > 0.45 * Math.max(size, 1) ? 2 : 1);
|
|
134
|
+
return edge;
|
|
135
|
+
}
|
|
136
|
+
/** True when the page has text on more than one line in the direction of `ref` (see pageTextEdge). */
|
|
137
|
+
function hasOtherLines(page, ref) {
|
|
138
|
+
pageTextEdge(page, ref);
|
|
139
|
+
const refLen = Math.hypot(ref.emX[0], ref.emX[1]) || 1;
|
|
140
|
+
const key = `${Math.round((ref.emX[0] / refLen) * 1000)},${Math.round((ref.emX[1] / refLen) * 1000)}|lines`;
|
|
141
|
+
return (page.edges.get(key) ?? 1) > 1;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Absolute projection of the end of the line through (refX, refY), every shift already planned
|
|
145
|
+
* on it included: whole-run shifts of its last run and, when that run holds earlier gaps of this
|
|
146
|
+
* plan (one Tj per line, several values on it), the tail shifts recorded on those segments.
|
|
147
|
+
* -Infinity when the line has no run.
|
|
148
|
+
*/
|
|
149
|
+
function lineEndAbs(page, ref, refX, refY, tol, shiftedRuns, segments) {
|
|
150
|
+
let lineEnd = -Infinity;
|
|
151
|
+
let last = null;
|
|
152
|
+
for (const r of runsOnLine(page, ref, refX, refY, tol)) {
|
|
153
|
+
if (r.endAlong > lineEnd) {
|
|
154
|
+
lineEnd = r.endAlong;
|
|
155
|
+
last = r.run;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (last === null)
|
|
159
|
+
return -Infinity;
|
|
160
|
+
let shift = shiftedRuns.get(runKey(last)) ?? 0;
|
|
161
|
+
for (const s of segments)
|
|
162
|
+
if (s.run === last && s.shiftAfter)
|
|
163
|
+
shift += s.shiftAfter * unitScale(last);
|
|
164
|
+
return alongAbs(ref, refX, refY) + lineEnd + shift;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* T225: user units the text at the end of the line through (refX, refY) can still move right
|
|
168
|
+
* before it passes the page's text edge (the farthest line end on the page).
|
|
169
|
+
*/
|
|
170
|
+
function roomToEdge(page, ref, refX, refY, tol, shiftedRuns, segments) {
|
|
171
|
+
const edge = pageTextEdge(page, ref);
|
|
172
|
+
const end = lineEndAbs(page, ref, refX, refY, tol, shiftedRuns, segments);
|
|
173
|
+
if (!Number.isFinite(edge) || !Number.isFinite(end))
|
|
174
|
+
return 0;
|
|
175
|
+
const size = Math.hypot(ref.emY[0], ref.emY[1]) || ref.glyphs[0]?.fontSize || 1;
|
|
176
|
+
return edge - end - COLUMN_MARGIN_EM * Math.max(size, 1);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* User units the line can still move right before it leaves the page (horizontal runs in page
|
|
180
|
+
* space only; the MediaBox is taken to start at x = 0). null when unknown (forms, rotated text).
|
|
181
|
+
*/
|
|
182
|
+
function roomToPageEdge(model, page, ref, refX, refY, tol, shiftedRuns, segments) {
|
|
183
|
+
if (!isHorizontal(ref) || !ref.streamKey.startsWith('p'))
|
|
184
|
+
return null;
|
|
185
|
+
const width = model.pages[ref.page]?.info.width;
|
|
186
|
+
if (!(width > 0))
|
|
187
|
+
return null;
|
|
188
|
+
const end = lineEndAbs(page, ref, refX, refY, tol, shiftedRuns, segments);
|
|
189
|
+
if (!Number.isFinite(end))
|
|
190
|
+
return null;
|
|
191
|
+
const size = Math.hypot(ref.emY[0], ref.emY[1]) || ref.glyphs[0]?.fontSize || 1;
|
|
192
|
+
return width - end - COLUMN_MARGIN_EM * Math.max(size, 1);
|
|
193
|
+
}
|
|
194
|
+
function projectOnto(ref, refX, refY, x, y) {
|
|
195
|
+
const ex = ref.emX[0];
|
|
196
|
+
const ey = ref.emX[1];
|
|
197
|
+
const len = Math.hypot(ex, ey) || 1;
|
|
198
|
+
const dx = x - refX;
|
|
199
|
+
const dy = y - refY;
|
|
200
|
+
return { along: (dx * ex + dy * ey) / len, across: (dx * -ey + dy * ex) / len };
|
|
201
|
+
}
|
|
202
|
+
function glyphEnd(run, i) {
|
|
203
|
+
const g = run.glyphs[i];
|
|
204
|
+
const scale = unitScale(run);
|
|
205
|
+
const ex = run.emX[0];
|
|
206
|
+
const ey = run.emX[1];
|
|
207
|
+
const len = Math.hypot(ex, ey) || 1;
|
|
208
|
+
return [g.x + (ex / len) * g.advance * scale, g.y + (ey / len) * g.advance * scale];
|
|
209
|
+
}
|
|
210
|
+
const BUCKET = 4;
|
|
211
|
+
function isHorizontal(run) {
|
|
212
|
+
return Math.abs(run.emX[1]) <= 1e-6 * Math.max(1, Math.abs(run.emX[0])) && run.emX[0] > 0;
|
|
213
|
+
}
|
|
214
|
+
function runsByPage(runs) {
|
|
215
|
+
const out = new Map();
|
|
216
|
+
for (const r of runs) {
|
|
217
|
+
if (r.oracle || r.glyphs.length === 0)
|
|
218
|
+
continue;
|
|
219
|
+
const key = spaceKey(r);
|
|
220
|
+
let page = out.get(key);
|
|
221
|
+
if (!page) {
|
|
222
|
+
page = { all: [], byBaseline: new Map(), rotated: [], edges: new Map() };
|
|
223
|
+
out.set(key, page);
|
|
224
|
+
}
|
|
225
|
+
page.all.push(r);
|
|
226
|
+
if (isHorizontal(r)) {
|
|
227
|
+
const b = Math.round(r.glyphs[0].y / BUCKET);
|
|
228
|
+
const list = page.byBaseline.get(b) ?? [];
|
|
229
|
+
list.push(r);
|
|
230
|
+
page.byBaseline.set(b, list);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
page.rotated.push(r);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
/** Candidate runs for a line through (refX, refY) with tolerance `tol`: the baseline buckets around refY plus every rotated run. */
|
|
239
|
+
function candidatesOnLine(page, ref, refY, tol) {
|
|
240
|
+
if (!isHorizontal(ref))
|
|
241
|
+
return page.all;
|
|
242
|
+
const lo = Math.round((refY - tol) / BUCKET) - 1;
|
|
243
|
+
const hi = Math.round((refY + tol) / BUCKET) + 1;
|
|
244
|
+
const out = [];
|
|
245
|
+
for (let b = lo; b <= hi; b++) {
|
|
246
|
+
const list = page.byBaseline.get(b);
|
|
247
|
+
if (list)
|
|
248
|
+
for (const r of list)
|
|
249
|
+
out.push(r);
|
|
250
|
+
}
|
|
251
|
+
for (const r of page.rotated)
|
|
252
|
+
out.push(r);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
const EMPTY_PAGE = { all: [], byBaseline: new Map(), rotated: [], edges: new Map() };
|
|
256
|
+
/** Runs share lines only within one page and one coordinate space (page, pattern cell, mask group). */
|
|
257
|
+
function spaceKey(run) {
|
|
258
|
+
return `${run.page}|${run.space ?? ''}`;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Runs that lie on the same line as the reference (same baseline direction, first glyph and run
|
|
262
|
+
* end within the tolerance of the baseline), ordered along it. A rotated line crossing horizontal
|
|
263
|
+
* text picks up nothing: a run merely touching the line at one point is not on it.
|
|
264
|
+
*/
|
|
265
|
+
function runsOnLine(pageRuns, ref, refX, refY, tol) {
|
|
266
|
+
const out = [];
|
|
267
|
+
const refLen = Math.hypot(ref.emX[0], ref.emX[1]) || 1;
|
|
268
|
+
for (const r of candidatesOnLine(pageRuns, ref, refY, tol)) {
|
|
269
|
+
const len = Math.hypot(r.emX[0], r.emX[1]) || 1;
|
|
270
|
+
const cos = (r.emX[0] * ref.emX[0] + r.emX[1] * ref.emX[1]) / (len * refLen);
|
|
271
|
+
if (cos < 0.996)
|
|
272
|
+
continue; // baselines differ by more than about 5 degrees
|
|
273
|
+
const g = r.glyphs[0];
|
|
274
|
+
const p = projectOnto(ref, refX, refY, g.x, g.y);
|
|
275
|
+
if (Math.abs(p.across) > tol)
|
|
276
|
+
continue;
|
|
277
|
+
const [ex, ey] = glyphEnd(r, r.glyphs.length - 1);
|
|
278
|
+
const pe = projectOnto(ref, refX, refY, ex, ey);
|
|
279
|
+
if (Math.abs(pe.across) > tol)
|
|
280
|
+
continue;
|
|
281
|
+
out.push({ run: r, startAlong: p.along, endAlong: pe.along });
|
|
282
|
+
}
|
|
283
|
+
out.sort((a, b) => a.startAlong - b.startAlong);
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The segment that should carry the placeholder: the first segment of the line group whose gap
|
|
288
|
+
* (first covered glyph to the end of the last covered glyph on that line) is the widest. Ties and
|
|
289
|
+
* anything unusual (many segments, raster runs) keep the first segment.
|
|
290
|
+
*/
|
|
291
|
+
function widestLineLead(segs) {
|
|
292
|
+
// Chrome shows one glyph per Tj, so a wrapped value can easily be a few dozen segments.
|
|
293
|
+
if (segs.length <= 1 || segs.length > 64 || segs.some((s) => s.run.oracle))
|
|
294
|
+
return segs[0];
|
|
295
|
+
let best = segs[0];
|
|
296
|
+
let bestWidth = -Infinity;
|
|
297
|
+
for (const lead of segs) {
|
|
298
|
+
const run = lead.run;
|
|
299
|
+
const g0 = run.glyphs[lead.from];
|
|
300
|
+
const tol = 0.45 * Math.max(g0.fontSize, 1);
|
|
301
|
+
let width = 0;
|
|
302
|
+
for (const s of segs) {
|
|
303
|
+
if (s.run.page !== run.page || spaceKey(s.run) !== spaceKey(run))
|
|
304
|
+
continue;
|
|
305
|
+
const gs = s.run.glyphs[s.from];
|
|
306
|
+
if (Math.abs(projectOnto(run, g0.x, g0.y, gs.x, gs.y).across) > tol)
|
|
307
|
+
continue;
|
|
308
|
+
const [ex, ey] = glyphEnd(s.run, s.to - 1);
|
|
309
|
+
const pe = projectOnto(run, g0.x, g0.y, ex, ey);
|
|
310
|
+
if (Math.abs(pe.across) <= tol)
|
|
311
|
+
width = Math.max(width, pe.along);
|
|
312
|
+
}
|
|
313
|
+
// Strictly wider wins, so equal widths keep flat-text order.
|
|
314
|
+
if (width > bestWidth + 0.5) {
|
|
315
|
+
best = lead;
|
|
316
|
+
bestWidth = width;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return best;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Decide, for every replacement, where the placeholder goes and how it is
|
|
323
|
+
* fitted. The first segment (flat-text order) carries the placeholder; other
|
|
324
|
+
* segments are excised. The gap is the width of every segment of the same
|
|
325
|
+
* replacement that sits on the same line as the first, so a name split over
|
|
326
|
+
* two shows on one line gets the whole width.
|
|
327
|
+
*/
|
|
328
|
+
export async function planEdits(model, allRuns, segments, replacements, resolve, fitPolicy = {}) {
|
|
329
|
+
const byReplacement = new Map();
|
|
330
|
+
for (const s of segments) {
|
|
331
|
+
const list = byReplacement.get(s.replacementIndex) ?? [];
|
|
332
|
+
list.push(s);
|
|
333
|
+
byReplacement.set(s.replacementIndex, list);
|
|
334
|
+
}
|
|
335
|
+
const sortedReplacements = [...replacements].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
336
|
+
const byPage = runsByPage(allRuns);
|
|
337
|
+
const shiftedRuns = new Map();
|
|
338
|
+
const resourcesNeeded = new Map();
|
|
339
|
+
const warnings = [];
|
|
340
|
+
let shiftedLines = 0;
|
|
341
|
+
for (const [ri, segs] of byReplacement) {
|
|
342
|
+
// T225: of a value wrapped over several lines, the widest line group carries the placeholder
|
|
343
|
+
// (before: always the first). A value whose first line holds only "ul." would otherwise get
|
|
344
|
+
// the whole placeholder squeezed into three glyphs at a line end, pushed past the margin.
|
|
345
|
+
const first = widestLineLead(segs);
|
|
346
|
+
if (segs.length === 1)
|
|
347
|
+
first.whole = true;
|
|
348
|
+
if (first.run.oracle)
|
|
349
|
+
continue; // raster pages: no placeholder in a stream
|
|
350
|
+
const text = sortedReplacements[ri].replacement;
|
|
351
|
+
const run = first.run;
|
|
352
|
+
const font = model.fonts.get(run.fontKey);
|
|
353
|
+
if (!font) {
|
|
354
|
+
warnings.push(`placeholder for a run without a loaded font (${run.streamKey}#${run.opIndex})`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// Gap: from the first covered glyph to the end of the last covered glyph on the same line.
|
|
358
|
+
const g0 = run.glyphs[first.from];
|
|
359
|
+
const refX = g0.x;
|
|
360
|
+
const refY = g0.y;
|
|
361
|
+
const tol = 0.45 * Math.max(g0.fontSize, 1);
|
|
362
|
+
let endAlong = 0;
|
|
363
|
+
let lastSeg = first;
|
|
364
|
+
const onFirstLine = new Set([first]);
|
|
365
|
+
for (const s of segs) {
|
|
366
|
+
if (s.run.page !== run.page)
|
|
367
|
+
continue;
|
|
368
|
+
const gs = s.run.glyphs[s.from];
|
|
369
|
+
const p = projectOnto(run, refX, refY, gs.x, gs.y);
|
|
370
|
+
if (Math.abs(p.across) > tol)
|
|
371
|
+
continue;
|
|
372
|
+
onFirstLine.add(s);
|
|
373
|
+
const [ex, ey] = glyphEnd(s.run, s.to - 1);
|
|
374
|
+
const pe = projectOnto(run, refX, refY, ex, ey);
|
|
375
|
+
if (pe.along >= endAlong) {
|
|
376
|
+
endAlong = pe.along;
|
|
377
|
+
lastSeg = s;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const scale = unitScale(run);
|
|
381
|
+
const gapText = endAlong / scale; // unscaled text space of the first run
|
|
382
|
+
const resolved = await resolve(font, text, run.streamKey);
|
|
383
|
+
const pageRuns = byPage.get(spaceKey(run)) ?? EMPTY_PAGE;
|
|
384
|
+
let fit = fitPlaceholder({ gap: gapText, fontSize: run.local.fontSize, hscale: run.local.hscale, widthPerEm: resolved.widthPerEm }, fitPolicy);
|
|
385
|
+
// Room before the next column (null = no text after the line's chain). With no column the
|
|
386
|
+
// hard limit is the page's text edge (Chrome and Word clip their content boxes, so text
|
|
387
|
+
// pushed past the margin is cut), or the page edge on a single-line page.
|
|
388
|
+
let column = null;
|
|
389
|
+
let hard = null;
|
|
390
|
+
if (fit.stage !== 'fits') {
|
|
391
|
+
// T225: a line with room moves before the placeholder shrinks. The rest of the line may go
|
|
392
|
+
// as far as the next column and the page's text edge allow; only what does not fit in the
|
|
393
|
+
// gap plus that room is shrunk (then condensed), and the line still moves by the rest.
|
|
394
|
+
column = roomOnLine(pageRuns, run, refX, refY, tol, endAlong, lastSeg, shiftedRuns, segments);
|
|
395
|
+
const edge = hasOtherLines(pageRuns, run)
|
|
396
|
+
? roomToEdge(pageRuns, run, refX, refY, tol, shiftedRuns, segments)
|
|
397
|
+
: (roomToPageEdge(model, pageRuns, run, refX, refY, tol, shiftedRuns, segments) ?? roomToEdge(pageRuns, run, refX, refY, tol, shiftedRuns, segments));
|
|
398
|
+
hard = column ?? edge;
|
|
399
|
+
const room = Math.max(0, Math.min(column ?? Infinity, edge));
|
|
400
|
+
if (room > 0.01) {
|
|
401
|
+
fit = fitPlaceholder({ gap: gapText + room / scale, fontSize: run.local.fontSize, hscale: run.local.hscale, widthPerEm: resolved.widthPerEm }, fitPolicy);
|
|
402
|
+
fit = { ...fit, overflow: Math.max(0, fit.width - gapText) };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (fit.overflow > 0) {
|
|
406
|
+
// The rest of the line can only move as far as the next column (text beyond the adjacency
|
|
407
|
+
// chain) or the text edge allows; past that the placeholder gives up its floors rather than
|
|
408
|
+
// overlap the column or be clipped at the margin.
|
|
409
|
+
const room = hard;
|
|
410
|
+
if (room !== null && fit.overflow * scale > room) {
|
|
411
|
+
// Never below 4 pt on the page: a smaller placeholder is unreadable; past that the tail
|
|
412
|
+
// may overlap the column (reported through `shiftedLines` and the warnings).
|
|
413
|
+
const sizeUser = Math.hypot(run.emY[0], run.emY[1]) || run.local.fontSize;
|
|
414
|
+
const minRatio = Math.min(1, Math.max(0.05, 4 / Math.max(sizeUser, 0.01)));
|
|
415
|
+
fit = fitPlaceholder({ gap: gapText + Math.max(0, room) / scale, fontSize: run.local.fontSize, hscale: run.local.hscale, widthPerEm: resolved.widthPerEm }, { ...fitPolicy, minSizeRatio: minRatio, minHorizontalScale: 0.5, allowOverflow: true });
|
|
416
|
+
const overflow = Math.max(0, fit.width - gapText);
|
|
417
|
+
fit = { ...fit, overflow: Math.min(overflow, Math.max(0, room) / scale) };
|
|
418
|
+
if (overflow * scale > Math.max(0, room) + 0.01)
|
|
419
|
+
warnings.push(`placeholder "${text}" on page ${run.page + 1} is wider than the room before the next column`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
first.placeholder = {
|
|
423
|
+
text,
|
|
424
|
+
bytes: resolved.bytes,
|
|
425
|
+
resourceName: resolved.resourceName,
|
|
426
|
+
originalFont: resolved.originalFont,
|
|
427
|
+
fit,
|
|
428
|
+
codes: resolved.codes,
|
|
429
|
+
slack: Math.min(0, fit.width - gapText),
|
|
430
|
+
};
|
|
431
|
+
if (!resolved.originalFont) {
|
|
432
|
+
const set = resourcesNeeded.get(run.streamKey) ?? new Set();
|
|
433
|
+
set.add(resolved.resourceName);
|
|
434
|
+
resourcesNeeded.set(run.streamKey, set);
|
|
435
|
+
}
|
|
436
|
+
// Delta of the line: the placeholder is narrower (slack < 0) or wider (overflow > 0) than the gap.
|
|
437
|
+
const deltaText = fit.width > gapText ? fit.overflow : fit.width - gapText;
|
|
438
|
+
if (fit.overflow > 0)
|
|
439
|
+
shiftedLines++;
|
|
440
|
+
shiftLine(pageRuns, run, refX, refY, tol, endAlong, lastSeg, deltaText * scale, shiftedRuns);
|
|
441
|
+
// Segments of the same replacement on other lines (a value wrapped at a line end, "Jan\nKowalski"):
|
|
442
|
+
// nothing is shown there, so the text after each of them closes up over the removed glyphs.
|
|
443
|
+
const rest = segs.filter((s) => !onFirstLine.has(s));
|
|
444
|
+
while (rest.length > 0) {
|
|
445
|
+
const lead = rest.shift();
|
|
446
|
+
const lg = lead.run.glyphs[lead.from];
|
|
447
|
+
const ltol = 0.45 * Math.max(lg.fontSize, 1);
|
|
448
|
+
let lineEnd = 0;
|
|
449
|
+
let lineLast = lead;
|
|
450
|
+
for (let k = rest.length - 1; k >= -1; k--) {
|
|
451
|
+
const s = k >= 0 ? rest[k] : lead;
|
|
452
|
+
if (s.run.page !== lead.run.page)
|
|
453
|
+
continue;
|
|
454
|
+
const gs = s.run.glyphs[s.from];
|
|
455
|
+
if (Math.abs(projectOnto(lead.run, lg.x, lg.y, gs.x, gs.y).across) > ltol)
|
|
456
|
+
continue;
|
|
457
|
+
if (k >= 0)
|
|
458
|
+
rest.splice(k, 1);
|
|
459
|
+
const [ex, ey] = glyphEnd(s.run, s.to - 1);
|
|
460
|
+
const pe = projectOnto(lead.run, lg.x, lg.y, ex, ey);
|
|
461
|
+
if (pe.along >= lineEnd) {
|
|
462
|
+
lineEnd = pe.along;
|
|
463
|
+
lineLast = s;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// A continuation line that starts with the removed tail is prose: the whole line closes up,
|
|
467
|
+
// justified word gaps included. Elsewhere only adjacent text moves (a table cell stays put).
|
|
468
|
+
const lineRuns = byPage.get(spaceKey(lead.run)) ?? EMPTY_PAGE;
|
|
469
|
+
const startsLine = lead.from === 0 && !runsOnLine(lineRuns, lead.run, lg.x, lg.y, ltol).some((r) => r.startAlong < -0.5 * ltol);
|
|
470
|
+
shiftLine(lineRuns, lead.run, lg.x, lg.y, ltol, lineEnd, lineLast, -lineEnd, shiftedRuns, startsLine ? CONTINUATION_ADJACENCY_EM : ADJACENCY_EM);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const byStream = new Map();
|
|
474
|
+
for (const s of segments) {
|
|
475
|
+
if (s.run.oracle)
|
|
476
|
+
continue;
|
|
477
|
+
const list = byStream.get(s.run.streamKey) ?? [];
|
|
478
|
+
list.push(s);
|
|
479
|
+
byStream.set(s.run.streamKey, list);
|
|
480
|
+
}
|
|
481
|
+
for (const list of byStream.values())
|
|
482
|
+
list.sort((a, b) => a.run.opIndex - b.run.opIndex || a.from - b.from);
|
|
483
|
+
return { byStream, shiftedRuns, resourcesNeeded, shiftedLines, warnings };
|
|
484
|
+
}
|
|
485
|
+
/** The runs to the right of a gap that move with it: chained while no tab-sized hole separates them. */
|
|
486
|
+
function chainAfterGap(pageRuns, ref, refX, refY, tol, gapEndAlong, lastSeg, adjacencyEm = ADJACENCY_EM) {
|
|
487
|
+
const size = Math.hypot(ref.emY[0], ref.emY[1]) || ref.glyphs[0]?.fontSize || 1;
|
|
488
|
+
const adjacency = adjacencyEm * Math.max(size, 1);
|
|
489
|
+
const lineRuns = runsOnLine(pageRuns, ref, refX, refY, tol)
|
|
490
|
+
.filter((r) => r.run !== lastSeg.run && r.startAlong >= gapEndAlong - 0.5 * adjacency);
|
|
491
|
+
// The run holding the gap end keeps its remaining glyphs (shifted through shiftAfter); the chain
|
|
492
|
+
// continues from where THAT run ends, not from the gap end.
|
|
493
|
+
const [lastEndX, lastEndY] = glyphEnd(lastSeg.run, lastSeg.run.glyphs.length - 1);
|
|
494
|
+
let prevEnd = Math.max(gapEndAlong, projectOnto(ref, refX, refY, lastEndX, lastEndY).along);
|
|
495
|
+
const chain = [];
|
|
496
|
+
let next = null;
|
|
497
|
+
for (const r of lineRuns) {
|
|
498
|
+
if (r.startAlong - prevEnd > adjacency) {
|
|
499
|
+
next = r;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
chain.push(r);
|
|
503
|
+
prevEnd = Math.max(prevEnd, r.endAlong);
|
|
504
|
+
}
|
|
505
|
+
return { chain, next, chainEnd: prevEnd };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Move everything after a gap on one line by `deltaUser` (user units along the baseline): the
|
|
509
|
+
* remaining glyphs of the run where the gap ends (through `shiftAfter`) and the adjacent runs
|
|
510
|
+
* further right, as long as they chain without a tab-sized hole.
|
|
511
|
+
*/
|
|
512
|
+
function shiftLine(pageRuns, ref, refX, refY, tol, gapEndAlong, lastSeg, deltaUser, shiftedRuns, adjacencyEm = ADJACENCY_EM) {
|
|
513
|
+
if (Math.abs(deltaUser) < 0.01)
|
|
514
|
+
return;
|
|
515
|
+
lastSeg.shiftAfter = (lastSeg.shiftAfter ?? 0) + deltaUser / unitScale(lastSeg.run);
|
|
516
|
+
const { chain } = chainAfterGap(pageRuns, ref, refX, refY, tol, gapEndAlong, lastSeg, adjacencyEm);
|
|
517
|
+
for (const r of chain)
|
|
518
|
+
shiftedRuns.set(runKey(r.run), (shiftedRuns.get(runKey(r.run)) ?? 0) + deltaUser);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* User units the text after a gap can still move right before it reaches the next column; null
|
|
522
|
+
* when nothing is to its right. Shifts already planned on this line (an earlier placeholder's
|
|
523
|
+
* overflow or slack) are taken into account on both sides.
|
|
524
|
+
*/
|
|
525
|
+
function roomOnLine(pageRuns, ref, refX, refY, tol, gapEndAlong, lastSeg, shiftedRuns, segments) {
|
|
526
|
+
const { chain, next, chainEnd } = chainAfterGap(pageRuns, ref, refX, refY, tol, gapEndAlong, lastSeg);
|
|
527
|
+
if (!next)
|
|
528
|
+
return null;
|
|
529
|
+
const size = Math.hypot(ref.emY[0], ref.emY[1]) || ref.glyphs[0]?.fontSize || 1;
|
|
530
|
+
// The run adjacent to the column is the last of the chain (or the gap's own run): its planned
|
|
531
|
+
// shift, positive or negative, is what already stands between the chain end and the column.
|
|
532
|
+
const tail = chain.length > 0 ? chain[chain.length - 1].run : lastSeg.run;
|
|
533
|
+
let chainShift = shiftedRuns.get(runKey(tail)) ?? 0;
|
|
534
|
+
// The tail of the gap's own run already carries every shift planned after earlier segments of
|
|
535
|
+
// that run (two values touching in one show): all of them stand between it and the column.
|
|
536
|
+
if (tail === lastSeg.run)
|
|
537
|
+
for (const s of segments)
|
|
538
|
+
if (s.run === lastSeg.run && s.shiftAfter)
|
|
539
|
+
chainShift += s.shiftAfter * unitScale(lastSeg.run);
|
|
540
|
+
const nextShift = shiftedRuns.get(runKey(next.run)) ?? 0;
|
|
541
|
+
return next.startAlong + nextShift - (chainEnd + chainShift) - COLUMN_MARGIN_EM * Math.max(size, 1);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Rewrite one stream. `runsByOp` gives the GlyphRun of every text-showing
|
|
545
|
+
* operator (first instance for shared XObjects). `segments` are the edits in
|
|
546
|
+
* this stream. Every unaffected operator is copied verbatim.
|
|
547
|
+
*/
|
|
548
|
+
export function rewriteStream(model, stream, runsByOp, segments, shiftedRuns) {
|
|
549
|
+
const { ctx } = model;
|
|
550
|
+
const fontsDict = getDict(ctx, stream.resources, 'Font');
|
|
551
|
+
const resolveFont = (n) => loadFont(ctx, fontsDict, n, model.fonts);
|
|
552
|
+
const sm = new StateMachine(initialState());
|
|
553
|
+
const out = [];
|
|
554
|
+
const usedCodes = new Map();
|
|
555
|
+
const removedSequences = [];
|
|
556
|
+
const editedFonts = new Set();
|
|
557
|
+
const segsByOp = new Map();
|
|
558
|
+
for (const s of segments) {
|
|
559
|
+
const list = segsByOp.get(s.run.opIndex) ?? [];
|
|
560
|
+
list.push(s);
|
|
561
|
+
segsByOp.set(s.run.opIndex, list);
|
|
562
|
+
}
|
|
563
|
+
let pendingReanchor = false;
|
|
564
|
+
let edits = 0;
|
|
565
|
+
const markUsed = (fontKey, codes) => {
|
|
566
|
+
let set = usedCodes.get(fontKey);
|
|
567
|
+
if (!set) {
|
|
568
|
+
set = new Set();
|
|
569
|
+
usedCodes.set(fontKey, set);
|
|
570
|
+
}
|
|
571
|
+
for (const c of codes)
|
|
572
|
+
set.add(c);
|
|
573
|
+
};
|
|
574
|
+
for (let i = 0; i < stream.ops.length; i++) {
|
|
575
|
+
const op = stream.ops[i];
|
|
576
|
+
if (TEXT_SHOW_OPS.has(op.op)) {
|
|
577
|
+
const run = runsByOp.get(i);
|
|
578
|
+
const segs = segsByOp.get(i) ?? [];
|
|
579
|
+
const shift = run ? (shiftedRuns.get(runKey(run)) ?? 0) / unitScale(run) : 0;
|
|
580
|
+
if (!run || (segs.length === 0 && !pendingReanchor && shift === 0)) {
|
|
581
|
+
// Verbatim; still track state and used codes.
|
|
582
|
+
if (run)
|
|
583
|
+
markUsed(run.fontKey, run.glyphs.map((g) => g.code));
|
|
584
|
+
if (op.op === "'" || op.op === '"')
|
|
585
|
+
sm.update(op, resolveFont);
|
|
586
|
+
out.push(op);
|
|
587
|
+
if (run)
|
|
588
|
+
sm.advanceText(totalAdvance(run));
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// Re-emit this run (edited, re-anchored or shifted).
|
|
592
|
+
const emitted = reemitRun(op, run, segs, shift, sm, model, resolveFont);
|
|
593
|
+
for (const e of emitted.ops)
|
|
594
|
+
out.push(e);
|
|
595
|
+
for (const [k, codes] of emitted.used)
|
|
596
|
+
markUsed(k, codes);
|
|
597
|
+
for (const rs of emitted.removed)
|
|
598
|
+
removedSequences.push(rs);
|
|
599
|
+
if (segs.length > 0)
|
|
600
|
+
editedFonts.add(run.fontKey);
|
|
601
|
+
edits += segs.length;
|
|
602
|
+
sm.advanceText(totalAdvance(run));
|
|
603
|
+
pendingReanchor = true;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (TEXT_POSITION_OPS.has(op.op) || op.op === 'ET') {
|
|
607
|
+
if (pendingReanchor && op.op !== 'Tm' && op.op !== 'ET') {
|
|
608
|
+
// Restore the line matrix the original stream had here, then apply the op.
|
|
609
|
+
out.push(makeOp('Tm', ...sm.tlm.map((v) => num(v))));
|
|
610
|
+
}
|
|
611
|
+
pendingReanchor = false;
|
|
612
|
+
}
|
|
613
|
+
if (op.op === 'BDC') {
|
|
614
|
+
const scrubbed = scrubMarkedContent(op);
|
|
615
|
+
out.push(scrubbed);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
out.push(op);
|
|
619
|
+
}
|
|
620
|
+
sm.update(op, resolveFont);
|
|
621
|
+
}
|
|
622
|
+
return { bytes: serializeOps(out, stream.source), usedCodes, removedSequences, editedFonts, edits };
|
|
623
|
+
}
|
|
624
|
+
function totalAdvance(run) {
|
|
625
|
+
const n = run.glyphs.length;
|
|
626
|
+
if (n === 0)
|
|
627
|
+
return 0;
|
|
628
|
+
return run.local.advances[n - 1] + run.glyphs[n - 1].advance;
|
|
629
|
+
}
|
|
630
|
+
/** Remove ActualText / Alt / E from an inline marked-content property dict. */
|
|
631
|
+
function scrubMarkedContent(op) {
|
|
632
|
+
const props = op.operands[1];
|
|
633
|
+
if (!props || props.kind !== 'dict')
|
|
634
|
+
return op;
|
|
635
|
+
const keys = ['ActualText', 'Alt', 'E'];
|
|
636
|
+
if (!keys.some((k) => props.entries.has(k)))
|
|
637
|
+
return op;
|
|
638
|
+
const entries = new Map(props.entries);
|
|
639
|
+
for (const k of keys)
|
|
640
|
+
entries.delete(k);
|
|
641
|
+
return makeOp('BDC', op.operands[0], { kind: 'dict', entries });
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Re-emit one text-showing operator as absolute-positioned pieces. `shift`
|
|
645
|
+
* is the line shift (unscaled text space) applying to the whole run (a run
|
|
646
|
+
* to the right of an overflowing placeholder); segment overflow additionally
|
|
647
|
+
* shifts the glyphs after each placeholder inside the run.
|
|
648
|
+
*/
|
|
649
|
+
function reemitRun(op, run, segs, shift, sm, model, resolveFont) {
|
|
650
|
+
const ops = [];
|
|
651
|
+
const used = new Map();
|
|
652
|
+
const removed = [];
|
|
653
|
+
const font = model.fonts.get(run.fontKey);
|
|
654
|
+
const t = run.local;
|
|
655
|
+
// Side effects of ' and " first (line advance, spacing), so Tlm is right.
|
|
656
|
+
if (op.op === "'") {
|
|
657
|
+
ops.push(makeOp('T*'));
|
|
658
|
+
}
|
|
659
|
+
else if (op.op === '"') {
|
|
660
|
+
ops.push(makeOp('Tw', op.operands[0] ?? num(0)));
|
|
661
|
+
ops.push(makeOp('Tc', op.operands[1] ?? num(0)));
|
|
662
|
+
ops.push(makeOp('T*'));
|
|
663
|
+
}
|
|
664
|
+
if (op.op === "'" || op.op === '"')
|
|
665
|
+
sm.update(op, resolveFont);
|
|
666
|
+
// Tokens of the original operand in order: glyphs and kerning numbers.
|
|
667
|
+
const tokens = tokensOf(op, run);
|
|
668
|
+
const removedSet = new Set();
|
|
669
|
+
for (const s of segs)
|
|
670
|
+
for (let g = s.from; g < s.to; g++)
|
|
671
|
+
removedSet.add(g);
|
|
672
|
+
const segAt = new Map(); // glyph index (segment start) -> segment
|
|
673
|
+
for (const s of segs)
|
|
674
|
+
segAt.set(s.from, s);
|
|
675
|
+
// Walk tokens, grouping kept glyph runs; emit absolute Tm before each kept group and each placeholder.
|
|
676
|
+
// A shift planned after a segment applies to what lies to the RIGHT of that segment's gap (by
|
|
677
|
+
// advance position), not to what comes later in the array: a TJ may jump back with a positive
|
|
678
|
+
// adjustment and draw earlier text to the left of the gap. A kept glyph is on the right when it
|
|
679
|
+
// starts past the middle of the gap: kerning pulls the next glyph a little into the gap
|
|
680
|
+
// (LibreOffice writes "Müller" 40 ", wohnhaft"), and comparing with the gap end left that comma
|
|
681
|
+
// at its old position, inside the text that moved left to close a narrow placeholder's slack.
|
|
682
|
+
const gapShifts = [];
|
|
683
|
+
for (const s of segs) {
|
|
684
|
+
if (!s.shiftAfter)
|
|
685
|
+
continue;
|
|
686
|
+
const last = run.glyphs[s.to - 1];
|
|
687
|
+
const start = t.advances[s.from];
|
|
688
|
+
const end = t.advances[s.to - 1] + last.advance;
|
|
689
|
+
gapShifts.push({ middle: (start + end) / 2, shift: s.shiftAfter });
|
|
690
|
+
}
|
|
691
|
+
const shiftAt = (glyphIndex) => {
|
|
692
|
+
let total = shift;
|
|
693
|
+
const at = t.advances[glyphIndex];
|
|
694
|
+
for (const g of gapShifts)
|
|
695
|
+
if (at + 1e-6 >= g.middle)
|
|
696
|
+
total += g.shift;
|
|
697
|
+
return total;
|
|
698
|
+
};
|
|
699
|
+
let i = 0;
|
|
700
|
+
const tmAt = (glyphIndex, offset) => mul(translate(t.advances[glyphIndex] + offset, 0), t.tm);
|
|
701
|
+
const emitTm = (m) => { ops.push(makeOp('Tm', ...m.map((v) => num(v)))); };
|
|
702
|
+
while (i < tokens.length) {
|
|
703
|
+
const tok = tokens[i];
|
|
704
|
+
if (tok.kind === 'adj') {
|
|
705
|
+
i++;
|
|
706
|
+
continue;
|
|
707
|
+
} // adjustments outside kept groups are dropped
|
|
708
|
+
const gi = tok.glyph;
|
|
709
|
+
if (removedSet.has(gi)) {
|
|
710
|
+
// Removed group, one segment at a time (two values can touch without a kept glyph between
|
|
711
|
+
// them): record the original code sequence, emit the placeholder the segment carries.
|
|
712
|
+
const seg = segAt.get(gi);
|
|
713
|
+
let j = i;
|
|
714
|
+
const codes = [];
|
|
715
|
+
const bytes = [];
|
|
716
|
+
let glyphCount = 0;
|
|
717
|
+
while (j < tokens.length) {
|
|
718
|
+
const tk = tokens[j];
|
|
719
|
+
if (tk.kind === 'glyph') {
|
|
720
|
+
if (!removedSet.has(tk.glyph))
|
|
721
|
+
break;
|
|
722
|
+
if (tk.glyph !== gi && segAt.has(tk.glyph))
|
|
723
|
+
break; // next segment starts here
|
|
724
|
+
const g = run.glyphs[tk.glyph];
|
|
725
|
+
codes.push(g.code);
|
|
726
|
+
for (let k = g.codeLength - 1; k >= 0; k--)
|
|
727
|
+
bytes.push((g.code >> (8 * k)) & 0xff);
|
|
728
|
+
glyphCount++;
|
|
729
|
+
}
|
|
730
|
+
j++;
|
|
731
|
+
}
|
|
732
|
+
// Only the complete code sequence of a whole value is reported for the verifier's byte scan:
|
|
733
|
+
// a fragment of a value split over two shows (" Kowalski") legitimately occurs elsewhere.
|
|
734
|
+
if (font && bytes.length > 0 && seg?.whole && glyphCount === seg.to - seg.from)
|
|
735
|
+
removed.push({ fontKey: run.fontKey, bytes: Uint8Array.from(bytes), codeLength: run.glyphs[gi].codeLength });
|
|
736
|
+
if (seg?.placeholder) {
|
|
737
|
+
const ph = seg.placeholder;
|
|
738
|
+
emitTm(tmAt(gi, shiftAt(gi)));
|
|
739
|
+
emitPlaceholder(ops, ph, run, font);
|
|
740
|
+
if (ph.originalFont) {
|
|
741
|
+
const arr = used.get(run.fontKey) ?? [];
|
|
742
|
+
for (const c of ph.codes)
|
|
743
|
+
arr.push(c);
|
|
744
|
+
used.set(run.fontKey, arr);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
i = j;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
// Kept group: glyphs (and adjustments between them) until the next removed glyph.
|
|
751
|
+
const items = [];
|
|
752
|
+
let pending = [];
|
|
753
|
+
const startGlyph = gi;
|
|
754
|
+
const groupShift = shiftAt(startGlyph);
|
|
755
|
+
let j = i;
|
|
756
|
+
const flush = () => {
|
|
757
|
+
if (pending.length) {
|
|
758
|
+
items.push(hexString(Uint8Array.from(pending)));
|
|
759
|
+
pending = [];
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
let adjSum = 0; // consecutive adjustment numbers add up
|
|
763
|
+
while (j < tokens.length) {
|
|
764
|
+
const tk = tokens[j];
|
|
765
|
+
if (tk.kind === 'glyph') {
|
|
766
|
+
if (removedSet.has(tk.glyph))
|
|
767
|
+
break;
|
|
768
|
+
// A glyph on the other side of a gap (a TJ jumping back past it) takes a different shift:
|
|
769
|
+
// it starts its own absolutely positioned group.
|
|
770
|
+
if (shiftAt(tk.glyph) !== groupShift)
|
|
771
|
+
break;
|
|
772
|
+
if (adjSum !== 0 && pending.length > 0) {
|
|
773
|
+
flush();
|
|
774
|
+
items.push(num(adjSum));
|
|
775
|
+
}
|
|
776
|
+
adjSum = 0;
|
|
777
|
+
const g = run.glyphs[tk.glyph];
|
|
778
|
+
for (let k = g.codeLength - 1; k >= 0; k--)
|
|
779
|
+
pending.push((g.code >> (8 * k)) & 0xff);
|
|
780
|
+
const arr = used.get(run.fontKey) ?? [];
|
|
781
|
+
arr.push(g.code);
|
|
782
|
+
used.set(run.fontKey, arr);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
// Keep adjustments only when glyphs of this group sit on both sides of them.
|
|
786
|
+
adjSum += tk.adj;
|
|
787
|
+
}
|
|
788
|
+
j++;
|
|
789
|
+
}
|
|
790
|
+
flush();
|
|
791
|
+
emitTm(tmAt(startGlyph, shiftAt(startGlyph)));
|
|
792
|
+
if (items.length === 1 && items[0].kind === 'string')
|
|
793
|
+
ops.push(makeOp('Tj', items[0]));
|
|
794
|
+
else if (items.length > 0)
|
|
795
|
+
ops.push(makeOp('TJ', array(items)));
|
|
796
|
+
i = j;
|
|
797
|
+
}
|
|
798
|
+
return { ops, used, removed };
|
|
799
|
+
}
|
|
800
|
+
/** Flatten the operand(s) of a show operator into glyph/adjustment tokens aligned with run.glyphs. */
|
|
801
|
+
function tokensOf(op, run) {
|
|
802
|
+
const tokens = [];
|
|
803
|
+
if (op.op !== 'TJ') {
|
|
804
|
+
for (let g = 0; g < run.glyphs.length; g++)
|
|
805
|
+
tokens.push({ kind: 'glyph', glyph: g });
|
|
806
|
+
return tokens;
|
|
807
|
+
}
|
|
808
|
+
const arr = op.operands[0];
|
|
809
|
+
if (!arr || arr.kind !== 'array')
|
|
810
|
+
return tokens;
|
|
811
|
+
let g = 0;
|
|
812
|
+
arr.items.forEach((it, idx) => {
|
|
813
|
+
if (it.kind === 'number')
|
|
814
|
+
tokens.push({ kind: 'adj', adj: it.value });
|
|
815
|
+
else if (it.kind === 'string') {
|
|
816
|
+
while (g < run.glyphs.length && run.glyphs[g].item === idx) {
|
|
817
|
+
tokens.push({ kind: 'glyph', glyph: g });
|
|
818
|
+
g++;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
return tokens;
|
|
823
|
+
}
|
|
824
|
+
function emitPlaceholder(ops, ph, run, font) {
|
|
825
|
+
const t = run.local;
|
|
826
|
+
const { fit } = ph;
|
|
827
|
+
const sizeChanged = Math.abs(fit.fontSize - t.fontSize) > 1e-9 || !ph.originalFont;
|
|
828
|
+
const hChanged = Math.abs(fit.hscale - t.hscale) > 1e-9;
|
|
829
|
+
if (sizeChanged)
|
|
830
|
+
ops.push(makeOp('Tf', name(ph.originalFont || !ph.resourceName ? run.fontName : ph.resourceName), num(fit.fontSize)));
|
|
831
|
+
if (hChanged)
|
|
832
|
+
ops.push(makeOp('Tz', num(fit.hscale * 100)));
|
|
833
|
+
if (t.charSpacing !== 0)
|
|
834
|
+
ops.push(makeOp('Tc', num(0)));
|
|
835
|
+
if (t.wordSpacing !== 0)
|
|
836
|
+
ops.push(makeOp('Tw', num(0)));
|
|
837
|
+
ops.push(makeOp('Tj', hexString(ph.bytes)));
|
|
838
|
+
if (t.wordSpacing !== 0)
|
|
839
|
+
ops.push(makeOp('Tw', num(t.wordSpacing)));
|
|
840
|
+
if (t.charSpacing !== 0)
|
|
841
|
+
ops.push(makeOp('Tc', num(t.charSpacing)));
|
|
842
|
+
if (hChanged)
|
|
843
|
+
ops.push(makeOp('Tz', num(t.hscale * 100)));
|
|
844
|
+
if (sizeChanged)
|
|
845
|
+
ops.push(makeOp('Tf', name(run.fontName), num(t.fontSize)));
|
|
846
|
+
void font;
|
|
847
|
+
}
|