@beyondwork/docx-react-component 1.0.95 → 1.0.97

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +33 -19
  3. package/src/api/v3/ui/_types.ts +11 -21
  4. package/src/api/v3/ui/chrome.ts +8 -9
  5. package/src/api/v3/ui/debug.ts +15 -77
  6. package/src/api/v3/ui/overlays-visibility.ts +9 -10
  7. package/src/api/v3/ui/overlays.ts +8 -75
  8. package/src/io/ooxml/parse-main-document.ts +30 -0
  9. package/src/io/ooxml/parse-picture.ts +14 -0
  10. package/src/io/ooxml/parse-shapes.ts +41 -1
  11. package/src/model/canonical-document.ts +17 -0
  12. package/src/runtime/document-runtime.ts +46 -1
  13. package/src/runtime/layout/layout-engine-version.ts +8 -1
  14. package/src/runtime/layout/page-story-resolver.ts +1 -0
  15. package/src/runtime/layout/paginated-layout-engine.ts +26 -10
  16. package/src/runtime/surface-projection.ts +114 -12
  17. package/src/runtime/workflow/rail/compose.ts +5 -0
  18. package/src/ui/WordReviewEditor.tsx +6 -10
  19. package/src/ui/editor-command-bag.ts +2 -0
  20. package/src/ui/ui-controller-factory.ts +2 -2
  21. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +12 -41
  22. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +3 -7
  23. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +1 -1
  24. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +22 -228
  25. package/src/ui-tailwind/debug/README.md +12 -50
  26. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +6 -6
  27. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +9 -20
  28. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +5 -6
  29. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +1 -4
  30. package/src/ui-tailwind/editor-surface/picture-effects.ts +96 -0
  31. package/src/ui-tailwind/editor-surface/pm-schema.ts +89 -62
  32. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +205 -0
  33. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +190 -0
  34. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +53 -53
  35. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +83 -20
  36. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +114 -4
  37. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +5 -0
  38. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +3 -0
  39. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +3 -0
  40. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  41. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +26 -0
  42. package/src/ui-tailwind/theme/editor-theme.css +82 -84
  43. package/src/ui-tailwind/tw-review-workspace.tsx +15 -0
@@ -0,0 +1,190 @@
1
+ // Refactor/11b Slice B — runtime decoration plugin (Option B fast-path).
2
+ //
3
+ // Before this slice, `tw-prosemirror-surface.tsx` rebuilt the entire
4
+ // `DecorationSet` from scratch on every commit via a React effect:
5
+ //
6
+ // view.setProps({ decorations: () => buildDecorations(...) });
7
+ //
8
+ // `buildDecorations` walks the whole PM doc + every comment thread + every
9
+ // revision + every workflow scope + every candidate + every locked zone.
10
+ // On a 500-page doc toggling "Show tracked changes" this was ~200–300 ms
11
+ // (documented residual in `docs/wiki/performance.md §Context-Analytics Hot
12
+ // Path`). On every keystroke it rebuilt the full set even when only the
13
+ // caret + a few characters moved.
14
+ //
15
+ // This plugin shifts the strategy to the idiomatic PM pattern:
16
+ //
17
+ // 1. Plugin state holds `{ inputs, set }` — the currently-authoritative
18
+ // decoration inputs and the computed DecorationSet.
19
+ // 2. On a transaction with `tr.setMeta(pluginKey, { inputs })`: inputs
20
+ // changed, rebuild the set once.
21
+ // 3. On a transaction without that meta: inputs are stable. Map the
22
+ // existing set through `tr.mapping` (O(decorations), not O(doc)).
23
+ //
24
+ // Runtime-origin doc replacements — where PM state is swapped wholesale
25
+ // via `view.updateState(state)` because the runtime produced a new
26
+ // snapshot — can't be handled by `tr.mapping`, so the consumer dispatches
27
+ // a meta to force a rebuild on those paths. Pure typing transactions
28
+ // (the `onInsertText` fast-text-edit-lane commit that `view.dispatch`es a
29
+ // local step) go through the no-meta branch and pay O(decorations) only.
30
+ //
31
+ // Scope of this slice: wraps the existing `buildDecorations` +
32
+ // `buildPageBreakDecorationsFromProps` unchanged. No restructuring of
33
+ // the 14 decoration sources inside `buildDecorations` — that's a
34
+ // follow-up that would split per-source subsets to let single-source
35
+ // updates skip untouched subsets.
36
+
37
+ import { type EditorState, type Transaction, Plugin, PluginKey } from "prosemirror-state";
38
+ import { type Decoration, type EditorView, DecorationSet } from "prosemirror-view";
39
+ import type { Node as PMNode } from "prosemirror-model";
40
+
41
+ import type {
42
+ EditorStoryTarget,
43
+ WorkflowBlockedCommandReason,
44
+ WorkflowCandidateRange,
45
+ WorkflowLockedZone,
46
+ WorkflowMetadataMarkup,
47
+ WorkflowScope,
48
+ } from "../../api/public-types";
49
+ import type { CommentDecorationModel, MarkupDisplay } from "../../ui/headless/comment-decoration-model";
50
+ import type {
51
+ RevisionDecorationModel,
52
+ RevisionDisplayFlags,
53
+ } from "../../ui/headless/revision-decoration-model";
54
+ import { buildDecorations } from "./pm-decorations";
55
+ import type { PositionMap } from "./pm-position-map";
56
+
57
+ /** Inputs that drive every call to `buildDecorations`, plus an opaque
58
+ * callback that supplies any additional decorations layered on top
59
+ * (today: page-break chrome widgets). Keeping page-break inputs inside
60
+ * the callback keeps this module decoupled from its current dependency
61
+ * graph — the caller captures whatever it needs in the closure. */
62
+ export interface RuntimeDecorationInputs {
63
+ positionMap: PositionMap;
64
+ commentModel: CommentDecorationModel | undefined;
65
+ revisionModel: RevisionDecorationModel | undefined;
66
+ markupDisplay: MarkupDisplay;
67
+ showTrackedChanges: boolean;
68
+ suggestionsEnabled: boolean;
69
+ workflowScopes: readonly WorkflowScope[] | undefined;
70
+ activeStory: EditorStoryTarget;
71
+ workflowCandidates: readonly WorkflowCandidateRange[] | undefined;
72
+ workflowBlockedReasons: readonly WorkflowBlockedCommandReason[] | undefined;
73
+ workflowLockedZones: readonly WorkflowLockedZone[] | undefined;
74
+ activeWorkflowWorkItemId: string | null | undefined;
75
+ activeWorkflowScopeIds: readonly string[] | undefined;
76
+ workflowMetadata: readonly WorkflowMetadataMarkup[] | undefined;
77
+ revisionDisplayByOffset: ReadonlyMap<number, RevisionDisplayFlags> | undefined;
78
+ /** Extra decorations layered on top of the base `buildDecorations`
79
+ * result. Today the page-break chrome widgets flow through here —
80
+ * their inputs + helper live in `tw-prosemirror-surface.tsx` so the
81
+ * consumer captures them in the closure and returns built
82
+ * decorations. `null`/empty returns are fine. */
83
+ extraDecorations?: (doc: PMNode) => readonly Decoration[];
84
+ }
85
+
86
+ interface DecorationState {
87
+ inputs: RuntimeDecorationInputs | null;
88
+ set: DecorationSet;
89
+ }
90
+
91
+ /** Key for the plugin. Consumers dispatch via
92
+ * `view.state.tr.setMeta(runtimeDecorationPluginKey, { inputs })`. */
93
+ export const runtimeDecorationPluginKey = new PluginKey<DecorationState>(
94
+ "wre-runtime-decorations",
95
+ );
96
+
97
+ /** Module-level counters — mirror the style used by the L1 identity cache.
98
+ * Read via `getRuntimeDecorationCounters()`. Useful for perf benches +
99
+ * tests that assert the fast-path actually runs. */
100
+ let fullRebuilds = 0;
101
+ let mappedTransactions = 0;
102
+ let noOpTransactions = 0;
103
+
104
+ export function getRuntimeDecorationCounters(): {
105
+ fullRebuilds: number;
106
+ mappedTransactions: number;
107
+ noOpTransactions: number;
108
+ } {
109
+ return { fullRebuilds, mappedTransactions, noOpTransactions };
110
+ }
111
+
112
+ export function __resetRuntimeDecorationCountersForTests(): void {
113
+ fullRebuilds = 0;
114
+ mappedTransactions = 0;
115
+ noOpTransactions = 0;
116
+ }
117
+
118
+ function rebuildSet(doc: PMNode, inputs: RuntimeDecorationInputs): DecorationSet {
119
+ const base = buildDecorations(
120
+ doc,
121
+ inputs.positionMap,
122
+ inputs.commentModel,
123
+ inputs.revisionModel,
124
+ inputs.markupDisplay,
125
+ inputs.showTrackedChanges,
126
+ inputs.suggestionsEnabled,
127
+ inputs.workflowScopes,
128
+ inputs.activeStory,
129
+ inputs.workflowCandidates,
130
+ inputs.workflowBlockedReasons,
131
+ inputs.workflowLockedZones,
132
+ inputs.activeWorkflowWorkItemId,
133
+ inputs.activeWorkflowScopeIds,
134
+ inputs.workflowMetadata,
135
+ inputs.revisionDisplayByOffset,
136
+ );
137
+ const extras = inputs.extraDecorations ? inputs.extraDecorations(doc) : [];
138
+ if (extras.length === 0) return base;
139
+ // Layer extras on top of the base set. `DecorationSet.add` is the
140
+ // supported join that keeps the base set's structure and adds new
141
+ // decorations to it.
142
+ return base.add(doc, [...extras]);
143
+ }
144
+
145
+ export function createRuntimeDecorationPlugin(): Plugin<DecorationState> {
146
+ return new Plugin<DecorationState>({
147
+ key: runtimeDecorationPluginKey,
148
+ state: {
149
+ init: () => ({ inputs: null, set: DecorationSet.empty }),
150
+ apply(tr, prev, _oldState, newState): DecorationState {
151
+ const meta = tr.getMeta(runtimeDecorationPluginKey) as
152
+ | { inputs: RuntimeDecorationInputs }
153
+ | null
154
+ | undefined;
155
+ if (meta) {
156
+ fullRebuilds += 1;
157
+ return { inputs: meta.inputs, set: rebuildSet(newState.doc, meta.inputs) };
158
+ }
159
+ if (tr.docChanged) {
160
+ mappedTransactions += 1;
161
+ return {
162
+ inputs: prev.inputs,
163
+ set: prev.set.map(tr.mapping, newState.doc),
164
+ };
165
+ }
166
+ noOpTransactions += 1;
167
+ return prev;
168
+ },
169
+ },
170
+ props: {
171
+ decorations(state: EditorState): DecorationSet {
172
+ return runtimeDecorationPluginKey.getState(state)?.set ?? DecorationSet.empty;
173
+ },
174
+ },
175
+ });
176
+ }
177
+
178
+ /** Convenience for consumers — dispatches a meta transaction that
179
+ * triggers a full rebuild. Call whenever any `RuntimeDecorationInputs`
180
+ * field changes identity or when the underlying doc was replaced
181
+ * wholesale (runtime-origin `view.updateState(state)` path). */
182
+ export function applyRuntimeDecorationInputs(
183
+ view: EditorView,
184
+ inputs: RuntimeDecorationInputs,
185
+ ): void {
186
+ const tr: Transaction = view.state.tr.setMeta(runtimeDecorationPluginKey, {
187
+ inputs,
188
+ });
189
+ view.dispatch(tr);
190
+ }
@@ -53,6 +53,11 @@ import { buildDecorations } from "./pm-decorations";
53
53
  import { buildPageBreakDecorations } from "./pm-page-break-decorations";
54
54
  import { findBlockIndexRangeForPage } from "./page-slice-util.ts";
55
55
  import { DecorationSet } from "prosemirror-view";
56
+ import {
57
+ applyRuntimeDecorationInputs,
58
+ createRuntimeDecorationPlugin,
59
+ type RuntimeDecorationInputs,
60
+ } from "./runtime-decoration-plugin.ts";
56
61
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
57
62
  import { buildPagePreviewMaps } from "../../api/public-types";
58
63
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
@@ -670,52 +675,61 @@ export const TwProseMirrorSurface = forwardRef<
670
675
  onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
671
676
  }),
672
677
  createSearchPlugin(),
678
+ // Refactor/11b Slice B — runtime decorations as PM plugin state.
679
+ // Non-meta transactions map the existing set through `tr.mapping`
680
+ // in O(decorations); meta-tagged transactions rebuild from inputs.
681
+ // Registered LAST so its `props.decorations(state)` is the
682
+ // runtime-authoritative set the view renders. Search-plugin
683
+ // decorations compose at the view level — PM merges `decorations`
684
+ // returned by each plugin's `props.decorations`.
685
+ createRuntimeDecorationPlugin(),
673
686
  ];
674
687
  }, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
675
688
 
676
689
  const applyDecorationProps = useCallback(
677
690
  (view: EditorView, positionMap: PositionMap): void => {
678
- const baseDecorations = buildDecorations(
679
- view.state.doc,
691
+ // Refactor/11b Slice B — dispatch inputs into the runtime decoration
692
+ // plugin instead of imperatively calling `view.setProps({decorations})`
693
+ // on every effect fire. The plugin holds the computed DecorationSet
694
+ // in its state; non-meta transactions (typing, selection) map the
695
+ // existing set through `tr.mapping` in O(decorations) rather than
696
+ // re-walking the doc + every comment thread / revision / workflow
697
+ // scope / candidate / locked zone from scratch.
698
+ const inputs: RuntimeDecorationInputs = {
680
699
  positionMap,
681
700
  commentModel,
682
701
  revisionModel,
683
702
  markupDisplay,
684
703
  showTrackedChanges,
685
704
  suggestionsEnabled,
686
- props.workflowScopes,
687
- snapshot.activeStory,
688
- props.workflowCandidates,
689
- props.workflowBlockedReasons,
690
- props.workflowLockedZones,
691
- props.activeWorkflowWorkItemId,
692
- props.activeWorkflowScopeIds,
693
- props.workflowMetadata,
705
+ workflowScopes: props.workflowScopes,
706
+ activeStory: snapshot.activeStory,
707
+ workflowCandidates: props.workflowCandidates,
708
+ workflowBlockedReasons: props.workflowBlockedReasons,
709
+ workflowLockedZones: props.workflowLockedZones,
710
+ activeWorkflowWorkItemId: props.activeWorkflowWorkItemId ?? null,
711
+ activeWorkflowScopeIds: props.activeWorkflowScopeIds,
712
+ workflowMetadata: props.workflowMetadata,
694
713
  revisionDisplayByOffset,
695
- );
696
- const pageBreakDecos = buildPageBreakDecorationsFromProps(
697
- props.geometryFacet,
698
- snapshot.activeStory.kind === "main",
699
- positionMap,
700
- props.isPageWorkspace ? "page" : "canvas",
701
- props.canonicalDocument,
702
- {
703
- headerBandPx: props.pageChromeHeaderBandPx,
704
- footerBandPx: props.pageChromeFooterBandPx,
705
- interGapPx: props.pageChromeInterGapPx,
706
- },
707
- snapshot.surface?.blocks,
708
- );
709
- const decorations = pageBreakDecos.length > 0
710
- ? DecorationSet.create(view.state.doc, [
711
- ...pageBreakDecos,
712
- ...extractDecorations(baseDecorations, view.state.doc),
713
- ])
714
- : baseDecorations;
715
- view.setProps({
716
- editable: () => canEdit,
717
- decorations: () => decorations,
718
- });
714
+ extraDecorations: (_doc) =>
715
+ buildPageBreakDecorationsFromProps(
716
+ props.geometryFacet,
717
+ snapshot.activeStory.kind === "main",
718
+ positionMap,
719
+ props.isPageWorkspace ? "page" : "canvas",
720
+ props.canonicalDocument,
721
+ {
722
+ headerBandPx: props.pageChromeHeaderBandPx,
723
+ footerBandPx: props.pageChromeFooterBandPx,
724
+ interGapPx: props.pageChromeInterGapPx,
725
+ },
726
+ snapshot.surface?.blocks,
727
+ ),
728
+ };
729
+ applyRuntimeDecorationInputs(view, inputs);
730
+ // `editable` is not decoration-shaped; keep the existing setProps
731
+ // path for it so the plugin-state swap stays scoped to decorations.
732
+ view.setProps({ editable: () => canEdit });
719
733
  decorationBuildKeyRef.current = decorationBuildKey;
720
734
  recordPerfSample("pm.decorations");
721
735
  incrementInvalidationCounter("pm.laneB.decorationUpdates");
@@ -914,24 +928,11 @@ export const TwProseMirrorSurface = forwardRef<
914
928
  props.isPageWorkspace,
915
929
  );
916
930
  positionMapRef.current = positionMap;
917
- const decorations = buildDecorations(
918
- state.doc,
919
- positionMap,
920
- commentModel,
921
- revisionModel,
922
- markupDisplay,
923
- showTrackedChanges,
924
- suggestionsEnabled,
925
- props.workflowScopes,
926
- snapshot.activeStory,
927
- props.workflowCandidates,
928
- props.workflowBlockedReasons,
929
- props.workflowLockedZones,
930
- props.activeWorkflowWorkItemId,
931
- props.activeWorkflowScopeIds,
932
- props.workflowMetadata,
933
- revisionDisplayByOffset,
934
- );
931
+ // Slice B — the runtime decoration plugin (registered in the plugin
932
+ // list) now owns the decoration set. The fresh-mount path dispatches
933
+ // inputs into the plugin via `applyDecorationProps` below; PM reads
934
+ // the set via the plugin's `props.decorations(state)` hook. No
935
+ // pre-mount `buildDecorations` call is needed.
935
936
  recordPerfSample("pm.rebuild");
936
937
  incrementInvalidationCounter("pm.laneA.rebuilds");
937
938
 
@@ -940,7 +941,6 @@ export const TwProseMirrorSurface = forwardRef<
940
941
  state,
941
942
  nodeViews: { ...tableNodeViews, ...chartNodeViews },
942
943
  editable: () => canEdit,
943
- decorations: () => decorations,
944
944
  });
945
945
  viewRef.current = view;
946
946
  recordPerfSample("pm.mount");
@@ -5,6 +5,7 @@ import type {
5
5
  SurfaceBlockSnapshot,
6
6
  SurfaceDrawingAnchor,
7
7
  SurfaceInlineSegment,
8
+ SurfacePictureEffects,
8
9
  } from "../../api/public-types.ts";
9
10
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
10
11
  import {
@@ -44,22 +45,31 @@ export interface FloatingImageOverlayItem {
44
45
  leftPx: number;
45
46
  widthPx: number;
46
47
  heightPx: number;
48
+ zIndex?: number;
47
49
  behindDoc: boolean;
48
50
  src: string | null;
49
51
  altText: string | null;
50
52
  detail: string | null;
53
+ pictureEffects?: SurfacePictureEffects;
54
+ shape?: {
55
+ label: string;
56
+ text: string;
57
+ fill?: Extract<Extract<SurfaceInlineSegment, { kind: "shape" }>["fill"], object>;
58
+ line?: Extract<SurfaceInlineSegment, { kind: "shape" }>["line"];
59
+ body?: Extract<SurfaceInlineSegment, { kind: "shape" }>["textBoxBody"];
60
+ marks?: Extract<SurfaceInlineSegment, { kind: "shape" }>["txbxMarks"];
61
+ markAttrs?: Extract<SurfaceInlineSegment, { kind: "shape" }>["txbxMarkAttrs"];
62
+ };
51
63
  }
52
64
 
53
65
  const SUPPORTED_HORIZONTAL_RELATIVE_FROM = new Set(["page", "margin"]);
54
- const SUPPORTED_VERTICAL_RELATIVE_FROM = new Set(["page", "margin"]);
66
+ const SUPPORTED_VERTICAL_RELATIVE_FROM = new Set(["page", "margin", "paragraph"]);
67
+ const SUPPORTED_FLOAT_WRAP_MODES = new Set(["none", "square", "topAndBottom"]);
55
68
 
56
69
  export function shouldRenderAbsoluteFloatingImageInPageOverlay(
57
70
  anchor: SurfaceDrawingAnchor | undefined,
58
71
  ): boolean {
59
- if (!anchor || anchor.display !== "floating" || anchor.wrapMode !== "none") {
60
- return false;
61
- }
62
- if (anchor.layoutInCell) {
72
+ if (!anchor || anchor.display !== "floating" || !SUPPORTED_FLOAT_WRAP_MODES.has(anchor.wrapMode)) {
63
73
  return false;
64
74
  }
65
75
  if (!anchor.positionH || !anchor.positionV) {
@@ -106,10 +116,13 @@ export function collectFloatingImageOverlayItems(input: {
106
116
  storyTarget: EditorStoryTarget,
107
117
  ) => {
108
118
  walkSurfaceBlocks(blocks, (segment) => {
109
- if (
110
- segment.kind !== "image" ||
111
- !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)
112
- ) {
119
+ if (!isOverlaySegment(segment)) {
120
+ return;
121
+ }
122
+ if (!shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)) {
123
+ return;
124
+ }
125
+ if (isMicrosoftSensitivityLabelOverlay(segment)) {
113
126
  return;
114
127
  }
115
128
 
@@ -119,14 +132,14 @@ export function collectFloatingImageOverlayItems(input: {
119
132
  if (!pageRect) {
120
133
  continue;
121
134
  }
122
- const localRect = resolveFloatingImageLocalRect(page, storyTarget, segment, pxPerTwip);
135
+ const localRect = resolveFloatingDrawingLocalRect(page, storyTarget, segment.anchor, pxPerTwip);
123
136
  if (!localRect) {
124
137
  continue;
125
138
  }
126
- const preview = input.mediaPreviews?.[segment.mediaId];
139
+ const preview = segment.kind === "image" ? input.mediaPreviews?.[segment.mediaId] : undefined;
127
140
  items.push({
128
141
  key: `${segment.segmentId}:${page.pageId}`,
129
- mediaId: segment.mediaId,
142
+ mediaId: segment.kind === "image" ? segment.mediaId : `shape:${segment.segmentId}`,
130
143
  from: segment.from,
131
144
  to: segment.to,
132
145
  pageId: page.pageId,
@@ -135,10 +148,29 @@ export function collectFloatingImageOverlayItems(input: {
135
148
  leftPx: localRect.leftPx,
136
149
  widthPx: localRect.widthPx,
137
150
  heightPx: localRect.heightPx,
151
+ ...(segment.anchor?.relativeHeight !== undefined
152
+ ? { zIndex: segment.anchor.relativeHeight }
153
+ : {}),
138
154
  behindDoc: Boolean(segment.anchor?.behindDoc),
139
155
  src: preview?.src ?? null,
140
- altText: segment.altText ?? null,
156
+ altText: segment.kind === "image" ? segment.altText ?? null : null,
141
157
  detail: segment.detail ?? null,
158
+ ...(segment.kind === "image" && segment.pictureEffects
159
+ ? { pictureEffects: segment.pictureEffects }
160
+ : {}),
161
+ ...(segment.kind === "shape"
162
+ ? {
163
+ shape: {
164
+ label: segment.label,
165
+ text: segment.txbxText ?? segment.label,
166
+ ...(segment.fill ? { fill: segment.fill } : {}),
167
+ ...(segment.line ? { line: segment.line } : {}),
168
+ ...(segment.textBoxBody ? { body: segment.textBoxBody } : {}),
169
+ ...(segment.txbxMarks ? { marks: segment.txbxMarks } : {}),
170
+ ...(segment.txbxMarkAttrs ? { markAttrs: segment.txbxMarkAttrs } : {}),
171
+ },
172
+ }
173
+ : {}),
142
174
  });
143
175
  }
144
176
  });
@@ -157,7 +189,36 @@ export function collectFloatingImageOverlayItems(input: {
157
189
  collectFromStory(secondary.blocks, secondary.target);
158
190
  }
159
191
 
160
- return items;
192
+ return items.sort(compareOverlayItems);
193
+ }
194
+
195
+ function isMicrosoftSensitivityLabelOverlay(
196
+ segment: Extract<SurfaceInlineSegment, { kind: "image" | "shape" }>,
197
+ ): boolean {
198
+ if (segment.kind !== "shape") return false;
199
+ const name = segment.anchor?.docPr?.name ?? "";
200
+ const descr = segment.anchor?.docPr?.descr ?? "";
201
+ if (/^MSIPCM/i.test(name)) {
202
+ return true;
203
+ }
204
+ return /"Placement"\s*:\s*"Footer"/.test(descr) &&
205
+ /"Index"\s*:\s*"FirstPage"/.test(descr) &&
206
+ /classification/i.test(segment.txbxText ?? "");
207
+ }
208
+
209
+ function compareOverlayItems(a: FloatingImageOverlayItem, b: FloatingImageOverlayItem): number {
210
+ if (a.pageIndex !== b.pageIndex) return a.pageIndex - b.pageIndex;
211
+ const aZ = a.zIndex ?? 0;
212
+ const bZ = b.zIndex ?? 0;
213
+ if (aZ !== bZ) return aZ - bZ;
214
+ return a.key.localeCompare(b.key);
215
+ }
216
+
217
+ function isOverlaySegment(
218
+ segment: SurfaceInlineSegment,
219
+ ): segment is Extract<SurfaceInlineSegment, { kind: "image" | "shape" }> {
220
+ if (segment.kind === "image") return true;
221
+ return segment.kind === "shape" && Boolean(segment.isTextBox && segment.anchor);
161
222
  }
162
223
 
163
224
  function walkSurfaceBlocks(
@@ -205,10 +266,10 @@ function resolveTargetPages(
205
266
  });
206
267
  }
207
268
 
208
- function resolveFloatingImageLocalRect(
269
+ function resolveFloatingDrawingLocalRect(
209
270
  page: PublicPageNode,
210
271
  activeStory: EditorStoryTarget,
211
- segment: Extract<SurfaceInlineSegment, { kind: "image" }>,
272
+ anchor: SurfaceDrawingAnchor | undefined,
212
273
  pxPerTwip: number,
213
274
  ): {
214
275
  topPx: number;
@@ -216,7 +277,6 @@ function resolveFloatingImageLocalRect(
216
277
  widthPx: number;
217
278
  heightPx: number;
218
279
  } | null {
219
- const anchor = segment.anchor;
220
280
  if (!anchor) {
221
281
  return null;
222
282
  }
@@ -245,12 +305,13 @@ function resolveHorizontalSpace(
245
305
  pxPerTwip: number,
246
306
  ): { startPx: number; sizePx: number } | null {
247
307
  const pageWidthPx = twipsToPx(page.layout.pageWidth, pxPerTwip);
308
+ const pageMarginLeftPx = twipsToPx(page.layout.marginLeft, pxPerTwip);
248
309
  const storyHost = resolveStoryHostSpace(page, activeStory, pxPerTwip);
249
310
  switch (anchor.positionH?.relativeFrom) {
250
311
  case "page":
251
- return { startPx: 0, sizePx: pageWidthPx };
312
+ return { startPx: -pageMarginLeftPx, sizePx: pageWidthPx };
252
313
  case "margin":
253
- return storyHost ? { startPx: storyHost.leftPx, sizePx: storyHost.widthPx } : null;
314
+ return storyHost ? { startPx: 0, sizePx: storyHost.widthPx } : null;
254
315
  default:
255
316
  return null;
256
317
  }
@@ -269,6 +330,8 @@ function resolveVerticalSpace(
269
330
  return { startPx: 0, sizePx: pageHeightPx };
270
331
  case "margin":
271
332
  return storyHost ? { startPx: storyHost.topPx, sizePx: storyHost.heightPx } : null;
333
+ case "paragraph":
334
+ return storyHost ? { startPx: storyHost.topPx, sizePx: storyHost.heightPx } : null;
272
335
  default:
273
336
  return null;
274
337
  }
@@ -339,7 +402,7 @@ function alignAxisPosition(
339
402
  page: PublicPageNode,
340
403
  orientation: "horizontal" | "vertical",
341
404
  ): number {
342
- const remainder = Math.max(0, space.sizePx - objectSizePx);
405
+ const remainder = space.sizePx - objectSizePx;
343
406
  switch (align) {
344
407
  case "left":
345
408
  case "top":