@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/NOTICE +5 -0
  3. package/README.md +131 -9
  4. package/dist/dom/errors.d.ts +1 -1
  5. package/dist/dom/ocr.d.ts +33 -7
  6. package/dist/dom/ocr.js +98 -4
  7. package/dist/dom/office.d.ts +20 -6
  8. package/dist/dom/office.js +44 -11
  9. package/dist/engine.js +4 -5
  10. package/dist/env.d.ts +3 -3
  11. package/dist/pdf/cmap.d.ts +83 -0
  12. package/dist/pdf/cmap.js +659 -0
  13. package/dist/pdf/encodings.d.ts +49 -0
  14. package/dist/pdf/encodings.js +181 -0
  15. package/dist/pdf/extract.d.ts +106 -0
  16. package/dist/pdf/extract.js +1064 -0
  17. package/dist/pdf/fallback-fonts.d.ts +146 -0
  18. package/dist/pdf/fallback-fonts.js +312 -0
  19. package/dist/pdf/fit.d.ts +35 -0
  20. package/dist/pdf/fit.js +60 -0
  21. package/dist/pdf/fonts.d.ts +82 -0
  22. package/dist/pdf/fonts.js +465 -0
  23. package/dist/pdf/glyphlist.d.ts +37 -0
  24. package/dist/pdf/glyphlist.js +485 -0
  25. package/dist/pdf/index.d.ts +21 -0
  26. package/dist/pdf/index.js +7 -0
  27. package/dist/pdf/lexer.d.ts +61 -0
  28. package/dist/pdf/lexer.js +554 -0
  29. package/dist/pdf/objects.d.ts +57 -0
  30. package/dist/pdf/objects.js +217 -0
  31. package/dist/pdf/oracle.d.ts +149 -0
  32. package/dist/pdf/oracle.js +384 -0
  33. package/dist/pdf/raster.d.ts +45 -0
  34. package/dist/pdf/raster.js +89 -0
  35. package/dist/pdf/read.d.ts +36 -0
  36. package/dist/pdf/read.js +65 -0
  37. package/dist/pdf/serializer.d.ts +21 -0
  38. package/dist/pdf/serializer.js +106 -0
  39. package/dist/pdf/standard-metrics.d.ts +27 -0
  40. package/dist/pdf/standard-metrics.js +72 -0
  41. package/dist/pdf/surgery.d.ts +131 -0
  42. package/dist/pdf/surgery.js +847 -0
  43. package/dist/pdf/textstate.d.ts +77 -0
  44. package/dist/pdf/textstate.js +280 -0
  45. package/dist/pdf/types.d.ts +191 -0
  46. package/dist/pdf/types.js +11 -0
  47. package/dist/pdf/verify.d.ts +33 -0
  48. package/dist/pdf/verify.js +446 -0
  49. package/dist/pdf/write.d.ts +52 -0
  50. package/dist/pdf/write.js +644 -0
  51. package/dist/pipeline.d.ts +15 -0
  52. package/dist/pipeline.js +123 -8
  53. package/dist/providers/bardsai.d.ts +1 -1
  54. package/dist/providers/bardsai.js +5 -1
  55. package/dist/providers/gliner-base.d.ts +1 -1
  56. package/dist/providers/gliner-base.js +5 -1
  57. package/dist/providers/gliner.d.ts +1 -1
  58. package/dist/providers/gliner.js +5 -1
  59. package/dist/regex/rules.data.js +13 -8
  60. package/dist/types.d.ts +17 -2
  61. package/dist/types.js +17 -0
  62. package/dist/worker-protocol.d.ts +11 -0
  63. package/dist/worker-protocol.js +41 -8
  64. package/fonts/liberation/LICENSE +102 -0
  65. package/fonts/liberation/LiberationMono-Bold.ttf +0 -0
  66. package/fonts/liberation/LiberationMono-BoldItalic.ttf +0 -0
  67. package/fonts/liberation/LiberationMono-Italic.ttf +0 -0
  68. package/fonts/liberation/LiberationMono-Regular.ttf +0 -0
  69. package/fonts/liberation/LiberationSans-Bold.ttf +0 -0
  70. package/fonts/liberation/LiberationSans-BoldItalic.ttf +0 -0
  71. package/fonts/liberation/LiberationSans-Italic.ttf +0 -0
  72. package/fonts/liberation/LiberationSans-Regular.ttf +0 -0
  73. package/fonts/liberation/LiberationSerif-Bold.ttf +0 -0
  74. package/fonts/liberation/LiberationSerif-BoldItalic.ttf +0 -0
  75. package/fonts/liberation/LiberationSerif-Italic.ttf +0 -0
  76. package/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
  77. package/package.json +11 -2
  78. package/rules/pl.json +7 -3
  79. 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
+ }