@beyondwork/docx-react-component 1.0.96 → 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 (41) 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/layout/layout-engine-version.ts +8 -1
  13. package/src/runtime/layout/page-story-resolver.ts +1 -0
  14. package/src/runtime/layout/paginated-layout-engine.ts +26 -10
  15. package/src/runtime/surface-projection.ts +114 -12
  16. package/src/ui/WordReviewEditor.tsx +6 -10
  17. package/src/ui/editor-command-bag.ts +2 -0
  18. package/src/ui/ui-controller-factory.ts +2 -2
  19. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +11 -25
  20. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +2 -2
  21. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +1 -1
  22. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +22 -220
  23. package/src/ui-tailwind/debug/README.md +12 -50
  24. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +6 -6
  25. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +9 -20
  26. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +5 -6
  27. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +1 -4
  28. package/src/ui-tailwind/editor-surface/picture-effects.ts +96 -0
  29. package/src/ui-tailwind/editor-surface/pm-schema.ts +89 -62
  30. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +205 -0
  31. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +190 -0
  32. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +53 -53
  33. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +83 -20
  34. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +114 -4
  35. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +5 -0
  36. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +3 -0
  37. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +3 -0
  38. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  39. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +26 -0
  40. package/src/ui-tailwind/theme/editor-theme.css +18 -11
  41. package/src/ui-tailwind/tw-review-workspace.tsx +15 -0
@@ -17,6 +17,7 @@ import {
17
17
  ROTATION_UNITS_PER_DEGREE,
18
18
  SRCRECT_UNITS_PER_PERCENT,
19
19
  } from "../../api/public-types.ts";
20
+ import { buildPictureFilterCss } from "./picture-effects.ts";
20
21
 
21
22
  const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
22
23
  const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
@@ -632,6 +633,7 @@ export const editorSchema = new Schema({
632
633
  flipH: { default: false },
633
634
  flipV: { default: false },
634
635
  srcRect: { default: null },
636
+ lum: { default: null },
635
637
  // Lane 6d N9 — float-wrap fields surfaced from `SurfaceDrawingAnchor`.
636
638
  wrapMode: { default: null },
637
639
  distMargins: { default: null },
@@ -653,14 +655,34 @@ export const editorSchema = new Schema({
653
655
  const widthEmu = node.attrs.widthEmu as number | null;
654
656
  const heightEmu = node.attrs.heightEmu as number | null;
655
657
  if (renderInPageOverlay && isFloating) {
658
+ const wrapMode = node.attrs.wrapMode as string | null;
659
+ const distMargins = node.attrs.distMargins as
660
+ | { top?: number; bottom?: number; left?: number; right?: number }
661
+ | null;
662
+ const overlayAnchorAttrs: Record<string, string> = {
663
+ class: "inline-block h-0 w-0 overflow-hidden align-middle",
664
+ "data-node-type": "image-floating-anchor",
665
+ title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Floating image anchor",
666
+ "aria-hidden": "true",
667
+ };
668
+ if (wrapMode) overlayAnchorAttrs["data-wrap-mode"] = wrapMode;
669
+ if (wrapMode === "topAndBottom" && heightEmu) {
670
+ const heightPx = Math.max(1, Math.round(heightEmu / EMU_PER_PX));
671
+ const marginTopPx = distMargins?.top ? Math.round(distMargins.top / EMU_PER_PX) : 0;
672
+ const marginBottomPx = distMargins?.bottom ? Math.round(distMargins.bottom / EMU_PER_PX) : 0;
673
+ overlayAnchorAttrs.class = "block overflow-hidden";
674
+ overlayAnchorAttrs.style = [
675
+ "display:block",
676
+ "width:0",
677
+ `height:${heightPx}px`,
678
+ `margin:${marginTopPx}px 0 ${marginBottomPx}px 0`,
679
+ "padding:0",
680
+ ].join(";");
681
+ overlayAnchorAttrs["data-overlay-reserves-flow"] = "true";
682
+ }
656
683
  return [
657
684
  "span",
658
- {
659
- class: "inline-block h-0 w-0 overflow-hidden align-middle",
660
- "data-node-type": "image-floating-anchor",
661
- title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Floating image anchor",
662
- "aria-hidden": "true",
663
- },
685
+ overlayAnchorAttrs,
664
686
  ];
665
687
  }
666
688
  if (!isMissing && src) {
@@ -673,6 +695,7 @@ export const editorSchema = new Schema({
673
695
  const srcRect = node.attrs.srcRect as
674
696
  | { top: number; bottom: number; left: number; right: number }
675
697
  | null;
698
+ const lum = node.attrs.lum as { bright?: number; contrast?: number } | null;
676
699
  const transformParts: string[] = [];
677
700
  if (rotation && rotation !== 0) {
678
701
  transformParts.push(`rotate(${(rotation / ROTATION_UNITS_PER_DEGREE).toFixed(3)}deg)`);
@@ -687,7 +710,6 @@ export const editorSchema = new Schema({
687
710
  `inset(${(srcRect.top / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.right / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.bottom / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.left / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}%)`,
688
711
  );
689
712
  }
690
- // N11.b filter effects → CSS filter on the img element.
691
713
  const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
692
714
  const outerShadow = node.attrs.outerShadow as {
693
715
  blurRad: number; dist: number; dir: number; color: string;
@@ -697,41 +719,16 @@ export const editorSchema = new Schema({
697
719
  radius: number; color: string;
698
720
  colorType: "srgbClr" | "schemeClr";
699
721
  } | null;
700
- const filterParts: string[] = [];
701
- if (softEdgeRadius) {
702
- filterParts.push(`blur(${(softEdgeRadius / EMU_PER_PX).toFixed(2)}px)`);
703
- }
704
- // Defense in depth: even though parse-picture.ts validates
705
- // srgbClr@val against a strict hex allowlist, re-validate here at
706
- // the CSS sink so a future parser refactor or a bypass that lands
707
- // attacker-controlled text in node.attrs cannot escape
708
- // `drop-shadow(#…)` into arbitrary CSS (e.g. `FF0000) url(…)/*`).
709
- // safeFilterHexColor returns `#RRGGBB` on valid hex input and
710
- // empty string otherwise, so schemeClr tokens (e.g. "accent1")
711
- // naturally skip this branch until a theme resolver runs.
712
- const safeFilterHexColor = (raw: unknown): string => {
713
- return typeof raw === "string" &&
714
- /^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(raw)
715
- ? `#${raw.toUpperCase()}`
716
- : "";
717
- };
718
- if (glow) {
719
- const glowColor = safeFilterHexColor(glow.color);
720
- if (glowColor) {
721
- filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px ${glowColor})`);
722
- }
723
- }
724
- if (outerShadow) {
725
- const shadowColor = safeFilterHexColor(outerShadow.color);
726
- if (shadowColor) {
727
- const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
728
- const distPx = outerShadow.dist / EMU_PER_PX;
729
- const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
730
- const dx = (distPx * Math.cos(dirRad)).toFixed(2);
731
- const dy = (distPx * Math.sin(dirRad)).toFixed(2);
732
- filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px ${shadowColor})`);
733
- }
734
- }
722
+ // N11.b filter effects → CSS filter on the img element. The helper
723
+ // re-validates colors at the CSS sink and maps OOXML luminance to
724
+ // an affine CSS filter, so brightened cover photos do not keep
725
+ // black pixels pinned at black.
726
+ const filter = buildPictureFilterCss({
727
+ ...(lum ? { lum } : {}),
728
+ ...(softEdgeRadius ? { softEdgeRadius } : {}),
729
+ ...(glow ? { glow } : {}),
730
+ ...(outerShadow ? { outerShadow } : {}),
731
+ });
735
732
  // N9 float-wrap → CSS float + shape-outside on the wrapper span.
736
733
  const wrapMode = node.attrs.wrapMode as string | null;
737
734
  const positionH = node.attrs.positionH as { align?: string } | null;
@@ -771,7 +768,7 @@ export const editorSchema = new Schema({
771
768
  heightPx ? `height:${heightPx}px` : "",
772
769
  transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
773
770
  clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
774
- filterParts.length > 0 ? `filter:${filterParts.join(" ")}` : "",
771
+ filter ? `filter:${filter}` : "",
775
772
  ].filter(Boolean).join(";");
776
773
  const wrapperStyle = wrapperStyleParts.join(";");
777
774
  const wrapperAttrs: Record<string, string> = {
@@ -822,32 +819,29 @@ export const editorSchema = new Schema({
822
819
  dropdownItems: { default: null },
823
820
  comboBoxItems: { default: null },
824
821
  showingPlcHdr: { default: false },
822
+ containsPageBreak: { default: false },
825
823
  },
826
824
  toDOM(node) {
827
- const sdtType = node.attrs.sdtType as string | null;
828
- const typeLabel = sdtType === "checkbox" ? "\u2611 Checkbox"
829
- : sdtType === "date" ? "\uD83D\uDCC5 Date"
830
- : sdtType === "dropDownList" ? "\u25BE Dropdown"
831
- : sdtType === "comboBox" ? "\u25BE Combo box"
832
- : sdtType === "plainText" ? "\u270E Plain text"
833
- : sdtType === "richText" ? "\u270E Rich text"
834
- : sdtType ?? undefined;
835
- const meta = [node.attrs.alias, node.attrs.tag, typeLabel].filter(Boolean).join(" \u00B7 ");
825
+ const containsPageBreak = Boolean(node.attrs.containsPageBreak);
826
+ if (containsPageBreak) {
827
+ return [
828
+ "section",
829
+ {
830
+ class: "my-0 border-0 bg-transparent p-0",
831
+ style: "min-height:var(--wre-page-frame-height-px,1056px);position:relative",
832
+ "data-node-type": "sdt_block",
833
+ "data-sdt-page-break": "true",
834
+ },
835
+ ["div", 0],
836
+ ];
837
+ }
836
838
  return [
837
839
  "section",
838
840
  {
839
- class: "my-2 rounded-xl border border-primary/15 bg-surface-raised/60 px-3 py-2",
841
+ class: "my-0 border-0 bg-transparent p-0",
840
842
  "data-node-type": "sdt_block",
841
- ...(sdtType ? { "data-sdt-type": sdtType } : {}),
843
+ ...(node.attrs.sdtType ? { "data-sdt-type": String(node.attrs.sdtType) } : {}),
842
844
  },
843
- [
844
- "div",
845
- {
846
- class: "mb-2 text-[11px] uppercase tracking-[0.18em] text-tertiary",
847
- contenteditable: "false",
848
- },
849
- meta || "Content control",
850
- ],
851
845
  ["div", 0],
852
846
  ];
853
847
  },
@@ -1103,8 +1097,10 @@ export const editorSchema = new Schema({
1103
1097
  txbxText: { default: null },
1104
1098
  wrapMode: { default: "none" },
1105
1099
  display: { default: "inline" },
1100
+ renderInPageOverlay: { default: false },
1106
1101
  widthEmu: { default: null },
1107
1102
  heightEmu: { default: null },
1103
+ positionH: { default: null },
1108
1104
  },
1109
1105
  toDOM(node) {
1110
1106
  // V2c.5: when the rich attrs are present (DrawingFrame source),
@@ -1114,6 +1110,37 @@ export const editorSchema = new Schema({
1114
1110
  // previews stay visually identical.
1115
1111
  const isV2c5 = node.attrs.label !== null || node.attrs.isTextBox === true;
1116
1112
  if (isV2c5) {
1113
+ if (Boolean(node.attrs.renderInPageOverlay) && node.attrs.display === "floating") {
1114
+ const wrapMode = node.attrs.wrapMode as string | null;
1115
+ const heightEmu = node.attrs.heightEmu as number | null;
1116
+ const widthEmu = node.attrs.widthEmu as number | null;
1117
+ const positionH = node.attrs.positionH as { align?: string } | null;
1118
+ const attrs: Record<string, string> = {
1119
+ class: "inline-block h-0 w-0 overflow-hidden align-middle",
1120
+ "data-node-type": "shape-floating-anchor",
1121
+ title: (node.attrs.detail as string) || (node.attrs.label as string) || "Floating shape anchor",
1122
+ "aria-hidden": "true",
1123
+ };
1124
+ if (wrapMode) attrs["data-wrap-mode"] = wrapMode;
1125
+ if (wrapMode === "square" && widthEmu && heightEmu) {
1126
+ const floatSide = positionH?.align === "right" ? "right" : "left";
1127
+ attrs.class = "block overflow-hidden";
1128
+ attrs.style = [
1129
+ `float:${floatSide}`,
1130
+ `width:${Math.max(1, Math.round(widthEmu / EMU_PER_PX))}px`,
1131
+ `height:${Math.max(1, Math.round(heightEmu / EMU_PER_PX))}px`,
1132
+ "margin:0",
1133
+ "padding:0",
1134
+ "opacity:0",
1135
+ "pointer-events:none",
1136
+ ].join(";");
1137
+ attrs["data-overlay-reserves-flow"] = "true";
1138
+ }
1139
+ return [
1140
+ "span",
1141
+ attrs,
1142
+ ];
1143
+ }
1117
1144
  const geometry = node.attrs.geometry as string | null;
1118
1145
  const fill = node.attrs.fill as
1119
1146
  | ShapeFill
@@ -2,6 +2,7 @@ import { Fragment, type Mark, type Node as PMNode, type Schema } from "prosemirr
2
2
  import { EditorState, NodeSelection, type Plugin, Selection, TextSelection } from "prosemirror-state";
3
3
 
4
4
  import type {
5
+ BlockNode,
5
6
  EditorSurfaceSnapshot,
6
7
  SelectionSnapshot,
7
8
  SurfaceBlockSnapshot,
@@ -10,6 +11,27 @@ import type {
10
11
  SurfaceTableCellSnapshot,
11
12
  } from "../../api/public-types";
12
13
  import { shouldRenderAbsoluteFloatingImageInPageOverlay } from "../page-stack/floating-image-overlay-model.ts";
14
+
15
+ /**
16
+ * Refactor/11b Slice A — mirror of the Symbol registered by
17
+ * `src/runtime/surface-projection.ts`. `Symbol.for` resolves to the
18
+ * same symbol in any layer, so no cross-layer import is required to
19
+ * share the channel. Reads the parallel `blockRefs` array that
20
+ * surface-projection attaches to the snapshot, feeding the L1 PM Node
21
+ * identity cache below.
22
+ */
23
+ const CANONICAL_BLOCK_REFS_SYMBOL = Symbol.for("wre.canonical-block-refs");
24
+
25
+ function getCanonicalBlockRefs(
26
+ snapshot: EditorSurfaceSnapshot,
27
+ ): readonly (BlockNode | null)[] | null {
28
+ const refs = (
29
+ snapshot as unknown as {
30
+ [CANONICAL_BLOCK_REFS_SYMBOL]?: readonly (BlockNode | null)[];
31
+ }
32
+ )[CANONICAL_BLOCK_REFS_SYMBOL];
33
+ return refs ?? null;
34
+ }
13
35
  import { editorSchema } from "./pm-schema";
14
36
  import { buildPositionMap, type PositionMap } from "./pm-position-map";
15
37
  import { getChartModel } from "../../api/v3/runtime/chart.ts";
@@ -19,6 +41,122 @@ export interface PMStateResult {
19
41
  positionMap: PositionMap;
20
42
  }
21
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // Refactor/11b Slice A — L1 PM Node identity cache
46
+ // ---------------------------------------------------------------------------
47
+ // PM's `ViewDesc.update()` uses `node.eq()` to skip rebuilding subtrees whose
48
+ // content + attrs + marks are unchanged. Our rebuilder historically produced
49
+ // structurally-equal but identity-different Node instances on every commit,
50
+ // defeating that short-circuit and making `view.updateState()` O(doc) instead
51
+ // of O(changed blocks).
52
+ //
53
+ // Fix: when a canonical `BlockNode` reference is unchanged across commits
54
+ // (the canonical document preserves identity for unmodified blocks via
55
+ // structural sharing), reuse the PM Node we built last time — keyed on the
56
+ // canonical ref plus a digest of external inputs that actually affect the
57
+ // emitted attrs (neighbor-driven flags, media preview src, the two
58
+ // unsupported-preview booleans).
59
+ //
60
+ // Scope: paragraphs only in this slice. Tables/SDTs/opaques skip the cache
61
+ // (typically rare or cheap). See slice commit body for deferrals.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ type BlockCacheBucket = Map<string, PMNode>;
65
+ /** Module-scoped cache. Keyed on canonical BlockNode reference — auto-evicts
66
+ * via WeakMap when the canonical doc rebuilds a block. Inner map is capped
67
+ * per bucket to bound memory when external keys churn (e.g. markup-mode
68
+ * toggles). */
69
+ const pmNodeCache = new WeakMap<BlockNode, BlockCacheBucket>();
70
+ const MAX_ENTRIES_PER_BLOCK = 4;
71
+
72
+ /** WeakMap<mediaPreviews, stableId> — turns mediaPreviews object identity
73
+ * into a stable number for external-key composition. Equal identity →
74
+ * equal id. Different identity (even same content) → different id.
75
+ *
76
+ * Empty objects (no enumerable keys) all map to id 0 — they're
77
+ * semantically equivalent from the rebuilder's view (no images to wire)
78
+ * and treating them as one canonical id avoids false-miss on callers
79
+ * that default-construct `{}` each render. */
80
+ const mediaPreviewsIdentityIds = new WeakMap<object, number>();
81
+ let nextMediaPreviewsIdentityId = 1;
82
+ function mediaPreviewsIdentity(obj: object): number {
83
+ // Fast path: empty object → canonical id 0.
84
+ let hasAnyKey = false;
85
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
86
+ for (const _k in obj) {
87
+ hasAnyKey = true;
88
+ break;
89
+ }
90
+ if (!hasAnyKey) return 0;
91
+ let id = mediaPreviewsIdentityIds.get(obj);
92
+ if (id === undefined) {
93
+ id = nextMediaPreviewsIdentityId++;
94
+ mediaPreviewsIdentityIds.set(obj, id);
95
+ }
96
+ return id;
97
+ }
98
+
99
+ let identityCacheHits = 0;
100
+ let identityCacheMisses = 0;
101
+
102
+ /** Test-only + perf-probe hook. Reads the accumulated hit/miss counts. */
103
+ export function getPmNodeCacheCounters(): { hits: number; misses: number } {
104
+ return { hits: identityCacheHits, misses: identityCacheMisses };
105
+ }
106
+
107
+ /** Test-only: reset the cache + counters. Production never calls this. */
108
+ export function __resetPmNodeCacheForTests(): void {
109
+ identityCacheHits = 0;
110
+ identityCacheMisses = 0;
111
+ }
112
+
113
+ /** External-key digest for a single paragraph's cache lookup. Encodes every
114
+ * input to `buildParagraph` that does NOT come from the `BlockNode` itself.
115
+ * Neighbor digests are minimal — they only need to reflect fields that
116
+ * influence the current paragraph's rendered PM attrs (listContinuation,
117
+ * contextualSpacingBefore/After). */
118
+ function paragraphNeighborDigest(
119
+ p: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
120
+ ): string {
121
+ if (!p) return "_";
122
+ const numInst = p.numbering?.numberingInstanceId ?? "";
123
+ const numLvl = p.numbering?.level ?? "";
124
+ const style = p.styleId ?? "";
125
+ const ctx = p.contextualSpacing ? "1" : "0";
126
+ return `${numInst}:${numLvl}:${style}:${ctx}`;
127
+ }
128
+
129
+ function paragraphExternalKey(
130
+ prev: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
131
+ next: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
132
+ mediaPreviewsId: number,
133
+ showUnsupportedObjectPreviews: boolean,
134
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
135
+ ): string {
136
+ return `p|${paragraphNeighborDigest(prev)}|${paragraphNeighborDigest(next)}|m${mediaPreviewsId}|${showUnsupportedObjectPreviews ? 1 : 0}|${renderAbsoluteFloatingObjectsInPageOverlay ? 1 : 0}`;
137
+ }
138
+
139
+ function cacheLookup(blockRef: BlockNode, externalKey: string): PMNode | null {
140
+ const bucket = pmNodeCache.get(blockRef);
141
+ if (!bucket) return null;
142
+ const node = bucket.get(externalKey);
143
+ return node ?? null;
144
+ }
145
+
146
+ function cacheStore(blockRef: BlockNode, externalKey: string, node: PMNode): void {
147
+ let bucket = pmNodeCache.get(blockRef);
148
+ if (!bucket) {
149
+ bucket = new Map();
150
+ pmNodeCache.set(blockRef, bucket);
151
+ } else if (bucket.size >= MAX_ENTRIES_PER_BLOCK) {
152
+ // Evict the oldest (insertion-order) entry. JS Maps iterate in insertion
153
+ // order, so `keys().next().value` is the oldest.
154
+ const oldestKey = bucket.keys().next().value;
155
+ if (oldestKey !== undefined) bucket.delete(oldestKey);
156
+ }
157
+ bucket.set(externalKey, node);
158
+ }
159
+
22
160
  /**
23
161
  * Test-friendly wrapper: build a PM EditorState from a minimal snapshot with no
24
162
  * selection or plugins. The snapshot is cast to EditorSurfaceSnapshot so callers
@@ -243,6 +381,7 @@ function buildPMDoc(
243
381
  mediaPreviews,
244
382
  showUnsupportedObjectPreviews,
245
383
  renderAbsoluteFloatingObjectsInPageOverlay,
384
+ getCanonicalBlockRefs(surface),
246
385
  );
247
386
 
248
387
  // Ensure at least one block (PM requires non-empty doc)
@@ -258,8 +397,10 @@ function buildPMBlocks(
258
397
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
259
398
  showUnsupportedObjectPreviews: boolean,
260
399
  renderAbsoluteFloatingObjectsInPageOverlay: boolean,
400
+ blockRefs: readonly (BlockNode | null)[] | null = null,
261
401
  ): PMNode[] {
262
402
  const nodes: PMNode[] = [];
403
+ const mediaId = mediaPreviewsIdentity(mediaPreviews);
263
404
 
264
405
  for (let index = 0; index < blocks.length; index += 1) {
265
406
  const block = blocks[index];
@@ -269,6 +410,39 @@ function buildPMBlocks(
269
410
  const nextParagraph = nextBlock?.kind === "paragraph" ? nextBlock : null;
270
411
 
271
412
  if (block.kind === "paragraph") {
413
+ // L1 identity cache — paragraphs only in this slice. Canonical ref
414
+ // comes from the parallel `blockRefs` array attached by
415
+ // `surface-projection`. Missing ref (null or no array provided) is
416
+ // treated as a cache miss: the function is backward-compatible with
417
+ // callers that don't supply refs (tests, cell-internal recursion).
418
+ const ref = blockRefs?.[index] ?? null;
419
+ if (ref !== null) {
420
+ const extKey = paragraphExternalKey(
421
+ previousParagraph,
422
+ nextParagraph,
423
+ mediaId,
424
+ showUnsupportedObjectPreviews,
425
+ renderAbsoluteFloatingObjectsInPageOverlay,
426
+ );
427
+ const cached = cacheLookup(ref, extKey);
428
+ if (cached) {
429
+ identityCacheHits += 1;
430
+ nodes.push(cached);
431
+ continue;
432
+ }
433
+ identityCacheMisses += 1;
434
+ const built = buildParagraph(
435
+ block,
436
+ previousParagraph,
437
+ nextParagraph,
438
+ mediaPreviews,
439
+ showUnsupportedObjectPreviews,
440
+ renderAbsoluteFloatingObjectsInPageOverlay,
441
+ );
442
+ cacheStore(ref, extKey, built);
443
+ nodes.push(built);
444
+ continue;
445
+ }
272
446
  nodes.push(
273
447
  buildParagraph(
274
448
  block,
@@ -501,6 +675,7 @@ function buildInlineContent(
501
675
  widthEmu: preview?.widthEmu ?? preview?.widthEmu ?? segment.anchor?.extent.widthEmu ?? null,
502
676
  heightEmu: preview?.heightEmu ?? segment.anchor?.extent.heightEmu ?? null,
503
677
  // Lane 6d N11 — picture effects.
678
+ lum: segment.pictureEffects?.lum ?? null,
504
679
  rotation: segment.pictureEffects?.rotation ?? null,
505
680
  flipH: segment.pictureEffects?.flipH ?? false,
506
681
  flipV: segment.pictureEffects?.flipV ?? false,
@@ -525,6 +700,10 @@ function buildInlineContent(
525
700
  return [buildOpaqueInlineOrComplexAtom(segment, mediaPreviews, showUnsupportedObjectPreviews)];
526
701
 
527
702
  case "shape":
703
+ {
704
+ const renderInPageOverlay =
705
+ renderAbsoluteFloatingObjectsInPageOverlay &&
706
+ shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor);
528
707
  return [
529
708
  editorSchema.nodes.shape_atom.create({
530
709
  label: segment.label,
@@ -536,10 +715,13 @@ function buildInlineContent(
536
715
  txbxText: segment.txbxText ?? null,
537
716
  wrapMode: segment.anchor?.wrapMode ?? "none",
538
717
  display: segment.anchor?.display ?? "inline",
718
+ renderInPageOverlay,
539
719
  widthEmu: segment.anchor?.extent.widthEmu ?? null,
540
720
  heightEmu: segment.anchor?.extent.heightEmu ?? null,
721
+ positionH: segment.anchor?.positionH ?? null,
541
722
  }),
542
723
  ];
724
+ }
543
725
 
544
726
  case "note_ref": {
545
727
  const text = editorSchema.text(
@@ -722,11 +904,34 @@ function buildSdtBlock(
722
904
  dropdownItems: block.dropdownItems ?? null,
723
905
  comboBoxItems: block.comboBoxItems ?? null,
724
906
  showingPlcHdr: block.showingPlcHdr ?? false,
907
+ containsPageBreak: surfaceBlocksContainPageBreak(block.children),
725
908
  },
726
909
  Fragment.from(children),
727
910
  );
728
911
  }
729
912
 
913
+ function surfaceBlocksContainPageBreak(blocks: readonly SurfaceBlockSnapshot[]): boolean {
914
+ for (const block of blocks) {
915
+ if (
916
+ block.kind === "paragraph" &&
917
+ block.segments.some((segment) => segment.kind === "opaque_inline" && segment.label === "Page break")
918
+ ) {
919
+ return true;
920
+ }
921
+ if (block.kind === "sdt_block" && surfaceBlocksContainPageBreak(block.children)) {
922
+ return true;
923
+ }
924
+ if (block.kind === "table") {
925
+ for (const row of block.rows) {
926
+ for (const cell of row.cells) {
927
+ if (surfaceBlocksContainPageBreak(cell.content)) return true;
928
+ }
929
+ }
930
+ }
931
+ }
932
+ return false;
933
+ }
934
+
730
935
  /**
731
936
  * Labels surface-projection emits for preserve-only complex fragments
732
937
  * (charts, SmartArt, drawing shapes, WordArt, legacy VML). These have