@beyondwork/docx-react-component 1.0.103 → 1.0.105

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +66 -1
  3. package/src/api/v3/_runtime-handle.ts +2 -0
  4. package/src/api/v3/ai/_pe2-evidence.ts +153 -0
  5. package/src/api/v3/ai/bundle.ts +13 -5
  6. package/src/api/v3/ai/inspect.ts +7 -1
  7. package/src/api/v3/ai/outline.ts +2 -7
  8. package/src/api/v3/ai/replacement.ts +113 -0
  9. package/src/api/v3/runtime/geometry.ts +79 -0
  10. package/src/api/v3/ui/_types.ts +86 -0
  11. package/src/api/v3/ui/index.ts +5 -0
  12. package/src/api/v3/ui/overlays.ts +104 -0
  13. package/src/io/ooxml/parse-drawing.ts +99 -1
  14. package/src/io/ooxml/parse-fields.ts +27 -6
  15. package/src/io/ooxml/parse-shapes.ts +130 -0
  16. package/src/model/canonical-document.ts +34 -3
  17. package/src/model/canonical-layout-inputs.ts +979 -0
  18. package/src/model/layout/index.ts +9 -0
  19. package/src/model/layout/page-graph-types.ts +150 -0
  20. package/src/model/layout/runtime-page-graph-types.ts +23 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +3 -3
  22. package/src/runtime/debug/build-debug-inspector-snapshot.ts +17 -4
  23. package/src/runtime/document-runtime.ts +30 -14
  24. package/src/runtime/event-refresh-hints.ts +35 -5
  25. package/src/runtime/formatting/formatting-context.ts +110 -9
  26. package/src/runtime/formatting/index.ts +2 -0
  27. package/src/runtime/formatting/layout-inputs.ts +67 -3
  28. package/src/runtime/geometry/caret-geometry.ts +82 -10
  29. package/src/runtime/geometry/geometry-facet.ts +44 -0
  30. package/src/runtime/geometry/geometry-index.ts +1268 -0
  31. package/src/runtime/geometry/geometry-types.ts +227 -1
  32. package/src/runtime/geometry/index.ts +26 -0
  33. package/src/runtime/geometry/inert-geometry-facet.ts +3 -0
  34. package/src/runtime/geometry/object-handles.ts +7 -4
  35. package/src/runtime/geometry/replacement-envelope.ts +41 -2
  36. package/src/runtime/layout/layout-engine-instance.ts +2 -0
  37. package/src/runtime/layout/layout-engine-version.ts +44 -1
  38. package/src/runtime/layout/page-graph.ts +877 -2
  39. package/src/runtime/layout/project-block-fragments.ts +101 -1
  40. package/src/runtime/layout/public-facet.ts +152 -0
  41. package/src/runtime/prerender/graph-canonicalize.ts +44 -0
  42. package/src/runtime/surface-projection.ts +43 -3
  43. package/src/runtime/workflow/coordinator.ts +57 -11
  44. package/src/ui/ui-controller-factory.ts +11 -0
  45. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +3 -0
@@ -127,6 +127,21 @@ export interface UiController {
127
127
  readonly subscribeOverlays?: (
128
128
  listener: UiListener<OverlayAnchorQuery>,
129
129
  ) => UiUnsubscribe;
130
+ /**
131
+ * PE2 overlay-lane snapshot resolver. This is the mounted-surface lane
132
+ * contract for selection/caret/redline/scope/comment/table/object/search
133
+ * and presence overlays. The bind-side supplies already-projected lane
134
+ * entries; L10 owns the observable shape, not layout or geometry caches.
135
+ */
136
+ readonly getOverlayLane?: (kind: UiOverlayLaneKind) => UiOverlayLaneSnapshot;
137
+ /**
138
+ * PE2 overlay-lane subscription. Must fan out lane changes without
139
+ * dispatching PM document transactions or invalidating L04 layout.
140
+ */
141
+ readonly subscribeOverlayLane?: (
142
+ kind: UiOverlayLaneKind,
143
+ listener: UiListener<UiOverlayLaneSnapshot>,
144
+ ) => UiUnsubscribe;
130
145
  /**
131
146
  * Overlay anchor resolver. Expected to be a direct projection of the
132
147
  * geometry facet (U4 — overlays derive from geometry, not DOM). Returns
@@ -267,6 +282,60 @@ export type OverlayAnchorQuery =
267
282
  | { kind: "page"; value: number }
268
283
  | { kind: "selection" };
269
284
 
285
+ /* ================================================================== */
286
+ /* PE2 overlay lanes */
287
+ /* ================================================================== */
288
+
289
+ export type UiOverlayLaneKind =
290
+ | "selection"
291
+ | "caret"
292
+ | "redlines"
293
+ | "field-scopes"
294
+ | "broad-scopes"
295
+ | "comments"
296
+ | "issues"
297
+ | "tables"
298
+ | "objects"
299
+ | "search"
300
+ | "presence";
301
+
302
+ export type UiOverlayLaneStatus =
303
+ | "resolved"
304
+ | "requires-rehydration"
305
+ | "unavailable";
306
+
307
+ export type UiOverlayLaneSource =
308
+ | "geometry"
309
+ | "workflow"
310
+ | "search"
311
+ | "awareness"
312
+ | "controller"
313
+ | "unavailable";
314
+
315
+ export interface UiOverlayLaneEntry {
316
+ readonly id: string;
317
+ readonly status: UiOverlayLaneStatus;
318
+ readonly anchor?: OverlayAnchorQuery;
319
+ readonly rects?: readonly GeometryRect[];
320
+ readonly reason?: string;
321
+ /**
322
+ * Lane-specific plain payload. Examples: peer identity for presence,
323
+ * issue severity, search rank, table/object classification. Kept plain
324
+ * so non-React consumers and debug runners can serialize snapshots.
325
+ */
326
+ readonly data?: Readonly<Record<string, unknown>>;
327
+ }
328
+
329
+ export interface UiOverlayLaneSnapshot {
330
+ readonly __mock?: true;
331
+ readonly kind: UiOverlayLaneKind;
332
+ readonly status: UiOverlayLaneStatus;
333
+ readonly entries: readonly UiOverlayLaneEntry[];
334
+ readonly revision: number;
335
+ readonly source: UiOverlayLaneSource;
336
+ readonly reason?: string;
337
+ }
338
+
270
339
  /* ================================================================== */
271
340
  /* Overlay visibility — U9 (state-classes X3) */
272
341
  /* ================================================================== */
@@ -453,6 +522,23 @@ export interface ApiV3UiOverlays {
453
522
  query: OverlayAnchorQuery,
454
523
  listener: UiListener<GeometryRect | null>,
455
524
  ): UiUnsubscribe;
525
+ /**
526
+ * PE2 overlay-lane read. Returns a plain snapshot for a mounted lane
527
+ * such as selection, caret, redlines, comments, tables, objects,
528
+ * search, or presence. When the host has not wired a lane yet, returns
529
+ * an explicit `unavailable` snapshot instead of measuring DOM or
530
+ * forcing geometry rehydration.
531
+ */
532
+ getLane(kind: UiOverlayLaneKind): UiOverlayLaneSnapshot;
533
+ /**
534
+ * PE2 overlay-lane subscription. The listener receives lane snapshots,
535
+ * never PM transactions. Presence/awareness updates must travel through
536
+ * this channel without invalidating L04 layout.
537
+ */
538
+ subscribeLane(
539
+ kind: UiOverlayLaneKind,
540
+ listener: UiListener<UiOverlayLaneSnapshot>,
541
+ ): UiUnsubscribe;
456
542
 
457
543
  /**
458
544
  * U9 · Composed overlay visibility. Merges the class-A policy from
@@ -43,6 +43,11 @@ export type {
43
43
  DebugSession,
44
44
  DebugAttachment,
45
45
  GeometryRect,
46
+ UiOverlayLaneEntry,
47
+ UiOverlayLaneKind,
48
+ UiOverlayLaneSnapshot,
49
+ UiOverlayLaneSource,
50
+ UiOverlayLaneStatus,
46
51
  } from "./_types.ts";
47
52
 
48
53
  // Chrome composition types (U5.b) — relocated to layer 10 in Slice 8.
@@ -42,6 +42,8 @@ import type {
42
42
  GeometryRect,
43
43
  OverlayAnchorQuery,
44
44
  OverlayVisibility,
45
+ UiOverlayLaneKind,
46
+ UiOverlayLaneSnapshot,
45
47
  UiListener,
46
48
  UiUnsubscribe,
47
49
  } from "./_types.ts";
@@ -195,6 +197,57 @@ export const subscribeQueryMetadata: ApiV3FnMetadata = {
195
197
  rwdReference: "§UI API § ui.overlays.subscribeQuery (KI-006 close). Per-query coalesced variant of `subscribe`. Registers a shared coarse `controller.subscribeOverlays` tick on the first query subscription; re-resolves each attached query on every tick via `getAnchor(query)` (memoized); fires per-query listeners only when the rect actually changes. Torn down on last unsubscribe. Listener receives the new `GeometryRect | null` value. Callers that need the full invalidation stream keep using `subscribe(listener)`.",
196
198
  };
197
199
 
200
+ // ----- PE2 overlay-lane contract -------------------------------------------
201
+
202
+ export const getLaneMetadata: ApiV3FnMetadata = {
203
+ name: "ui.overlays.getLane",
204
+ status: "live-with-adapter",
205
+ sourceLayer: "presentation",
206
+ liveEvidence: {
207
+ runnerTest: "test/api/v3/ui/overlays-lanes.test.ts",
208
+ commit: "refactor-10-pe2-overlay-lanes",
209
+ },
210
+ mockShape: {
211
+ deterministic: true,
212
+ seededFrom: "fixed",
213
+ shapeDescription:
214
+ "Unavailable UiOverlayLaneSnapshot with entries:[] and revision:0 when the mounted controller has not wired the requested PE2 overlay lane yet.",
215
+ carriesMockFlag: true,
216
+ },
217
+ uxIntent: { uiVisible: false },
218
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "ui-overlays-lane-read" },
219
+ stateClass: "C-local",
220
+ persistsTo: "none",
221
+ rwdReference:
222
+ "§UI API § PE2 overlay lanes. Reads the mounted controller's lane snapshot for selection/caret/redlines/field-scopes/broad-scopes/comments/issues/tables/objects/search/presence. Returns explicit status:'unavailable' when the lane is not wired; never measures DOM or wakes layout.",
223
+ };
224
+
225
+ export const subscribeLaneMetadata: ApiV3FnMetadata = {
226
+ name: "ui.overlays.subscribeLane",
227
+ status: "live-with-adapter",
228
+ sourceLayer: "presentation",
229
+ liveEvidence: {
230
+ runnerTest: "test/api/v3/ui/overlays-lanes.test.ts",
231
+ commit: "refactor-10-pe2-overlay-lanes",
232
+ },
233
+ uxIntent: {
234
+ uiVisible: true,
235
+ expectsUxResponse: "surface-refresh",
236
+ expectedDelta: "overlay-lane subscriber attached; future lane snapshots propagate through the listener without PM document transactions",
237
+ },
238
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "ui-overlays-lane-subscribe" },
239
+ stateClass: "C-local",
240
+ persistsTo: "none",
241
+ bidirectional: true,
242
+ subscriptionShape: {
243
+ eventType: "ui.overlays.lane_changed",
244
+ payloadType: "UiOverlayLaneSnapshot",
245
+ coalescing: "raf",
246
+ },
247
+ rwdReference:
248
+ "§UI API § PE2 overlay lanes. Adapter delegates to UiController.subscribeOverlayLane(kind, listener). Presence/awareness, selection, search, and review lane updates are observable UI state only; this method does not dispatch PM document transactions or invalidate L04 layout.",
249
+ };
250
+
198
251
  // ----- U9 overlay-visibility metadata (state-classes X3) -----
199
252
 
200
253
  export const getVisibilityMetadata: ApiV3FnMetadata = {
@@ -354,6 +407,21 @@ export function createOverlaysFamily(ctx: UiApiContext) {
354
407
  const queryChannels = new Map<string, QueryChannel>();
355
408
  let queryCoarseUnsubscribe: (() => void) | null = null;
356
409
 
410
+ function unavailableLane(
411
+ kind: UiOverlayLaneKind,
412
+ reason = "overlay lane is not wired on the active controller",
413
+ ): UiOverlayLaneSnapshot {
414
+ return {
415
+ __mock: true,
416
+ kind,
417
+ status: "unavailable",
418
+ entries: Object.freeze([]),
419
+ revision: 0,
420
+ source: "unavailable",
421
+ reason,
422
+ };
423
+ }
424
+
357
425
  function queryKey(q: OverlayAnchorQuery): string {
358
426
  // `selection` is a singleton kind with no `value` — key by kind alone.
359
427
  return q.kind === "selection" ? "selection" : `${q.kind}:${q.value}`;
@@ -560,6 +628,42 @@ export function createOverlaysFamily(ctx: UiApiContext) {
560
628
  };
561
629
  },
562
630
 
631
+ // ----- PE2 overlay lanes -----
632
+
633
+ getLane(kind: UiOverlayLaneKind): UiOverlayLaneSnapshot {
634
+ const hook = ctx.binding?.controller.getOverlayLane;
635
+ return hook
636
+ ? hook(kind)
637
+ : unavailableLane(kind);
638
+ },
639
+
640
+ subscribeLane(
641
+ kind: UiOverlayLaneKind,
642
+ listener: UiListener<UiOverlayLaneSnapshot>,
643
+ ): UiUnsubscribe {
644
+ const controller = ctx.binding?.controller;
645
+ if (!controller) {
646
+ throw new Error(
647
+ "ui.overlays.subscribeLane: no controller bound — call ui.session.bind(controller) first",
648
+ );
649
+ }
650
+ if (!controller.subscribeOverlayLane) {
651
+ throw new Error(
652
+ `ui.overlays.subscribeLane: controller of kind "${controller.kind}" did not provide a subscribeOverlayLane hook`,
653
+ );
654
+ }
655
+ const unsubscribe = controller.subscribeOverlayLane(kind, listener);
656
+ emitUxResponse(ctx.handle, {
657
+ apiFn: subscribeLaneMetadata.name,
658
+ intent: subscribeLaneMetadata.uxIntent.expectedDelta ?? "",
659
+ mockOrLive: "live-with-adapter",
660
+ uiVisible: true,
661
+ expectedDelta: subscribeLaneMetadata.uxIntent.expectedDelta,
662
+ actualDelta: { kind: "surface-refresh", payload: { subscribed: "ui.overlays.lane", lane: kind } },
663
+ });
664
+ return unsubscribe;
665
+ },
666
+
563
667
  // ----- U9 overlay-visibility (state-classes X3) -----
564
668
 
565
669
  getVisibility(kind: OverlayKind): OverlayVisibility {
@@ -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
+ }