@beyondwork/docx-react-component 1.0.103 → 1.0.104

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +63 -1
  3. package/src/api/v3/_runtime-handle.ts +2 -0
  4. package/src/api/v3/ai/outline.ts +2 -7
  5. package/src/api/v3/runtime/geometry.ts +79 -0
  6. package/src/io/ooxml/parse-drawing.ts +99 -1
  7. package/src/io/ooxml/parse-fields.ts +27 -6
  8. package/src/io/ooxml/parse-shapes.ts +130 -0
  9. package/src/model/canonical-document.ts +34 -3
  10. package/src/model/canonical-layout-inputs.ts +979 -0
  11. package/src/model/layout/index.ts +6 -0
  12. package/src/model/layout/page-graph-types.ts +61 -0
  13. package/src/model/layout/runtime-page-graph-types.ts +10 -0
  14. package/src/runtime/collab/runtime-collab-sync.ts +3 -3
  15. package/src/runtime/debug/build-debug-inspector-snapshot.ts +17 -4
  16. package/src/runtime/document-runtime.ts +30 -14
  17. package/src/runtime/event-refresh-hints.ts +3 -0
  18. package/src/runtime/formatting/formatting-context.ts +110 -9
  19. package/src/runtime/formatting/index.ts +2 -0
  20. package/src/runtime/formatting/layout-inputs.ts +67 -3
  21. package/src/runtime/geometry/caret-geometry.ts +82 -10
  22. package/src/runtime/geometry/geometry-facet.ts +36 -0
  23. package/src/runtime/geometry/geometry-index.ts +891 -0
  24. package/src/runtime/geometry/geometry-types.ts +221 -1
  25. package/src/runtime/geometry/index.ts +26 -0
  26. package/src/runtime/geometry/inert-geometry-facet.ts +3 -0
  27. package/src/runtime/geometry/replacement-envelope.ts +41 -2
  28. package/src/runtime/layout/layout-engine-version.ts +16 -1
  29. package/src/runtime/layout/page-graph.ts +191 -1
  30. package/src/runtime/prerender/graph-canonicalize.ts +30 -0
  31. package/src/runtime/surface-projection.ts +43 -3
  32. package/src/runtime/workflow/coordinator.ts +57 -11
  33. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +3 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.103",
4
+ "version": "1.0.104",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -1223,6 +1223,23 @@ export interface SurfacePictureEffects {
1223
1223
  glow?: { radius: number; color: string; colorType: "srgbClr" | "schemeClr" };
1224
1224
  }
1225
1225
 
1226
+ export interface SurfacePreserveOnlyObjectSizing {
1227
+ sourceId?: string;
1228
+ display: "inline" | "floating" | "unknown";
1229
+ extentEmu?: { widthEmu: number; heightEmu: number };
1230
+ fallbackHint:
1231
+ | "drawing-inline"
1232
+ | "drawing-floating"
1233
+ | "chart"
1234
+ | "smartart"
1235
+ | "shape"
1236
+ | "wordart"
1237
+ | "vml-shape"
1238
+ | "ole-object"
1239
+ | "opaque-object";
1240
+ relationshipIds?: string[];
1241
+ }
1242
+
1226
1243
  export type SurfaceInlineSegment =
1227
1244
  | {
1228
1245
  segmentId: string;
@@ -1338,6 +1355,20 @@ export type SurfaceInlineSegment =
1338
1355
  * can render `ChartSurface` instead of the fallback bitmap or badge.
1339
1356
  */
1340
1357
  parsedChartId?: string;
1358
+ /**
1359
+ * PE2 preserve-only object handoff. Present for unsupported drawing,
1360
+ * chart, SmartArt, WordArt, VML, and opaque-object previews when import
1361
+ * could recover source ids, relationship ids, fallback hints, or
1362
+ * extents. Layout consumers may size placeholders from this metadata;
1363
+ * it is not a layout computation.
1364
+ */
1365
+ preserveOnlyObject?: SurfacePreserveOnlyObjectSizing;
1366
+ /**
1367
+ * Drawing anchor geometry for preserve-only objects that originated in a
1368
+ * `drawing_frame`. Mirrors image/shape anchor semantics so L04 can join
1369
+ * the object to source/canonical/layout rows without re-parsing OOXML.
1370
+ */
1371
+ anchor?: SurfaceDrawingAnchor;
1341
1372
  state: "locked-preserve-only";
1342
1373
  }
1343
1374
  | {
@@ -1403,6 +1434,7 @@ export type SurfaceInlineSegment =
1403
1434
  isTextBox?: boolean;
1404
1435
  /** Text box body layout from `wps:bodyPr` / `a:bodyPr`. */
1405
1436
  textBoxBody?: SurfaceTextBoxBodyProperties;
1437
+ preserveOnlyObject?: SurfacePreserveOnlyObjectSizing;
1406
1438
  /** First-paragraph plain-text preview when `isTextBox` is true. */
1407
1439
  txbxText?: string;
1408
1440
  /** Run-level marks from the first text-box text run, when available. */
@@ -3609,6 +3641,11 @@ export interface WordReviewEditorPasteEvent {
3609
3641
  source: "paste" | "drop";
3610
3642
  }
3611
3643
 
3644
+ export interface WorkflowEventOrigin {
3645
+ readonly source: "api" | "runtime" | "collab" | string;
3646
+ readonly at?: string;
3647
+ }
3648
+
3612
3649
  export type WordReviewEditorEvent =
3613
3650
  | {
3614
3651
  type: "ready";
@@ -3724,16 +3761,38 @@ export type WordReviewEditorEvent =
3724
3761
  type: "workflow_overlay_changed";
3725
3762
  documentId: string;
3726
3763
  snapshot: WorkflowScopeSnapshot;
3764
+ origin?: WorkflowEventOrigin;
3727
3765
  }
3728
3766
  | {
3729
3767
  type: "workflow_active_work_item_changed";
3730
3768
  documentId: string;
3731
3769
  activeWorkItemId: string | null;
3770
+ origin?: WorkflowEventOrigin;
3732
3771
  }
3733
3772
  | {
3734
3773
  type: "workflow_metadata_changed";
3735
3774
  documentId: string;
3736
3775
  snapshot: WorkflowMetadataSnapshot;
3776
+ origin?: WorkflowEventOrigin;
3777
+ }
3778
+ | {
3779
+ type: "workflow_shared_state_changed";
3780
+ documentId: string;
3781
+ state: SharedWorkflowState | null;
3782
+ origin: WorkflowEventOrigin;
3783
+ }
3784
+ | {
3785
+ type: "workflow_visibility_policy_changed";
3786
+ documentId: string;
3787
+ kind?: OverlayKind;
3788
+ policy?: OverlayVisibilityPolicy | null;
3789
+ origin: WorkflowEventOrigin;
3790
+ }
3791
+ | {
3792
+ type: "workflow_markup_mode_policy_changed";
3793
+ documentId: string;
3794
+ policy: WorkflowMarkupModePolicy | null;
3795
+ origin: WorkflowEventOrigin;
3737
3796
  }
3738
3797
  | {
3739
3798
  type: "host_annotation_overlay_changed";
@@ -5010,7 +5069,10 @@ export interface WordReviewEditorRef {
5010
5069
  * `replaceText` calls to block with `workflow_comment_only`.
5011
5070
  */
5012
5071
  getWorkflowOverlay(): WorkflowOverlay | null;
5013
- setSharedWorkflowState(state: SharedWorkflowState | null): void;
5072
+ setSharedWorkflowState(
5073
+ state: SharedWorkflowState | null,
5074
+ origin?: WorkflowEventOrigin,
5075
+ ): void;
5014
5076
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
5015
5077
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
5016
5078
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -48,6 +48,7 @@ export type RuntimeApiHandle = Pick<
48
48
  | "getCompatibilityReport"
49
49
  | "getWarnings"
50
50
  | "getRenderSnapshot"
51
+ | "getDocumentNavigationSnapshot"
51
52
  // Canonical document read (ai.inspect + ai.bundle + ai.resolve families,
52
53
  // added in refactor/08 Slice 2/3 graduations)
53
54
  | "getCanonicalDocument"
@@ -155,6 +156,7 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
155
156
  getCompatibilityReport: true,
156
157
  getWarnings: true,
157
158
  getRenderSnapshot: true,
159
+ getDocumentNavigationSnapshot: true,
158
160
  getCanonicalDocument: true,
159
161
  findAllText: true,
160
162
  replaceText: true,
@@ -19,7 +19,6 @@ import type {
19
19
  DocumentOutlineHeadingSnapshot,
20
20
  DocumentOutlineSnapshot,
21
21
  } from "../../public-types.ts";
22
- import { createDocumentNavigationSnapshot } from "../../../runtime/document-navigation.ts";
23
22
  import { createDocumentOutlineSnapshot } from "../../../runtime/document-outline.ts";
24
23
  import {
25
24
  computeBlockPositions,
@@ -68,7 +67,7 @@ export const getDocumentOutlineMetadata: ApiV3FnMetadata = {
68
67
  stateClass: "A-canonical",
69
68
  persistsTo: "canonical",
70
69
  rwdReference:
71
- "§AI API § ai.getDocumentOutline. Composes createDocumentNavigationSnapshot (L04/07) with createDocumentOutlineSnapshot (L07) to surface the heading tree. Read-only; no audit emission.",
70
+ "§AI API § ai.getDocumentOutline. Composes runtime.getDocumentNavigationSnapshot() (L04 graph-derived navigation) with createDocumentOutlineSnapshot (L07) to surface the heading tree. Read-only; no audit emission.",
72
71
  };
73
72
 
74
73
  export function createOutlineFamily(runtime: RuntimeApiHandle) {
@@ -82,11 +81,7 @@ export function createOutlineFamily(runtime: RuntimeApiHandle) {
82
81
  const snapshot = runtime.getRenderSnapshot();
83
82
  const document = runtime.getCanonicalDocument();
84
83
  const selectionHead = snapshot.selection.head;
85
- const navigation = createDocumentNavigationSnapshot(
86
- document,
87
- selectionHead,
88
- snapshot.activeStory,
89
- );
84
+ const navigation = runtime.getDocumentNavigationSnapshot();
90
85
  const outline = createDocumentOutlineSnapshot({
91
86
  navigation,
92
87
  activeStory: snapshot.activeStory,
@@ -15,6 +15,26 @@ import type { RuntimeApiHandle } from "../_runtime-handle.ts";
15
15
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
16
16
  import { mockPayload } from "../_mocks.ts";
17
17
  import type { MockPayload } from "../_layer-metadata.ts";
18
+ import type {
19
+ GeometryIndex,
20
+ GeometryIndexCoverage,
21
+ } from "../../../runtime/geometry/index.ts";
22
+
23
+ function createUnavailableGeometryCoverage(): GeometryIndexCoverage {
24
+ return {
25
+ status: "unavailable",
26
+ pageCount: 0,
27
+ regionCount: 0,
28
+ sliceCount: 0,
29
+ lineCount: 0,
30
+ anchorCount: 0,
31
+ hitTargetCount: 0,
32
+ semanticEntryCount: 0,
33
+ replacementEnvelopeCount: 0,
34
+ objectHandleCount: 0,
35
+ precision: { exact: 0, "within-tolerance": 0, heuristic: 0 },
36
+ };
37
+ }
18
38
 
19
39
  export interface BlockRectEntry {
20
40
  readonly blockId: string;
@@ -57,6 +77,50 @@ export interface HitTestResult {
57
77
  readonly __mock?: true;
58
78
  }
59
79
 
80
+ /* ================================================================== */
81
+ /* PE2 geometry index + coverage */
82
+ /* ================================================================== */
83
+
84
+ export const getGeometryIndexMetadata: ApiV3FnMetadata = {
85
+ name: "runtime.geometry.getGeometryIndex",
86
+ status: "live-with-adapter",
87
+ sourceLayer: "geometry-projection",
88
+ liveEvidence: {
89
+ runnerTest: "test/api/v3/geometry-uses-geometry-handle.test.ts",
90
+ commit: "refactor-07-pe2-geometry-index-read-surface",
91
+ },
92
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
93
+ agentMetadata: {
94
+ readOrMutate: "read",
95
+ boundedScope: "document",
96
+ auditCategory: "geometry-read",
97
+ },
98
+ stateClass: "A-canonical",
99
+ persistsTo: "canonical",
100
+ rwdReference:
101
+ "§Runtime API § runtime.geometry.getGeometryIndex. PE2 read surface over GeometryFacet.getGeometryIndex(): renderer-neutral pages, regions, slices, lines, anchors, hit targets, semantic entries, replacement envelopes, object handles, and coverage. Returns null when no geometry facet or render frame is warm. Promotes to live when L05 projects directly from L04 page slices instead of the render-frame adapter.",
102
+ };
103
+
104
+ export const getGeometryCoverageMetadata: ApiV3FnMetadata = {
105
+ name: "runtime.geometry.getGeometryCoverage",
106
+ status: "live-with-adapter",
107
+ sourceLayer: "geometry-projection",
108
+ liveEvidence: {
109
+ runnerTest: "test/api/v3/geometry-uses-geometry-handle.test.ts",
110
+ commit: "refactor-07-pe2-geometry-index-read-surface",
111
+ },
112
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
113
+ agentMetadata: {
114
+ readOrMutate: "read",
115
+ boundedScope: "document",
116
+ auditCategory: "geometry-read",
117
+ },
118
+ stateClass: "A-canonical",
119
+ persistsTo: "canonical",
120
+ rwdReference:
121
+ "§Runtime API § runtime.geometry.getGeometryCoverage. PE2 lightweight status read over GeometryFacet.getGeometryCoverage(). Always returns a coverage object; unwired/pre-paint states report status:'unavailable' with zero counts so agents and evidence runners can distinguish no geometry from empty geometry.",
122
+ };
123
+
60
124
  /* ================================================================== */
61
125
  /* getCaret — Slice-4 caret geometry (Slice X3 of §4 of coord-05) */
62
126
  /* ================================================================== */
@@ -224,6 +288,21 @@ export function createGeometryFamily(runtime: RuntimeApiHandle) {
224
288
  const geometry = runtime.geometry;
225
289
 
226
290
  return {
291
+ getGeometryIndex(): GeometryIndex | null {
292
+ // @endStateApi — live-with-adapter. Exposes the Layer-05 PE2
293
+ // geometry index through the v3 runtime seam. The current L05
294
+ // index is projected from the render frame; callers still get
295
+ // renderer-neutral value objects and no DOM/PM/runtime instances.
296
+ return geometry?.getGeometryIndex() ?? null;
297
+ },
298
+
299
+ getGeometryCoverage(): GeometryIndexCoverage {
300
+ // @endStateApi — live-with-adapter. Coverage is intentionally
301
+ // total: an unwired/pre-paint handle reports an unavailable zero
302
+ // summary instead of null so evidence code can branch on status.
303
+ return geometry?.getGeometryCoverage() ?? createUnavailableGeometryCoverage();
304
+ },
305
+
227
306
  getBlockRects(blockIds: readonly string[]): ReadonlyArray<BlockRectEntry & Partial<MockPayload>> {
228
307
  // @endStateApi — live-with-adapter. Routes directly through the
229
308
  // Layer-05 GeometryFacet's `getBlockRects(blockId)`, which walks
@@ -2,7 +2,12 @@ import type { OpcRelationship } from "./part-manifest.ts";
2
2
  import { normalizePartPath, resolveRelationshipTarget } from "./part-manifest.ts";
3
3
  import type { InlineMediaPart } from "./parse-inline-media.ts";
4
4
  import type { ChartPartLookup } from "./parse-complex-content.ts";
5
- import type { AnchorGeometry, DrawingFrameNode, Mutable } from "../../model/canonical-document.ts";
5
+ import type {
6
+ AnchorGeometry,
7
+ DrawingFrameNode,
8
+ Mutable,
9
+ PreserveOnlyObjectSizing,
10
+ } from "../../model/canonical-document.ts";
6
11
  import { parseAnchorGeometry } from "./parse-anchor.ts";
7
12
  import { parsePicture } from "./parse-picture.ts";
8
13
  import { parseShapeContent, type TxbxBlockParser } from "./parse-shapes.ts";
@@ -144,6 +149,13 @@ function resolveFromBranch(
144
149
  if (uri === WPS_SHAPE_GRAPHIC_URI && isWordArtGraphicData(graphicData)) return null;
145
150
 
146
151
  const content = resolveContent(uri, graphicData, outerRawXml, opts);
152
+ attachPreserveOnlyObjectSizing(content, {
153
+ anchor: geometry,
154
+ container,
155
+ fallbackHint: fallbackHintForContent(content, geometry),
156
+ rawXml: outerRawXml,
157
+ sourcePartPath: opts.sourcePartPath,
158
+ });
147
159
  return { type: "drawing_frame", anchor: geometry, content };
148
160
  }
149
161
 
@@ -231,6 +243,92 @@ function resolveContent(
231
243
  return { type: "opaque", rawXml };
232
244
  }
233
245
 
246
+ function attachPreserveOnlyObjectSizing(
247
+ content: DrawingFrameNode["content"],
248
+ context: {
249
+ anchor: AnchorGeometry;
250
+ container: XmlElementNode;
251
+ fallbackHint: PreserveOnlyObjectSizing["fallbackHint"] | null;
252
+ rawXml: string;
253
+ sourcePartPath: string | undefined;
254
+ },
255
+ ): void {
256
+ if (content.type === "picture" || context.fallbackHint === null) {
257
+ return;
258
+ }
259
+
260
+ (content as Mutable<typeof content>).preserveOnlyObject = {
261
+ sourceId: createPreserveOnlyObjectSourceId(
262
+ context.sourcePartPath,
263
+ context.anchor,
264
+ context.rawXml,
265
+ ),
266
+ display: context.anchor.display,
267
+ extentEmu: {
268
+ widthEmu: context.anchor.extent.widthEmu,
269
+ heightEmu: context.anchor.extent.heightEmu,
270
+ },
271
+ fallbackHint: context.fallbackHint,
272
+ relationshipIds: collectRelationshipIds(context.container),
273
+ };
274
+ }
275
+
276
+ function fallbackHintForContent(
277
+ content: DrawingFrameNode["content"],
278
+ anchor: AnchorGeometry,
279
+ ): PreserveOnlyObjectSizing["fallbackHint"] | null {
280
+ switch (content.type) {
281
+ case "picture":
282
+ return null;
283
+ case "chart_preview":
284
+ return "chart";
285
+ case "smartart_preview":
286
+ return "smartart";
287
+ case "shape":
288
+ return "shape";
289
+ case "opaque":
290
+ return anchor.display === "floating" ? "drawing-floating" : "drawing-inline";
291
+ }
292
+ }
293
+
294
+ function createPreserveOnlyObjectSourceId(
295
+ sourcePartPath: string | undefined,
296
+ anchor: AnchorGeometry,
297
+ rawXml: string,
298
+ ): string {
299
+ const part = sourcePartPath ?? "/word/document.xml";
300
+ const docPrId = anchor.docPr?.id;
301
+ if (docPrId) return `part:${part}#drawing-docPr:${docPrId}`;
302
+ return `part:${part}#drawing:${hashString(rawXml)}`;
303
+ }
304
+
305
+ function collectRelationshipIds(node: XmlElementNode): string[] {
306
+ const ids = new Set<string>();
307
+ const visit = (current: XmlElementNode): void => {
308
+ const id =
309
+ current.attributes["r:id"] ??
310
+ current.attributes["r:embed"] ??
311
+ current.attributes.embed ??
312
+ current.attributes["r:link"] ??
313
+ current.attributes.link;
314
+ if (id) ids.add(id);
315
+ for (const child of current.children) {
316
+ if (child.type === "element") visit(child);
317
+ }
318
+ };
319
+ visit(node);
320
+ return [...ids].sort();
321
+ }
322
+
323
+ function hashString(value: string): string {
324
+ let hash = 2166136261;
325
+ for (let index = 0; index < value.length; index += 1) {
326
+ hash ^= value.charCodeAt(index);
327
+ hash = Math.imul(hash, 16777619);
328
+ }
329
+ return (hash >>> 0).toString(16).padStart(8, "0");
330
+ }
331
+
234
332
  function extractChartRelId(graphicData: XmlElementNode | undefined): string | null {
235
333
  if (!graphicData) return null;
236
334
  const chart = findFirstDescendant(graphicData, "chart");
@@ -330,6 +330,11 @@ import type {
330
330
  TocStructure,
331
331
  Mutable,
332
332
  } from "../../model/canonical-document.ts";
333
+ import {
334
+ MAIN_STORY_KEY,
335
+ createHeaderFooterStoryKey,
336
+ createNoteStoryKey,
337
+ } from "../../model/canonical-layout-inputs.ts";
333
338
  import { parseFieldSwitches } from "./parse-field-switches.ts";
334
339
 
335
340
  const FIELD_FAMILY_PATTERN =
@@ -439,6 +444,7 @@ export function buildFieldRegistry(
439
444
  ...(classification.target ? { fieldTarget: classification.target } : {}),
440
445
  displayText,
441
446
  paragraphIndex,
447
+ storyKey: MAIN_STORY_KEY,
442
448
  refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
443
449
  ...(classification.switches ? { switches: classification.switches } : {}),
444
450
  };
@@ -454,7 +460,7 @@ export function buildFieldRegistry(
454
460
  }
455
461
  });
456
462
  if (document.subParts) {
457
- walkSubPartFields(document.subParts, (node, pIdx) => {
463
+ walkSubPartFields(document.subParts, (node, pIdx, storyKey) => {
458
464
  paragraphIndex = pIdx;
459
465
  if (node.type === "field") {
460
466
  const classification = node.fieldFamily
@@ -469,6 +475,7 @@ export function buildFieldRegistry(
469
475
  ...(classification.target ? { fieldTarget: classification.target } : {}),
470
476
  displayText,
471
477
  paragraphIndex,
478
+ storyKey,
472
479
  refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
473
480
  ...(classification.switches ? { switches: classification.switches } : {}),
474
481
  };
@@ -1062,27 +1069,41 @@ function walkFieldDocument(
1062
1069
 
1063
1070
  function walkSubPartFields(
1064
1071
  subParts: Mutable<SubPartsCatalog>,
1065
- visit: (node: DocumentNode, paragraphIndex: number) => void,
1072
+ visit: (node: DocumentNode, paragraphIndex: number, storyKey: string) => void,
1066
1073
  ): void {
1067
1074
  for (const header of subParts.headers) {
1075
+ const storyKey = createHeaderFooterStoryKey({
1076
+ kind: "header",
1077
+ relationshipId: header.relationshipId,
1078
+ variant: header.variant,
1079
+ ...(header.sectionIndex !== undefined ? { sectionIndex: header.sectionIndex } : {}),
1080
+ });
1068
1081
  for (const block of header.blocks) {
1069
- walkFieldDocument(block, visit);
1082
+ walkFieldDocument(block, (node, paragraphIndex) => visit(node, paragraphIndex, storyKey));
1070
1083
  }
1071
1084
  }
1072
1085
  for (const footer of subParts.footers) {
1086
+ const storyKey = createHeaderFooterStoryKey({
1087
+ kind: "footer",
1088
+ relationshipId: footer.relationshipId,
1089
+ variant: footer.variant,
1090
+ ...(footer.sectionIndex !== undefined ? { sectionIndex: footer.sectionIndex } : {}),
1091
+ });
1073
1092
  for (const block of footer.blocks) {
1074
- walkFieldDocument(block, visit);
1093
+ walkFieldDocument(block, (node, paragraphIndex) => visit(node, paragraphIndex, storyKey));
1075
1094
  }
1076
1095
  }
1077
1096
  if (subParts.footnoteCollection) {
1078
1097
  for (const note of Object.values(subParts.footnoteCollection.footnotes)) {
1098
+ const storyKey = createNoteStoryKey("footnote", note.noteId);
1079
1099
  for (const block of note.blocks) {
1080
- walkFieldDocument(block, visit);
1100
+ walkFieldDocument(block, (node, paragraphIndex) => visit(node, paragraphIndex, storyKey));
1081
1101
  }
1082
1102
  }
1083
1103
  for (const note of Object.values(subParts.footnoteCollection.endnotes)) {
1104
+ const storyKey = createNoteStoryKey("endnote", note.noteId);
1084
1105
  for (const block of note.blocks) {
1085
- walkFieldDocument(block, visit);
1106
+ walkFieldDocument(block, (node, paragraphIndex) => visit(node, paragraphIndex, storyKey));
1086
1107
  }
1087
1108
  }
1088
1109
  }
@@ -15,6 +15,7 @@ import type {
15
15
  ShapeContent,
16
16
  TextBoxBodyProperties,
17
17
  Mutable,
18
+ PreserveOnlyObjectSizing,
18
19
  } from "../../model/canonical-document.ts";
19
20
  import { parseFill } from "./parse-fill.ts";
20
21
  import {
@@ -52,6 +53,7 @@ export interface ParsedWpsShape {
52
53
  geometry?: string;
53
54
  /** Original drawing XML for lossless round-trip export. */
54
55
  rawXml: string;
56
+ preserveOnlyObject?: PreserveOnlyObjectSizing;
55
57
  }
56
58
 
57
59
  export interface ParsedWordArt {
@@ -62,6 +64,7 @@ export interface ParsedWordArt {
62
64
  geometry?: string;
63
65
  /** Original drawing XML for lossless round-trip export. */
64
66
  rawXml: string;
67
+ preserveOnlyObject?: PreserveOnlyObjectSizing;
65
68
  }
66
69
 
67
70
  export interface ParsedVmlShape {
@@ -72,6 +75,7 @@ export interface ParsedVmlShape {
72
75
  text?: string;
73
76
  /** Original w:pict XML for lossless round-trip export. */
74
77
  rawXml: string;
78
+ preserveOnlyObject?: PreserveOnlyObjectSizing;
75
79
  }
76
80
 
77
81
  export type ParsedShape = ParsedWpsShape | ParsedWordArt | ParsedVmlShape;
@@ -113,6 +117,11 @@ export function parseShapeXml(
113
117
  text: text ?? "",
114
118
  ...(prst ? { geometry: prst } : {}),
115
119
  rawXml: drawingXml,
120
+ preserveOnlyObject: createDrawingPreserveOnlyObject(
121
+ drawingXml,
122
+ "wordart",
123
+ collectRelationshipIds(root),
124
+ ),
116
125
  };
117
126
  }
118
127
 
@@ -158,6 +167,11 @@ export function parseShapeXml(
158
167
  ...(txbxBlocks && txbxBlocks.length > 0 ? { txbxBlocks } : {}),
159
168
  ...(prst ? { geometry: prst } : {}),
160
169
  rawXml: drawingXml,
170
+ preserveOnlyObject: createDrawingPreserveOnlyObject(
171
+ drawingXml,
172
+ "shape",
173
+ collectRelationshipIds(root),
174
+ ),
161
175
  };
162
176
  }
163
177
 
@@ -200,6 +214,11 @@ export function parseVmlXml(pictXml: string): ParsedVmlShape | null {
200
214
  ...(shapeType ? { shapeType } : {}),
201
215
  ...(text ? { text } : {}),
202
216
  rawXml: pictXml,
217
+ preserveOnlyObject: createVmlPreserveOnlyObject(
218
+ pictXml,
219
+ vmlNode,
220
+ collectRelationshipIds(root),
221
+ ),
203
222
  };
204
223
  }
205
224
 
@@ -361,3 +380,114 @@ function readIntAttr(node: XmlElementNode, attr: string): number | undefined {
361
380
  const parsed = parseInt(raw, 10);
362
381
  return Number.isFinite(parsed) ? parsed : undefined;
363
382
  }
383
+
384
+ function createDrawingPreserveOnlyObject(
385
+ rawXml: string,
386
+ fallbackHint: PreserveOnlyObjectSizing["fallbackHint"],
387
+ relationshipIds: string[],
388
+ ): PreserveOnlyObjectSizing {
389
+ const extentEmu = readDrawingExtentEmu(rawXml);
390
+ return {
391
+ sourceId: `drawing:${hashString(rawXml)}`,
392
+ display: rawXml.includes("<wp:anchor") ? "floating" : "inline",
393
+ ...(extentEmu ? { extentEmu } : {}),
394
+ fallbackHint,
395
+ ...(relationshipIds.length > 0 ? { relationshipIds } : {}),
396
+ };
397
+ }
398
+
399
+ function createVmlPreserveOnlyObject(
400
+ rawXml: string,
401
+ node: XmlElementNode,
402
+ relationshipIds: string[],
403
+ ): PreserveOnlyObjectSizing {
404
+ const extentEmu = readVmlExtentEmu(node.attributes.style);
405
+ return {
406
+ sourceId: `vml:${node.attributes.id ?? hashString(rawXml)}`,
407
+ display: "unknown",
408
+ ...(extentEmu ? { extentEmu } : {}),
409
+ fallbackHint: "vml-shape",
410
+ ...(relationshipIds.length > 0 ? { relationshipIds } : {}),
411
+ };
412
+ }
413
+
414
+ function readDrawingExtentEmu(
415
+ rawXml: string,
416
+ ): { widthEmu: number; heightEmu: number } | undefined {
417
+ const extentMatch = /<wp:extent\b[^>]*\bcx=["']([0-9]+)["'][^>]*\bcy=["']([0-9]+)["'][^>]*\/?>/u.exec(rawXml);
418
+ if (extentMatch) {
419
+ return {
420
+ widthEmu: Number.parseInt(extentMatch[1] ?? "0", 10),
421
+ heightEmu: Number.parseInt(extentMatch[2] ?? "0", 10),
422
+ };
423
+ }
424
+ const xfrmMatch = /<a:ext\b[^>]*\bcx=["']([0-9]+)["'][^>]*\bcy=["']([0-9]+)["'][^>]*\/?>/u.exec(rawXml);
425
+ if (!xfrmMatch) return undefined;
426
+ return {
427
+ widthEmu: Number.parseInt(xfrmMatch[1] ?? "0", 10),
428
+ heightEmu: Number.parseInt(xfrmMatch[2] ?? "0", 10),
429
+ };
430
+ }
431
+
432
+ function readVmlExtentEmu(
433
+ style: string | undefined,
434
+ ): { widthEmu: number; heightEmu: number } | undefined {
435
+ if (!style) return undefined;
436
+ const widthTwips = readCssLengthTwips(style, "width");
437
+ const heightTwips = readCssLengthTwips(style, "height");
438
+ if (widthTwips === undefined || heightTwips === undefined) return undefined;
439
+ return {
440
+ widthEmu: Math.round(widthTwips * 635),
441
+ heightEmu: Math.round(heightTwips * 635),
442
+ };
443
+ }
444
+
445
+ function readCssLengthTwips(style: string, property: "width" | "height"): number | undefined {
446
+ const match = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([0-9.]+)\\s*(pt|in|cm|mm|px)?`, "iu")
447
+ .exec(style);
448
+ if (!match) return undefined;
449
+ const value = Number.parseFloat(match[1] ?? "");
450
+ if (!Number.isFinite(value)) return undefined;
451
+ const unit = (match[2] ?? "pt").toLowerCase();
452
+ switch (unit) {
453
+ case "pt":
454
+ return Math.round(value * 20);
455
+ case "in":
456
+ return Math.round(value * 1440);
457
+ case "cm":
458
+ return Math.round(value * 567);
459
+ case "mm":
460
+ return Math.round(value * 56.7);
461
+ case "px":
462
+ return Math.round(value * 15);
463
+ default:
464
+ return undefined;
465
+ }
466
+ }
467
+
468
+ function collectRelationshipIds(node: XmlElementNode): string[] {
469
+ const ids = new Set<string>();
470
+ const visit = (current: XmlElementNode): void => {
471
+ const id =
472
+ current.attributes["r:id"] ??
473
+ current.attributes["r:embed"] ??
474
+ current.attributes.embed ??
475
+ current.attributes["r:link"] ??
476
+ current.attributes.link;
477
+ if (id) ids.add(id);
478
+ for (const child of current.children) {
479
+ if (child.type === "element") visit(child);
480
+ }
481
+ };
482
+ visit(node);
483
+ return [...ids].sort();
484
+ }
485
+
486
+ function hashString(value: string): string {
487
+ let hash = 2166136261;
488
+ for (let index = 0; index < value.length; index += 1) {
489
+ hash ^= value.charCodeAt(index);
490
+ hash = Math.imul(hash, 16777619);
491
+ }
492
+ return (hash >>> 0).toString(16).padStart(8, "0");
493
+ }