web-doc 0.11.0 → 0.12.1

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 +33 -64
  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 +4 -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 +2026 -1004
  35. package/package.json +1 -1
@@ -90,14 +90,16 @@ class PdfJsBackend {
90
90
  #module;
91
91
  #document;
92
92
  #worker;
93
+ #workerPort;
93
94
  #loadingTask;
94
95
  #maxPixels;
95
96
  #pages = new Map();
96
97
  #closed = false;
97
- constructor(module, document, worker, loadingTask, maxPixels) {
98
+ constructor(module, document, worker, workerPort, loadingTask, maxPixels) {
98
99
  this.#module = module;
99
100
  this.#document = document;
100
101
  this.#worker = worker;
102
+ this.#workerPort = workerPort;
101
103
  this.#loadingTask = loadingTask;
102
104
  this.#maxPixels = maxPixels;
103
105
  this.pageCount = document.numPages;
@@ -155,7 +157,7 @@ class PdfJsBackend {
155
157
  const document = await withTimeout(loadingTask.promise, context.limits.maxOperationMs, "PDF parsing");
156
158
  if (context.signal.aborted)
157
159
  throw abortError();
158
- return new PdfJsBackend(module, document, worker, loadingTask, context.limits.maxDecodedPixels);
160
+ return new PdfJsBackend(module, document, worker, workerPort, loadingTask, context.limits.maxDecodedPixels);
159
161
  }
160
162
  catch (error) {
161
163
  await loadingTask.destroy().catch(() => undefined);
@@ -168,9 +170,14 @@ class PdfJsBackend {
168
170
  }
169
171
  }
170
172
  async renderPage(target, pageIndex, zoom, devicePixelRatio, signal) {
171
- this.#assertOpen();
172
- const page = await this.#page(pageIndex);
173
- if (signal?.aborted)
173
+ if (this.#closed || signal?.aborted)
174
+ throw abortError();
175
+ const page = await this.#page(pageIndex).catch((error) => {
176
+ if (this.#closed || signal?.aborted)
177
+ throw abortError();
178
+ throw error;
179
+ });
180
+ if (this.#closed || signal?.aborted)
174
181
  throw abortError();
175
182
  const viewport = page.getViewport({ scale: CSS_UNITS * zoom });
176
183
  const dpr = Math.max(1, devicePixelRatio);
@@ -200,11 +207,12 @@ class PdfJsBackend {
200
207
  signal?.addEventListener("abort", onAbort, { once: true });
201
208
  try {
202
209
  await renderTask.promise;
203
- if (signal?.aborted)
210
+ if (this.#closed || signal?.aborted)
204
211
  throw abortError();
205
212
  }
206
213
  catch (error) {
207
- if (signal?.aborted ||
214
+ if (this.#closed ||
215
+ signal?.aborted ||
208
216
  error instanceof this.#module.RenderingCancelledException ||
209
217
  (error instanceof Error && error.name === "RenderingCancelledException"))
210
218
  throw abortError();
@@ -251,11 +259,14 @@ class PdfJsBackend {
251
259
  this.#closed = true;
252
260
  this.#pages.clear();
253
261
  try {
254
- await this.#document.cleanup();
262
+ // Destroy cancels and awaits active renders. PDF.js cleanup is only
263
+ // safe while idle, so it must not run before retiring this document.
255
264
  await this.#loadingTask.destroy();
256
265
  }
257
266
  finally {
258
267
  this.#worker.destroy();
268
+ // PDFWorker does not terminate an externally supplied port; we own it.
269
+ this.#workerPort.terminate();
259
270
  }
260
271
  }
261
272
  #page(pageIndex) {
@@ -1,6 +1,6 @@
1
1
  import type { DocumentFormat, RegisteredFont, ResourceLimits, TextRun, ViewerWarning } from "../contracts.js";
2
2
  import type { EditSession } from "./sessions.js";
3
- import type { ChangeMode, EditableFormat, EditElement, EditFindOptions, EditOperation, EditSessionBase, ElementQuery, OperationIssue, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, TextTarget } from "./types.js";
3
+ import type { ChangeMode, EditableFormat, EditElement, EditFindOptions, EditOperation, EditSessionBase, ElementQuery, OperationIssue, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, TextTarget, TextAnchorMigration } from "./types.js";
4
4
  export interface EditEngineContext {
5
5
  readonly format: EditableFormat;
6
6
  readonly fileName?: string;
@@ -74,6 +74,7 @@ export interface EngineChange {
74
74
  readonly removedIds: readonly string[];
75
75
  /** Old id → new id, when the format had to rename an element. */
76
76
  readonly remappedIds?: Readonly<Record<string, string>>;
77
+ readonly textAnchorMigrations?: readonly TextAnchorMigration[];
77
78
  /** A superset of the pages whose content changed. */
78
79
  readonly changedPages: readonly number[];
79
80
  /**
@@ -1,6 +1,6 @@
1
1
  import type { EngineBatch, EngineChange, MaterializedDocument, RestoreTarget } from "../../engine.js";
2
2
  import type { EditFindOptions, EditOperation, ElementQuery, OperationIssue, PagePoint, PageRect, TextPosition, TextRange, TextTarget } from "../../types.js";
3
- import type { PageLayout, PdfElement, PdfOperation, TextFont, TextLayout } from "../types.js";
3
+ import type { PageLayout, PdfElement, PdfOperation, PdfTextParagraph, TextFont, TextLayout } from "../types.js";
4
4
  import type { EditWorkerBitmap } from "../../../worker-protocol.js";
5
5
  import { FontLibrary } from "./fonts.js";
6
6
  import { ImageCache } from "./images.js";
@@ -76,7 +76,9 @@ export declare class PdfEditDocument {
76
76
  /** Elements under a point, top-most (drawn last) first. */
77
77
  elementsAt(pageIndex: number, point: PagePoint): PdfElement[];
78
78
  findText(query: string, options: EditFindOptions): TextTarget[];
79
- /** Lines, glyph boxes and styles of a text, text box or table element. */
79
+ /** Resolves the canonical imported paragraph, when its rows can be grouped safely. */
80
+ textParagraph(elementId: string): PdfTextParagraph | undefined;
81
+ /** Lines, glyph boxes and styles of a text, text box, paragraph or table element. */
80
82
  textLayout(elementId: string): TextLayout | undefined;
81
83
  /** The browser face of the font a text or text box element is drawn in, see `TextFont`. */
82
84
  textFont(elementId: string): TextFont | undefined;
@@ -7,6 +7,8 @@ import { textFaceOf } from "./text-font.js";
7
7
  import { fontRequestsOf } from "./text-box.js";
8
8
  import { issueCollector, } from "./operations.js";
9
9
  import { insertTextBox } from "./text-box.js";
10
+ import { discoverParagraphs, paragraphElement, } from "./paragraph.js";
11
+ import { replaceParagraphText } from "./paragraph-edit.js";
10
12
  import { replaceText, setTextStyle } from "./existing-text.js";
11
13
  import { deleteElement, moveElement, resizeElement } from "./transform.js";
12
14
  import { deletePage, insertPage, movePage, rotatePage } from "./pages.js";
@@ -90,6 +92,7 @@ function referenceOf(operation) {
90
92
  return typeof target === "string" ? parseReference(target) : undefined;
91
93
  }
92
94
  const handlers = {
95
+ replaceParagraphText: replaceParagraphText,
93
96
  insertTextBox: insertTextBox,
94
97
  replaceText: replaceText,
95
98
  setTextStyle: setTextStyle,
@@ -209,6 +212,7 @@ export class PdfEditDocument {
209
212
  const createdIds = [];
210
213
  const createdByOperation = [];
211
214
  const removedIds = [];
215
+ const textAnchorMigrations = [];
212
216
  const changedPages = new Set();
213
217
  const warnings = [];
214
218
  // The first change of a signed, certified, tagged or PDF/A file is the
@@ -230,6 +234,8 @@ export class PdfEditDocument {
230
234
  createdIds.push(...result.createdIds);
231
235
  createdByOperation[operationIndex] = [...result.createdIds];
232
236
  removedIds.push(...(result.removedIds ?? []));
237
+ for (const migration of result.textAnchorMigrations ?? [])
238
+ textAnchorMigrations.push({ ...migration, operationIndex });
233
239
  for (const pageIndex of result.changedPages)
234
240
  changedPages.add(pageIndex);
235
241
  warnings.push(...result.warnings);
@@ -238,6 +244,7 @@ export class PdfEditDocument {
238
244
  return {
239
245
  createdIds,
240
246
  removedIds,
247
+ ...(textAnchorMigrations.length ? { textAnchorMigrations } : {}),
241
248
  changedPages: [...changedPages].sort((a, b) => a - b),
242
249
  pageCount: this.pageCount,
243
250
  warnings,
@@ -368,7 +375,13 @@ export class PdfEditDocument {
368
375
  const pageIndex = this.#pageIndexOf(id);
369
376
  if (pageIndex === undefined)
370
377
  return undefined;
371
- return this.#elementsOf(pageIndex).find((element) => element.id === id);
378
+ const element = this.#elementsOf(pageIndex).find((element) => element.id === id);
379
+ if (element)
380
+ return element;
381
+ const target = this.#pages[pageIndex]?.paragraphs?.get(id);
382
+ return target?.paragraph.id === id
383
+ ? paragraphElement(target.paragraph)
384
+ : undefined;
372
385
  }
373
386
  /** Elements under a point, top-most (drawn last) first. */
374
387
  elementsAt(pageIndex, point) {
@@ -395,25 +408,60 @@ export class PdfEditDocument {
395
408
  });
396
409
  return targets;
397
410
  }
398
- /** Lines, glyph boxes and styles of a text, text box or table element. */
411
+ /** Resolves the canonical imported paragraph, when its rows can be grouped safely. */
412
+ textParagraph(elementId) {
413
+ return this.#paragraphOf(elementId)?.paragraph;
414
+ }
415
+ #paragraphOf(id) {
416
+ const pageIndex = this.#pageIndexOf(id);
417
+ if (pageIndex === undefined)
418
+ return undefined;
419
+ this.#elementsOf(pageIndex);
420
+ return this.#pages[pageIndex]?.paragraphs?.get(id);
421
+ }
422
+ /** Lines, glyph boxes and styles of a text, text box, paragraph or table element. */
399
423
  textLayout(elementId) {
400
424
  const pageIndex = this.#pageIndexOf(elementId);
401
425
  if (pageIndex === undefined)
402
426
  return undefined;
403
- return this.#scanText(pageIndex, (scan) => {
427
+ const paragraph = this.#paragraphOf(elementId);
428
+ return this.#scanText(pageIndex, (raw) => {
429
+ const scan = paragraph?.paragraph.id === elementId
430
+ ? this.#paragraphScan(raw, paragraph)
431
+ : raw;
404
432
  const element = scan.elements.find((entry) => entry.id === elementId);
405
- return element ? layoutOf(this.#pdfium, scan, element) : undefined;
433
+ const layout = element
434
+ ? layoutOf(this.#pdfium, scan, element)
435
+ : undefined;
436
+ return layout && paragraph?.paragraph.id === elementId
437
+ ? {
438
+ ...layout,
439
+ lines: [...layout.lines]
440
+ .sort((a, b) => a.range.start.offset - b.range.start.offset)
441
+ .map((line) => ({
442
+ ...line,
443
+ fontSize: paragraph.paragraph.textStyle.fontSize,
444
+ })),
445
+ }
446
+ : layout;
406
447
  });
407
448
  }
408
449
  /** The browser face of the font a text or text box element is drawn in, see `TextFont`. */
409
450
  textFont(elementId) {
410
451
  const location = this.#locate(elementId);
411
- const element = location && this.getElement(elementId);
412
- if (!element || (element.kind !== "text" && element.kind !== "textBox"))
452
+ if (!location)
453
+ return undefined;
454
+ // A paragraph not yet edited is not listed among the elements; its id
455
+ // still locates its rows.
456
+ const kind = this.getElement(elementId)?.kind ??
457
+ (this.#paragraphOf(elementId)?.paragraph.id === elementId
458
+ ? "paragraph"
459
+ : undefined);
460
+ if (kind !== "text" && kind !== "textBox" && kind !== "paragraph")
413
461
  return undefined;
414
462
  const { lib } = this.#pdfium;
415
463
  return this.#withPage(location.pageIndex, (page) => {
416
- // A text box's lines share their font; its first line answers.
464
+ // A text box's lines and a paragraph's rows share their font; the first answers.
417
465
  const object = location.indexes
418
466
  .map((index) => lib.FPDFPage_GetObject(page, index))
419
467
  .find((entry) => lib.FPDFPageObj_GetType(entry) === OBJECT_TEXT);
@@ -465,7 +513,10 @@ export class PdfEditDocument {
465
513
  if (!Number.isSafeInteger(pixels) ||
466
514
  pixels > this.#limits.maxDecodedPixels)
467
515
  throw new ViewerError("resource-limit", "The rendered page exceeds maxDecodedPixels", { details: { actual: pixels, limit: this.#limits.maxDecodedPixels } });
468
- const wanted = new Set(elementIds);
516
+ const wanted = new Set(elementIds.flatMap((id) => {
517
+ const paragraph = this.#paragraphOf(id)?.paragraph;
518
+ return paragraph?.id === id ? paragraph.memberIds : [id];
519
+ }));
469
520
  const records = this.#objectsOf(pageIndex, page);
470
521
  const suppressed = [];
471
522
  records.forEach((record, index) => {
@@ -529,7 +580,12 @@ export class PdfEditDocument {
529
580
  if (pageIndex === undefined ||
530
581
  pageIndex !== this.#pageIndexOf(range.end.elementId))
531
582
  return [];
532
- return this.#scanText(pageIndex, (scan) => rectsOf(this.#pdfium, scan, range));
583
+ const paragraph = range.start.elementId === range.end.elementId
584
+ ? this.#paragraphOf(range.start.elementId)
585
+ : undefined;
586
+ return this.#scanText(pageIndex, (scan) => rectsOf(this.#pdfium, paragraph?.paragraph.id === range.start.elementId
587
+ ? this.#paragraphScan(scan, paragraph)
588
+ : scan, range));
533
589
  }
534
590
  dispose() {
535
591
  this.#fonts.release(this.#pdfium, this.#document.handle);
@@ -561,10 +617,14 @@ export class PdfEditDocument {
561
617
  // part of the base, so a replay meets them and picks the same id.
562
618
  newId: (pageIndex, suffix = "") => `${this.#unusedId(pageIndex, `${this.#pages[pageIndex].key}:n${stateId}.${operationIndex}.${created++}`)}${suffix}`,
563
619
  withPage: (pageIndex, use) => this.#writePage(pageIndex, use),
620
+ readPage: (pageIndex, use) => this.#withPage(pageIndex, use),
621
+ paragraph: (id) => this.#paragraphOf(id),
622
+ pageElements: (pageIndex) => this.#elementsOf(pageIndex),
564
623
  appendObjects: (pageIndex, records) => {
565
624
  const page = this.#pages[pageIndex];
566
625
  page.objects = [...(page.objects ?? []), ...records];
567
626
  delete page.elements;
627
+ delete page.paragraphs;
568
628
  },
569
629
  locate: (id) => this.#locate(id),
570
630
  element: (id) => this.getElement(id),
@@ -591,6 +651,7 @@ export class PdfEditDocument {
591
651
  return;
592
652
  delete page.geometry;
593
653
  delete page.elements;
654
+ delete page.paragraphs;
594
655
  },
595
656
  spliceObjects: (pageIndex, start, count, records) => {
596
657
  const page = this.#pages[pageIndex];
@@ -598,13 +659,16 @@ export class PdfEditDocument {
598
659
  objects.splice(start, count, ...records);
599
660
  page.objects = objects;
600
661
  delete page.elements;
662
+ delete page.paragraphs;
601
663
  },
602
664
  };
603
665
  }
604
666
  /** Cached elements carry page indexes, which a structure change makes stale. */
605
667
  #forgetElements() {
606
- for (const page of this.#pages)
668
+ for (const page of this.#pages) {
607
669
  delete page.elements;
670
+ delete page.paragraphs;
671
+ }
608
672
  }
609
673
  #locate(id) {
610
674
  const pageIndex = this.#pageIndexOf(id);
@@ -617,7 +681,17 @@ export class PdfEditDocument {
617
681
  indexes.push(index);
618
682
  });
619
683
  const record = records[indexes[0] ?? -1];
620
- return record ? { pageIndex, indexes, record } : undefined;
684
+ if (record)
685
+ return { pageIndex, indexes, record };
686
+ const paragraph = this.#paragraphOf(id);
687
+ const first = paragraph && records[paragraph.indexes[0] ?? -1];
688
+ return paragraph?.paragraph.id === id && first
689
+ ? {
690
+ pageIndex,
691
+ indexes: paragraph.indexes,
692
+ record: { ...first, id, mark: paragraph.spec },
693
+ }
694
+ : undefined;
621
695
  }
622
696
  /** Loads a page, lets `use` change it, regenerates its content stream. */
623
697
  #writePage(pageIndex, use) {
@@ -636,6 +710,7 @@ export class PdfEditDocument {
636
710
  details: { stage: "apply", pageIndex },
637
711
  });
638
712
  delete this.#pages[pageIndex].elements;
713
+ delete this.#pages[pageIndex].paragraphs;
639
714
  return result;
640
715
  }
641
716
  finally {
@@ -657,7 +732,13 @@ export class PdfEditDocument {
657
732
  return record.elements;
658
733
  return this.#withPage(pageIndex, (page, textPage, geometry) => {
659
734
  const { elements } = scanPage(this.#pdfium, page, textPage, pageIndex, geometry, this.#objectsOf(pageIndex, page));
660
- record.elements = Object.freeze(elements);
735
+ record.paragraphs = discoverParagraphs(this.#pdfium, page, pageIndex, geometry, this.#objectsOf(pageIndex, page), elements, textPage, this.#measurer);
736
+ record.elements = Object.freeze(elements.map((element) => {
737
+ const paragraph = record.paragraphs?.get(element.id)?.paragraph;
738
+ return paragraph && paragraph.id !== element.id
739
+ ? { ...element, textEditingTarget: paragraph.id }
740
+ : element;
741
+ }));
661
742
  return record.elements;
662
743
  });
663
744
  }
@@ -779,6 +860,28 @@ export class PdfEditDocument {
779
860
  };
780
861
  return record.geometry;
781
862
  }
863
+ #paragraphScan(scan, target) {
864
+ const paragraph = target.paragraph;
865
+ if (paragraph.memberIds.includes(paragraph.id))
866
+ return scan;
867
+ const members = new Map(paragraph.members.map((member) => [member.elementId, member]));
868
+ return {
869
+ ...scan,
870
+ elements: [
871
+ ...scan.elements.filter((element) => !members.has(element.id)),
872
+ paragraphElement(paragraph),
873
+ ],
874
+ offsets: scan.offsets.map((position) => {
875
+ const member = position && members.get(position.elementId);
876
+ return member && position
877
+ ? {
878
+ elementId: paragraph.id,
879
+ offset: Math.min(member.end, member.start + position.offset),
880
+ }
881
+ : position;
882
+ }),
883
+ };
884
+ }
782
885
  /** Loads a page with its text page and the character-to-element mapping. */
783
886
  #scanText(pageIndex, use) {
784
887
  return this.#withPage(pageIndex, (page, textPage, geometry) => {
@@ -2,6 +2,7 @@ import type { PageRect } from "../../types.js";
2
2
  import type { PdfElement, PdfTextStyle } from "../types.js";
3
3
  import { type PageGeometry } from "./geometry.js";
4
4
  import type { Pdfium } from "./pdfium.js";
5
+ import type { ParagraphSpec } from "./paragraph.js";
5
6
  /** FPDFPageObj_GetType values. */
6
7
  export declare const OBJECT_TEXT = 1;
7
8
  export declare const OBJECT_PATH = 2;
@@ -11,10 +12,13 @@ export declare const MARK_NAME = "WebDoc";
11
12
  export declare const MARK_PARAM = "webdoc";
12
13
  /** What a `WebDoc` mark says about the objects it tags. */
13
14
  export interface MarkParams {
14
- readonly kind: "textBox" | "table";
15
+ readonly kind: "textBox" | "table" | "paragraph";
15
16
  readonly id: string;
16
17
  readonly [key: string]: unknown;
17
18
  }
19
+ export declare function paragraphSpecOf(mark: unknown): ParagraphSpec | undefined;
20
+ /** Paragraph marks store the upright frame; the page may subsequently rotate. */
21
+ export declare function paragraphBounds(spec: ParagraphSpec, geometry: PageGeometry): PageRect;
18
22
  /** One PDFium page object with what the model knows about it. */
19
23
  export interface ObjectRecord {
20
24
  /** Element id of a plain object; objects of a marked group share their group id. */
@@ -1,6 +1,6 @@
1
1
  import { validateSchema } from "../../schema.js";
2
- import { tableMarkSchema, tableMemberMarkSchema, textBoxMarkSchema, } from "../schemas.js";
3
- import { round, roundRect, unionRects, userRectToPage, } from "./geometry.js";
2
+ import { tableMarkSchema, tableMemberMarkSchema, textBoxMarkSchema, paragraphMarkSchema, } from "../schemas.js";
3
+ import { pageToUser, round, roundRect, unionRects, userRectToPage, } from "./geometry.js";
4
4
  /** FPDFPageObj_GetType values. */
5
5
  export const OBJECT_TEXT = 1;
6
6
  export const OBJECT_PATH = 2;
@@ -11,6 +11,20 @@ export const MARK_PARAM = "webdoc";
11
11
  /** Font descriptor flag bits (PDF 32000-1 table 123). */
12
12
  const FLAG_ITALIC = 1 << 6;
13
13
  const FLAG_FORCE_BOLD = 1 << 18;
14
+ export function paragraphSpecOf(mark) {
15
+ return validateSchema(mark, paragraphMarkSchema, 0).length === 0
16
+ ? mark
17
+ : undefined;
18
+ }
19
+ /** Paragraph marks store the upright frame; the page may subsequently rotate. */
20
+ export function paragraphBounds(spec, geometry) {
21
+ if (geometry.rotation === 0)
22
+ return spec.rect;
23
+ const upright = { ...geometry, rotation: 0 };
24
+ const top = pageToUser(upright, spec.rect.x, spec.rect.y);
25
+ const bottom = pageToUser(upright, spec.rect.x + spec.rect.width, spec.rect.y + spec.rect.height);
26
+ return roundRect(userRectToPage(geometry, top.x, bottom.y, bottom.x, top.y));
27
+ }
14
28
  /**
15
29
  * Reads the objects of a loaded page in drawing order and turns them into
16
30
  * elements: plain objects become one element each, objects tagged with the
@@ -46,7 +60,7 @@ export function scanPage(pdfium, page, textPage, pageIndex, geometry, records) {
46
60
  const members = groups.get(element.id);
47
61
  const record = members && records.find((entry) => entry.id === element.id);
48
62
  return members && record?.mark
49
- ? compositeElement(record.mark, members, pageIndex)
63
+ ? compositeElement(record.mark, members, pageIndex, geometry)
50
64
  : element;
51
65
  }),
52
66
  byObject,
@@ -77,6 +91,8 @@ function isValidMark(params) {
77
91
  switch (params.kind) {
78
92
  case "textBox":
79
93
  return validateSchema(params, textBoxMarkSchema, 0).length === 0;
94
+ case "paragraph":
95
+ return validateSchema(params, paragraphMarkSchema, 0).length === 0;
80
96
  case "table":
81
97
  return (validateSchema(params, tableMarkSchema, 0).length === 0 ||
82
98
  validateSchema(params, tableMemberMarkSchema, 0).length === 0);
@@ -104,11 +120,58 @@ export function markIsFresh(pdfium, page, textPage, geometry, mark, indexes) {
104
120
  .map((object) => textOf(pdfium, object, textPage))
105
121
  .join(" "));
106
122
  const tolerance = 2;
107
- if (mark.kind === "textBox") {
123
+ if (mark.kind === "paragraph") {
124
+ const spec = paragraphSpecOf(mark);
125
+ if (!spec ||
126
+ objects.some((object) => {
127
+ if (lib.FPDFPageObj_GetType(object) !== OBJECT_TEXT) {
128
+ if (spec.text.trim().length !== 0 ||
129
+ lib.FPDFPageObj_GetType(object) !== OBJECT_PATH)
130
+ return true;
131
+ const mode = pdfium.readNumbers(2, "i32", ([fill, stroke]) => lib.FPDFPath_GetDrawMode(object, fill, stroke));
132
+ const color = pdfium.readNumbers(4, "i32", ([r, g, b, a]) => lib.FPDFPageObj_GetFillColor(object, r, g, b, a));
133
+ return mode?.[0] !== 1 || mode[1] !== 0 || color?.[3] !== 0;
134
+ }
135
+ const current = textStyle(pdfium, object);
136
+ return (current.fontSize !== round(spec.style.fontSize) ||
137
+ current.color !== spec.style.color ||
138
+ current.bold !== spec.style.bold ||
139
+ current.italic !== spec.style.italic);
140
+ }))
141
+ return false;
142
+ const frame = paragraphBounds(spec, geometry);
143
+ if (union.y + union.height > frame.y + frame.height + tolerance)
144
+ return false;
145
+ const nativeLines = objects
146
+ .filter((object) => lib.FPDFPageObj_GetType(object) === OBJECT_TEXT)
147
+ .map((object) => textOf(pdfium, object, textPage).trimEnd());
148
+ if (nativeLines.length !== spec.lines.length ||
149
+ nativeLines.some((line, index) => line !== spec.lines[index]))
150
+ return false;
151
+ // Native wrapping may split at whitespace or inside a long word, but it
152
+ // cannot invent, reorder or omit any non-whitespace logical character.
153
+ let cursor = 0;
154
+ for (const line of nativeLines) {
155
+ while (!spec.text.startsWith(line, cursor) &&
156
+ cursor < spec.text.length &&
157
+ /\s/u.test(spec.text[cursor]))
158
+ cursor += 1;
159
+ if (!spec.text.startsWith(line, cursor))
160
+ return false;
161
+ cursor += line.length;
162
+ }
163
+ if (spec.text.slice(cursor).trim())
164
+ return false;
165
+ if (geometry.rotation !== 0)
166
+ return (union.x >= frame.x - tolerance &&
167
+ union.y >= frame.y - tolerance &&
168
+ union.x + union.width <= frame.x + frame.width + tolerance);
169
+ }
170
+ if (mark.kind === "textBox" || mark.kind === "paragraph") {
108
171
  const rect = mark.rect;
109
172
  const text = mark.text;
110
173
  const style = mark.style;
111
- return (drawn === normalizeText(text) &&
174
+ return ((mark.kind === "paragraph" || drawn === normalizeText(text)) &&
112
175
  union.x >= rect.x - tolerance &&
113
176
  union.x + union.width <= rect.x + rect.width + tolerance &&
114
177
  // The first line's ink starts inside the box's first line band; the
@@ -169,7 +232,7 @@ function plainElement(pdfium, object, textPage, record, pageIndex, geometry) {
169
232
  return { ...base, kind: "other", operations: TRANSFORM_OPERATIONS };
170
233
  }
171
234
  }
172
- function compositeElement(mark, members, pageIndex) {
235
+ function compositeElement(mark, members, pageIndex, geometry) {
173
236
  const bounds = roundRect(unionRects(members.map((member) => member.bounds)));
174
237
  const text = members
175
238
  .filter((member) => member.kind === "text" && member.text)
@@ -198,6 +261,25 @@ function compositeElement(mark, members, pageIndex) {
198
261
  operations: TABLE_OPERATIONS,
199
262
  };
200
263
  }
264
+ const paragraph = paragraphSpecOf(mark);
265
+ if (paragraph)
266
+ return {
267
+ id: mark.id,
268
+ kind: "paragraph",
269
+ pageIndex,
270
+ bounds: paragraphBounds(paragraph, geometry),
271
+ text: paragraph.text,
272
+ textStyle: paragraph.style,
273
+ ...(geometry.rotation ? { rotation: geometry.rotation * 90 } : {}),
274
+ operations: geometry.rotation
275
+ ? ["deleteElement"]
276
+ : [
277
+ "replaceText",
278
+ "replaceParagraphText",
279
+ "setTextStyle",
280
+ "deleteElement",
281
+ ],
282
+ };
201
283
  const style = members.find((member) => member.textStyle)?.textStyle;
202
284
  return {
203
285
  id: mark.id,