web-doc 0.10.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 (58) hide show
  1. package/dist/adapters/office.js +4 -2
  2. package/dist/adapters/pdf.js +19 -8
  3. package/dist/edit/docx/engine.d.ts +9 -1
  4. package/dist/edit/docx/engine.js +22 -0
  5. package/dist/edit/docx/provider.d.ts +3 -1
  6. package/dist/edit/docx/provider.js +3 -0
  7. package/dist/edit/docx/session.d.ts +6 -2
  8. package/dist/edit/docx/session.js +7 -1
  9. package/dist/edit/docx/style.d.ts +1 -1
  10. package/dist/edit/docx/style.js +3 -2
  11. package/dist/edit/docx/types.d.ts +11 -1
  12. package/dist/edit/engine.d.ts +2 -1
  13. package/dist/edit/pdf/engine/document.d.ts +4 -2
  14. package/dist/edit/pdf/engine/document.js +115 -12
  15. package/dist/edit/pdf/engine/elements.d.ts +5 -1
  16. package/dist/edit/pdf/engine/elements.js +88 -6
  17. package/dist/edit/pdf/engine/existing-text.js +16 -61
  18. package/dist/edit/pdf/engine/fonts.d.ts +14 -0
  19. package/dist/edit/pdf/engine/fonts.js +114 -0
  20. package/dist/edit/pdf/engine/handler.js +2 -0
  21. package/dist/edit/pdf/engine/layout.js +1 -1
  22. package/dist/edit/pdf/engine/operations.d.ts +7 -1
  23. package/dist/edit/pdf/engine/paragraph-edit.d.ts +4 -0
  24. package/dist/edit/pdf/engine/paragraph-edit.js +331 -0
  25. package/dist/edit/pdf/engine/paragraph.d.ts +31 -0
  26. package/dist/edit/pdf/engine/paragraph.js +445 -0
  27. package/dist/edit/pdf/engine/pdfium.d.ts +3 -0
  28. package/dist/edit/pdf/engine/text-box.js +16 -3
  29. package/dist/edit/pdf/engine/text-font.js +2 -19
  30. package/dist/edit/pdf/engine/transform.js +14 -3
  31. package/dist/edit/pdf/provider.d.ts +3 -1
  32. package/dist/edit/pdf/provider.js +3 -0
  33. package/dist/edit/pdf/range-map.d.ts +2 -1
  34. package/dist/edit/pdf/range-map.js +38 -8
  35. package/dist/edit/pdf/schemas.d.ts +2 -0
  36. package/dist/edit/pdf/schemas.js +37 -0
  37. package/dist/edit/pdf/session.d.ts +3 -1
  38. package/dist/edit/pdf/session.js +35 -6
  39. package/dist/edit/pdf/types.d.ts +38 -8
  40. package/dist/edit/pptx/elements.d.ts +4 -1
  41. package/dist/edit/pptx/elements.js +35 -0
  42. package/dist/edit/pptx/engine.d.ts +5 -1
  43. package/dist/edit/pptx/engine.js +11 -0
  44. package/dist/edit/pptx/handler.js +8 -0
  45. package/dist/edit/pptx/provider.d.ts +3 -1
  46. package/dist/edit/pptx/provider.js +3 -0
  47. package/dist/edit/pptx/session.d.ts +6 -2
  48. package/dist/edit/pptx/session.js +7 -1
  49. package/dist/edit/pptx/types.d.ts +11 -1
  50. package/dist/edit/range-style.d.ts +24 -0
  51. package/dist/edit/range-style.js +46 -0
  52. package/dist/edit/session.js +5 -0
  53. package/dist/edit/types.d.ts +13 -0
  54. package/dist/viewport.js +11 -0
  55. package/dist/worker-protocol.d.ts +1 -1
  56. package/dist/workers/ooxml-edit-worker.js +90 -2
  57. package/dist/workers/pdf-edit-worker.js +2019 -1003
  58. package/package.json +1 -1
@@ -86,7 +86,6 @@ export class OfficeDocumentAdapter {
86
86
  useGoogleFonts: false,
87
87
  maxZipEntryBytes: context.limits.maxZipEntryBytes,
88
88
  mode: "main",
89
- ...(reopening ? { progressiveLayout: true } : {}),
90
89
  };
91
90
  try {
92
91
  const kind = kindFor(format);
@@ -113,7 +112,10 @@ export class OfficeDocumentAdapter {
113
112
  }
114
113
  const buffer = exactArrayBuffer(data);
115
114
  if (kind === "presentation") {
116
- const backend = await this.#loadPptx(buffer, engineOptions);
115
+ const backend = await this.#loadPptx(buffer, {
116
+ ...engineOptions,
117
+ ...(reopening ? { progressiveLayout: true } : {}),
118
+ });
117
119
  throwIfAborted(context.signal, backend);
118
120
  context.reportProgress({
119
121
  phase: "parsing",
@@ -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) {
@@ -3,10 +3,12 @@ import type { BatchMode, EditEngine, EngineBatch, EngineChange, MaterializedDocu
3
3
  import { OoxmlPackage } from "../ooxml/package.js";
4
4
  import type { EditFindOptions, EditOperation, ElementQuery, OperationIssue, PagePoint, TextTarget } from "../types.js";
5
5
  import { DocxModel, type AnyRecord } from "./model.js";
6
- import type { DocxElement, DocxRevision } from "./types.js";
6
+ import type { DocxElement, DocxRevision, DocxTextStyle } from "./types.js";
7
+ import { type TextSpan } from "../range-style.js";
7
8
  /** Reads the DOCX session adds on top of the core, served by the engine and the worker client alike. */
8
9
  export interface DocxEngineReads {
9
10
  revisions(id: string, signal: AbortSignal): Promise<readonly DocxRevision[]>;
11
+ textStyle(id: string, span: TextSpan | undefined, signal: AbortSignal): Promise<Partial<DocxTextStyle> | undefined>;
10
12
  }
11
13
  export declare class DocxEditEngine implements EditEngine, DocxEngineReads {
12
14
  #private;
@@ -25,6 +27,12 @@ export declare class DocxEditEngine implements EditEngine, DocxEngineReads {
25
27
  apply(input: EngineBatch | readonly EditOperation[], signal: AbortSignal): Promise<EngineChange>;
26
28
  /** The revisions of a paragraph; none for another element or an unknown id. */
27
29
  revisions(id: string, signal: AbortSignal): Promise<readonly DocxRevision[]>;
30
+ /**
31
+ * The style a span of a paragraph shows: what every run it covers shares;
32
+ * the paragraph mark's for an empty paragraph. None for another element,
33
+ * an unknown id or a span past the text.
34
+ */
35
+ textStyle(id: string, span: TextSpan | undefined, signal: AbortSignal): Promise<Partial<DocxTextStyle> | undefined>;
28
36
  materialize(purposeOrSignal?: "show" | "save" | AbortSignal, options?: MaterializeOptions, signal?: AbortSignal): Promise<Uint8Array>;
29
37
  /**
30
38
  * `save` is the package as the session changed it: ids written only on
@@ -7,9 +7,11 @@ import { NO_PAGE, toElement } from "./elements.js";
7
7
  import { docxHandlers } from "./handlers.js";
8
8
  import { freshParagraphId, paragraphsOf } from "./ids.js";
9
9
  import { DocxModel } from "./model.js";
10
+ import { resolveTextStyle } from "./style.js";
10
11
  import { issueCollector, } from "./operations.js";
11
12
  import { docxOperationSchemas } from "./schemas.js";
12
13
  import { revisionsOf } from "./tracked.js";
14
+ import { runsCovering, sharedStyle, spanFits, } from "../range-style.js";
13
15
  import { attributeProblem, namespacePatches } from "./write.js";
14
16
  /*
15
17
  * The DOCX edit engine: the package layer under a block index of the body
@@ -303,6 +305,26 @@ export class DocxEditEngine {
303
305
  return [];
304
306
  return revisionsOf(model.document, record.node);
305
307
  }
308
+ /**
309
+ * The style a span of a paragraph shows: what every run it covers shares;
310
+ * the paragraph mark's for an empty paragraph. None for another element,
311
+ * an unknown id or a span past the text.
312
+ */
313
+ async textStyle(id, span, signal) {
314
+ const model = await this.model(signal);
315
+ const record = model.byId.get(id);
316
+ if (!record || record.kind !== "paragraph")
317
+ return undefined;
318
+ const { text, items } = record.text;
319
+ const range = span ?? { start: 0, end: text.length };
320
+ if (!spanFits(range, text.length))
321
+ return undefined;
322
+ const covered = runsCovering(items, range);
323
+ const mark = record.pPr?.children.find((child) => child.local === "rPr");
324
+ return sharedStyle(covered.length > 0
325
+ ? covered.map((item) => resolveTextStyle(model.styles, record.pPr, item.rPr))
326
+ : [resolveTextStyle(model.styles, record.pPr, mark)]);
327
+ }
306
328
  async materialize(purposeOrSignal = "show", options = {}, signal = new AbortController().signal) {
307
329
  return (await this.materializeDocument(purposeOrSignal, options, signal))
308
330
  .bytes;
@@ -2,7 +2,8 @@ import type { EditEngineContext } from "../engine.js";
2
2
  import { type OoxmlEditProviderOptions } from "../ooxml/worker.js";
3
3
  import { WorkerEngineClient } from "../worker-engine.js";
4
4
  import type { DocxEngineReads } from "./engine.js";
5
- import type { DocxRevision } from "./types.js";
5
+ import type { TextSpan } from "../range-style.js";
6
+ import type { DocxRevision, DocxTextStyle } from "./types.js";
6
7
  export type DocxEditProviderOptions = OoxmlEditProviderOptions;
7
8
  /**
8
9
  * Starts the OOXML edit worker for `original` as a DOCX engine. Loaded
@@ -12,5 +13,6 @@ export declare function loadDocxEditEngine(original: Uint8Array, context: EditEn
12
13
  export declare class DocxEditEngineClient extends WorkerEngineClient implements DocxEngineReads {
13
14
  readonly schemas: import("../types.js").OperationSchemaSet;
14
15
  revisions(id: string, signal: AbortSignal): Promise<readonly DocxRevision[]>;
16
+ textStyle(id: string, span: TextSpan | undefined, signal: AbortSignal): Promise<Partial<DocxTextStyle> | undefined>;
15
17
  start(original: Uint8Array): Promise<void>;
16
18
  }
@@ -24,6 +24,9 @@ export class DocxEditEngineClient extends WorkerEngineClient {
24
24
  revisions(id, signal) {
25
25
  return this.request("edit-docx-revisions", { id }, signal);
26
26
  }
27
+ textStyle(id, span, signal) {
28
+ return this.request("edit-docx-text-style", { id, span }, signal);
29
+ }
27
30
  async start(original) {
28
31
  const data = original.slice().buffer;
29
32
  const open = {
@@ -1,7 +1,7 @@
1
1
  import type { DescribeOptions, DocumentDescription, EditCheckpoint, OutlineOptions, OutlineResult, TargetCandidate, TargetQuery, ToolCall, ToolCallOptions, ToolResult, ToolSet } from "../ai/types.js";
2
2
  import type { EditSessionAccess, EditSessionCore } from "../engine.js";
3
- import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, PageRect, ReadItem, ReadOptions, ReadResult, SavedDocument, TextTarget } from "../types.js";
4
- import type { DocxDeleteElementOperation, DocxEditSession, DocxElement, DocxFields, DocxInsertImageOperation, DocxInsertParagraphOperation, DocxInsertTableOperation, DocxMoveElementOperation, DocxOperation, DocxReplaceTextOperation, DocxRevision, DocxSaveOptions, DocxSetParagraphStyleOperation, DocxSetTableCellOperation, DocxSetTextStyleOperation } from "./types.js";
3
+ import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, PageRect, ReadItem, ReadOptions, ReadResult, SavedDocument, TextRange, TextTarget } from "../types.js";
4
+ import type { DocxDeleteElementOperation, DocxEditSession, DocxElement, DocxFields, DocxInsertImageOperation, DocxInsertParagraphOperation, DocxInsertTableOperation, DocxMoveElementOperation, DocxOperation, DocxReplaceTextOperation, DocxRevision, DocxSaveOptions, DocxSetParagraphStyleOperation, DocxSetTableCellOperation, DocxSetTextStyleOperation, DocxTextStyle } from "./types.js";
5
5
  /** Per paragraph id (eight hex digits), the union of its runs per page. */
6
6
  type Placement = ReadonlyMap<string, ReadonlyMap<number, PageRect>>;
7
7
  export declare class DocxSession implements DocxEditSession {
@@ -33,6 +33,10 @@ export declare class DocxSession implements DocxEditSession {
33
33
  get tools(): ToolSet;
34
34
  callTool(call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
35
35
  getRevisions(elementId: string, options?: ReadOptions): Promise<ReadResult<DocxRevision>>;
36
+ getTextStyle(fields: {
37
+ readonly target: string;
38
+ readonly range?: TextRange;
39
+ }, options?: ReadOptions): Promise<ReadItem<Partial<DocxTextStyle>>>;
36
40
  replaceText(fields: DocxFields<DocxReplaceTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
37
41
  setTextStyle(fields: DocxFields<DocxSetTextStyleOperation>, options?: ApplyOptions): Promise<EditReceipt>;
38
42
  setParagraphStyle(fields: DocxFields<DocxSetParagraphStyleOperation>, options?: ApplyOptions): Promise<EditReceipt>;
@@ -2,6 +2,7 @@ import { readDescription, readOutline } from "../ai/outline.js";
2
2
  import { resolveTargets } from "../ai/targets.js";
3
3
  import { buildToolSet, callTool as runTool } from "../ai/tools.js";
4
4
  import { ViewerError } from "../../errors.js";
5
+ import { spanOnTarget } from "../range-style.js";
5
6
  export class DocxSession {
6
7
  format = "docx";
7
8
  #core;
@@ -134,6 +135,10 @@ export class DocxSession {
134
135
  getRevisions(elementId, options) {
135
136
  return this.#core.readItems(options, (engine, signal) => docxReads(engine).revisions(elementId, signal));
136
137
  }
138
+ async getTextStyle(fields, options) {
139
+ const span = spanOnTarget(fields.target, fields.range);
140
+ return this.#core.readItem(options, (engine, signal) => docxReads(engine).textStyle(fields.target, span, signal));
141
+ }
137
142
  replaceText(fields, options) {
138
143
  return this.apply([{ op: "replaceText", ...fields }], options);
139
144
  }
@@ -461,7 +466,8 @@ function matchesQuery(element, query) {
461
466
  }
462
467
  function docxReads(engine) {
463
468
  const candidate = engine;
464
- if (typeof candidate.revisions !== "function")
469
+ if (typeof candidate.revisions !== "function" ||
470
+ typeof candidate.textStyle !== "function")
465
471
  throw new ViewerError("edit-unsupported", "The engine does not provide the DOCX reads", { details: { format: "docx", reason: "no-reads" } });
466
472
  return candidate;
467
473
  }
@@ -34,7 +34,7 @@ export declare class DocxStyles {
34
34
  /** A style and the styles it is based on, nearest first; cycles stop. */
35
35
  chain(id: string | undefined): StyleRecord[];
36
36
  }
37
- /** `w:b`, `w:i`, `w:default`-style toggles: present without a value means on. */
37
+ /** `w:b`/`w:i` toggle values: a present element without `w:val` means on. */
38
38
  export declare function isOn(value: string | undefined): boolean;
39
39
  /** The run property holders in resolution order for a run of a paragraph. */
40
40
  export declare function runPropertySources(styles: DocxStyles, pPr: XmlElement | undefined, rPr: XmlElement | undefined): readonly XmlElement[];
@@ -95,11 +95,12 @@ export class DocxStyles {
95
95
  if (!id)
96
96
  continue;
97
97
  const basedOn = part.attribute(node.children.find((child) => child.local === "basedOn") ?? node, "w:val");
98
+ const defaultAttribute = part.attribute(node, "w:default");
98
99
  const record = {
99
100
  id,
100
101
  type: part.attribute(node, "w:type") ?? "paragraph",
101
102
  ...(basedOn && basedOn !== id ? { basedOn } : {}),
102
- isDefault: isOn(part.attribute(node, "w:default")),
103
+ isDefault: defaultAttribute !== undefined && isOn(defaultAttribute),
103
104
  ...pick(node, "rPr", "pPr"),
104
105
  };
105
106
  styles.set(id, record);
@@ -175,7 +176,7 @@ async function readTheme(pkg, themePart, signal) {
175
176
  return none;
176
177
  }
177
178
  }
178
- /** `w:b`, `w:i`, `w:default`-style toggles: present without a value means on. */
179
+ /** `w:b`/`w:i` toggle values: a present element without `w:val` means on. */
179
180
  export function isOn(value) {
180
181
  return value === undefined || !/^(0|false|off)$/i.test(value);
181
182
  }
@@ -1,4 +1,4 @@
1
- import type { ApplyOptions, BinaryData, EditColor, EditElement, EditReceipt, EditSessionBase, ReadOptions, ReadResult, SavedDocument, SaveOptions, TextRange } from "../types.js";
1
+ import type { ApplyOptions, BinaryData, EditColor, EditElement, EditReceipt, EditSessionBase, ReadItem, ReadOptions, ReadResult, SavedDocument, SaveOptions, TextRange } from "../types.js";
2
2
  export type DocxElementKind = "paragraph" | "table" | "image" | "other";
3
3
  export type DocxTextAlign = "left" | "center" | "right" | "justify";
4
4
  /** Word's sixteen highlight colours (`w:highlight/@w:val`). */
@@ -193,6 +193,16 @@ export interface DocxEditSession extends EditSessionBase<DocxOperation, DocxElem
193
193
  save(options?: DocxSaveOptions): Promise<SavedDocument>;
194
194
  /** The tracked changes a paragraph holds, in document order; empty for other elements. */
195
195
  getRevisions(elementId: string, options?: ReadOptions): Promise<ReadResult<DocxRevision>>;
196
+ /**
197
+ * The text style a range of a paragraph shows: each property every run it
198
+ * covers shares, a property they differ on left out. Without a range, the
199
+ * whole text; a collapsed range reads the run before it, whose style text
200
+ * typed there takes. `undefined` for an element without text.
201
+ */
202
+ getTextStyle(fields: {
203
+ readonly target: string;
204
+ readonly range?: TextRange;
205
+ }, options?: ReadOptions): Promise<ReadItem<Partial<DocxTextStyle>>>;
196
206
  /** Replaces the whole text of a paragraph, or the part a range covers. */
197
207
  replaceText(fields: DocxFields<DocxReplaceTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
198
208
  /** Changes run properties; unspecified ones keep their bytes. */
@@ -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. */