@beyondwork/docx-react-component 1.0.72 → 1.0.73

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 +37 -0
  3. package/src/api/v3/ai/policy.ts +31 -0
  4. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  5. package/src/api/v3/ui/viewport.ts +1 -1
  6. package/src/core/state/editor-state.ts +49 -6
  7. package/src/io/export/serialize-footnotes.ts +6 -0
  8. package/src/io/export/serialize-headers-footers.ts +6 -0
  9. package/src/io/export/serialize-main-document.ts +7 -0
  10. package/src/io/export/serialize-paragraph-formatting.ts +1 -1
  11. package/src/io/normalize/normalize-text.ts +38 -2
  12. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  13. package/src/io/ooxml/parse-main-document.ts +127 -2
  14. package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
  15. package/src/runtime/layout/layout-engine-version.ts +22 -1
  16. package/src/runtime/layout/paginated-layout-engine.ts +47 -0
  17. package/src/runtime/scopes/action-validation.ts +30 -4
  18. package/src/runtime/scopes/replacement/apply.ts +1 -0
  19. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  20. package/src/runtime/scopes/semantic-scope-types.ts +19 -0
  21. package/src/runtime/surface-projection.ts +55 -0
  22. package/src/session/import/loader-types.ts +18 -0
  23. package/src/session/import/loader.ts +2 -0
  24. package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
  25. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  26. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
  27. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  28. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
  29. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
  30. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
  31. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
  32. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  33. package/src/ui-tailwind/tw-review-workspace.tsx +21 -14
@@ -926,8 +926,29 @@
926
926
  * Cache envelopes from v53 invalidate because any document with
927
927
  * `<w:br w:type="page"/>` (common in CCEP templates with explicit
928
928
  * schedule/appendix boundaries) now paginates differently.
929
+ *
930
+ * 55 — coord-04 §1.19.d. L03 (`ca553b1c` 2026-04-23) graduated
931
+ * `SurfaceBlockSnapshot.paragraph.frameProperties` from the canonical
932
+ * `<w:framePr>` model (L01 parse + L02 domain shape already shipped).
933
+ * L04 now honors it: paragraphs carrying out-of-flow frame properties
934
+ * (ECMA-376 §17.3.1.11 — `hAnchor` / `vAnchor` / `xAlign` / `yAlign` /
935
+ * `xTwips` / `yTwips` positioning with text wrapping around) return
936
+ * 0 from `measureBlockHeight` so the pagination flow does not
937
+ * double-count them. The `dropCap="drop"` / `dropCap="margin"` case
938
+ * is excluded — those frame only the initial letter, leaving the
939
+ * rest of the paragraph in the main flow. L11 owns the absolute-
940
+ * positioned render.
941
+ *
942
+ * CCEP parity-lock corpus unaffected: no body-level `<w:framePr>`
943
+ * exists in the 6-doc lock (`EnvelopeAddress` style carries a
944
+ * framePr in `EU & Global Consultancy Services Agreement` but is
945
+ * never referenced by a paragraph). Change is a forward-looking
946
+ * correctness fix for the cross-layer chain now that L01→L02→L03→L04
947
+ * all emit/honor framePr end-to-end. Cache envelopes from v54
948
+ * invalidate because any future document with a real body framePr
949
+ * paginates differently.
929
950
  */
930
- export const LAYOUT_ENGINE_VERSION = 54 as const;
951
+ export const LAYOUT_ENGINE_VERSION = 55 as const;
931
952
 
932
953
  /**
933
954
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -988,6 +988,11 @@ function measureBlockHeight(
988
988
  const compute = (): number => {
989
989
  switch (block.kind) {
990
990
  case "paragraph": {
991
+ // §1.19.d — out-of-flow framed paragraphs (ECMA-376 §17.3.1.11)
992
+ // contribute 0 to inline flow height; L11 owns the positioned render.
993
+ if (isOutOfFlowFrame(block.frameProperties)) {
994
+ return 0;
995
+ }
991
996
  const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
992
997
  if (formatting) {
993
998
  // Provider path: sum per-line heights so canvas-backed measurements
@@ -1168,6 +1173,17 @@ export function __resolveCellWidth(
1168
1173
  return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
1169
1174
  }
1170
1175
 
1176
+ /**
1177
+ * Exposed for coord §1.19.d framePr unit tests; not part of the
1178
+ * stable surface.
1179
+ */
1180
+ export function __test_measureBlockHeight(
1181
+ block: SurfaceBlockSnapshot | undefined,
1182
+ columnWidth: number,
1183
+ ): number {
1184
+ return measureBlockHeight(block, columnWidth);
1185
+ }
1186
+
1171
1187
  function resolveCellWidth(
1172
1188
  gridColumns: readonly number[],
1173
1189
  startColumn: number,
@@ -2054,6 +2070,37 @@ function currentPageNoteIds(
2054
2070
  return notes;
2055
2071
  }
2056
2072
 
2073
+ /**
2074
+ * OOXML §17.3.1.11 — `<w:framePr>`.
2075
+ *
2076
+ * A paragraph carrying frame properties is rendered as a text frame:
2077
+ * it is positioned relative to the page/margin/text (per `hAnchor` /
2078
+ * `vAnchor` / `xAlign` / `yAlign` / `xTwips` / `yTwips`) and the main
2079
+ * text flow wraps around it (per `wrap`). Consequently the framed
2080
+ * paragraph's height MUST NOT contribute to inline block-height
2081
+ * accounting on the page — otherwise the paginator double-counts the
2082
+ * frame (once as inline block, once as the positioned render).
2083
+ *
2084
+ * The exception is `dropCap` (`drop` / `margin`): the frame wraps
2085
+ * only the initial letter; the rest of the paragraph remains in the
2086
+ * main flow, so the paragraph must continue to contribute its
2087
+ * (non-dropped) height. `dropCap="none"` or absent is a full-frame.
2088
+ *
2089
+ * L11 owns the absolute-positioned render; L04 only needs to keep
2090
+ * the frame out of the flow accounting.
2091
+ */
2092
+ function isOutOfFlowFrame(
2093
+ frameProperties:
2094
+ | Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["frameProperties"]
2095
+ | undefined,
2096
+ ): boolean {
2097
+ if (!frameProperties) return false;
2098
+ if (frameProperties.dropCap === "drop" || frameProperties.dropCap === "margin") {
2099
+ return false;
2100
+ }
2101
+ return true;
2102
+ }
2103
+
2057
2104
  function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
2058
2105
  return block.kind === "paragraph" && block.segments.some(
2059
2106
  (segment) =>
@@ -111,6 +111,14 @@ export interface ComposeScopeValidationInputs {
111
111
  * no blockers / warnings / approval.
112
112
  */
113
113
  readonly actionId?: AIAction;
114
+ /**
115
+ * Caller-opt-in preservation policy. Honored at the preservation step
116
+ * only — when `opaqueFragments === true`, opaque-fragment findings
117
+ * downgrade from blockers to warnings (still surfaced on
118
+ * `validation.warnings` + audit). Other policy flags are advisory
119
+ * here; the compile step consumes them to drive per-step behavior.
120
+ */
121
+ readonly preservePolicy?: ReplacementScope["preserve"];
114
122
  }
115
123
 
116
124
  /**
@@ -240,6 +248,7 @@ function collectGuardVerdict(
240
248
  function collectPreservationVerdict(
241
249
  inputs: ComposeScopeValidationInputs,
242
250
  blockedReasons: string[],
251
+ warnings: ValidationIssue[],
243
252
  ): void {
244
253
  const { document, scope, positionMap } = inputs;
245
254
  if (!document) return;
@@ -250,10 +259,27 @@ function collectPreservationVerdict(
250
259
  ? (pm.markerScopes.get(scope.handle.stableRef.value) ?? null)
251
260
  : null;
252
261
  const verdict = computePreservationVerdict(document, range, pm);
253
- if (!verdict.replaceable) {
254
- for (const reason of verdict.reasons) {
255
- blockedReasons.push(`preserve:${reason}`);
262
+ if (verdict.replaceable) return;
263
+ const opaqueOptIn = inputs.preservePolicy?.opaqueFragments === true;
264
+ for (const reason of verdict.reasons) {
265
+ const code = `preserve:${reason}`;
266
+ // Opt-in downgrade (2026-04-24): opaque-fragment reasons move from
267
+ // blockers → warnings when the caller opted in via
268
+ // `preservePolicy.opaqueFragments === true`. Scope-marker-inside
269
+ // reasons stay as blockers — they'd require the sibling scope to be
270
+ // destroyed, which is a different kind of safety (scope-identity,
271
+ // not preserve-only payload). Other preserve reasons keep blocker
272
+ // semantics until they grow their own opt-in knob.
273
+ if (opaqueOptIn && reason.startsWith("opaque-fragment:")) {
274
+ warnings.push({
275
+ code,
276
+ message:
277
+ "Opaque fragment present in target range; caller opted in to preserve — compile will narrow the replace range to text-only.",
278
+ source: "preserve",
279
+ });
280
+ continue;
256
281
  }
282
+ blockedReasons.push(code);
257
283
  }
258
284
  }
259
285
 
@@ -339,7 +365,7 @@ export function composeScopeValidation(
339
365
  const warnings: ValidationIssue[] = [];
340
366
 
341
367
  collectGuardVerdict(inputs.scope, inputs.runtime, blockedReasons, warnings);
342
- collectPreservationVerdict(inputs, blockedReasons);
368
+ collectPreservationVerdict(inputs, blockedReasons, warnings);
343
369
  collectCompatibilityVerdict(inputs.runtime, blockedReasons, warnings);
344
370
 
345
371
  const actionId =
@@ -147,6 +147,7 @@ export function applyScopeReplacement(
147
147
  document: docBefore,
148
148
  enumeratedScope: resolvedEnumerated,
149
149
  ...(inputs.actionId ? { actionId: inputs.actionId } : {}),
150
+ ...(proposed.preserve ? { preservePolicy: proposed.preserve } : {}),
150
151
  });
151
152
 
152
153
  if (!verdict.safe) {
@@ -12,10 +12,13 @@
12
12
  * compiler-backed.
13
13
  */
14
14
 
15
- import type { CanonicalDocument } from "../../../model/canonical-document.ts";
15
+ import type {
16
+ CanonicalDocument,
17
+ InlineNode,
18
+ } from "../../../model/canonical-document.ts";
16
19
  import { resolveEffectiveFormattingForScope } from "../_formatting-seam.ts";
17
20
  import type { ParagraphLikeEnumeratedScope } from "../enumerate-scopes.ts";
18
- import { computeBlockPositions } from "../position-map.ts";
21
+ import { buildScopePositionMap, computeBlockPositions } from "../position-map.ts";
19
22
  import { deriveReplaceability } from "../replaceability.ts";
20
23
  import {
21
24
  isStructuredReplacementContent,
@@ -32,6 +35,99 @@ import {
32
35
  extractParagraphText,
33
36
  } from "./_paragraph-text.ts";
34
37
 
38
+ function inlineLengthLocal(node: InlineNode): number {
39
+ switch (node.type) {
40
+ case "text":
41
+ return Array.from(node.text).length;
42
+ case "hyperlink":
43
+ case "field":
44
+ return (node.children as readonly InlineNode[]).reduce(
45
+ (total, child) => total + inlineLengthLocal(child),
46
+ 0,
47
+ );
48
+ case "bookmark_start":
49
+ case "bookmark_end":
50
+ case "scope_marker_start":
51
+ case "scope_marker_end":
52
+ return 0;
53
+ default:
54
+ return 1;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Opt-in preservation helper: walk a paragraph's inline children,
60
+ * return the longest contiguous text-only run's [from, to] sub-range
61
+ * within the caller's `target` range. Opaque inlines / images / other
62
+ * non-text inlines break the run. Returns `null` when no text-only run
63
+ * exists inside the target range (paragraph is entirely opaques or the
64
+ * range falls on a non-text inline).
65
+ *
66
+ * Used by coord-08 §17 — `ReplacementPreservePolicy.opaqueFragments =
67
+ * true` narrows the replace range to this text-only sub-range so the
68
+ * opaque inlines survive the apply.
69
+ */
70
+ function longestTextOnlyRangeInParagraph(
71
+ paragraph: ParagraphLikeEnumeratedScope["paragraph"],
72
+ paragraphFrom: number,
73
+ target: { readonly from: number; readonly to: number },
74
+ ): { readonly from: number; readonly to: number } | null {
75
+ let best: { readonly from: number; readonly to: number } | null = null;
76
+ let runStart: number | null = null;
77
+ let cursor = paragraphFrom;
78
+
79
+ const consider = (from: number, to: number) => {
80
+ // Clip to the caller's target range.
81
+ const clippedFrom = Math.max(from, target.from);
82
+ const clippedTo = Math.min(to, target.to);
83
+ if (clippedTo <= clippedFrom) return;
84
+ if (!best || clippedTo - clippedFrom > best.to - best.from) {
85
+ best = { from: clippedFrom, to: clippedTo };
86
+ }
87
+ };
88
+
89
+ const isTextRun = (child: InlineNode): boolean => {
90
+ if (child.type === "text" || child.type === "tab" || child.type === "hard_break") {
91
+ // tab + hard_break carry length 1 and are safe to text-replace
92
+ // (the runtime's text.insert handles them structurally). Treat
93
+ // as part of the text run.
94
+ return true;
95
+ }
96
+ if (child.type === "hyperlink" || child.type === "field") {
97
+ // Hyperlink / field wrappers contribute their text-only children
98
+ // to the run; they're not themselves opaque.
99
+ return true;
100
+ }
101
+ if (
102
+ child.type === "bookmark_start" ||
103
+ child.type === "bookmark_end" ||
104
+ child.type === "scope_marker_start" ||
105
+ child.type === "scope_marker_end"
106
+ ) {
107
+ // Zero-width boundary markers — preserve via length 0. Don't
108
+ // break the text run.
109
+ return true;
110
+ }
111
+ return false;
112
+ };
113
+
114
+ for (const child of paragraph.children) {
115
+ const len = inlineLengthLocal(child);
116
+ const childFrom = cursor;
117
+ const childTo = cursor + len;
118
+ if (isTextRun(child)) {
119
+ if (runStart === null) runStart = childFrom;
120
+ } else {
121
+ // Opaque / image / non-text inline breaks the run.
122
+ if (runStart !== null) consider(runStart, childFrom);
123
+ runStart = null;
124
+ }
125
+ cursor = childTo;
126
+ }
127
+ if (runStart !== null) consider(runStart, cursor);
128
+ return best;
129
+ }
130
+
35
131
  export interface CompileParagraphOptions {
36
132
  readonly document?: CanonicalDocument;
37
133
  readonly paragraphIndex?: number;
@@ -125,10 +221,71 @@ export function compileParagraphReplacement(
125
221
  const blockRange = blocks.find((b) => b.blockIndex === entry.blockIndex);
126
222
  if (!blockRange) return null;
127
223
 
224
+ // Inline-range correction (2026-04-24 bug fix): when the scope is
225
+ // marker-backed AND the marker pair sits strictly INSIDE the
226
+ // paragraph (marker range is narrower than the paragraph's full
227
+ // block range), the replace range must follow the markers — not
228
+ // the paragraph. Otherwise surrounding text in the same paragraph
229
+ // gets destroyed on every applyReplacementScope call. The markers'
230
+ // positions are already tracked in `buildScopePositionMap().
231
+ // markerScopes`; we consult it here only when the scope is marker-
232
+ // backed so non-marker paragraphs pay zero cost.
233
+ let effectiveRange: { readonly from: number; readonly to: number } = {
234
+ from: blockRange.from,
235
+ to: blockRange.to,
236
+ };
237
+ let rangeKind: "block" | "inline-marker" | "opaque-preserving-text" = "block";
238
+ if (entry.handle.provenance === "marker-backed") {
239
+ const positionMap = buildScopePositionMap(options.document);
240
+ const markerRange = positionMap.markerScopes.get(entry.handle.scopeId);
241
+ if (
242
+ markerRange &&
243
+ markerRange.from >= blockRange.from &&
244
+ markerRange.to <= blockRange.to &&
245
+ // Strictly narrower than the paragraph — only then is the marker
246
+ // a sub-paragraph inline range. Whole-paragraph marker scopes
247
+ // (markers wrapping all of the paragraph's text) continue to use
248
+ // the block range, preserving the existing replace-whole-paragraph
249
+ // semantics.
250
+ (markerRange.from > blockRange.from || markerRange.to < blockRange.to)
251
+ ) {
252
+ effectiveRange = markerRange;
253
+ rangeKind = "inline-marker";
254
+ }
255
+ }
256
+
257
+ // Coord-08 §17 (2026-04-24) — opt-in opaque preservation. When the
258
+ // caller sets `preserve.opaqueFragments === true`, narrow the
259
+ // replace range to the longest contiguous text-only sub-range so
260
+ // opaque inlines (images, charts, preserve-only fragments) survive
261
+ // the apply at their original positions.
262
+ if (proposed.preserve?.opaqueFragments === true) {
263
+ const textOnly = longestTextOnlyRangeInParagraph(
264
+ entry.paragraph,
265
+ blockRange.from,
266
+ effectiveRange,
267
+ );
268
+ if (!textOnly) {
269
+ // Paragraph is entirely opaques within the target range — no
270
+ // text-only sub-range to replace. Refuse cleanly; the composer's
271
+ // `preserve:opaque-fragment:*` warning already signalled the
272
+ // opt-in intent.
273
+ return null;
274
+ }
275
+ effectiveRange = textOnly;
276
+ rangeKind = "opaque-preserving-text";
277
+ }
278
+
128
279
  if (proposed.proposedContent.kind === "text") {
129
280
  const text = proposed.proposedContent.text ?? "";
130
281
  const stepKind =
131
282
  options.posture === "suggest-mode" ? "text-insert-tracked" : "text-replace";
283
+ const summaryScope =
284
+ rangeKind === "inline-marker"
285
+ ? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
286
+ : rangeKind === "opaque-preserving-text"
287
+ ? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
288
+ : `paragraph #${entry.blockIndex}`;
132
289
  return {
133
290
  scopeId: entry.handle.scopeId,
134
291
  targetKind: "paragraph",
@@ -138,9 +295,9 @@ export function compileParagraphReplacement(
138
295
  kind: stepKind,
139
296
  summary:
140
297
  stepKind === "text-replace"
141
- ? `replace paragraph #${entry.blockIndex} text (len ${text.length})`
142
- : `suggest-mode paragraph #${entry.blockIndex} text replace (len ${text.length})`,
143
- range: { from: blockRange.from, to: blockRange.to },
298
+ ? `replace ${summaryScope} text (len ${text.length})`
299
+ : `suggest-mode ${summaryScope} text replace (len ${text.length})`,
300
+ range: { from: effectiveRange.from, to: effectiveRange.to },
144
301
  text,
145
302
  },
146
303
  ]),
@@ -161,6 +318,12 @@ export function compileParagraphReplacement(
161
318
  const fragment = proposed.proposedContent.structured;
162
319
  if (!isStructuredReplacementContent(fragment)) return null;
163
320
  const blockCount = fragment.blocks.length;
321
+ const summaryScope =
322
+ rangeKind === "inline-marker"
323
+ ? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
324
+ : rangeKind === "opaque-preserving-text"
325
+ ? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
326
+ : `paragraph #${entry.blockIndex}`;
164
327
  return {
165
328
  scopeId: entry.handle.scopeId,
166
329
  targetKind: "paragraph",
@@ -168,8 +331,8 @@ export function compileParagraphReplacement(
168
331
  steps: Object.freeze([
169
332
  {
170
333
  kind: "fragment-replace",
171
- summary: `replace paragraph #${entry.blockIndex} with structured fragment (${blockCount} block(s))`,
172
- range: { from: blockRange.from, to: blockRange.to },
334
+ summary: `replace ${summaryScope} with structured fragment (${blockCount} block(s))`,
335
+ range: { from: effectiveRange.from, to: effectiveRange.to },
173
336
  fragment,
174
337
  },
175
338
  ]),
@@ -296,6 +296,25 @@ export interface ReplacementPreservePolicy {
296
296
  readonly comments?: boolean;
297
297
  readonly revisions?: boolean;
298
298
  readonly bookmarks?: boolean;
299
+ /**
300
+ * Opt-in (2026-04-24): allow the replace to proceed when the target
301
+ * range contains opaque fragments (inline images, charts, or other
302
+ * `preserve-only` payload) that the runtime cannot safely
303
+ * re-serialize. When `true`:
304
+ *
305
+ * - `computePreservationVerdict` downgrades
306
+ * `preserve:opaque-fragment:*` reasons from blockers to warnings
307
+ * (still surfaced on `validation.warnings` + audit-bundle).
308
+ * - `compileParagraphReplacement` narrows the replace range to the
309
+ * longest contiguous text-only sub-range, leaving opaques at
310
+ * their original inline offsets. If no text sub-range exists
311
+ * (paragraph is entirely opaques), compile still refuses.
312
+ *
313
+ * Default (absent / `false`) preserves the pre-opt-in refusal
314
+ * semantics. Callers that don't set this flag are protected from
315
+ * accidental opaque-fragment loss.
316
+ */
317
+ readonly opaqueFragments?: boolean;
299
318
  }
300
319
 
301
320
  export interface ReplacementScope {
@@ -796,6 +796,58 @@ function resolveSurfaceParagraphShading(
796
796
  };
797
797
  }
798
798
 
799
+ /**
800
+ * Strip the verbatim-OOXML `rawXml` round-trip field from
801
+ * `FrameProperties` before forwarding to the public surface. Consumers
802
+ * (L04 paginated-layout placement, L11 render) read the modeled
803
+ * positioning + size fields; `rawXml` exists only so the serializer can
804
+ * merge extension attributes (`w14:*`, `w15:*`, `mc:Ignorable`) back
805
+ * onto the element on export.
806
+ */
807
+ function toSurfaceFrameProperties(
808
+ frame: NonNullable<CanonicalParagraphFormatting["frameProperties"]>,
809
+ ): NonNullable<
810
+ Extract<
811
+ import("../api/public-types.ts").SurfaceBlockSnapshot,
812
+ { kind: "paragraph" }
813
+ >["frameProperties"]
814
+ > {
815
+ const {
816
+ widthTwips,
817
+ heightTwips,
818
+ hRule,
819
+ xTwips,
820
+ yTwips,
821
+ xAlign,
822
+ yAlign,
823
+ hAnchor,
824
+ vAnchor,
825
+ wrap,
826
+ hSpaceTwips,
827
+ vSpaceTwips,
828
+ dropCap,
829
+ lines,
830
+ anchorLock,
831
+ } = frame;
832
+ return {
833
+ ...(widthTwips !== undefined ? { widthTwips } : {}),
834
+ ...(heightTwips !== undefined ? { heightTwips } : {}),
835
+ ...(hRule !== undefined ? { hRule } : {}),
836
+ ...(xTwips !== undefined ? { xTwips } : {}),
837
+ ...(yTwips !== undefined ? { yTwips } : {}),
838
+ ...(xAlign !== undefined ? { xAlign } : {}),
839
+ ...(yAlign !== undefined ? { yAlign } : {}),
840
+ ...(hAnchor !== undefined ? { hAnchor } : {}),
841
+ ...(vAnchor !== undefined ? { vAnchor } : {}),
842
+ ...(wrap !== undefined ? { wrap } : {}),
843
+ ...(hSpaceTwips !== undefined ? { hSpaceTwips } : {}),
844
+ ...(vSpaceTwips !== undefined ? { vSpaceTwips } : {}),
845
+ ...(dropCap !== undefined ? { dropCap } : {}),
846
+ ...(lines !== undefined ? { lines } : {}),
847
+ ...(anchorLock !== undefined ? { anchorLock } : {}),
848
+ };
849
+ }
850
+
799
851
  function resolveSurfaceParagraphFormatting(
800
852
  formatting: CanonicalParagraphFormatting,
801
853
  themeResolver: ThemeColorResolver | undefined,
@@ -1066,6 +1118,9 @@ function createParagraphBlock(
1066
1118
  : {}),
1067
1119
  ...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
1068
1120
  ...(paragraph.borders ? { borders: paragraph.borders } : {}),
1121
+ ...(paragraph.frameProperties
1122
+ ? { frameProperties: toSurfaceFrameProperties(paragraph.frameProperties) }
1123
+ : {}),
1069
1124
  ...(paragraph.shading
1070
1125
  ? { shading: resolveSurfaceParagraphShading(paragraph.shading, themeResolver) }
1071
1126
  : {}),
@@ -119,6 +119,24 @@ export interface LoadDocxEditorSessionOptions {
119
119
  * FRESH offload on first-open.
120
120
  */
121
121
  offloadEmbeddedDocuments?: boolean;
122
+ /**
123
+ * Phase 1 cosmetic-marker strip. When `true` (the default), the
124
+ * OOXML parser drops `<w:lastRenderedPageBreak/>`, `<w:proofErr/>`,
125
+ * and `<w:noBreakHyphen/>` during the body walk instead of emitting
126
+ * them as `opaque_inline` nodes. Aggregate counts are surfaced as
127
+ * a single `diagnostic:parser.skipped-cosmetic-markers` warning on
128
+ * the returned canonical document.
129
+ *
130
+ * These markers carry no contract semantics (Word regenerates them
131
+ * on reopen) and are structural boundaries that today cause
132
+ * `replaceText` to refuse ranges that happen to cross them. See
133
+ * `docs/architecture/cosmetic-marker-strip.md`.
134
+ *
135
+ * Set to `false` to preserve pre-strip behavior exactly — useful for
136
+ * byte-preservation tests and diagnostic scripts that need to
137
+ * inspect every marker.
138
+ */
139
+ stripCosmeticMarkers?: boolean;
122
140
  }
123
141
 
124
142
  /**
@@ -540,6 +540,7 @@ export async function loadDocxSessionAsync(
540
540
  mediaParts,
541
541
  mainDocumentPath,
542
542
  chartPartLookup,
543
+ { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
543
544
  );
544
545
  } finally {
545
546
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -1312,6 +1313,7 @@ export function loadDocxSessionSync(
1312
1313
  mediaParts,
1313
1314
  mainDocumentPath,
1314
1315
  chartPartLookup,
1316
+ { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
1315
1317
  );
1316
1318
  } finally {
1317
1319
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -200,6 +200,15 @@ export const editorSchema = new Schema({
200
200
  hiddenTextOnly: { default: null },
201
201
  placeholderCulled: { default: null },
202
202
  blockId: { default: null },
203
+ /**
204
+ * `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
205
+ * (ECMA-376 §17.3.1.11). When set (and `dropCap` is neither `"drop"`
206
+ * nor `"margin"`), `toDOM` emits `position: absolute` with the
207
+ * xTwips/yTwips/widthTwips/heightTwips fields; the inline flow
208
+ * already treats the paragraph as zero-height per L04
209
+ * `measureBlockHeight` short-circuit (a298391e / coord-04 §1.19.d).
210
+ */
211
+ frameProperties: { default: null },
203
212
  },
204
213
  parseDOM: [{ tag: "p" }],
205
214
  toDOM(node) {
@@ -278,6 +287,29 @@ export const editorSchema = new Schema({
278
287
  }
279
288
  const pageBreak = node.attrs.pageBreakBefore as boolean | null;
280
289
  if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
290
+ // `<w:framePr>` out-of-flow frame — mirror the static-path branch in
291
+ // tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
292
+ // positions the frame identically to pages 2+.
293
+ const framePr = node.attrs.frameProperties as
294
+ | {
295
+ xTwips?: number;
296
+ yTwips?: number;
297
+ widthTwips?: number;
298
+ heightTwips?: number;
299
+ hRule?: "auto" | "atLeast" | "exact";
300
+ dropCap?: "none" | "drop" | "margin";
301
+ }
302
+ | null;
303
+ if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
304
+ styles.push("position: absolute");
305
+ if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
306
+ if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
307
+ if (typeof framePr.widthTwips === "number") styles.push(`width: ${framePr.widthTwips / 20}pt`);
308
+ if (typeof framePr.heightTwips === "number") {
309
+ if (framePr.hRule === "exact") styles.push(`height: ${framePr.heightTwips / 20}pt`);
310
+ else if (framePr.hRule === "atLeast") styles.push(`min-height: ${framePr.heightTwips / 20}pt`);
311
+ }
312
+ }
281
313
  const hiddenTextOnly = node.attrs.hiddenTextOnly as boolean | null;
282
314
  if (hiddenTextOnly) {
283
315
  attrs["data-hidden-paragraph"] = "true";
@@ -426,6 +426,12 @@ function buildParagraph(
426
426
  bidi: block.bidi ?? cascade?.bidi ?? null,
427
427
  pageBreakBefore: block.pageBreakBefore ?? cascade?.pageBreakBefore ?? null,
428
428
  hiddenTextOnly: fullyVanishedParagraph || null,
429
+ // `<w:framePr>` out-of-flow frame — forward to the PM paragraph node
430
+ // so `pm-schema.ts::paragraph.toDOM` emits the absolute positioning
431
+ // that matches the static `buildParagraphStyle` path (L04
432
+ // short-circuits inline flow height in measureBlockHeight; L11 owns
433
+ // the absolute render on BOTH the PM path and the static path).
434
+ frameProperties: block.frameProperties ?? null,
429
435
  },
430
436
  content.length > 0 ? Fragment.from(content) : undefined,
431
437
  );