@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
|
@@ -264,6 +264,7 @@ function normalizeParagraph(
|
|
|
264
264
|
...(paragraph.suppressLineNumbers !== undefined
|
|
265
265
|
? { suppressLineNumbers: paragraph.suppressLineNumbers }
|
|
266
266
|
: {}),
|
|
267
|
+
...(paragraph.frameProperties ? { frameProperties: paragraph.frameProperties } : {}),
|
|
267
268
|
// A.7: preserve w14:paraId / w14:textId across import → export so
|
|
268
269
|
// downstream tools that diff documents by paragraph id stay stable.
|
|
269
270
|
...(paragraph.wordExtensionIds
|
|
@@ -715,9 +716,30 @@ function registerComplexPreviewMedia(
|
|
|
715
716
|
function normalizeHyperlink(node: ParsedHyperlinkNode): {
|
|
716
717
|
type: "hyperlink";
|
|
717
718
|
href: string;
|
|
718
|
-
children: Array<
|
|
719
|
+
children: Array<
|
|
720
|
+
| TextNode
|
|
721
|
+
| { type: "hard_break" }
|
|
722
|
+
| { type: "column_break" }
|
|
723
|
+
| { type: "page_break" }
|
|
724
|
+
| { type: "tab" }
|
|
725
|
+
| { type: "symbol"; char: string; font?: string; marks?: TextMark[] }
|
|
726
|
+
>;
|
|
719
727
|
} {
|
|
720
|
-
|
|
728
|
+
// Canonical `HyperlinkNode.children` accepts the full inline-leaf set
|
|
729
|
+
// (TextNode | HardBreakNode | ColumnBreakNode | PageBreakNode | TabNode |
|
|
730
|
+
// SymbolNode). Matching the canonical shape here keeps rare
|
|
731
|
+
// hyperlink-inside-break patterns (a link spanning a column or page
|
|
732
|
+
// break in Word's output) from silently dropping at the normalize step —
|
|
733
|
+
// same class of drop that `coord-04 §1.19.b` fixed one level up in
|
|
734
|
+
// `normalizeInlineChildren`.
|
|
735
|
+
const children: Array<
|
|
736
|
+
| TextNode
|
|
737
|
+
| { type: "hard_break" }
|
|
738
|
+
| { type: "column_break" }
|
|
739
|
+
| { type: "page_break" }
|
|
740
|
+
| { type: "tab" }
|
|
741
|
+
| { type: "symbol"; char: string; font?: string; marks?: TextMark[] }
|
|
742
|
+
> = [];
|
|
721
743
|
|
|
722
744
|
for (const child of node.children) {
|
|
723
745
|
switch (child.type) {
|
|
@@ -743,6 +765,20 @@ function normalizeHyperlink(node: ParsedHyperlinkNode): {
|
|
|
743
765
|
case "hard_break":
|
|
744
766
|
children.push({ type: "hard_break" });
|
|
745
767
|
break;
|
|
768
|
+
case "column_break":
|
|
769
|
+
children.push({ type: "column_break" });
|
|
770
|
+
break;
|
|
771
|
+
case "page_break":
|
|
772
|
+
children.push({ type: "page_break" });
|
|
773
|
+
break;
|
|
774
|
+
case "symbol":
|
|
775
|
+
children.push({
|
|
776
|
+
type: "symbol",
|
|
777
|
+
char: child.char,
|
|
778
|
+
...(child.font ? { font: child.font } : {}),
|
|
779
|
+
...(child.marks && child.marks.length > 0 ? { marks: child.marks } : {}),
|
|
780
|
+
});
|
|
781
|
+
break;
|
|
746
782
|
}
|
|
747
783
|
}
|
|
748
784
|
|
|
@@ -652,9 +652,9 @@ function extractNodeText(node: XmlNode): string {
|
|
|
652
652
|
|
|
653
653
|
function compareThreadsByAnchor(left: CommentThread, right: CommentThread): number {
|
|
654
654
|
const leftStart =
|
|
655
|
-
left.anchor.kind === "range" ? left.anchor.
|
|
655
|
+
left.anchor.kind === "range" ? left.anchor.from : Number.MAX_SAFE_INTEGER;
|
|
656
656
|
const rightStart =
|
|
657
|
-
right.anchor.kind === "range" ? right.anchor.
|
|
657
|
+
right.anchor.kind === "range" ? right.anchor.from : Number.MAX_SAFE_INTEGER;
|
|
658
658
|
|
|
659
659
|
if (leftStart !== rightStart) {
|
|
660
660
|
return leftStart - rightStart;
|
|
@@ -328,6 +328,37 @@ function parseParagraphElement(
|
|
|
328
328
|
activeComplexField = null;
|
|
329
329
|
}
|
|
330
330
|
pushFieldNode(children, child, "simple");
|
|
331
|
+
} else if (name === "sdt") {
|
|
332
|
+
// coord-11 §22 — structured-document-tag wrapping run-level content
|
|
333
|
+
// inside a header/footer paragraph. Word commonly uses these to
|
|
334
|
+
// bundle the page-number field + decorative drawings (e.g. CCEP's
|
|
335
|
+
// footer "Copyright CCEP STRICTLY CONFIDENTIAL" red rectangle +
|
|
336
|
+
// "Page N" label both sit inside one `<w:sdt>` in footer1.xml).
|
|
337
|
+
// Without this case the sdt was silently dropped at the paragraph
|
|
338
|
+
// walker and every run it carried — including WPS shapes bearing
|
|
339
|
+
// the brand-strip text — never reached the canonical tree.
|
|
340
|
+
// Treat `<w:sdtContent>` as a transparent wrapper and re-process
|
|
341
|
+
// its `<w:r>` / `<w:hyperlink>` / `<w:sdt>` children as if they
|
|
342
|
+
// were direct paragraph children.
|
|
343
|
+
const sdtContent = findChildElementOptional(child, "sdtContent");
|
|
344
|
+
if (sdtContent) {
|
|
345
|
+
for (const grandchild of sdtContent.children) {
|
|
346
|
+
if (grandchild.type !== "element") continue;
|
|
347
|
+
const gname = localName(grandchild.name);
|
|
348
|
+
if (gname === "r") {
|
|
349
|
+
activeComplexField = appendRunNodes(grandchild, children, activeComplexField, sourceXml, opts);
|
|
350
|
+
} else if (gname === "hyperlink") {
|
|
351
|
+
children.push(parseHyperlinkElement(grandchild, opts));
|
|
352
|
+
} else if (gname === "bookmarkStart" || gname === "bookmarkEnd") {
|
|
353
|
+
children.push(parseBookmarkElement(grandchild));
|
|
354
|
+
} else if (gname === "fldSimple") {
|
|
355
|
+
pushFieldNode(children, grandchild, "simple");
|
|
356
|
+
}
|
|
357
|
+
// Nested sdt / other elements ignored — deeper nesting is rare
|
|
358
|
+
// enough that opaque round-trip via the block-level sdt parser
|
|
359
|
+
// handles it if it matters.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
331
362
|
}
|
|
332
363
|
}
|
|
333
364
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BorderSpec,
|
|
3
3
|
CellShading,
|
|
4
|
+
FrameProperties,
|
|
4
5
|
TextMark,
|
|
5
6
|
ParagraphBorders,
|
|
6
7
|
ParagraphShading,
|
|
@@ -39,6 +40,7 @@ import { parseComplexContentXml, type ChartPartLookup } from "./parse-complex-co
|
|
|
39
40
|
import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
|
|
40
41
|
import { parseObject } from "./parse-object.ts";
|
|
41
42
|
import { parseDrawingFrame } from "./parse-drawing.ts";
|
|
43
|
+
import { readFrameProperties } from "./parse-paragraph-formatting.ts";
|
|
42
44
|
import { classifyFieldInstruction } from "./parse-fields.ts";
|
|
43
45
|
import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
|
|
44
46
|
import { resolveHighlightColor } from "./highlight-colors.ts";
|
|
@@ -217,6 +219,41 @@ function captureGrabBagFromContainer(
|
|
|
217
219
|
export interface ParsedMainDocument {
|
|
218
220
|
blocks: ParsedBlockNode[];
|
|
219
221
|
finalSectionProperties?: SectionProperties;
|
|
222
|
+
/**
|
|
223
|
+
* Aggregate count of cosmetic markers stripped during parse (see
|
|
224
|
+
* {@link ParseMainDocumentOptions.stripCosmeticMarkers}). Keyed by
|
|
225
|
+
* local element name (e.g. `lastRenderedPageBreak`). Absent when no
|
|
226
|
+
* markers were stripped.
|
|
227
|
+
*/
|
|
228
|
+
skippedCosmeticMarkerCounts?: Readonly<Record<string, number>>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Cosmetic markers that Word re-inserts on reopen and that carry no
|
|
233
|
+
* contract semantics. Stripping them at parse time unblocks
|
|
234
|
+
* `replaceText` on ranges that today cross them as `opaque_inline`
|
|
235
|
+
* boundaries. See `docs/architecture/cosmetic-marker-strip.md`.
|
|
236
|
+
*
|
|
237
|
+
* This is the Phase 1 set. Bookmark-pair stripping (with reference
|
|
238
|
+
* scan) is Phase 2.
|
|
239
|
+
*/
|
|
240
|
+
export const COSMETIC_MARKER_ELEMENT_NAMES: ReadonlySet<string> = new Set([
|
|
241
|
+
"lastRenderedPageBreak",
|
|
242
|
+
"proofErr",
|
|
243
|
+
"noBreakHyphen",
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
export interface ParseMainDocumentOptions {
|
|
247
|
+
/**
|
|
248
|
+
* When `true` (the default), drops `<w:lastRenderedPageBreak/>`,
|
|
249
|
+
* `<w:proofErr/>`, and `<w:noBreakHyphen/>` during the parse walk
|
|
250
|
+
* instead of emitting them as `opaque_inline` nodes. Counts are
|
|
251
|
+
* reported on {@link ParsedMainDocument.skippedCosmeticMarkerCounts}.
|
|
252
|
+
*
|
|
253
|
+
* Set to `false` to preserve the pre-strip behavior exactly — every
|
|
254
|
+
* cosmetic marker becomes an `opaque_inline` with its source XML.
|
|
255
|
+
*/
|
|
256
|
+
stripCosmeticMarkers?: boolean;
|
|
220
257
|
}
|
|
221
258
|
|
|
222
259
|
export type ParsedBlockNode =
|
|
@@ -256,6 +293,15 @@ export interface ParsedParagraphNode {
|
|
|
256
293
|
bidi?: boolean;
|
|
257
294
|
suppressLineNumbers?: boolean;
|
|
258
295
|
cnfStyle?: string;
|
|
296
|
+
/**
|
|
297
|
+
* `<w:framePr>` declared directly on the paragraph's own `<w:pPr>`.
|
|
298
|
+
* Coord-04 §1.19.d step 2 (inline path). The style-cascade path
|
|
299
|
+
* flows through `CanonicalParagraphFormatting.frameProperties` on
|
|
300
|
+
* the style side; this slot captures the direct-override path so
|
|
301
|
+
* L02 `ParagraphNode.frameProperties` (added 2026-04-24 `4b3ea0b2`)
|
|
302
|
+
* can reach its canonical shape.
|
|
303
|
+
*/
|
|
304
|
+
frameProperties?: FrameProperties;
|
|
259
305
|
/** A.7: preserved w14 extension ids (paraId/textId). */
|
|
260
306
|
wordExtensionIds?: {
|
|
261
307
|
paraId?: string;
|
|
@@ -656,24 +702,61 @@ export function setActiveParseTelemetryBus(bus: ParseTelemetryBus | undefined):
|
|
|
656
702
|
activeParseTelemetryBus = bus;
|
|
657
703
|
}
|
|
658
704
|
|
|
705
|
+
/**
|
|
706
|
+
* Request-scoped cosmetic-marker strip context. Set by
|
|
707
|
+
* `parseMainDocumentXml` for the duration of a single parse; read at
|
|
708
|
+
* the four emission sites in `parseBodyChild` / `parseRun` /
|
|
709
|
+
* `parseRunContentOnly` / `parseRevisionContainer`. Using a module
|
|
710
|
+
* variable instead of threading the flag through ~15 intermediate
|
|
711
|
+
* function signatures keeps the call sites readable; the try/finally
|
|
712
|
+
* in the entry point ensures the variable never leaks across calls.
|
|
713
|
+
*
|
|
714
|
+
* Re-entrancy invariant matches `activeChartPartLookup` above.
|
|
715
|
+
*/
|
|
716
|
+
interface CosmeticStripContext {
|
|
717
|
+
readonly strip: boolean;
|
|
718
|
+
readonly counts: Record<string, number>;
|
|
719
|
+
}
|
|
720
|
+
let activeCosmeticStripContext: CosmeticStripContext | null = null;
|
|
721
|
+
|
|
722
|
+
function noteStrippedCosmeticMarker(tag: string): void {
|
|
723
|
+
if (!activeCosmeticStripContext) return;
|
|
724
|
+
activeCosmeticStripContext.counts[tag] =
|
|
725
|
+
(activeCosmeticStripContext.counts[tag] ?? 0) + 1;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function shouldStripCosmeticMarker(): boolean {
|
|
729
|
+
return activeCosmeticStripContext?.strip === true;
|
|
730
|
+
}
|
|
731
|
+
|
|
659
732
|
export function parseMainDocumentXml(
|
|
660
733
|
xml: string,
|
|
661
734
|
relationships: readonly OpcRelationship[] = [],
|
|
662
735
|
mediaParts: ReadonlyMap<string, InlineMediaPart> = new Map(),
|
|
663
736
|
sourcePartPath = "/word/document.xml",
|
|
664
737
|
chartPartLookup?: ChartPartLookup,
|
|
738
|
+
parseOptions: ParseMainDocumentOptions = {},
|
|
665
739
|
): ParsedMainDocument {
|
|
666
740
|
activeChartPartLookup = chartPartLookup;
|
|
741
|
+
const stripContext: CosmeticStripContext = {
|
|
742
|
+
strip: parseOptions.stripCosmeticMarkers !== false,
|
|
743
|
+
counts: Object.create(null) as Record<string, number>,
|
|
744
|
+
};
|
|
745
|
+
activeCosmeticStripContext = stripContext;
|
|
667
746
|
const bus = activeParseTelemetryBus;
|
|
668
747
|
const started = bus?.isEnabled("parse") ? performanceNow() : 0;
|
|
669
748
|
try {
|
|
670
749
|
const result = parseMainDocumentXmlInner(xml, relationships, mediaParts, sourcePartPath);
|
|
750
|
+
if (Object.keys(stripContext.counts).length > 0) {
|
|
751
|
+
result.skippedCosmeticMarkerCounts = Object.freeze({ ...stripContext.counts });
|
|
752
|
+
}
|
|
671
753
|
if (bus?.isEnabled("parse")) {
|
|
672
754
|
emitParseSummary(bus, result, sourcePartPath, performanceNow() - started);
|
|
673
755
|
}
|
|
674
756
|
return result;
|
|
675
757
|
} finally {
|
|
676
758
|
activeChartPartLookup = undefined;
|
|
759
|
+
activeCosmeticStripContext = null;
|
|
677
760
|
}
|
|
678
761
|
}
|
|
679
762
|
|
|
@@ -704,6 +787,13 @@ function emitParseSummary(
|
|
|
704
787
|
blockCount: result.blocks.length,
|
|
705
788
|
blockKindCounts: counts,
|
|
706
789
|
ms,
|
|
790
|
+
// Strip counts are surfaced here (telemetry-only) rather than as a
|
|
791
|
+
// warning on `diagnostics.warnings` — the markers carry no
|
|
792
|
+
// contract semantics and surfacing them in the user-visible
|
|
793
|
+
// warnings feed would be noise. Available to debug UX / tests via
|
|
794
|
+
// the `parse` channel; absent when the feature is disabled or no
|
|
795
|
+
// markers were stripped.
|
|
796
|
+
skippedCosmeticMarkerCounts: result.skippedCosmeticMarkerCounts,
|
|
707
797
|
},
|
|
708
798
|
});
|
|
709
799
|
}
|
|
@@ -1004,6 +1094,7 @@ function parseBodyChild(
|
|
|
1004
1094
|
let bidi: ParsedParagraphNode["bidi"];
|
|
1005
1095
|
let suppressLineNumbers: ParsedParagraphNode["suppressLineNumbers"];
|
|
1006
1096
|
let cnfStyle: ParsedParagraphNode["cnfStyle"];
|
|
1097
|
+
let frameProperties: ParsedParagraphNode["frameProperties"];
|
|
1007
1098
|
let sectionProperties: SectionProperties | undefined;
|
|
1008
1099
|
let sectionPropertiesXml: string | undefined;
|
|
1009
1100
|
let paragraphSupported = true;
|
|
@@ -1050,6 +1141,12 @@ function parseBodyChild(
|
|
|
1050
1141
|
bidi = readOnOffParagraphProperty(child, "bidi");
|
|
1051
1142
|
suppressLineNumbers = readOnOffParagraphProperty(child, "suppressLineNumbers");
|
|
1052
1143
|
cnfStyle = readParagraphCnfStyle(child);
|
|
1144
|
+
{
|
|
1145
|
+
const framePrNode = child.children.find(
|
|
1146
|
+
(c): c is XmlElementNode => c.type === "element" && localName(c.name) === "framePr",
|
|
1147
|
+
);
|
|
1148
|
+
if (framePrNode) frameProperties = readFrameProperties(framePrNode);
|
|
1149
|
+
}
|
|
1053
1150
|
sectionProperties = readSectionPropertiesFromPPr(child);
|
|
1054
1151
|
sectionPropertiesXml = readSectionPropertiesXmlFromPPr(child, sourceXml);
|
|
1055
1152
|
paragraphSupported = paragraphSupported && supportsParagraphProperties(child);
|
|
@@ -1148,6 +1245,10 @@ function parseBodyChild(
|
|
|
1148
1245
|
flushActiveComplexField(children, () => {
|
|
1149
1246
|
activeComplexField = null;
|
|
1150
1247
|
}, activeComplexField);
|
|
1248
|
+
if (shouldStripCosmeticMarker()) {
|
|
1249
|
+
noteStrippedCosmeticMarker("proofErr");
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1151
1252
|
children.push({
|
|
1152
1253
|
type: "opaque_inline",
|
|
1153
1254
|
rawXml: sourceXml.slice(child.start, child.end),
|
|
@@ -1235,6 +1336,7 @@ function parseBodyChild(
|
|
|
1235
1336
|
...(bidi !== undefined ? { bidi } : {}),
|
|
1236
1337
|
...(suppressLineNumbers !== undefined ? { suppressLineNumbers } : {}),
|
|
1237
1338
|
...(cnfStyle ? { cnfStyle } : {}),
|
|
1339
|
+
...(frameProperties ? { frameProperties } : {}),
|
|
1238
1340
|
...(wordExtensionIds ? { wordExtensionIds } : {}),
|
|
1239
1341
|
...(sectionProperties ? { sectionProperties } : {}),
|
|
1240
1342
|
...(sectionPropertiesXml ? { sectionPropertiesXml } : {}),
|
|
@@ -2584,6 +2686,11 @@ function parseRun(
|
|
|
2584
2686
|
}
|
|
2585
2687
|
case "lastRenderedPageBreak":
|
|
2586
2688
|
case "proofErr":
|
|
2689
|
+
case "noBreakHyphen":
|
|
2690
|
+
if (shouldStripCosmeticMarker()) {
|
|
2691
|
+
noteStrippedCosmeticMarker(localName(child.name));
|
|
2692
|
+
break;
|
|
2693
|
+
}
|
|
2587
2694
|
result.push({
|
|
2588
2695
|
type: "opaque_inline",
|
|
2589
2696
|
rawXml: sourceXml.slice(child.start, child.end),
|
|
@@ -2657,12 +2764,23 @@ function parseRevisionContainer(
|
|
|
2657
2764
|
result.push(hyperlink);
|
|
2658
2765
|
break;
|
|
2659
2766
|
}
|
|
2767
|
+
case "proofErr":
|
|
2768
|
+
case "lastRenderedPageBreak":
|
|
2769
|
+
case "noBreakHyphen":
|
|
2770
|
+
if (shouldStripCosmeticMarker()) {
|
|
2771
|
+
noteStrippedCosmeticMarker(localName(child.name));
|
|
2772
|
+
break;
|
|
2773
|
+
}
|
|
2774
|
+
return [
|
|
2775
|
+
{
|
|
2776
|
+
type: "opaque_inline",
|
|
2777
|
+
rawXml: sourceXml.slice(node.start, node.end),
|
|
2778
|
+
},
|
|
2779
|
+
];
|
|
2660
2780
|
case "commentRangeStart":
|
|
2661
2781
|
case "commentRangeEnd":
|
|
2662
2782
|
case "bookmarkStart":
|
|
2663
2783
|
case "bookmarkEnd":
|
|
2664
|
-
case "proofErr":
|
|
2665
|
-
case "lastRenderedPageBreak":
|
|
2666
2784
|
return [
|
|
2667
2785
|
{
|
|
2668
2786
|
type: "opaque_inline",
|
|
@@ -2835,10 +2953,17 @@ function parseRunContentOnly(
|
|
|
2835
2953
|
case "commentReference":
|
|
2836
2954
|
case "lastRenderedPageBreak":
|
|
2837
2955
|
case "proofErr":
|
|
2956
|
+
case "noBreakHyphen": {
|
|
2957
|
+
const tag = localName(child.name);
|
|
2958
|
+
if (shouldStripCosmeticMarker() && tag !== "commentReference") {
|
|
2959
|
+
noteStrippedCosmeticMarker(tag);
|
|
2960
|
+
break;
|
|
2961
|
+
}
|
|
2838
2962
|
if (options.preserveUnsupportedReviewMarkup) {
|
|
2839
2963
|
return { nodes: [], supported: false };
|
|
2840
2964
|
}
|
|
2841
2965
|
break;
|
|
2966
|
+
}
|
|
2842
2967
|
default:
|
|
2843
2968
|
return { nodes: [], supported: false };
|
|
2844
2969
|
}
|
|
@@ -204,7 +204,7 @@ function readShading(node: XmlElementNode): ParagraphShading | undefined {
|
|
|
204
204
|
* The typed attributes cover the CCEP cases we've seen (2-column inset
|
|
205
205
|
* text frames, drop-caps); extension attrs are rare in that corpus.
|
|
206
206
|
*/
|
|
207
|
-
function readFrameProperties(node: XmlElementNode): FrameProperties | undefined {
|
|
207
|
+
export function readFrameProperties(node: XmlElementNode): FrameProperties | undefined {
|
|
208
208
|
const out: FrameProperties = {};
|
|
209
209
|
const width = readIntAttr(node, "w:w");
|
|
210
210
|
if (width !== undefined) out.widthTwips = width;
|
package/src/model/anchor.ts
CHANGED
|
@@ -25,9 +25,17 @@ export interface BoundaryAssoc {
|
|
|
25
25
|
end: Assoc;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Range anchor — flat shape `{ kind: "range", from, to, assoc }` after the
|
|
30
|
+
* 2026-04-23 flat-wins collapse. Prior to the collapse this carried a nested
|
|
31
|
+
* `{ range: DocRange, assoc }` shape. See `canonical-document.ts` CanonicalAnchor
|
|
32
|
+
* for the matching canonical shape + `repairCanonicalDocumentEnvelope` for the
|
|
33
|
+
* persisted-snapshot legacy-to-flat migration.
|
|
34
|
+
*/
|
|
28
35
|
export interface RangeAnchor {
|
|
29
36
|
kind: "range";
|
|
30
|
-
|
|
37
|
+
from: Position;
|
|
38
|
+
to: Position;
|
|
31
39
|
assoc: BoundaryAssoc;
|
|
32
40
|
}
|
|
33
41
|
|
|
@@ -2013,7 +2013,8 @@ export interface BoundaryAssoc {
|
|
|
2013
2013
|
export type CanonicalAnchor =
|
|
2014
2014
|
| {
|
|
2015
2015
|
kind: "range";
|
|
2016
|
-
|
|
2016
|
+
from: number;
|
|
2017
|
+
to: number;
|
|
2017
2018
|
assoc: BoundaryAssoc;
|
|
2018
2019
|
}
|
|
2019
2020
|
| {
|
|
@@ -2329,7 +2330,7 @@ export function repairCanonicalDocumentEnvelope(
|
|
|
2329
2330
|
review:
|
|
2330
2331
|
record.review === undefined
|
|
2331
2332
|
? base.review
|
|
2332
|
-
: (record.review as CanonicalDocument["review"]),
|
|
2333
|
+
: (migrateLegacyReviewAnchors(record.review) as CanonicalDocument["review"]),
|
|
2333
2334
|
preservation:
|
|
2334
2335
|
record.preservation === undefined
|
|
2335
2336
|
? base.preservation
|
|
@@ -2355,6 +2356,67 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
2355
2356
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2356
2357
|
}
|
|
2357
2358
|
|
|
2359
|
+
/**
|
|
2360
|
+
* Schema-compat shim for the 2026-04-23 flat-wins anchor collapse. Legacy
|
|
2361
|
+
* persisted snapshots carry range anchors as `{kind: "range", range: {from, to},
|
|
2362
|
+
* assoc}`. Rewrite those to the flat shape `{kind: "range", from, to, assoc}`
|
|
2363
|
+
* on rehydration so old snapshots continue to load.
|
|
2364
|
+
*/
|
|
2365
|
+
export function migrateLegacyReviewAnchors(review: unknown): unknown {
|
|
2366
|
+
if (!isPlainRecord(review)) {
|
|
2367
|
+
return review;
|
|
2368
|
+
}
|
|
2369
|
+
const migrated: Record<string, unknown> = { ...review };
|
|
2370
|
+
if (isPlainRecord(review.comments)) {
|
|
2371
|
+
migrated.comments = Object.fromEntries(
|
|
2372
|
+
Object.entries(review.comments).map(([id, record]) => [
|
|
2373
|
+
id,
|
|
2374
|
+
migrateReviewRecordAnchor(record),
|
|
2375
|
+
]),
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
if (isPlainRecord(review.revisions)) {
|
|
2379
|
+
migrated.revisions = Object.fromEntries(
|
|
2380
|
+
Object.entries(review.revisions).map(([id, record]) => [
|
|
2381
|
+
id,
|
|
2382
|
+
migrateReviewRecordAnchor(record),
|
|
2383
|
+
]),
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
return migrated;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function migrateReviewRecordAnchor(record: unknown): unknown {
|
|
2390
|
+
if (!isPlainRecord(record)) {
|
|
2391
|
+
return record;
|
|
2392
|
+
}
|
|
2393
|
+
const anchor = record.anchor;
|
|
2394
|
+
if (!isPlainRecord(anchor) || anchor.kind !== "range") {
|
|
2395
|
+
return record;
|
|
2396
|
+
}
|
|
2397
|
+
if (typeof anchor.from === "number" && typeof anchor.to === "number") {
|
|
2398
|
+
return record;
|
|
2399
|
+
}
|
|
2400
|
+
const nested = anchor.range;
|
|
2401
|
+
if (
|
|
2402
|
+
isPlainRecord(nested) &&
|
|
2403
|
+
typeof nested.from === "number" &&
|
|
2404
|
+
typeof nested.to === "number"
|
|
2405
|
+
) {
|
|
2406
|
+
const { range: _range, ...rest } = anchor;
|
|
2407
|
+
void _range;
|
|
2408
|
+
return {
|
|
2409
|
+
...record,
|
|
2410
|
+
anchor: {
|
|
2411
|
+
...rest,
|
|
2412
|
+
from: nested.from,
|
|
2413
|
+
to: nested.to,
|
|
2414
|
+
},
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
return record;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2358
2420
|
export function serializeCanonicalDocument(document: CanonicalDocument): string {
|
|
2359
2421
|
assertCanonicalDocument(document);
|
|
2360
2422
|
return stableStringify(document);
|
|
@@ -3507,7 +3569,18 @@ function validateAnchor(
|
|
|
3507
3569
|
}
|
|
3508
3570
|
|
|
3509
3571
|
if (kind === "range") {
|
|
3510
|
-
|
|
3572
|
+
if (typeof record.from !== "number") {
|
|
3573
|
+
issues.push({
|
|
3574
|
+
path: `${path}.from`,
|
|
3575
|
+
message: "range anchor from must be a number.",
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
if (typeof record.to !== "number") {
|
|
3579
|
+
issues.push({
|
|
3580
|
+
path: `${path}.to`,
|
|
3581
|
+
message: "range anchor to must be a number.",
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3511
3584
|
validateBoundaryAssoc(record.assoc, `${path}.assoc`, issues);
|
|
3512
3585
|
} else if (kind === "node") {
|
|
3513
3586
|
if (typeof record.at !== "number") {
|
|
@@ -170,6 +170,15 @@ export function describeStructuredWrapperBlock(
|
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
172
|
if (block.properties.sdtType === "docPartObj") {
|
|
173
|
+
// coord-02 §11 P0 — a Template content control that wraps a
|
|
174
|
+
// drawing_frame (CCEP header logos, cover hero photos) must stay
|
|
175
|
+
// recursable so surface-projection emits the inner image segment.
|
|
176
|
+
// Collapsing to opaque_block here silently drops every image
|
|
177
|
+
// inside a docPartObj SDT. Only collapse when the content is
|
|
178
|
+
// genuinely wrapper-heavy with no user-visible media.
|
|
179
|
+
if (sdtContainsDrawingFrame(block)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
173
182
|
return {
|
|
174
183
|
featureKey: "content-controls",
|
|
175
184
|
label: "Template content control",
|
|
@@ -220,6 +229,21 @@ function createDetail(fragment: OpaqueFragmentRecord): string {
|
|
|
220
229
|
: "Preserved whole-unit to keep unsupported OOXML intact.";
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
function sdtContainsDrawingFrame(block: Extract<BlockNode, { type: "sdt" }>): boolean {
|
|
233
|
+
const visit = (node: unknown): boolean => {
|
|
234
|
+
if (!node || typeof node !== "object") return false;
|
|
235
|
+
const n = node as { type?: string; children?: readonly unknown[] };
|
|
236
|
+
if (n.type === "drawing_frame") return true;
|
|
237
|
+
if (Array.isArray(n.children)) {
|
|
238
|
+
for (const child of n.children) {
|
|
239
|
+
if (visit(child)) return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
};
|
|
244
|
+
return block.children.some(visit);
|
|
245
|
+
}
|
|
246
|
+
|
|
223
247
|
function isTocContentControl(block: Extract<BlockNode, { type: "sdt" }>): boolean {
|
|
224
248
|
const searchText = [
|
|
225
249
|
block.properties.alias,
|
|
@@ -98,7 +98,7 @@ function normalizeCommentAnchor(
|
|
|
98
98
|
|
|
99
99
|
if (
|
|
100
100
|
mappedAnchor.kind === "range" &&
|
|
101
|
-
!rangeStaysWithinCommentableStory(nextContent, mappedAnchor.
|
|
101
|
+
!rangeStaysWithinCommentableStory(nextContent, { from: mappedAnchor.from, to: mappedAnchor.to })
|
|
102
102
|
) {
|
|
103
103
|
return detachReviewAnchor(previousRange, "invalidatedByStructureChange");
|
|
104
104
|
}
|
|
@@ -153,8 +153,8 @@ export function applyRevisionAction(
|
|
|
153
153
|
|
|
154
154
|
const story = parseTextStory(options.document.content);
|
|
155
155
|
const range = normalizeRange(
|
|
156
|
-
revision.anchor.
|
|
157
|
-
revision.anchor.
|
|
156
|
+
revision.anchor.from,
|
|
157
|
+
revision.anchor.to,
|
|
158
158
|
);
|
|
159
159
|
|
|
160
160
|
if (range.to > story.size) {
|
|
@@ -811,8 +811,8 @@ function resolveParagraphMarkDeletionRange(
|
|
|
811
811
|
|
|
812
812
|
const paragraphs = mapParagraphRanges(story);
|
|
813
813
|
const anchorPosition = normalizeRange(
|
|
814
|
-
revision.anchor.
|
|
815
|
-
revision.anchor.
|
|
814
|
+
revision.anchor.from,
|
|
815
|
+
revision.anchor.to,
|
|
816
816
|
).from;
|
|
817
817
|
const paragraph = paragraphs.find(
|
|
818
818
|
(candidate) =>
|
|
@@ -96,7 +96,7 @@ export function collectScopeTagTouches(
|
|
|
96
96
|
|
|
97
97
|
function anchorRange(anchor: CanonicalAnchor): { from: number; to: number } {
|
|
98
98
|
if (anchor.kind === "range") {
|
|
99
|
-
return { from: anchor.
|
|
99
|
+
return { from: anchor.from, to: anchor.to };
|
|
100
100
|
}
|
|
101
101
|
if (anchor.kind === "node") {
|
|
102
102
|
return { from: anchor.at, to: anchor.at };
|
|
@@ -88,10 +88,10 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
88
88
|
|
|
89
89
|
if (active.kind === "range") {
|
|
90
90
|
const directionForward = selection.anchor <= selection.head;
|
|
91
|
-
const isCollapsed = active.
|
|
91
|
+
const isCollapsed = active.from === active.to;
|
|
92
92
|
|
|
93
93
|
if (isCollapsed) {
|
|
94
|
-
const collapsedAt = mapPosition(active.
|
|
94
|
+
const collapsedAt = mapPosition(active.from, 1, mapping).position;
|
|
95
95
|
return {
|
|
96
96
|
anchor: collapsedAt,
|
|
97
97
|
head: collapsedAt,
|
|
@@ -107,7 +107,7 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
107
107
|
const mapped = mapAnchor(active, mapping);
|
|
108
108
|
|
|
109
109
|
if (mapped.kind === "range") {
|
|
110
|
-
const { from, to } = mapped
|
|
110
|
+
const { from, to } = mapped;
|
|
111
111
|
return {
|
|
112
112
|
anchor: directionForward ? from : to,
|
|
113
113
|
head: directionForward ? to : from,
|
|
@@ -122,7 +122,7 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
122
122
|
// positions that land inside a deleted span to `step.from +
|
|
123
123
|
// insertSize`, which is the correct logical "where the selection
|
|
124
124
|
// used to start" position.
|
|
125
|
-
const collapsedAt = mapPosition(active.
|
|
125
|
+
const collapsedAt = mapPosition(active.from, 1, mapping).position;
|
|
126
126
|
return {
|
|
127
127
|
anchor: collapsedAt,
|
|
128
128
|
head: collapsedAt,
|
|
@@ -147,9 +147,9 @@ export function mapLocalSelectionOnRemoteReplay(
|
|
|
147
147
|
}
|
|
148
148
|
if (mapped.kind === "range") {
|
|
149
149
|
return {
|
|
150
|
-
anchor: mapped.
|
|
151
|
-
head: mapped.
|
|
152
|
-
isCollapsed: mapped.
|
|
150
|
+
anchor: mapped.from,
|
|
151
|
+
head: mapped.to,
|
|
152
|
+
isCollapsed: mapped.from === mapped.to,
|
|
153
153
|
activeRange: mapped,
|
|
154
154
|
};
|
|
155
155
|
}
|