@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.
- package/package.json +1 -1
- package/src/api/public-types.ts +37 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/viewport.ts +1 -1
- package/src/core/state/editor-state.ts +49 -6
- package/src/io/export/serialize-footnotes.ts +6 -0
- package/src/io/export/serialize-headers-footers.ts +6 -0
- package/src/io/export/serialize-main-document.ts +7 -0
- package/src/io/export/serialize-paragraph-formatting.ts +1 -1
- package/src/io/normalize/normalize-text.ts +38 -2
- package/src/io/ooxml/parse-headers-footers.ts +31 -0
- package/src/io/ooxml/parse-main-document.ts +127 -2
- package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
- package/src/runtime/layout/layout-engine-version.ts +22 -1
- package/src/runtime/layout/paginated-layout-engine.ts +47 -0
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/replacement/apply.ts +1 -0
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/semantic-scope-types.ts +19 -0
- package/src/runtime/surface-projection.ts +55 -0
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
- package/src/ui-tailwind/theme/editor-theme.css +15 -16
- 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 =
|
|
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 (
|
|
254
|
-
|
|
255
|
-
|
|
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 {
|
|
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
|
|
142
|
-
: `suggest-mode
|
|
143
|
-
range: { from:
|
|
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
|
|
172
|
-
range: { from:
|
|
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
|
);
|