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
@@ -10,17 +10,23 @@ export function mapRangeThrough(range, records) {
10
10
  return { start: start.position, end: end.position };
11
11
  }
12
12
  function carry(position, records) {
13
- const carried = { position, gone: false };
13
+ const carried = { position, gone: false, migrations: [] };
14
14
  for (const record of records) {
15
15
  const forward = record.kind === "apply" || record.kind === "redo";
16
- const operations = forward
17
- ? record.operations
18
- : [...record.operations].reverse();
19
- for (const operation of operations) {
20
- if (forward)
16
+ const indexed = record.operations.map((operation, index) => ({
17
+ operation,
18
+ index,
19
+ }));
20
+ for (const { operation, index } of forward ? indexed : indexed.reverse()) {
21
+ const migrations = (record.textAnchorMigrations ?? []).filter((migration) => migration.operationIndex === index);
22
+ if (forward) {
23
+ migrateForward(carried, migrations);
21
24
  applyForward(carried, operation);
22
- else
25
+ }
26
+ else {
23
27
  applyBackward(carried, operation);
28
+ migrateBackward(carried, migrations);
29
+ }
24
30
  }
25
31
  const { receipt } = record;
26
32
  const id = carried.position.elementId;
@@ -34,6 +40,30 @@ function carry(position, records) {
34
40
  }
35
41
  return carried;
36
42
  }
43
+ function migrateForward(carried, migrations) {
44
+ const migration = migrations.find((entry) => entry.sourceElementId === carried.position.elementId);
45
+ if (!migration || carried.gone)
46
+ return;
47
+ carried.migrations.push(migration);
48
+ carried.position = {
49
+ elementId: migration.targetElementId,
50
+ offset: migration.targetOffset +
51
+ Math.min(carried.position.offset, migration.sourceLength),
52
+ };
53
+ }
54
+ function migrateBackward(carried, migrations) {
55
+ const previous = carried.migrations.at(-1);
56
+ if (!previous ||
57
+ carried.position.elementId !== previous.targetElementId ||
58
+ !migrations.some((entry) => entry.sourceElementId === previous.sourceElementId &&
59
+ entry.targetElementId === previous.targetElementId))
60
+ return;
61
+ carried.migrations.pop();
62
+ carried.position = {
63
+ elementId: previous.sourceElementId,
64
+ offset: Math.max(0, Math.min(previous.sourceLength, carried.position.offset - previous.targetOffset)),
65
+ };
66
+ }
37
67
  function applyForward(carried, operation) {
38
68
  const id = carried.position.elementId;
39
69
  if (operation.op === "deleteElement" && targetOf(operation) === id) {
@@ -84,7 +114,7 @@ function applyBackward(carried, operation) {
84
114
  };
85
115
  }
86
116
  function asReplace(operation, elementId) {
87
- if (operation.op !== "replaceText")
117
+ if (operation.op !== "replaceText" && operation.op !== "replaceParagraphText")
88
118
  return undefined;
89
119
  const candidate = operation;
90
120
  if (candidate.target !== elementId || typeof candidate.text !== "string")
@@ -9,3 +9,5 @@ export declare const tableMarkSchema: JsonSchema;
9
9
  /** The mark on a table's text objects: the id alone. */
10
10
  export declare const tableMemberMarkSchema: JsonSchema;
11
11
  export declare const textBoxMarkSchema: JsonSchema;
12
+ /** Persisted native imported paragraph; no unvalidated layout inputs are trusted. */
13
+ export declare const paragraphMarkSchema: JsonSchema;
@@ -143,6 +143,11 @@ export const pdfOperationSchemas = Object.freeze({
143
143
  target: { $ref: "#/$defs/target" },
144
144
  style: { $ref: "#/$defs/textBoxStyle" },
145
145
  }, ["target", "style"]),
146
+ replaceParagraphText: operation("replaceParagraphText", {
147
+ target: { $ref: "#/$defs/target" },
148
+ text: { type: "string", maxLength: 20000 },
149
+ range: { $ref: "#/$defs/textRange" },
150
+ }, ["target", "text"]),
146
151
  resizeElement: operation("resizeElement", {
147
152
  target: { $ref: "#/$defs/target" },
148
153
  rect: { $ref: "#/$defs/rect" },
@@ -292,3 +297,35 @@ export const textBoxMarkSchema = {
292
297
  },
293
298
  $defs: definitions,
294
299
  };
300
+ /** Persisted native imported paragraph; no unvalidated layout inputs are trusted. */
301
+ export const paragraphMarkSchema = {
302
+ type: "object",
303
+ required: ["kind", "id", "rect", "text", "lines", "style", "baselineOffset"],
304
+ additionalProperties: false,
305
+ properties: {
306
+ kind: { const: "paragraph" },
307
+ id: { $ref: "#/$defs/target" },
308
+ rect: { $ref: "#/$defs/rect" },
309
+ text: { type: "string", maxLength: 20000 },
310
+ baselineOffset: { type: "number", minimum: 0, maximum: 2 },
311
+ lines: {
312
+ type: "array",
313
+ maxItems: 20000,
314
+ items: { type: "string", maxLength: 20000 },
315
+ },
316
+ style: {
317
+ type: "object",
318
+ required: [
319
+ "fontFamily",
320
+ "fontSize",
321
+ "bold",
322
+ "italic",
323
+ "color",
324
+ "lineHeight",
325
+ ],
326
+ additionalProperties: false,
327
+ properties: definitions.textBoxStyle.properties,
328
+ },
329
+ },
330
+ $defs: definitions,
331
+ };
@@ -2,7 +2,7 @@ import type { TextSelection } from "../../contracts.js";
2
2
  import type { DescribeOptions, DocumentDescription, EditCheckpoint, OutlineOptions, OutlineResult, TargetCandidate, TargetQuery, ToolCall, ToolCallOptions, ToolResult, ToolSet } from "../ai/types.js";
3
3
  import type { EditSessionCore } from "../engine.js";
4
4
  import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, PageRect, ReadItem, ReadOptions, ReadResult, SavedDocument, TextPosition, TextRange, TextTarget } from "../types.js";
5
- import type { DeleteElementOperation, DeletePageOperation, Fields, InsertImageOperation, InsertPageOperation, InsertShapeOperation, InsertTableOperation, InsertTextBoxOperation, MoveElementOperation, MovePageOperation, PdfEditSession, PdfElement, PdfOperation, PdfSaveOptions, ReplaceTextOperation, ResizeElementOperation, RotatePageOperation, SetShapeStyleOperation, SetTableCellOperation, PageBitmap, PageLayout, RenderOptions, SetTextStyleOperation, TextFont, TextLayout } from "./types.js";
5
+ import type { DeleteElementOperation, DeletePageOperation, Fields, InsertImageOperation, InsertPageOperation, InsertShapeOperation, InsertTableOperation, InsertTextBoxOperation, MoveElementOperation, MovePageOperation, PdfEditSession, PdfElement, PdfOperation, PdfSaveOptions, PdfTextParagraph, ReplaceParagraphTextOperation, ReplaceTextOperation, ResizeElementOperation, RotatePageOperation, SetShapeStyleOperation, SetTableCellOperation, PageBitmap, PageLayout, RenderOptions, SetTextStyleOperation, TextFont, TextLayout } from "./types.js";
6
6
  /**
7
7
  * The PDF session: the core session narrowed to PDF operations and elements.
8
8
  * Typed methods are added here as their operations ship; each is `apply()`
@@ -38,6 +38,8 @@ export declare class PdfSession implements PdfEditSession {
38
38
  dropCheckpoint(id: string): void;
39
39
  get tools(): ToolSet;
40
40
  callTool(call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
41
+ getTextParagraph(elementId: string, options?: ReadOptions): Promise<ReadItem<PdfTextParagraph>>;
42
+ replaceParagraphText(fields: Fields<ReplaceParagraphTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
41
43
  getTextLayout(elementId: string, options?: ReadOptions): Promise<ReadItem<TextLayout>>;
42
44
  getTextFont(elementId: string, options?: ReadOptions): Promise<ReadItem<TextFont>>;
43
45
  positionAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadItem<TextPosition>>;
@@ -66,22 +66,25 @@ export class PdfSession {
66
66
  if (receipt.dryRun || receipt.revision === last)
67
67
  return;
68
68
  this.#refreshGeometry(receipt);
69
- let involved = operations;
69
+ let involved = {
70
+ operations,
71
+ textAnchorMigrations: receipt.textAnchorMigrations ?? [],
72
+ };
70
73
  switch (kind) {
71
74
  case "apply":
72
- this.#applied.push(operations);
75
+ this.#applied.push(involved);
73
76
  this.#undone = [];
74
77
  break;
75
78
  case "undo":
76
- involved = this.#applied.pop() ?? [];
79
+ involved = this.#applied.pop() ?? involved;
77
80
  this.#undone.push(involved);
78
81
  break;
79
82
  case "redo":
80
- involved = this.#undone.pop() ?? [];
83
+ involved = this.#undone.pop() ?? involved;
81
84
  this.#applied.push(involved);
82
85
  break;
83
86
  case "reset":
84
- involved = this.#applied.flat();
87
+ involved = combineBatches(this.#applied);
85
88
  this.#applied = [];
86
89
  this.#undone = [];
87
90
  break;
@@ -95,7 +98,8 @@ export class PdfSession {
95
98
  this.#log.push({
96
99
  revision: receipt.revision,
97
100
  kind,
98
- operations: involved,
101
+ operations: involved.operations,
102
+ textAnchorMigrations: involved.textAnchorMigrations,
99
103
  receipt,
100
104
  });
101
105
  if (this.#log.length > LOG_LIMIT)
@@ -195,6 +199,17 @@ export class PdfSession {
195
199
  callTool(call, options) {
196
200
  return runTool(this, this.tools, call, options);
197
201
  }
202
+ getTextParagraph(elementId, options) {
203
+ return this.#core.readItem(options, (engine, signal) => {
204
+ const reads = pdfReads(engine);
205
+ if (!reads.textParagraph)
206
+ throw new ViewerError("edit-unsupported", "This engine does not resolve PDF paragraphs");
207
+ return reads.textParagraph(elementId, signal);
208
+ });
209
+ }
210
+ replaceParagraphText(fields, options) {
211
+ return this.apply([{ op: "replaceParagraphText", ...fields }], options);
212
+ }
198
213
  getTextLayout(elementId, options) {
199
214
  return this.#core.readItem(options, (engine, signal) => pdfReads(engine).textLayout(elementId, signal));
200
215
  }
@@ -302,6 +317,20 @@ export class PdfSession {
302
317
  }
303
318
  /** How many committed calls `mapRange` can look back over. */
304
319
  const LOG_LIMIT = 512;
320
+ /** Reset reverses all committed batches, retaining each migration's operation. */
321
+ function combineBatches(batches) {
322
+ const operations = [];
323
+ const textAnchorMigrations = [];
324
+ for (const batch of batches) {
325
+ for (const migration of batch.textAnchorMigrations)
326
+ textAnchorMigrations.push({
327
+ ...migration,
328
+ operationIndex: operations.length + migration.operationIndex,
329
+ });
330
+ operations.push(...batch.operations);
331
+ }
332
+ return { operations, textAnchorMigrations };
333
+ }
305
334
  /** The engine behind a PDF session answers the overlay reads; a stand-in may not. */
306
335
  function pdfReads(engine) {
307
336
  const reads = engine;
@@ -1,6 +1,6 @@
1
1
  import type { TextSelection } from "../../contracts.js";
2
2
  import type { ApplyOptions, BinaryData, EditElement, EditReceipt, EditSessionBase, PagePoint, PageRect, ReadItem, ReadOptions, ReadResult, SavedDocument, SaveOptions, TextPosition, TextRange } from "../types.js";
3
- export type PdfElementKind = "text" | "image" | "shape" | "textBox" | "table" | "other";
3
+ export type PdfElementKind = "text" | "image" | "shape" | "textBox" | "paragraph" | "table" | "other";
4
4
  export type PdfTextAlign = "left" | "center" | "right";
5
5
  export interface PdfTextStyle {
6
6
  readonly fontFamily: string;
@@ -12,7 +12,7 @@ export interface PdfTextStyle {
12
12
  readonly color: string;
13
13
  /** Text boxes only. */
14
14
  readonly align?: PdfTextAlign;
15
- /** Text boxes only; a multiple of the font size. */
15
+ /** Text boxes and paragraphs; a multiple of the font size. */
16
16
  readonly lineHeight?: number;
17
17
  }
18
18
  export interface PdfShapeStyle {
@@ -26,7 +26,9 @@ export interface PdfShapeStyle {
26
26
  }
27
27
  export interface PdfElement extends EditElement {
28
28
  readonly kind: PdfElementKind;
29
- /** Present for `text` and `textBox`. */
29
+ /** Canonical native paragraph target, when this imported row belongs to one. */
30
+ readonly textEditingTarget?: string;
31
+ /** Present for `text`, `textBox` and `paragraph`. */
30
32
  readonly textStyle?: PdfTextStyle;
31
33
  /** Present for `shape` and `table`. */
32
34
  readonly shapeStyle?: PdfShapeStyle;
@@ -57,7 +59,7 @@ export interface InsertTextBoxOperation {
57
59
  }
58
60
  export interface ReplaceTextOperation {
59
61
  readonly op: "replaceText";
60
- /** A `textBox` or `text` element. */
62
+ /** A `textBox`, `text` or `paragraph` element; supported fields depend on its capabilities. */
61
63
  readonly target: string;
62
64
  readonly text: string;
63
65
  /**
@@ -68,9 +70,33 @@ export interface ReplaceTextOperation {
68
70
  */
69
71
  readonly range?: TextRange;
70
72
  }
73
+ /** A confidently resolved homogeneous paragraph of imported horizontal text. */
74
+ export interface PdfTextParagraph {
75
+ readonly id: string;
76
+ readonly pageIndex: number;
77
+ readonly text: string;
78
+ readonly bounds: PageRect;
79
+ readonly textStyle: PdfTextStyle & {
80
+ readonly lineHeight: number;
81
+ };
82
+ readonly memberIds: readonly string[];
83
+ /** Half-open UTF-16 spans in the paragraph's logical text. */
84
+ readonly members: readonly {
85
+ readonly elementId: string;
86
+ readonly start: number;
87
+ readonly end: number;
88
+ }[];
89
+ }
90
+ export interface ReplaceParagraphTextOperation {
91
+ readonly op: "replaceParagraphText";
92
+ readonly target: string;
93
+ readonly text: string;
94
+ /** Both endpoints name the paragraph id, not an individual visual row. */
95
+ readonly range?: TextRange;
96
+ }
71
97
  export interface SetTextStyleOperation {
72
98
  readonly op: "setTextStyle";
73
- /** A `textBox` or `text` element. */
99
+ /** A `textBox`, `text`, or `paragraph` element. */
74
100
  readonly target: string;
75
101
  readonly style: PdfTextBoxStyle;
76
102
  }
@@ -211,7 +237,7 @@ export type RotatePageOperation = {
211
237
  readonly rotation?: never;
212
238
  };
213
239
  /** The PDF operation union; operations are added as they ship. */
214
- export type PdfOperation = InsertTextBoxOperation | ReplaceTextOperation | SetTextStyleOperation | ResizeElementOperation | MoveElementOperation | DeleteElementOperation | InsertPageOperation | DeletePageOperation | MovePageOperation | RotatePageOperation | InsertShapeOperation | SetShapeStyleOperation | InsertImageOperation | InsertTableOperation | SetTableCellOperation;
240
+ export type PdfOperation = InsertTextBoxOperation | ReplaceTextOperation | ReplaceParagraphTextOperation | SetTextStyleOperation | ResizeElementOperation | MoveElementOperation | DeleteElementOperation | InsertPageOperation | DeletePageOperation | MovePageOperation | RotatePageOperation | InsertShapeOperation | SetShapeStyleOperation | InsertImageOperation | InsertTableOperation | SetTableCellOperation;
215
241
  export interface PdfSaveOptions extends SaveOptions {
216
242
  /**
217
243
  * `full` rewrites the file, so deleted content is gone and the bytes do not
@@ -315,10 +341,14 @@ export type Fields<T extends PdfOperation> = T extends unknown ? Omit<T, "op"> :
315
341
  export interface PdfEditSession extends EditSessionBase<PdfOperation, PdfElement> {
316
342
  readonly format: "pdf";
317
343
  save(options?: PdfSaveOptions): Promise<SavedDocument>;
318
- /** Lines, glyph boxes and styles of a `text`, `textBox` or `table` element. */
344
+ /** Resolves an imported row or a paragraph id; undefined when grouping is unsafe. */
345
+ getTextParagraph(elementId: string, options?: ReadOptions): Promise<ReadItem<PdfTextParagraph>>;
346
+ /** Reflows the complete paragraph as one atomic history change. */
347
+ replaceParagraphText(fields: Fields<ReplaceParagraphTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
348
+ /** Lines, glyph boxes and styles of a `text`, `textBox`, `paragraph` or `table` element. */
319
349
  getTextLayout(elementId: string, options?: ReadOptions): Promise<ReadItem<TextLayout>>;
320
350
  /**
321
- * The font a `text` or `textBox` element is drawn in, as a browser loads it,
351
+ * The font a `text`, `textBox` or `paragraph` element is drawn in, as a browser loads it,
322
352
  * or why it cannot be loaded; see `TextFont`.
323
353
  */
324
354
  getTextFont(elementId: string, options?: ReadOptions): Promise<ReadItem<TextFont>>;
@@ -4,7 +4,8 @@ import { type Matrix, type PlacedFrame, type Xfrm } from "./geometry.js";
4
4
  import type { DeckModel, SlideRecord } from "./model.js";
5
5
  import { type StyleSource } from "./style.js";
6
6
  import { type TextModel } from "./text.js";
7
- import type { PptxElement, PptxPlaceholder } from "./types.js";
7
+ import { type TextSpan } from "../range-style.js";
8
+ import type { PptxElement, PptxPlaceholder, PptxTextStyle } from "./types.js";
8
9
  /** What the engine knows about one element beyond what it reports. */
9
10
  export interface ShapeRecord {
10
11
  readonly element: PptxElement;
@@ -28,6 +29,8 @@ export interface ShapeRecord {
28
29
  /** Inside the fallback branch of `mc:AlternateContent`. */
29
30
  readonly readOnly: boolean;
30
31
  readonly parent?: ShapeRecord;
32
+ /** The style a span of the shape's text shows: what every run it covers shares. */
33
+ readonly styleOf?: (span: TextSpan) => Partial<PptxTextStyle> | undefined;
31
34
  }
32
35
  export interface SlideElements {
33
36
  readonly slide: SlideRecord;
@@ -1,6 +1,7 @@
1
1
  import { groupMatrix, IDENTITY, multiply, placeFrame, readXfrm, } from "./geometry.js";
2
2
  import { resolveTextStyle, readShapeStyle } from "./style.js";
3
3
  import { firstTextRun, readTableCells, readTextModel, } from "./text.js";
4
+ import { runsCovering, sharedStyle } from "../range-style.js";
4
5
  /*
5
6
  * The elements of a slide, read from its XML: every child of the shape tree
6
7
  * with its frame (own, or inherited from the layout and master for a
@@ -236,6 +237,11 @@ function readRecord(context, node, parents, parent, readOnly, walk) {
236
237
  ...(text ? { text } : {}),
237
238
  readOnly,
238
239
  ...(parent ? { parent } : {}),
240
+ ...(text && txBody
241
+ ? {
242
+ styleOf: (span) => rangeStyleOf(context, placeholder, txBody, text, span),
243
+ }
244
+ : {}),
239
245
  };
240
246
  }
241
247
  function operationsFor(kind, hasText) {
@@ -285,6 +291,35 @@ export function listStylesOf(context, txBody, placeholder) {
285
291
  lists.push(defaults);
286
292
  return lists;
287
293
  }
294
+ /**
295
+ * The style a span of a text body shows: each run it covers resolves through
296
+ * its paragraph and the inherited lists, and the properties they all share
297
+ * are kept. An empty paragraph reads its end-of-paragraph properties.
298
+ */
299
+ function rangeStyleOf(context, placeholder, txBody, text, span) {
300
+ const lists = listStylesOf(context, txBody, placeholder);
301
+ const styleOf = (paragraph, rPr) => resolveTextStyle({
302
+ ...(rPr ? { rPr: { part: context.part, node: rPr } } : {}),
303
+ ...(paragraph.pPr
304
+ ? { pPr: { part: context.part, node: paragraph.pPr } }
305
+ : {}),
306
+ level: paragraph.level,
307
+ lists,
308
+ fonts: context.fonts,
309
+ });
310
+ const runs = text.paragraphs.flatMap((paragraph) => paragraph.runs.map((run) => ({
311
+ start: run.start,
312
+ end: run.end,
313
+ style: () => styleOf(paragraph, run.rPr),
314
+ })));
315
+ const covered = runsCovering(runs, span);
316
+ if (covered.length > 0)
317
+ return sharedStyle(covered.map((run) => run.style()));
318
+ const paragraph = text.paragraphs.find((candidate) => candidate.start <= span.start && span.start <= candidate.end) ?? text.paragraphs[0];
319
+ return paragraph
320
+ ? sharedStyle([styleOf(paragraph, paragraph.endParaRPr)])
321
+ : undefined;
322
+ }
288
323
  function textStyleOf(context, placeholder, txBody, text) {
289
324
  const first = firstTextRun(text);
290
325
  const paragraph = first?.paragraph ?? text.paragraphs[0];
@@ -3,11 +3,13 @@ import type { EditEngine, EngineBatch, EngineChange, MaterializedDocument, Mater
3
3
  import { OoxmlPackage } from "../ooxml/package.js";
4
4
  import type { EditFindOptions, EditOperation, ElementQuery, OperationIssue, PagePoint, TextTarget } from "../types.js";
5
5
  import { DeckModel } from "./model.js";
6
- import type { PptxElement, PptxLayoutInfo, PptxSlideInfo } from "./types.js";
6
+ import type { PptxElement, PptxLayoutInfo, PptxSlideInfo, PptxTextStyle } from "./types.js";
7
+ import { type TextSpan } from "../range-style.js";
7
8
  /** The reads behind `getSlides` and `getLayouts`, beyond the core engine interface. */
8
9
  export interface PptxEngineReads {
9
10
  slides(signal: AbortSignal): Promise<readonly PptxSlideInfo[]>;
10
11
  layouts(signal: AbortSignal): Promise<readonly PptxLayoutInfo[]>;
12
+ textStyle(id: string, span: TextSpan | undefined, signal: AbortSignal): Promise<Partial<PptxTextStyle> | undefined>;
11
13
  }
12
14
  export declare class PptxEditEngine implements EditEngine, PptxEngineReads {
13
15
  #private;
@@ -32,6 +34,8 @@ export declare class PptxEditEngine implements EditEngine, PptxEngineReads {
32
34
  elementsAt(pageIndex: number, point: PagePoint, signal: AbortSignal): Promise<readonly PptxElement[]>;
33
35
  findText(query: string, options: EditFindOptions, signal: AbortSignal): Promise<readonly TextTarget[]>;
34
36
  slides(signal: AbortSignal): Promise<readonly PptxSlideInfo[]>;
37
+ /** The style a span of a shape's text shows; none for an element without text or a span past it. */
38
+ textStyle(id: string, span: TextSpan | undefined, signal: AbortSignal): Promise<Partial<PptxTextStyle> | undefined>;
35
39
  layouts(signal: AbortSignal): Promise<readonly PptxLayoutInfo[]>;
36
40
  dispose(): Promise<void>;
37
41
  }
@@ -8,6 +8,7 @@ import { DeckModel } from "./model.js";
8
8
  import { pptxHandlers } from "./handlers.js";
9
9
  import { issueCollector, } from "./operations.js";
10
10
  import { pptxOperationSchemas } from "./schemas.js";
11
+ import { spanFits } from "../range-style.js";
11
12
  const SLIDE_PART = /^\/ppt\/slides\/slide(\d+)\.xml$/i;
12
13
  const SLIDE_RELS = /^\/ppt\/slides\/_rels\/slide(\d+)\.xml\.rels$/i;
13
14
  export class PptxEditEngine {
@@ -357,6 +358,16 @@ export class PptxEditEngine {
357
358
  });
358
359
  return out;
359
360
  }
361
+ /** The style a span of a shape's text shows; none for an element without text or a span past it. */
362
+ async textStyle(id, span, signal) {
363
+ const record = await this.#locate(id, signal);
364
+ if (!record?.styleOf || !record.text)
365
+ return undefined;
366
+ const range = span ?? { start: 0, end: record.text.text.length };
367
+ if (!spanFits(range, record.text.text.length))
368
+ return undefined;
369
+ return record.styleOf(range);
370
+ }
360
371
  async layouts(signal) {
361
372
  const model = await this.model(signal);
362
373
  return model.layouts.map((layout) => ({
@@ -88,6 +88,14 @@ export function createOoxmlEditHandler() {
88
88
  return pptx().layouts(signal);
89
89
  case "edit-docx-revisions":
90
90
  return docx().revisions(payload.id, signal);
91
+ case "edit-docx-text-style": {
92
+ const { id, span } = payload;
93
+ return docx().textStyle(id, span, signal);
94
+ }
95
+ case "edit-pptx-text-style": {
96
+ const { id, span } = payload;
97
+ return pptx().textStyle(id, span, signal);
98
+ }
91
99
  case "edit-dispose":
92
100
  await state?.dispose();
93
101
  state = undefined;
@@ -1,8 +1,9 @@
1
1
  import type { EditEngineContext } from "../engine.js";
2
2
  import { type OoxmlEditProviderOptions } from "../ooxml/worker.js";
3
3
  import { WorkerEngineClient } from "../worker-engine.js";
4
+ import type { TextSpan } from "../range-style.js";
4
5
  import type { PptxEngineReads } from "./engine.js";
5
- import type { PptxLayoutInfo, PptxSlideInfo } from "./types.js";
6
+ import type { PptxLayoutInfo, PptxSlideInfo, PptxTextStyle } from "./types.js";
6
7
  export type PptxEditProviderOptions = OoxmlEditProviderOptions;
7
8
  /**
8
9
  * Starts the OOXML edit worker for `original` as a PPTX engine. Loaded
@@ -14,4 +15,5 @@ export declare class PptxEditEngineClient extends WorkerEngineClient implements
14
15
  start(original: Uint8Array): Promise<void>;
15
16
  slides(signal: AbortSignal): Promise<readonly PptxSlideInfo[]>;
16
17
  layouts(signal: AbortSignal): Promise<readonly PptxLayoutInfo[]>;
18
+ textStyle(id: string, span: TextSpan | undefined, signal: AbortSignal): Promise<Partial<PptxTextStyle> | undefined>;
17
19
  }
@@ -37,4 +37,7 @@ export class PptxEditEngineClient extends WorkerEngineClient {
37
37
  layouts(signal) {
38
38
  return this.request("edit-pptx-layouts", undefined, signal);
39
39
  }
40
+ textStyle(id, span, signal) {
41
+ return this.request("edit-pptx-text-style", { id, span }, signal);
42
+ }
40
43
  }
@@ -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, ReadItem, ReadOptions, ReadResult, SavedDocument, TextTarget } from "../types.js";
4
- import type { PptxDeleteElementOperation, PptxDeleteSlideOperation, PptxDuplicateSlideOperation, PptxEditSession, PptxElement, PptxFields, PptxInsertImageOperation, PptxInsertSlideOperation, PptxInsertTableOperation, PptxInsertTextBoxOperation, PptxLayoutInfo, PptxMoveElementOperation, PptxMoveSlideOperation, PptxOperation, PptxReplaceTextOperation, PptxResizeElementOperation, PptxSaveOptions, PptxSetShapeStyleOperation, PptxSetTableCellOperation, PptxSetTextStyleOperation, PptxSlideInfo } from "./types.js";
3
+ import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, SavedDocument, TextRange, TextTarget } from "../types.js";
4
+ import type { PptxDeleteElementOperation, PptxDeleteSlideOperation, PptxDuplicateSlideOperation, PptxEditSession, PptxElement, PptxFields, PptxInsertImageOperation, PptxInsertSlideOperation, PptxInsertTableOperation, PptxInsertTextBoxOperation, PptxLayoutInfo, PptxMoveElementOperation, PptxMoveSlideOperation, PptxOperation, PptxReplaceTextOperation, PptxResizeElementOperation, PptxSaveOptions, PptxSetShapeStyleOperation, PptxSetTableCellOperation, PptxSetTextStyleOperation, PptxSlideInfo, PptxTextStyle } from "./types.js";
5
5
  /**
6
6
  * The PPTX session: the core session narrowed to PPTX operations and
7
7
  * elements, plus the slide and layout reads. Each typed method is `apply()`
@@ -42,6 +42,10 @@ export declare class PptxSession implements PptxEditSession {
42
42
  callTool(call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
43
43
  getSlides(options?: ReadOptions): Promise<ReadResult<PptxSlideInfo>>;
44
44
  getLayouts(options?: ReadOptions): Promise<ReadResult<PptxLayoutInfo>>;
45
+ getTextStyle(fields: {
46
+ readonly target: string;
47
+ readonly range?: TextRange;
48
+ }, options?: ReadOptions): Promise<ReadItem<Partial<PptxTextStyle>>>;
45
49
  replaceText(fields: PptxFields<PptxReplaceTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
46
50
  setTextStyle(fields: PptxFields<PptxSetTextStyleOperation>, options?: ApplyOptions): Promise<EditReceipt>;
47
51
  setShapeStyle(fields: PptxFields<PptxSetShapeStyleOperation>, options?: ApplyOptions): Promise<EditReceipt>;
@@ -2,6 +2,7 @@ import { ViewerError } from "../../errors.js";
2
2
  import { readDescription, readOutline } from "../ai/outline.js";
3
3
  import { resolveTargets } from "../ai/targets.js";
4
4
  import { buildToolSet, callTool as runTool } from "../ai/tools.js";
5
+ import { spanOnTarget } from "../range-style.js";
5
6
  /**
6
7
  * The PPTX session: the core session narrowed to PPTX operations and
7
8
  * elements, plus the slide and layout reads. Each typed method is `apply()`
@@ -118,6 +119,10 @@ export class PptxSession {
118
119
  getLayouts(options) {
119
120
  return this.#core.readItems(options, (engine, signal) => pptxReads(engine).layouts(signal));
120
121
  }
122
+ async getTextStyle(fields, options) {
123
+ const span = spanOnTarget(fields.target, fields.range);
124
+ return this.#core.readItem(options, (engine, signal) => pptxReads(engine).textStyle(fields.target, span, signal));
125
+ }
121
126
  replaceText(fields, options) {
122
127
  return this.apply([{ op: "replaceText", ...fields }], options);
123
128
  }
@@ -165,7 +170,8 @@ export class PptxSession {
165
170
  function pptxReads(engine) {
166
171
  const candidate = engine;
167
172
  if (typeof candidate.slides !== "function" ||
168
- typeof candidate.layouts !== "function")
173
+ typeof candidate.layouts !== "function" ||
174
+ typeof candidate.textStyle !== "function")
169
175
  throw new ViewerError("edit-unsupported", "The engine does not provide the PPTX reads", { details: { format: "pptx", reason: "no-reads" } });
170
176
  return candidate;
171
177
  }
@@ -1,4 +1,4 @@
1
- import type { ApplyOptions, BinaryData, EditColor, EditElement, EditReceipt, EditSessionBase, PagePoint, PageRect, ReadOptions, ReadResult, SavedDocument, SaveOptions, TextRange } from "../types.js";
1
+ import type { ApplyOptions, BinaryData, EditColor, EditElement, EditReceipt, EditSessionBase, PagePoint, PageRect, ReadItem, ReadOptions, ReadResult, SavedDocument, SaveOptions, TextRange } from "../types.js";
2
2
  export type PptxElementKind = "shape" | "image" | "table" | "connector" | "group" | "other";
3
3
  export type PptxTextAlign = "left" | "center" | "right" | "justify";
4
4
  /** The style of the first run with text, resolved through the placeholder chain and the theme. */
@@ -201,6 +201,16 @@ export interface PptxEditSession extends EditSessionBase<PptxOperation, PptxElem
201
201
  getSlides(options?: ReadOptions): Promise<ReadResult<PptxSlideInfo>>;
202
202
  /** Every layout of every master, for `insertSlide`. */
203
203
  getLayouts(options?: ReadOptions): Promise<ReadResult<PptxLayoutInfo>>;
204
+ /**
205
+ * The text style a range of a shape's text shows: each property every run
206
+ * it covers shares, a property they differ on left out. Without a range,
207
+ * the whole text; a collapsed range reads the run before it, whose style
208
+ * text typed there takes. `undefined` for an element without text.
209
+ */
210
+ getTextStyle(fields: {
211
+ readonly target: string;
212
+ readonly range?: TextRange;
213
+ }, options?: ReadOptions): Promise<ReadItem<Partial<PptxTextStyle>>>;
204
214
  /** Replaces the whole text of a shape, or the part a range covers. */
205
215
  replaceText(fields: PptxFields<PptxReplaceTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
206
216
  /** Changes run and paragraph properties; unspecified ones keep their bytes. */
@@ -0,0 +1,24 @@
1
+ import type { TextRange } from "./types.js";
2
+ /** Half-open UTF-16 offsets in an element's text. */
3
+ export interface TextSpan {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ }
7
+ /**
8
+ * The span a range names on its target, or `undefined` for no range (the
9
+ * whole text). Both ends must lie on the target.
10
+ */
11
+ export declare function spanOnTarget(target: string, range: TextRange | undefined): TextSpan | undefined;
12
+ /**
13
+ * The runs a span covers. A collapsed span reads the run before it, whose
14
+ * style text typed there takes, or the first run after it at the very start.
15
+ */
16
+ export declare function runsCovering<R extends TextSpan>(runs: readonly R[], span: TextSpan): R[];
17
+ /**
18
+ * What every style has in common: a property two of them differ on, or one
19
+ * some leave out, is left out. Values compare as JSON, so a theme colour and
20
+ * its plain form differ.
21
+ */
22
+ export declare function sharedStyle<T extends object>(styles: readonly T[]): Partial<T> | undefined;
23
+ /** Whether a span lies inside a text of `length` code units. */
24
+ export declare function spanFits(span: TextSpan, length: number): boolean;
@@ -0,0 +1,46 @@
1
+ import { ViewerError } from "../errors.js";
2
+ /**
3
+ * The span a range names on its target, or `undefined` for no range (the
4
+ * whole text). Both ends must lie on the target.
5
+ */
6
+ export function spanOnTarget(target, range) {
7
+ if (!range)
8
+ return undefined;
9
+ if (range.start.elementId !== target || range.end.elementId !== target)
10
+ throw new ViewerError("invalid-operation", "Both ends of the range must lie on the target", { details: { target } });
11
+ return { start: range.start.offset, end: range.end.offset };
12
+ }
13
+ /**
14
+ * The runs a span covers. A collapsed span reads the run before it, whose
15
+ * style text typed there takes, or the first run after it at the very start.
16
+ */
17
+ export function runsCovering(runs, span) {
18
+ if (span.start < span.end)
19
+ return runs.filter((run) => run.start < span.end && run.end > span.start);
20
+ const before = runs
21
+ .filter((run) => run.start < span.start && run.end >= span.start)
22
+ .at(-1);
23
+ const at = before ?? runs.find((run) => run.start >= span.start);
24
+ return at ? [at] : [];
25
+ }
26
+ /**
27
+ * What every style has in common: a property two of them differ on, or one
28
+ * some leave out, is left out. Values compare as JSON, so a theme colour and
29
+ * its plain form differ.
30
+ */
31
+ export function sharedStyle(styles) {
32
+ const [first, ...rest] = styles;
33
+ if (!first)
34
+ return undefined;
35
+ const shared = {};
36
+ for (const key of Object.keys(first)) {
37
+ const value = JSON.stringify(first[key]);
38
+ if (rest.every((style) => key in style && JSON.stringify(style[key]) === value))
39
+ shared[key] = first[key];
40
+ }
41
+ return shared;
42
+ }
43
+ /** Whether a span lies inside a text of `length` code units. */
44
+ export function spanFits(span, length) {
45
+ return span.start >= 0 && span.start <= span.end && span.end <= length;
46
+ }
@@ -739,6 +739,11 @@ export class EditSessionController {
739
739
  ...(change.remappedIds
740
740
  ? { remappedIds: Object.freeze({ ...change.remappedIds }) }
741
741
  : {}),
742
+ ...(change.textAnchorMigrations?.length
743
+ ? {
744
+ textAnchorMigrations: Object.freeze(change.textAnchorMigrations.map((migration) => Object.freeze({ ...migration }))),
745
+ }
746
+ : {}),
742
747
  changedPages: Object.freeze([...change.changedPages]),
743
748
  pageCount: change.pageCount,
744
749
  warnings: Object.freeze(change.warnings.map((warning) => Object.freeze({ ...warning }))),