web-doc 0.11.0 → 0.12.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 (35) hide show
  1. package/dist/adapters/pdf.js +19 -8
  2. package/dist/edit/engine.d.ts +2 -1
  3. package/dist/edit/pdf/engine/document.d.ts +4 -2
  4. package/dist/edit/pdf/engine/document.js +115 -12
  5. package/dist/edit/pdf/engine/elements.d.ts +5 -1
  6. package/dist/edit/pdf/engine/elements.js +88 -6
  7. package/dist/edit/pdf/engine/existing-text.js +16 -61
  8. package/dist/edit/pdf/engine/fonts.d.ts +14 -0
  9. package/dist/edit/pdf/engine/fonts.js +114 -0
  10. package/dist/edit/pdf/engine/handler.js +2 -0
  11. package/dist/edit/pdf/engine/layout.js +1 -1
  12. package/dist/edit/pdf/engine/operations.d.ts +7 -1
  13. package/dist/edit/pdf/engine/paragraph-edit.d.ts +4 -0
  14. package/dist/edit/pdf/engine/paragraph-edit.js +331 -0
  15. package/dist/edit/pdf/engine/paragraph.d.ts +31 -0
  16. package/dist/edit/pdf/engine/paragraph.js +445 -0
  17. package/dist/edit/pdf/engine/pdfium.d.ts +3 -0
  18. package/dist/edit/pdf/engine/text-box.js +16 -3
  19. package/dist/edit/pdf/engine/text-font.js +2 -19
  20. package/dist/edit/pdf/engine/transform.js +14 -3
  21. package/dist/edit/pdf/provider.d.ts +3 -1
  22. package/dist/edit/pdf/provider.js +3 -0
  23. package/dist/edit/pdf/range-map.d.ts +2 -1
  24. package/dist/edit/pdf/range-map.js +38 -8
  25. package/dist/edit/pdf/schemas.d.ts +2 -0
  26. package/dist/edit/pdf/schemas.js +37 -0
  27. package/dist/edit/pdf/session.d.ts +3 -1
  28. package/dist/edit/pdf/session.js +35 -6
  29. package/dist/edit/pdf/types.d.ts +38 -8
  30. package/dist/edit/session.js +5 -0
  31. package/dist/edit/types.d.ts +13 -0
  32. package/dist/viewport.js +11 -0
  33. package/dist/worker-protocol.d.ts +1 -1
  34. package/dist/workers/pdf-edit-worker.js +2019 -1003
  35. package/package.json +1 -1
@@ -0,0 +1,445 @@
1
+ import { OBJECT_TEXT, paragraphSpecOf, } from "./elements.js";
2
+ import { roundRect, round, unionRects, userToPage, } from "./geometry.js";
3
+ import { fontBytes, fontCanDraw } from "./fonts.js";
4
+ export function paragraphElement(paragraph) {
5
+ return {
6
+ id: paragraph.id,
7
+ kind: "paragraph",
8
+ pageIndex: paragraph.pageIndex,
9
+ text: paragraph.text,
10
+ bounds: paragraph.bounds,
11
+ textStyle: paragraph.textStyle,
12
+ operations: [
13
+ "replaceParagraphText",
14
+ "replaceText",
15
+ "setTextStyle",
16
+ "deleteElement",
17
+ ],
18
+ };
19
+ }
20
+ const MIN_CONTINUATION_FILL = 0.9;
21
+ const MAX_CONNECTED_EDGE_GAP = 0.65;
22
+ const MAX_LEFT_ALIGNMENT_DELTA = 1;
23
+ const ADJACENT_BASELINE_EM = 0.25;
24
+ const ADJACENT_TEXT_GAP_EM = 3;
25
+ const LIST_ITEM_START = /^(?:[•◦▪‣·*–—-]|\(?\d{1,3}[.)]|\(?[a-z][.)])\s/iu;
26
+ const NUMERIC_VALUE = /^[\d\s.,:/%()+−–—-]+$/u;
27
+ // Public PDF geometry is canonicalized to thousandths of a point. This only
28
+ // absorbs native floating-point rounding, not visible tracking or kerning.
29
+ const GLYPH_POSITION_PRECISION = 0.001;
30
+ const MAX_PAIR_ADJUSTMENT_EM = 0.1;
31
+ const MATRIX_RELATIVE_PRECISION = 0.000001;
32
+ /**
33
+ * Conservative import recognition. Only whole, equally styled native lines
34
+ * with uniform upright matrices and regular left-aligned leading participate.
35
+ * Content-stream order is deliberately irrelevant to reading order.
36
+ */
37
+ export function discoverParagraphs(pdfium, page, pageIndex, geometry, records, elements, textPage, measurer) {
38
+ const result = new Map();
39
+ if (geometry.rotation !== 0)
40
+ return result;
41
+ const indexesById = new Map();
42
+ records.forEach((record, index) => {
43
+ const indexes = indexesById.get(record.id) ?? [];
44
+ indexes.push(index);
45
+ indexesById.set(record.id, indexes);
46
+ });
47
+ const elementsById = new Map();
48
+ for (const element of elements)
49
+ if (!elementsById.has(element.id))
50
+ elementsById.set(element.id, element);
51
+ for (const element of elements) {
52
+ const indexes = indexesById.get(element.id) ?? [];
53
+ const spec = paragraphSpecOf(records[indexes[0] ?? -1]?.mark);
54
+ if (!spec)
55
+ continue;
56
+ const paragraph = {
57
+ id: element.id,
58
+ pageIndex,
59
+ text: spec.text,
60
+ bounds: spec.rect,
61
+ textStyle: spec.style,
62
+ memberIds: [element.id],
63
+ members: [{ elementId: element.id, start: 0, end: spec.text.length }],
64
+ };
65
+ result.set(element.id, { paragraph, spec, indexes });
66
+ }
67
+ // Arbitrarily rotated pages/objects require transformed layout and are left
68
+ // independently editable until that native layout contract exists.
69
+ const rows = [];
70
+ const uprightTexts = new Map();
71
+ const fontKeys = new Map();
72
+ const programs = [];
73
+ const fontKey = (font) => {
74
+ const cached = fontKeys.get(font);
75
+ if (cached)
76
+ return cached;
77
+ let key;
78
+ if (!pdfium.lib.FPDFFont_GetIsEmbedded(font))
79
+ key = `standard:${pdfium.readUtf8String((buffer, bytes) => pdfium.lib.FPDFFont_GetBaseFontName(font, buffer, bytes))}`;
80
+ else {
81
+ const bytes = fontBytes(pdfium, font);
82
+ let at = bytes
83
+ ? programs.findIndex((program) => program.length === bytes.length &&
84
+ program.every((byte, index) => byte === bytes[index]))
85
+ : -1;
86
+ if (at < 0 && bytes) {
87
+ at = programs.length;
88
+ programs.push(bytes);
89
+ }
90
+ key = bytes ? `embedded:${at}` : `unreadable:${font}`;
91
+ }
92
+ fontKeys.set(font, key);
93
+ return key;
94
+ };
95
+ const { lib } = pdfium;
96
+ const rightEdges = new Map();
97
+ const glyphsByObject = new Map();
98
+ for (let at = 0; at < lib.FPDFText_CountChars(textPage); at += 1) {
99
+ const unicode = lib.FPDFText_GetUnicode(textPage, at);
100
+ if (!Number.isInteger(unicode) ||
101
+ unicode < 32 ||
102
+ unicode > 0x10ffff ||
103
+ (unicode >= 0xd800 && unicode <= 0xdfff))
104
+ continue;
105
+ const object = lib.FPDFText_GetTextObject(textPage, at);
106
+ if (!object)
107
+ continue;
108
+ const text = String.fromCodePoint(unicode);
109
+ const origin = pdfium.readNumbers(2, "double", ([x, y]) => lib.FPDFText_GetCharOrigin(textPage, at, x, y));
110
+ if (origin) {
111
+ const point = userToPage(geometry, origin[0], origin[1]);
112
+ const glyphs = glyphsByObject.get(object) ?? [];
113
+ glyphs.push({ text, ...point });
114
+ glyphsByObject.set(object, glyphs);
115
+ }
116
+ if (/\s/u.test(text))
117
+ continue;
118
+ const box = pdfium.readNumbers(4, "float", ([out]) => lib.FPDFText_GetLooseCharBox(textPage, at, out));
119
+ if (box)
120
+ rightEdges.set(object, Math.max(rightEdges.get(object) ?? -Infinity, userToPage(geometry, box[2], box[1]).x));
121
+ }
122
+ records.forEach((record, index) => {
123
+ if (record.mark ||
124
+ record.staleMarkId ||
125
+ record.type !== OBJECT_TEXT ||
126
+ record.id.includes("/"))
127
+ return;
128
+ const element = elementsById.get(record.id);
129
+ if (!element?.text?.trim())
130
+ return;
131
+ const object = lib.FPDFPage_GetObject(page, index);
132
+ const matrix = pdfium.readNumbers(6, "float", ([out]) => lib.FPDFPageObj_GetMatrix(object, out));
133
+ if (!matrix ||
134
+ matrix[0] <= 0 ||
135
+ matrix[3] <= 0 ||
136
+ Math.abs(matrix[1]) > 0.001 ||
137
+ Math.abs(matrix[2]) > 0.001)
138
+ return;
139
+ const origin = userToPage(geometry, matrix[4], matrix[5]);
140
+ uprightTexts.set(element.id, { element, baseline: origin.y });
141
+ if (!element.textStyle ||
142
+ element.text !== element.text.trimStart() ||
143
+ element.rotation ||
144
+ element.text.includes("\u0002") ||
145
+ /[\r\n\t]/u.test(element.text) ||
146
+ LIST_ITEM_START.test(element.text) ||
147
+ NUMERIC_VALUE.test(element.text) ||
148
+ Math.abs(matrix[0] - matrix[3]) >
149
+ Math.max(matrix[0], matrix[3]) * MATRIX_RELATIVE_PRECISION)
150
+ return;
151
+ const rgba = pdfium.readNumbers(4, "i32", ([r, g, b, a]) => lib.FPDFPageObj_GetFillColor(object, r, g, b, a));
152
+ if (rgba?.[3] !== 255 || lib.FPDFTextObj_GetTextRenderMode(object) !== 0)
153
+ return;
154
+ const font = lib.FPDFTextObj_GetFont(object);
155
+ const rawSize = pdfium.readNumbers(1, "float", ([out]) => lib.FPDFTextObj_GetFontSize(object, out))?.[0];
156
+ if (!rawSize)
157
+ return;
158
+ const effective = {
159
+ ...element,
160
+ textStyle: {
161
+ ...element.textStyle,
162
+ fontSize: round(rawSize * matrix[0]),
163
+ },
164
+ };
165
+ if (effective.textStyle.fontSize < 1 || effective.textStyle.fontSize > 500)
166
+ return;
167
+ // Text/size reflow recomputes default native advances; bounded source pair
168
+ // adjustments are not retained. Color-only changes retain source positions.
169
+ // Non-writable fonts require the separate explicit substitution warning.
170
+ if (fontCanDraw(pdfium, font, element.text) &&
171
+ !hasSupportedAdvances(glyphsByObject.get(object) ?? [], element.text, font, rawSize * matrix[0], measurer))
172
+ return;
173
+ const width = (rightEdges.get(object) ?? element.bounds.x + element.bounds.width) -
174
+ origin.x;
175
+ if (!(width > 0) || element.bounds.width > width + 2)
176
+ return;
177
+ rows.push({
178
+ element: effective,
179
+ index,
180
+ font: fontKey(font),
181
+ fontHandle: font,
182
+ x: origin.x,
183
+ baseline: origin.y,
184
+ width,
185
+ firstWordAdvance: observedFirstWordAdvance(glyphsByObject.get(object) ?? [], element.text),
186
+ });
187
+ });
188
+ rows.sort((a, b) => a.baseline - b.baseline || a.x - b.x);
189
+ // Neighboring cells/list labels share a baseline and a nearby horizontal
190
+ // gap. Distant columns and rotated sidebars do not invalidate body text.
191
+ // Keep blocked rows in `aligned` so leading inference is unchanged.
192
+ const blockedRows = rowsWithAdjacentText(rows, [...uprightTexts.values()]);
193
+ const used = new Set();
194
+ for (const first of rows) {
195
+ if (used.has(first.element.id) || blockedRows.has(first.element.id))
196
+ continue;
197
+ const aligned = rows.filter((row) => !used.has(row.element.id) &&
198
+ row.baseline >= first.baseline &&
199
+ compatible(first, row));
200
+ const gaps = aligned
201
+ .slice(1)
202
+ .map((row, index) => row.baseline - aligned[index].baseline)
203
+ .filter((gap) => gap >= first.element.textStyle.fontSize * 0.8 &&
204
+ gap <= first.element.textStyle.fontSize * 2.1);
205
+ if (gaps.length === 0)
206
+ continue;
207
+ const leading = Math.min(...gaps);
208
+ const group = [];
209
+ for (const row of aligned) {
210
+ const previous = group.at(-1);
211
+ if (previous &&
212
+ (Math.abs(row.baseline - previous.baseline - leading) >
213
+ Math.max(1, leading * 0.15) ||
214
+ /[-\u00ad\u0002]$/u.test(previous.element.text.trim())))
215
+ break;
216
+ group.push(row);
217
+ }
218
+ if (group.length < 2 ||
219
+ group.some((row) => blockedRows.has(row.element.id)))
220
+ continue;
221
+ const bounds = unionRects(group.map((row) => row.element.bounds));
222
+ const rect = roundRect({
223
+ x: first.x,
224
+ y: bounds.y,
225
+ width: Math.max(...group.map((row) => row.width)),
226
+ height: bounds.height,
227
+ });
228
+ const ids = new Set(group.map((row) => row.element.id));
229
+ const firstIndex = Math.min(...group.map((row) => row.index));
230
+ // A short non-final line needs native evidence that the next word could
231
+ // not fit; otherwise it may end an independent paragraph.
232
+ if (group
233
+ .slice(0, -1)
234
+ .some((row, index) => !continuesAtWrap(row, group[index + 1], rect.width, pdfium, measurer)) ||
235
+ elements.some((element) => {
236
+ if (ids.has(element.id) || !element.text?.trim())
237
+ return false;
238
+ const b = element.bounds;
239
+ const upright = uprightTexts.get(element.id);
240
+ const sharesRow = upright && group.some((row) => isAdjacentText(row, upright));
241
+ if (sharesRow)
242
+ return true;
243
+ // Do not expose the matching tail/head of an unsupported connected
244
+ // section (indentation, mixed style, excluded source characters).
245
+ const sharesColumn = b.x < rect.x + rect.width && b.x + b.width > rect.x;
246
+ const edgeGap = Math.max(b.y - (rect.y + rect.height), rect.y - (b.y + b.height));
247
+ return sharesColumn && edgeGap <= leading * MAX_CONNECTED_EDGE_GAP;
248
+ }))
249
+ continue;
250
+ // Ambiguous interleaving/overlap may be another run, heading or figure.
251
+ // A background completely behind the group is safe to keep untouched.
252
+ if (elements.some((element) => !ids.has(element.id) &&
253
+ intersects(rect, element.bounds) &&
254
+ !backgroundBehind(element, rect, records, firstIndex)))
255
+ continue;
256
+ const id = `${first.element.id}:paragraph`;
257
+ if (elementsById.has(id))
258
+ continue;
259
+ let text = "";
260
+ const members = group.map((row) => {
261
+ if (text)
262
+ text += " ";
263
+ const start = text.length;
264
+ text += row.element.text.trim();
265
+ return { elementId: row.element.id, start, end: text.length };
266
+ });
267
+ if (text.length > 20000)
268
+ continue;
269
+ const style = {
270
+ ...first.element.textStyle,
271
+ lineHeight: leading / first.element.textStyle.fontSize,
272
+ };
273
+ const paragraph = {
274
+ id,
275
+ pageIndex,
276
+ text,
277
+ bounds: rect,
278
+ textStyle: style,
279
+ memberIds: [...ids],
280
+ members,
281
+ };
282
+ const target = {
283
+ paragraph,
284
+ spec: {
285
+ kind: "paragraph",
286
+ id,
287
+ text,
288
+ lines: group.map((row) => row.element.text.trim()),
289
+ rect,
290
+ style,
291
+ baselineOffset: (first.baseline - rect.y) / style.fontSize,
292
+ },
293
+ indexes: group.map((row) => row.index).sort((a, b) => a - b),
294
+ };
295
+ result.set(id, target);
296
+ for (const member of members) {
297
+ result.set(member.elementId, target);
298
+ used.add(member.elementId);
299
+ }
300
+ }
301
+ return result;
302
+ }
303
+ function compatible(a, b) {
304
+ return (Math.abs(a.x - b.x) <= MAX_LEFT_ALIGNMENT_DELTA && sameTypography(a, b));
305
+ }
306
+ function sameTypography(a, b) {
307
+ const x = a.element.textStyle;
308
+ const y = b.element.textStyle;
309
+ return (a.font === b.font &&
310
+ x.fontSize === y.fontSize &&
311
+ x.color === y.color &&
312
+ x.bold === y.bold &&
313
+ x.italic === y.italic);
314
+ }
315
+ function rowsWithAdjacentText(rows, texts) {
316
+ texts.sort((a, b) => a.baseline - b.baseline);
317
+ const blocked = new Set();
318
+ for (const row of rows) {
319
+ const tolerance = row.element.textStyle.fontSize * ADJACENT_BASELINE_EM;
320
+ let low = 0;
321
+ let high = texts.length;
322
+ while (low < high) {
323
+ const mid = (low + high) >>> 1;
324
+ if (texts[mid].baseline < row.baseline - tolerance)
325
+ low = mid + 1;
326
+ else
327
+ high = mid;
328
+ }
329
+ for (let at = low; at < texts.length; at += 1) {
330
+ const other = texts[at];
331
+ if (other.baseline > row.baseline + tolerance)
332
+ break;
333
+ if (isAdjacentText(row, other)) {
334
+ blocked.add(row.element.id);
335
+ break;
336
+ }
337
+ }
338
+ }
339
+ return blocked;
340
+ }
341
+ function isAdjacentText(row, other) {
342
+ if (row.element.id === other.element.id)
343
+ return false;
344
+ const size = row.element.textStyle.fontSize;
345
+ if (Math.abs(row.baseline - other.baseline) > size * ADJACENT_BASELINE_EM)
346
+ return false;
347
+ const a = row.element.bounds;
348
+ const b = other.element.bounds;
349
+ const gap = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width));
350
+ return gap <= size * ADJACENT_TEXT_GAP_EM;
351
+ }
352
+ function continuesAtWrap(row, next, width, pdfium, measurer) {
353
+ if (row.width >= width * MIN_CONTINUATION_FILL)
354
+ return true;
355
+ // Source glyph origins also work for fonts whose encoding cannot be reused
356
+ // (for example a subset missing U+0020). Do not measure its notdef space.
357
+ if (next.firstWordAdvance !== undefined)
358
+ return row.width + next.firstWordAdvance > width + GLYPH_POSITION_PRECISION;
359
+ const nextWord = next.element.text.trim().split(/\s/u)[0];
360
+ if (!nextWord || !fontCanDraw(pdfium, row.fontHandle, ` ${nextWord}`))
361
+ return false;
362
+ const needed = measurer.advance(row.fontHandle, row.element.textStyle.fontSize, ` ${nextWord}`);
363
+ return (Number.isFinite(needed) &&
364
+ row.width + needed > width + GLYPH_POSITION_PRECISION);
365
+ }
366
+ function observedFirstWordAdvance(glyphs, text) {
367
+ const prefix = /^\S+\s+/u.exec(text)?.[0];
368
+ if (!prefix)
369
+ return undefined;
370
+ const count = [...prefix].length;
371
+ const first = glyphs[0];
372
+ const nextWord = glyphs[count];
373
+ if (!first ||
374
+ !nextWord ||
375
+ glyphs
376
+ .slice(0, count)
377
+ .map((glyph) => glyph.text)
378
+ .join("") !== prefix)
379
+ return undefined;
380
+ const advance = nextWord.x - first.x;
381
+ return Number.isFinite(advance) && advance > 0 ? advance : undefined;
382
+ }
383
+ /** A bounded eligibility rule, not a claim that source TJ positioning survives reflow. */
384
+ function hasSupportedAdvances(glyphs, text, font, fontSize, measurer) {
385
+ const characters = [...text.trimEnd()];
386
+ const first = glyphs[0];
387
+ if (!first || glyphs.length < characters.length)
388
+ return false;
389
+ const pairLimit = fontSize * MAX_PAIR_ADJUSTMENT_EM;
390
+ let previousAdvance = 0;
391
+ let cumulativeAdjustment = 0;
392
+ let adjustedPairs = 0;
393
+ let defaultPairs = 0;
394
+ for (const [index, character] of characters.entries()) {
395
+ const glyph = glyphs[index];
396
+ if (!glyph ||
397
+ glyph.text !== character ||
398
+ !Number.isFinite(glyph.x) ||
399
+ !Number.isFinite(glyph.y) ||
400
+ Math.abs(glyph.y - first.y) > GLYPH_POSITION_PRECISION)
401
+ return false;
402
+ const previous = glyphs[index - 1];
403
+ if (previous) {
404
+ const adjustment = glyph.x - previous.x - previousAdvance;
405
+ const changed = Math.abs(adjustment) > GLYPH_POSITION_PRECISION;
406
+ // Real and inferred word spaces cannot be treated as pair adjustments.
407
+ if (/\s/u.test(previous.text + character)) {
408
+ if (changed)
409
+ return false;
410
+ }
411
+ else if (changed) {
412
+ if (Math.abs(adjustment) > pairLimit)
413
+ return false;
414
+ adjustedPairs += 1;
415
+ }
416
+ else {
417
+ defaultPairs += 1;
418
+ }
419
+ cumulativeAdjustment += adjustment;
420
+ if (Math.abs(cumulativeAdjustment) > pairLimit)
421
+ return false;
422
+ }
423
+ previousAdvance = measurer.advance(font, fontSize, character);
424
+ if (!Number.isFinite(previousAdvance) || previousAdvance <= 0)
425
+ return false;
426
+ }
427
+ // Uniform tracking cannot pass merely because its per-glyph change is tiny.
428
+ return adjustedPairs === 0 || defaultPairs > adjustedPairs;
429
+ }
430
+ export function intersects(a, b) {
431
+ return (a.x < b.x + b.width &&
432
+ a.x + a.width > b.x &&
433
+ a.y < b.y + b.height &&
434
+ a.y + a.height > b.y);
435
+ }
436
+ function backgroundBehind(element, rect, records, first) {
437
+ const b = element.bounds;
438
+ return (element.kind !== "text" &&
439
+ element.text === undefined &&
440
+ records.findIndex((record) => record.id === element.id) < first &&
441
+ b.x <= rect.x &&
442
+ b.y <= rect.y &&
443
+ b.x + b.width >= rect.x + rect.width &&
444
+ b.y + b.height >= rect.y + rect.height);
445
+ }
@@ -59,6 +59,7 @@ export interface PdfiumFunctions {
59
59
  FPDFPath_SetDrawMode(path: number, fillMode: number, stroke: boolean): boolean;
60
60
  FPDFTextObj_GetText(textObject: number, textPage: number, buffer: number, bytes: number): number;
61
61
  FPDFTextObj_GetFontSize(textObject: number, size: number): boolean;
62
+ FPDFTextObj_GetTextRenderMode(textObject: number): number;
62
63
  FPDFTextObj_GetFont(textObject: number): number;
63
64
  FPDFFont_GetFamilyName(font: number, buffer: number, bytes: number): number;
64
65
  FPDFFont_GetBaseFontName(font: number, buffer: number, bytes: number): number;
@@ -69,6 +70,8 @@ export interface PdfiumFunctions {
69
70
  FPDFPageObj_Destroy(object: number): void;
70
71
  FPDFPage_RemoveObject(page: number, object: number): boolean;
71
72
  FPDFFont_GetWeight(font: number): number;
73
+ /** The advance of the character the font encodes `glyph`, a UTF-16 code unit, as: the PDF's own width for it. */
74
+ FPDFFont_GetGlyphWidth(font: number, glyph: number, fontSize: number, width: number): boolean;
72
75
  FPDFText_GetCharBox(textPage: number, index: number, left: number, right: number, bottom: number, top: number): boolean;
73
76
  FPDFText_GetTextObject(textPage: number, index: number): number;
74
77
  FPDFText_GetUnicode(textPage: number, index: number): number;
@@ -124,9 +124,9 @@ export function removeObjects(context, location) {
124
124
  const object = lib.FPDFPage_GetObject(page, index);
125
125
  lib.FPDFPage_RemoveObject(page, object);
126
126
  lib.FPDFPageObj_Destroy(object);
127
+ context.spliceObjects(location.pageIndex, index, 1, []);
127
128
  }
128
129
  });
129
- context.spliceObjects(location.pageIndex, first, location.indexes.length, []);
130
130
  return first;
131
131
  }
132
132
  /** The location and stored inputs of a text box, or the issue that stops the edit. */
@@ -194,6 +194,7 @@ export function fontRequestsOf(operations, markOf, elementOf) {
194
194
  text: operation.text,
195
195
  });
196
196
  break;
197
+ case "replaceParagraphText":
197
198
  case "replaceText": {
198
199
  const box = spec(operation.target);
199
200
  if (box) {
@@ -201,14 +202,26 @@ export function fontRequestsOf(operations, markOf, elementOf) {
201
202
  break;
202
203
  }
203
204
  const element = elementOf(operation.target);
204
- if (element?.kind === "text")
205
+ if (element?.kind === "text" || element?.kind === "paragraph")
205
206
  requests.push({
206
207
  family: element.textStyle?.fontFamily ?? "Helvetica",
207
- text: operation.text,
208
+ text: element.kind === "paragraph" && operation.range
209
+ ? (element.text ?? "").slice(0, operation.range.start.offset) +
210
+ operation.text +
211
+ (element.text ?? "").slice(operation.range.end.offset)
212
+ : operation.text,
208
213
  });
209
214
  break;
210
215
  }
211
216
  case "setTextStyle": {
217
+ const paragraph = elementOf(operation.target);
218
+ if (paragraph?.kind === "paragraph" &&
219
+ operation.style.fontSize !== undefined &&
220
+ operation.style.fontSize !== paragraph.textStyle?.fontSize)
221
+ requests.push({
222
+ family: paragraph.textStyle?.fontFamily ?? "Helvetica",
223
+ text: paragraph.text ?? "",
224
+ });
212
225
  const box = spec(operation.target);
213
226
  if (box)
214
227
  requests.push({
@@ -1,5 +1,6 @@
1
1
  import { glyphUnicode, readCff } from "./cff.js";
2
2
  import { textStyle } from "./elements.js";
3
+ import { fontBytes } from "./fonts.js";
3
4
  import { cffOpenType, completeSfnt, hasUnicodeCmap } from "./opentype.js";
4
5
  export function textFaceOf(pdfium, object, cache) {
5
6
  const { lib } = pdfium;
@@ -9,7 +10,7 @@ export function textFaceOf(pdfium, object, cache) {
9
10
  if (!lib.FPDFFont_GetIsEmbedded(font))
10
11
  return { key: `name:${baseName}`, family, missing: "not-embedded" };
11
12
  // A Type 3 font is embedded as drawings, with no program to read.
12
- const program = fontProgram(pdfium, font);
13
+ const program = fontBytes(pdfium, font);
13
14
  if (!program)
14
15
  return { key: `embedded:${baseName}`, family, missing: "unreadable" };
15
16
  const key = `${baseName}#${fnv1a(program).toString(16)}`;
@@ -54,24 +55,6 @@ function faceOf(program, baseName) {
54
55
  ? { face: { data, format: "opentype" } }
55
56
  : { missing: "no-unicode" };
56
57
  }
57
- /** The font program a PDF font object embeds, or `undefined` for a font it does not embed. */
58
- function fontProgram(pdfium, font) {
59
- const { lib } = pdfium;
60
- const size = pdfium.readNumbers(1, "i32", ([out]) => lib.FPDFFont_GetFontData(font, 0, 0, out))?.[0];
61
- if (!size)
62
- return undefined;
63
- const buffer = pdfium.malloc(size);
64
- const out = pdfium.malloc(4);
65
- try {
66
- return lib.FPDFFont_GetFontData(font, buffer, size, out)
67
- ? pdfium.readBytes(buffer, size)
68
- : undefined;
69
- }
70
- finally {
71
- pdfium.free(out);
72
- pdfium.free(buffer);
73
- }
74
- }
75
58
  /** FNV-1a over the program: a cheap, stable key for one font. */
76
59
  function fnv1a(bytes) {
77
60
  let hash = 0x811c9dc5;
@@ -11,6 +11,10 @@ export const moveElement = {
11
11
  const target = anyTarget(operation.target, context, issue);
12
12
  if (!target)
13
13
  return;
14
+ if (target.element.kind === "paragraph") {
15
+ issue("/target", "unsupported-target", "Imported paragraphs cannot be moved");
16
+ return;
17
+ }
14
18
  if ((operation.to === undefined) === (operation.by === undefined)) {
15
19
  issue("", "one-of", "Give exactly one of `to` and `by`");
16
20
  return;
@@ -52,8 +56,9 @@ export const resizeElement = {
52
56
  const target = anyTarget(operation.target, context, issue);
53
57
  if (!target)
54
58
  return;
55
- if (target.element.kind === "table") {
56
- issue("/target", "unsupported-target", "Tables cannot be resized");
59
+ if (target.element.kind === "table" ||
60
+ target.element.kind === "paragraph") {
61
+ issue("/target", "unsupported-target", "Tables and imported paragraphs cannot be resized");
57
62
  return;
58
63
  }
59
64
  validateRect(operation.rect, context.geometry(target.location.pageIndex), issue);
@@ -97,10 +102,16 @@ export const deleteElement = {
97
102
  },
98
103
  apply(operation, context) {
99
104
  const { location } = anyTarget(operation.target, context);
105
+ const paragraph = context.paragraph(operation.target)?.paragraph;
100
106
  removeObjects(context, location);
101
107
  return {
102
108
  ...changed(location, { overflow: false }),
103
- removedIds: [operation.target],
109
+ removedIds: [
110
+ ...new Set([
111
+ operation.target,
112
+ ...(paragraph?.id === operation.target ? paragraph.memberIds : []),
113
+ ]),
114
+ ],
104
115
  };
105
116
  },
106
117
  };
@@ -2,9 +2,10 @@ import { type WorkerLike } from "../../worker-client.js";
2
2
  import type { EditEngineContext } from "../engine.js";
3
3
  import type { PagePoint, PageRect, TextPosition, TextRange } from "../types.js";
4
4
  import { WorkerEngineClient } from "../worker-engine.js";
5
- import type { PageBitmap, PageLayout, TextFont, TextLayout } from "./types.js";
5
+ import type { PageBitmap, PageLayout, PdfTextParagraph, TextFont, TextLayout } from "./types.js";
6
6
  /** The reads behind the overlay primitives, beyond the core engine interface. */
7
7
  export interface PdfEngineReads {
8
+ textParagraph?(id: string, signal: AbortSignal): Promise<PdfTextParagraph | undefined>;
8
9
  textLayout(id: string, signal: AbortSignal): Promise<TextLayout | undefined>;
9
10
  textFont(id: string, signal: AbortSignal): Promise<TextFont | undefined>;
10
11
  positionAt(pageIndex: number, point: PagePoint, signal: AbortSignal): Promise<TextPosition | undefined>;
@@ -28,6 +29,7 @@ export declare function loadPdfEditEngine(original: Uint8Array, context: EditEng
28
29
  export declare class PdfEditEngineClient extends WorkerEngineClient implements PdfEngineReads {
29
30
  readonly schemas: import("../types.js").OperationSchemaSet;
30
31
  start(original: Uint8Array, options: PdfEditProviderOptions): Promise<void>;
32
+ textParagraph(id: string, signal: AbortSignal): Promise<PdfTextParagraph | undefined>;
31
33
  textLayout(id: string, signal: AbortSignal): Promise<TextLayout | undefined>;
32
34
  textFont(id: string, signal: AbortSignal): Promise<TextFont | undefined>;
33
35
  positionAt(pageIndex: number, point: PagePoint, signal: AbortSignal): Promise<TextPosition | undefined>;
@@ -50,6 +50,9 @@ export class PdfEditEngineClient extends WorkerEngineClient {
50
50
  .filter((source) => source instanceof ArrayBuffer),
51
51
  ]);
52
52
  }
53
+ textParagraph(id, signal) {
54
+ return this.request("edit-text-paragraph", { id }, signal);
55
+ }
53
56
  textLayout(id, signal) {
54
57
  return this.request("edit-text-layout", { id }, signal);
55
58
  }
@@ -1,4 +1,4 @@
1
- import type { EditOperation, EditReceipt, TextRange } from "../types.js";
1
+ import type { EditOperation, EditReceipt, TextRange, TextAnchorMigration } from "../types.js";
2
2
  export interface MutationRecord {
3
3
  /** The revision the call produced. */
4
4
  readonly revision: number;
@@ -10,6 +10,7 @@ export interface MutationRecord {
10
10
  * offsets inside surviving elements are left as they were.
11
11
  */
12
12
  readonly operations: readonly EditOperation[];
13
+ readonly textAnchorMigrations?: readonly TextAnchorMigration[];
13
14
  readonly receipt: EditReceipt;
14
15
  }
15
16
  /** The range after the records, or `undefined` when its element is gone. */