@beyondwork/docx-react-component 1.0.72 → 1.0.74
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/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +70 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/api/v3/ui/viewport.ts +1 -1
- package/src/compare/diff-engine.ts +1 -2
- package/src/core/commands/index.ts +14 -15
- package/src/core/selection/anchor-conversion.ts +2 -2
- package/src/core/selection/mapping.ts +10 -8
- package/src/core/selection/review-anchors.ts +3 -3
- package/src/core/state/editor-state.ts +49 -6
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- 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/export/serialize-runtime-revisions.ts +10 -10
- package/src/io/export/split-review-boundaries.ts +4 -4
- package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
- package/src/io/normalize/normalize-text.ts +38 -2
- package/src/io/ooxml/parse-comments.ts +2 -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/model/anchor.ts +9 -1
- package/src/model/canonical-document.ts +76 -3
- package/src/preservation/store.ts +24 -0
- package/src/review/store/comment-anchors.ts +1 -1
- package/src/review/store/comment-remapping.ts +1 -1
- package/src/review/store/revision-actions.ts +4 -4
- package/src/review/store/revision-types.ts +1 -1
- package/src/review/store/scope-tag-diff.ts +1 -1
- package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
- package/src/runtime/document-runtime.ts +205 -37
- package/src/runtime/formatting/formatting-context.ts +1 -1
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +30 -1
- package/src/runtime/layout/paginated-layout-engine.ts +47 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/replacement/apply.ts +1 -0
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/scopes/semantic-scope-types.ts +19 -0
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +94 -4
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- package/src/session/import/review-import.ts +12 -12
- package/src/session/import/workflow-scope-import.ts +9 -8
- package/src/shell/session-bootstrap.ts +4 -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/surface-build-keys.ts +5 -2
- 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/page-stack/use-visible-block-range.ts +99 -43
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
- package/src/ui-tailwind/theme/editor-theme.css +15 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +34 -49
- package/src/validation/compatibility-engine.ts +1 -1
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
- package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
package/src/api/v3/ui/scope.ts
CHANGED
|
@@ -33,9 +33,92 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
|
|
36
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
ScopeBundle,
|
|
38
|
+
ScopeCardModel,
|
|
39
|
+
ScopeRailSnapshot,
|
|
40
|
+
SemanticScope,
|
|
41
|
+
SemanticScopeKind,
|
|
42
|
+
} from "../../public-types.ts";
|
|
37
43
|
import type { UiApiContext } from "./_context.ts";
|
|
38
44
|
|
|
45
|
+
export interface UiScopeListFilter {
|
|
46
|
+
readonly kind?: SemanticScopeKind;
|
|
47
|
+
readonly limit?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UiScopeRailOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Narrow the snapshot to a single page index. Omit (default) to
|
|
53
|
+
* span every page the runtime's page graph knows about.
|
|
54
|
+
*/
|
|
55
|
+
readonly pageIndex?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const cardMetadata: ApiV3FnMetadata = {
|
|
59
|
+
name: "ui.scope.card",
|
|
60
|
+
status: "live-with-adapter",
|
|
61
|
+
sourceLayer: "presentation",
|
|
62
|
+
liveEvidence: {
|
|
63
|
+
runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
|
|
64
|
+
commit: "refactor-10-ki-008-ui-scope-card-rail",
|
|
65
|
+
},
|
|
66
|
+
uxIntent: { uiVisible: false },
|
|
67
|
+
agentMetadata: {
|
|
68
|
+
readOrMutate: "read",
|
|
69
|
+
boundedScope: "scope",
|
|
70
|
+
auditCategory: "ui-scope-card-read",
|
|
71
|
+
},
|
|
72
|
+
// Same classification as other `ui.scope.*` reads — canonical scope
|
|
73
|
+
// projection (not class-C local preference). KI-008 close.
|
|
74
|
+
stateClass: "A-canonical",
|
|
75
|
+
persistsTo: "canonical",
|
|
76
|
+
rwdReference:
|
|
77
|
+
"§UI API § ui.scope.card. Wraps handle.compileScopeCardById(scopeId) which projects runtime.workflow.getAllScopeCardModels() filtered to the matching scope. Returns plain-value ScopeCardModel — no React handlers, no DOM refs. Hosts wire event handlers in their own chrome tree.",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const railMetadata: ApiV3FnMetadata = {
|
|
81
|
+
name: "ui.scope.rail",
|
|
82
|
+
status: "live-with-adapter",
|
|
83
|
+
sourceLayer: "presentation",
|
|
84
|
+
liveEvidence: {
|
|
85
|
+
runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
|
|
86
|
+
commit: "refactor-10-ki-008-ui-scope-card-rail",
|
|
87
|
+
},
|
|
88
|
+
uxIntent: { uiVisible: false },
|
|
89
|
+
agentMetadata: {
|
|
90
|
+
readOrMutate: "read",
|
|
91
|
+
boundedScope: "document",
|
|
92
|
+
auditCategory: "ui-scope-rail-read",
|
|
93
|
+
},
|
|
94
|
+
stateClass: "A-canonical",
|
|
95
|
+
persistsTo: "canonical",
|
|
96
|
+
rwdReference:
|
|
97
|
+
"§UI API § ui.scope.rail. Wraps handle.compileScopeRailSnapshot({pageIndex?}) which projects runtime.workflow.getRailSegments(pageIndex) / getAllRailSegments() into a ScopeRailSnapshot envelope. Plain-value data; no React handlers.",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const listMetadata: ApiV3FnMetadata = {
|
|
101
|
+
name: "ui.scope.list",
|
|
102
|
+
status: "live-with-adapter",
|
|
103
|
+
sourceLayer: "presentation",
|
|
104
|
+
liveEvidence: {
|
|
105
|
+
runnerTest: "test/api/v3/ui/scope-list.test.ts",
|
|
106
|
+
commit: "refactor-10-ki-008-ui-scope-list",
|
|
107
|
+
},
|
|
108
|
+
uxIntent: { uiVisible: false },
|
|
109
|
+
agentMetadata: {
|
|
110
|
+
readOrMutate: "read",
|
|
111
|
+
boundedScope: "document",
|
|
112
|
+
auditCategory: "ui-scope-list",
|
|
113
|
+
},
|
|
114
|
+
// Same classification as `ui.scope.getBundle` — reads canonical
|
|
115
|
+
// scope state (not class-C local preference). KI-008 closure.
|
|
116
|
+
stateClass: "A-canonical",
|
|
117
|
+
persistsTo: "canonical",
|
|
118
|
+
rwdReference:
|
|
119
|
+
"§UI API § ui.scope.list. Wraps handle.compileScopeList(filter?) to enumerate compiled SemanticScope records through the L10 seam without importing src/runtime/scopes/**. Filter matches ai.listScopes({kind?, limit?}) for identity-set symmetry.",
|
|
120
|
+
};
|
|
121
|
+
|
|
39
122
|
export const getBundleMetadata: ApiV3FnMetadata = {
|
|
40
123
|
name: "ui.scope.getBundle",
|
|
41
124
|
status: "live-with-adapter",
|
|
@@ -67,5 +150,34 @@ export function createScopeFamily(ctx: UiApiContext) {
|
|
|
67
150
|
const nowUtc = new Date().toISOString();
|
|
68
151
|
return compile.call(ctx.handle, scopeId, nowUtc);
|
|
69
152
|
},
|
|
153
|
+
list(filter?: UiScopeListFilter): readonly SemanticScope[] {
|
|
154
|
+
// KI-008 — enumeration through the handle's batch primitive
|
|
155
|
+
// (L10 purity blocks importing src/runtime/scopes/**). Returns
|
|
156
|
+
// a fresh array so callers that sort / splice don't leak into
|
|
157
|
+
// internal compiler state.
|
|
158
|
+
const enumerate = ctx.handle.compileScopeList;
|
|
159
|
+
if (typeof enumerate !== "function") return Object.freeze([]);
|
|
160
|
+
return enumerate.call(ctx.handle, filter);
|
|
161
|
+
},
|
|
162
|
+
card(scopeId: string): ScopeCardModel | null {
|
|
163
|
+
// KI-008 close — single-scope presentation projection. Returns
|
|
164
|
+
// a plain-value ScopeCardModel (posture, label, issue, suggestion
|
|
165
|
+
// groups, review actions, agent-pending flag). Host chrome wires
|
|
166
|
+
// event handlers around it.
|
|
167
|
+
const compile = ctx.handle.compileScopeCardById;
|
|
168
|
+
if (typeof compile !== "function") return null;
|
|
169
|
+
return compile.call(ctx.handle, scopeId);
|
|
170
|
+
},
|
|
171
|
+
rail(options?: UiScopeRailOptions): ScopeRailSnapshot {
|
|
172
|
+
// KI-008 close — rail snapshot. When `options.pageIndex` is
|
|
173
|
+
// supplied, narrows to that page; otherwise spans every page
|
|
174
|
+
// in the runtime's page graph. Envelope shape is stable either
|
|
175
|
+
// way; callers check `snapshot.pageIndex` to detect narrowing.
|
|
176
|
+
const compile = ctx.handle.compileScopeRailSnapshot;
|
|
177
|
+
if (typeof compile !== "function") {
|
|
178
|
+
return { segments: Object.freeze([]) };
|
|
179
|
+
}
|
|
180
|
+
return compile.call(ctx.handle, options);
|
|
181
|
+
},
|
|
70
182
|
};
|
|
71
183
|
}
|
|
@@ -112,7 +112,7 @@ export const scrollToPageMetadata: ApiV3FnMetadata = {
|
|
|
112
112
|
stateClass: "C-local",
|
|
113
113
|
persistsTo: "none",
|
|
114
114
|
rwdReference:
|
|
115
|
-
"§UI API § ui.viewport.scrollToPage. Resolves pageNumber → scrollY via handle.geometry.getPage(pageIndex); dispatches through controller.dispatchScroll({ kind:'page', value, behavior }); returns the settled {actualPage, scrollY}. 1-based page numbers; clamps to [1, pageCount]. First-class API for visual-fidelity harness + 'Go to page N' UX — replaces DOM-scrape fallback (coord-10 §γ).
|
|
115
|
+
"§UI API § ui.viewport.scrollToPage. Resolves pageNumber → scrollY via handle.geometry.getPage(pageIndex); dispatches through controller.dispatchScroll({ kind:'page', value, behavior }); returns the settled {actualPage, scrollY}. 1-based page numbers; clamps to [1, pageCount]. First-class API for visual-fidelity harness + 'Go to page N' UX — replaces DOM-scrape fallback (coord-10 §γ). Parity note: reads the same `handle.geometry.getPage(i).frame.topPx` source as `runtime.viewport.getPageAnchor` (L07 coord-07 §2.9, shipped 2026-04-24 in `src/api/v3/runtime/viewport.ts`), so `actualPage + scrollY` here and `{scrollY, pageRect}` on the runtime side stay consistent by construction. No direct delegation today because `scripts/ci-check-ui-api-layer-purity.mjs` restricts `src/api/v3/ui/**` from importing `src/api/v3/runtime/**`; both surfaces are thin wrappers over the shared geometry facet.",
|
|
116
116
|
};
|
|
117
117
|
|
|
118
118
|
// ----- X5 markup-mode metadata (state-classes cross-cutting Slice X5) -----
|
|
@@ -410,8 +410,7 @@ function createParagraphRevisionRecords(
|
|
|
410
410
|
changeId: revision.changeId,
|
|
411
411
|
kind: revision.kind,
|
|
412
412
|
anchor: {
|
|
413
|
-
kind: "range",
|
|
414
|
-
range: { from: position, to: position },
|
|
413
|
+
kind: "range", from: position, to: position,
|
|
415
414
|
assoc: { start: -1, end: 1 },
|
|
416
415
|
},
|
|
417
416
|
authorId,
|
|
@@ -1525,9 +1525,9 @@ export function remapSelection(
|
|
|
1525
1525
|
|
|
1526
1526
|
if (activeRange.kind === "range") {
|
|
1527
1527
|
return {
|
|
1528
|
-
anchor: activeRange.
|
|
1529
|
-
head: activeRange.
|
|
1530
|
-
isCollapsed: activeRange.
|
|
1528
|
+
anchor: activeRange.from,
|
|
1529
|
+
head: activeRange.to,
|
|
1530
|
+
isCollapsed: activeRange.from === activeRange.to,
|
|
1531
1531
|
activeRange,
|
|
1532
1532
|
};
|
|
1533
1533
|
}
|
|
@@ -1664,8 +1664,8 @@ function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
|
|
|
1664
1664
|
);
|
|
1665
1665
|
|
|
1666
1666
|
if (activeRange.kind === "range") {
|
|
1667
|
-
const rangeFrom = activeRange.
|
|
1668
|
-
const rangeTo = activeRange.
|
|
1667
|
+
const rangeFrom = activeRange.from;
|
|
1668
|
+
const rangeTo = activeRange.to;
|
|
1669
1669
|
const collapsed = rangeFrom === rangeTo;
|
|
1670
1670
|
const anchorWithinRange = selection.anchor >= rangeFrom && selection.anchor <= rangeTo;
|
|
1671
1671
|
const headWithinRange = selection.head >= rangeFrom && selection.head <= rangeTo;
|
|
@@ -2256,7 +2256,8 @@ function createAuthoredRevision(
|
|
|
2256
2256
|
kind,
|
|
2257
2257
|
anchor: {
|
|
2258
2258
|
kind: "range",
|
|
2259
|
-
|
|
2259
|
+
from,
|
|
2260
|
+
to,
|
|
2260
2261
|
assoc: { start: 1, end: -1 },
|
|
2261
2262
|
},
|
|
2262
2263
|
authorId,
|
|
@@ -2396,8 +2397,7 @@ function applySuggestingInsert(
|
|
|
2396
2397
|
const updatedRevision: CanonicalRevisionRecord = {
|
|
2397
2398
|
...existingInsertion,
|
|
2398
2399
|
anchor: {
|
|
2399
|
-
kind: "range",
|
|
2400
|
-
range: { from: existingInsertion.anchor.range.from, to: insertedTo },
|
|
2400
|
+
kind: "range", from: existingInsertion.anchor.from, to: insertedTo,
|
|
2401
2401
|
assoc: { start: 1, end: -1 },
|
|
2402
2402
|
},
|
|
2403
2403
|
};
|
|
@@ -2635,19 +2635,18 @@ function applySuggestingDelete(
|
|
|
2635
2635
|
if (existingDeletion) {
|
|
2636
2636
|
// Extend the existing deletion revision to cover the new range
|
|
2637
2637
|
const extendedFrom = Math.min(
|
|
2638
|
-
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.
|
|
2638
|
+
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.from : deleteFrom,
|
|
2639
2639
|
deleteFrom,
|
|
2640
2640
|
);
|
|
2641
2641
|
const extendedTo = Math.max(
|
|
2642
|
-
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.
|
|
2642
|
+
existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.to : deleteTo,
|
|
2643
2643
|
deleteTo,
|
|
2644
2644
|
);
|
|
2645
2645
|
|
|
2646
2646
|
const updatedRevision: CanonicalRevisionRecord = {
|
|
2647
2647
|
...existingDeletion,
|
|
2648
2648
|
anchor: {
|
|
2649
|
-
kind: "range",
|
|
2650
|
-
range: { from: extendedFrom, to: extendedTo },
|
|
2649
|
+
kind: "range", from: extendedFrom, to: extendedTo,
|
|
2651
2650
|
assoc: { start: 1, end: -1 },
|
|
2652
2651
|
},
|
|
2653
2652
|
};
|
|
@@ -2933,7 +2932,7 @@ function findAdjacentInsertionRevision(
|
|
|
2933
2932
|
revision.status === "open" &&
|
|
2934
2933
|
revision.metadata?.source === "runtime" &&
|
|
2935
2934
|
revision.anchor.kind === "range" &&
|
|
2936
|
-
revision.anchor.
|
|
2935
|
+
revision.anchor.to === cursorPos &&
|
|
2937
2936
|
revision.authorId === authorId
|
|
2938
2937
|
) {
|
|
2939
2938
|
return revision;
|
|
@@ -2954,8 +2953,8 @@ function findOverlappingAuthoredDeletion(
|
|
|
2954
2953
|
revision.metadata?.source === "runtime" &&
|
|
2955
2954
|
revision.anchor.kind === "range"
|
|
2956
2955
|
) {
|
|
2957
|
-
const revFrom = revision.anchor.
|
|
2958
|
-
const revTo = revision.anchor.
|
|
2956
|
+
const revFrom = revision.anchor.from;
|
|
2957
|
+
const revTo = revision.anchor.to;
|
|
2959
2958
|
// Adjacent or overlapping: the deletion is directly next to or overlapping the range
|
|
2960
2959
|
if (
|
|
2961
2960
|
(from >= revFrom && from <= revTo) ||
|
|
@@ -66,9 +66,11 @@ export function createRangeAnchor(
|
|
|
66
66
|
to = from,
|
|
67
67
|
assoc: BoundaryAssoc = DEFAULT_BOUNDARY_ASSOC,
|
|
68
68
|
): RangeAnchor {
|
|
69
|
+
const normalized = normalizeRange({ from, to });
|
|
69
70
|
return {
|
|
70
71
|
kind: "range",
|
|
71
|
-
|
|
72
|
+
from: normalized.from,
|
|
73
|
+
to: normalized.to,
|
|
72
74
|
assoc,
|
|
73
75
|
};
|
|
74
76
|
}
|
|
@@ -89,7 +91,7 @@ export function createEmptyMapping(): TransactionMapping {
|
|
|
89
91
|
|
|
90
92
|
export function getEffectiveRange(anchor: EditorAnchorProjection): DocRange {
|
|
91
93
|
if (anchor.kind === "range") {
|
|
92
|
-
return anchor.
|
|
94
|
+
return { from: anchor.from, to: anchor.to };
|
|
93
95
|
}
|
|
94
96
|
if (anchor.kind === "node") {
|
|
95
97
|
return { from: anchor.at, to: anchor.at };
|
|
@@ -140,13 +142,13 @@ export function mapAnchor(
|
|
|
140
142
|
return createNodeAnchor(mapped.position, anchor.assoc);
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
const from = mapPosition(anchor.
|
|
144
|
-
const to = mapPosition(anchor.
|
|
145
|
+
const from = mapPosition(anchor.from, anchor.assoc.start, mapping);
|
|
146
|
+
const to = mapPosition(anchor.to, anchor.assoc.end, mapping);
|
|
145
147
|
const mappedRange = normalizeRange({ from: from.position, to: to.position });
|
|
146
148
|
|
|
147
149
|
if (from.deleted && to.deleted && mappedRange.from === mappedRange.to) {
|
|
148
150
|
return createDetachedAnchor(
|
|
149
|
-
anchor.
|
|
151
|
+
{ from: anchor.from, to: anchor.to },
|
|
150
152
|
mapping.metadata?.invalidatesStructures
|
|
151
153
|
? "invalidatedByStructureChange"
|
|
152
154
|
: "deleted",
|
|
@@ -187,8 +189,8 @@ export function areAnchorsEqual(
|
|
|
187
189
|
|
|
188
190
|
if (left.kind === "range" && right.kind === "range") {
|
|
189
191
|
return (
|
|
190
|
-
left.
|
|
191
|
-
left.
|
|
192
|
+
left.from === right.from &&
|
|
193
|
+
left.to === right.to &&
|
|
192
194
|
left.assoc.start === right.assoc.start &&
|
|
193
195
|
left.assoc.end === right.assoc.end
|
|
194
196
|
);
|
|
@@ -213,7 +215,7 @@ export function anchorUnaffectedByMapping(
|
|
|
213
215
|
): boolean {
|
|
214
216
|
if (anchor.kind === "detached") return true;
|
|
215
217
|
if (mapping.steps.length === 0) return true;
|
|
216
|
-
const end = anchor.kind === "range" ? anchor.
|
|
218
|
+
const end = anchor.kind === "range" ? anchor.to : anchor.at;
|
|
217
219
|
return mapping.steps.every((s) => s.from > end);
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -40,7 +40,7 @@ export function mapReviewAnchor(
|
|
|
40
40
|
|
|
41
41
|
export function getAnchorRange(anchor: ReviewAnchor): DocRange {
|
|
42
42
|
if (anchor.kind === "range") {
|
|
43
|
-
return normalizeRange(anchor.
|
|
43
|
+
return normalizeRange({ from: anchor.from, to: anchor.to });
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
if (anchor.kind === "node") {
|
|
@@ -136,7 +136,7 @@ export function commentAnchorRejectionReason(
|
|
|
136
136
|
return "invalid_comment_anchor";
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
const normalized = normalizeRange(anchor.
|
|
139
|
+
const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
|
|
140
140
|
if (normalized.from === normalized.to) {
|
|
141
141
|
return "invalid_comment_anchor";
|
|
142
142
|
}
|
|
@@ -164,7 +164,7 @@ export function snapCommentAnchorAwayFromTable(
|
|
|
164
164
|
): ReviewAnchor | null {
|
|
165
165
|
if (anchor.kind !== "range") return null;
|
|
166
166
|
|
|
167
|
-
const normalized = normalizeRange(anchor.
|
|
167
|
+
const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
|
|
168
168
|
if (normalized.from === normalized.to) return null;
|
|
169
169
|
|
|
170
170
|
// If the anchor is already flagged invalid for other reasons (crosses an
|
|
@@ -582,14 +582,57 @@ export function createPersistedEditorSnapshot(
|
|
|
582
582
|
}
|
|
583
583
|
|
|
584
584
|
function estimateParagraphCount(content: unknown): number {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
585
|
+
// Canonical shape: `{type:"doc", children: BlockNode[]}`. Older
|
|
586
|
+
// shapes (array / `.blocks`) handled for persistence-snapshot
|
|
587
|
+
// fallback. KI-P4 (2026-04-23): pre-fix the array + .blocks
|
|
588
|
+
// branches never matched the current envelope, so the fallback
|
|
589
|
+
// returned 1 on any non-empty document regardless of paragraph
|
|
590
|
+
// count. Fix counts ParagraphNode entries recursively, descending
|
|
591
|
+
// into table cells + SDT / customXml blocks so nested paragraphs
|
|
592
|
+
// contribute to the total.
|
|
593
|
+
let count = 0;
|
|
594
|
+
const walk = (node: unknown): void => {
|
|
595
|
+
if (!node || typeof node !== "object") return;
|
|
596
|
+
const typed = node as { type?: unknown };
|
|
597
|
+
if (typed.type === "paragraph") {
|
|
598
|
+
count += 1;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (typed.type === "table") {
|
|
602
|
+
const rows = (node as { rows?: unknown[] }).rows;
|
|
603
|
+
if (Array.isArray(rows)) {
|
|
604
|
+
for (const row of rows) {
|
|
605
|
+
const cells = (row as { cells?: unknown[] }).cells;
|
|
606
|
+
if (Array.isArray(cells)) {
|
|
607
|
+
for (const cell of cells) {
|
|
608
|
+
const children = (cell as { children?: unknown[] }).children;
|
|
609
|
+
if (Array.isArray(children)) children.forEach(walk);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const children = (node as { children?: unknown[] }).children;
|
|
617
|
+
if (Array.isArray(children)) children.forEach(walk);
|
|
618
|
+
};
|
|
588
619
|
|
|
589
|
-
if (content && typeof content === "object"
|
|
590
|
-
|
|
620
|
+
if (content && typeof content === "object") {
|
|
621
|
+
const children = (content as { children?: unknown[] }).children;
|
|
622
|
+
if (Array.isArray(children)) {
|
|
623
|
+
children.forEach(walk);
|
|
624
|
+
return count;
|
|
625
|
+
}
|
|
626
|
+
const blocks = (content as { blocks?: unknown[] }).blocks;
|
|
627
|
+
if (Array.isArray(blocks)) {
|
|
628
|
+
blocks.forEach(walk);
|
|
629
|
+
return count;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (Array.isArray(content)) {
|
|
633
|
+
content.forEach(walk);
|
|
634
|
+
return count;
|
|
591
635
|
}
|
|
592
|
-
|
|
593
636
|
return extractText(content).length > 0 ? 1 : 0;
|
|
594
637
|
}
|
|
595
638
|
|
|
@@ -119,6 +119,7 @@ export class ExportSession {
|
|
|
119
119
|
this.packageRelationships,
|
|
120
120
|
this.ownedPaths,
|
|
121
121
|
);
|
|
122
|
+
ensurePicNamespaceOnStoryParts(this.workingParts);
|
|
122
123
|
const parts = pruneReservedManifestParts(this.workingParts);
|
|
123
124
|
const manifest = buildManifest(parts, this.packageRelationships, this.sourcePackage.manifest.contentTypes);
|
|
124
125
|
return {
|
|
@@ -140,6 +141,58 @@ export function createExportSession(
|
|
|
140
141
|
return new ExportSession(sourcePackage, ownedOutputPaths);
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* LibreOffice strict-parser safety net. Word tolerates `pic:` prefix
|
|
146
|
+
* usage (from `<pic:pic>` / `<pic:spPr>` inside drawings) without
|
|
147
|
+
* declaring `xmlns:pic` at the part root, because it inherits scope
|
|
148
|
+
* from `<wp:inline xmlns:pic=…>` wrappers or from `mc:AlternateContent`
|
|
149
|
+
* fallbacks. LibreOffice refuses the file ("Namespace prefix pic on
|
|
150
|
+
* spPr is not defined"). Our own serializers already inject
|
|
151
|
+
* `xmlns:pic` on freshly serialized parts — but parts that pass
|
|
152
|
+
* through unmodified from the source package keep the original
|
|
153
|
+
* declaration (which may be missing it). This sweep runs at toPackage
|
|
154
|
+
* time over the main story + every header / footer part, and injects
|
|
155
|
+
* `xmlns:pic` on the root element when the part uses the `pic:` prefix
|
|
156
|
+
* but doesn't declare it. Byte-stable for parts already correct.
|
|
157
|
+
*
|
|
158
|
+
* See commit a3e05a8e (serializer-side fix) + this (pass-through fix).
|
|
159
|
+
*/
|
|
160
|
+
const PIC_NAMESPACE_URI = "http://schemas.openxmlformats.org/drawingml/2006/picture";
|
|
161
|
+
const PIC_NAMESPACE_DECL = ` xmlns:pic="${PIC_NAMESPACE_URI}"`;
|
|
162
|
+
|
|
163
|
+
function ensurePicNamespaceOnStoryParts(
|
|
164
|
+
parts: Map<string, OpcPackagePart>,
|
|
165
|
+
): void {
|
|
166
|
+
for (const [path, part] of parts.entries()) {
|
|
167
|
+
if (!isPicSensitiveStoryPath(path)) continue;
|
|
168
|
+
const patched = ensurePicNamespaceOnRoot(part.bytes);
|
|
169
|
+
if (patched !== part.bytes) {
|
|
170
|
+
parts.set(path, { ...part, bytes: patched, crc32: 0 });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPicSensitiveStoryPath(path: string): boolean {
|
|
176
|
+
// Normalize for leading slash variation.
|
|
177
|
+
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
|
178
|
+
if (normalized === "word/document.xml") return true;
|
|
179
|
+
if (/^word\/(?:header|footer|footnotes|endnotes)[^/]*\.xml$/.test(normalized)) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ensurePicNamespaceOnRoot(bytes: Uint8Array): Uint8Array {
|
|
184
|
+
const xml = new TextDecoder().decode(bytes);
|
|
185
|
+
// Match the first element (skipping the XML prolog + whitespace).
|
|
186
|
+
const rootMatch = xml.match(/^([\s\S]*?)<([A-Za-z][\w.:-]*)([^>]*)>/);
|
|
187
|
+
if (!rootMatch) return bytes;
|
|
188
|
+
const [fullMatch, prefix, tagName, rootAttrs] = rootMatch;
|
|
189
|
+
if (rootAttrs.includes("xmlns:pic=")) return bytes; // already declared
|
|
190
|
+
if (!/\bpic:[A-Za-z]/.test(xml)) return bytes; // doesn't use pic: prefix
|
|
191
|
+
const patchedRoot = `<${tagName}${rootAttrs}${PIC_NAMESPACE_DECL}>`;
|
|
192
|
+
const patched = `${prefix}${patchedRoot}${xml.slice(fullMatch.length)}`;
|
|
193
|
+
return new TextEncoder().encode(patched);
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
function clonePartsForExport(
|
|
144
197
|
parts: Map<string, OpcPackagePart>,
|
|
145
198
|
ownedPaths: ReadonlySet<string>,
|
|
@@ -269,16 +269,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
|
|
|
269
269
|
continue;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
const startParagraph = findParagraphForEndpoint(paragraphs, anchor.
|
|
273
|
-
const endParagraph = findParagraphForEndpoint(paragraphs, anchor.
|
|
272
|
+
const startParagraph = findParagraphForEndpoint(paragraphs, anchor.from, "start");
|
|
273
|
+
const endParagraph = findParagraphForEndpoint(paragraphs, anchor.to, "end");
|
|
274
274
|
|
|
275
275
|
if (!startParagraph || !endParagraph) {
|
|
276
276
|
skippedCommentIds.push(thread.commentId);
|
|
277
277
|
continue;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
const startIndex = startParagraph.boundaries.get(anchor.
|
|
281
|
-
const endIndex = endParagraph.boundaries.get(anchor.
|
|
280
|
+
const startIndex = startParagraph.boundaries.get(anchor.from);
|
|
281
|
+
const endIndex = endParagraph.boundaries.get(anchor.to);
|
|
282
282
|
|
|
283
283
|
if (startIndex === undefined || endIndex === undefined) {
|
|
284
284
|
skippedCommentIds.push(thread.commentId);
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./table-properties-xml.ts";
|
|
19
19
|
import { twip } from "./twip.ts";
|
|
20
20
|
import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
|
|
21
|
+
import { buildFrameXml } from "./serialize-paragraph-formatting.ts";
|
|
21
22
|
|
|
22
23
|
export const WORD_FOOTNOTES_CONTENT_TYPE =
|
|
23
24
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
|
|
@@ -222,6 +223,11 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
|
|
|
222
223
|
if (paragraph.styleId) {
|
|
223
224
|
parts.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
|
|
224
225
|
}
|
|
226
|
+
// Coord-04 §1.19.d — direct-paragraph framePr (footnotes path).
|
|
227
|
+
{
|
|
228
|
+
const frameXml = buildFrameXml(paragraph.frameProperties);
|
|
229
|
+
if (frameXml) parts.push(frameXml);
|
|
230
|
+
}
|
|
225
231
|
if (paragraph.alignment) {
|
|
226
232
|
parts.push(`<w:jc w:val="${escapeXmlAttribute(paragraph.alignment)}"/>`);
|
|
227
233
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./table-properties-xml.ts";
|
|
19
19
|
import { twip } from "./twip.ts";
|
|
20
20
|
import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
|
|
21
|
+
import { buildFrameXml } from "./serialize-paragraph-formatting.ts";
|
|
21
22
|
|
|
22
23
|
export const WORD_HEADER_CONTENT_TYPE =
|
|
23
24
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
|
|
@@ -186,6 +187,11 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
|
|
|
186
187
|
if (paragraph.styleId) {
|
|
187
188
|
parts.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
|
|
188
189
|
}
|
|
190
|
+
// Coord-04 §1.19.d — direct-paragraph framePr (headers/footers path).
|
|
191
|
+
{
|
|
192
|
+
const frameXml = buildFrameXml(paragraph.frameProperties);
|
|
193
|
+
if (frameXml) parts.push(frameXml);
|
|
194
|
+
}
|
|
189
195
|
if (paragraph.spacing) {
|
|
190
196
|
const s = paragraph.spacing;
|
|
191
197
|
const attrs: string[] = [];
|
|
@@ -22,6 +22,7 @@ import { SCOPE_MARKER_BOOKMARK_PREFIX } from "../ooxml/parse-scope-markers.ts";
|
|
|
22
22
|
import { getOpaqueFragment } from "../../preservation/store.ts";
|
|
23
23
|
import { retainRelationshipsForFragment } from "../../preservation/relationship-retention.ts";
|
|
24
24
|
import { serializeParagraphNumberingProperties } from "./serialize-numbering.ts";
|
|
25
|
+
import { buildFrameXml } from "./serialize-paragraph-formatting.ts";
|
|
25
26
|
import {
|
|
26
27
|
serializeTableCellPropertiesXml,
|
|
27
28
|
serializeTablePropertiesXml,
|
|
@@ -716,6 +717,12 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
|
|
|
716
717
|
pushOnOffParagraphProperty(children, "keepNext", paragraph.keepNext);
|
|
717
718
|
pushOnOffParagraphProperty(children, "keepLines", paragraph.keepLines);
|
|
718
719
|
pushOnOffParagraphProperty(children, "pageBreakBefore", paragraph.pageBreakBefore);
|
|
720
|
+
// ECMA-376 §17.3.1 canonical slot for framePr: between pageBreakBefore
|
|
721
|
+
// and pBdr. Coord-04 §1.19.d — direct-paragraph path.
|
|
722
|
+
{
|
|
723
|
+
const frameXml = buildFrameXml(paragraph.frameProperties);
|
|
724
|
+
if (frameXml) children.push(frameXml);
|
|
725
|
+
}
|
|
719
726
|
pushOnOffParagraphProperty(children, "widowControl", paragraph.widowControl);
|
|
720
727
|
if (paragraph.outlineLevel !== undefined) {
|
|
721
728
|
children.push(`<w:outlineLvl w:val="${paragraph.outlineLevel}"/>`);
|
|
@@ -93,7 +93,7 @@ function buildSpacingXml(s: ParagraphSpacing | undefined): string {
|
|
|
93
93
|
return attrs.length > 0 ? `<w:spacing ${attrs.join(" ")}/>` : "";
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function buildFrameXml(f: FrameProperties | undefined): string {
|
|
96
|
+
export function buildFrameXml(f: FrameProperties | undefined): string {
|
|
97
97
|
if (!f) return "";
|
|
98
98
|
// Prefer parsed rawXml when available — preserves extension attributes
|
|
99
99
|
// (`w14:*`, `w15:*`, `mc:Ignorable`) that the typed field set doesn't
|
|
@@ -135,8 +135,8 @@ function createParagraphPropertyChangeReplacement(
|
|
|
135
135
|
}
|
|
136
136
|
const paragraphBoundary = findParagraphBoundaryForRange(
|
|
137
137
|
boundaries,
|
|
138
|
-
revision.anchor.
|
|
139
|
-
revision.anchor.
|
|
138
|
+
revision.anchor.from,
|
|
139
|
+
revision.anchor.to,
|
|
140
140
|
);
|
|
141
141
|
if (!paragraphBoundary) {
|
|
142
142
|
return undefined;
|
|
@@ -168,14 +168,14 @@ function createRunPropertyChangeReplacement(
|
|
|
168
168
|
}
|
|
169
169
|
const paragraphBoundary = findParagraphBoundaryForRange(
|
|
170
170
|
boundaries,
|
|
171
|
-
revision.anchor.
|
|
172
|
-
revision.anchor.
|
|
171
|
+
revision.anchor.from,
|
|
172
|
+
revision.anchor.to,
|
|
173
173
|
);
|
|
174
174
|
if (!paragraphBoundary) {
|
|
175
175
|
return undefined;
|
|
176
176
|
}
|
|
177
|
-
const startIndex = paragraphBoundary.boundaries.get(revision.anchor.
|
|
178
|
-
const endIndex = paragraphBoundary.boundaries.get(revision.anchor.
|
|
177
|
+
const startIndex = paragraphBoundary.boundaries.get(revision.anchor.from);
|
|
178
|
+
const endIndex = paragraphBoundary.boundaries.get(revision.anchor.to);
|
|
179
179
|
if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
|
|
180
180
|
return undefined;
|
|
181
181
|
}
|
|
@@ -245,13 +245,13 @@ function createRangeRevisionReplacement(
|
|
|
245
245
|
if (anchor.kind !== "range") {
|
|
246
246
|
return undefined;
|
|
247
247
|
}
|
|
248
|
-
const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.
|
|
248
|
+
const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.from, anchor.to);
|
|
249
249
|
if (!paragraphBoundary) {
|
|
250
250
|
return undefined;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
const startIndex = paragraphBoundary.boundaries.get(anchor.
|
|
254
|
-
const endIndex = paragraphBoundary.boundaries.get(anchor.
|
|
253
|
+
const startIndex = paragraphBoundary.boundaries.get(anchor.from);
|
|
254
|
+
const endIndex = paragraphBoundary.boundaries.get(anchor.to);
|
|
255
255
|
if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
|
|
256
256
|
return undefined;
|
|
257
257
|
}
|
|
@@ -340,7 +340,7 @@ function findParagraphBoundaryForAnchor(
|
|
|
340
340
|
boundaries: readonly RevisionParagraphBoundary[],
|
|
341
341
|
revision: RevisionRecord,
|
|
342
342
|
): RevisionParagraphBoundary | undefined {
|
|
343
|
-
const anchor = revision.anchor.kind === "range" ? revision.anchor.
|
|
343
|
+
const anchor = revision.anchor.kind === "range" ? revision.anchor.from : undefined;
|
|
344
344
|
if (anchor === undefined) {
|
|
345
345
|
return undefined;
|
|
346
346
|
}
|
|
@@ -35,8 +35,8 @@ function collectSplitPositions(
|
|
|
35
35
|
continue;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
positions.add(thread.anchor.
|
|
39
|
-
positions.add(thread.anchor.
|
|
38
|
+
positions.add(thread.anchor.from);
|
|
39
|
+
positions.add(thread.anchor.to);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
for (const revision of revisions) {
|
|
@@ -51,8 +51,8 @@ function collectSplitPositions(
|
|
|
51
51
|
continue;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
positions.add(revision.anchor.
|
|
55
|
-
positions.add(revision.anchor.
|
|
54
|
+
positions.add(revision.anchor.from);
|
|
55
|
+
positions.add(revision.anchor.to);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
return positions;
|
|
@@ -200,8 +200,8 @@ function collectSplitPoints(
|
|
|
200
200
|
): number[] {
|
|
201
201
|
const points = new Set<number>();
|
|
202
202
|
for (const revision of revisions) {
|
|
203
|
-
const from = revision.anchor.
|
|
204
|
-
const to = revision.anchor.
|
|
203
|
+
const from = revision.anchor.from;
|
|
204
|
+
const to = revision.anchor.to;
|
|
205
205
|
if (from > start && from < end) {
|
|
206
206
|
points.add(from);
|
|
207
207
|
}
|