@beyondwork/docx-react-component 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -0,0 +1,99 @@
1
+ /**
2
+ * L7 Phase 2.5 Plan B B.7 — shallow structural probe for `word/document.xml`.
3
+ *
4
+ * Emits the ordered (kind, blockId) list for top-level body children without
5
+ * building the canonical-document model. Used by `customxml-probe` to verify
6
+ * that a cached laycache envelope's `structuralHash` still matches the
7
+ * document the envelope was written against.
8
+ *
9
+ * **Correctness requirement.** The output must match what
10
+ * `surface-projection.ts:createSurfaceBlock` emits when walking a
11
+ * canonical document produced by the full parse pipeline. Specifically:
12
+ * - Paragraph blockIds use a GLOBAL counter incremented on every
13
+ * `<w:p>` encountered ANYWHERE in the tree (top-level or nested
14
+ * inside a table cell). The top-level paragraph's blockId is
15
+ * `paragraph-${counter_at_time_of_encounter}`.
16
+ * - Table blockIds use a GLOBAL counter incremented on every
17
+ * `<w:tbl>` at any depth.
18
+ * - Other top-level elements (`<w:sdt>`, `<w:altChunk>`, `<w:sectPr>`)
19
+ * are NOT emitted by this probe.
20
+ *
21
+ * **Known limitation (2026-04-19 shipping state).** The full parse
22
+ * promotes certain `<w:p>` elements to `opaque_block` based on their
23
+ * content — e.g. paragraphs containing structured content controls
24
+ * (`<w:sdt>`), floating drawings (`<w:drawing>` with `<wp:anchor>`),
25
+ * or `<mc:AlternateContent>` markup-compat wrappers. The shallow probe
26
+ * cannot detect these patterns without a deeper walk, so it counts such
27
+ * paragraphs as plain `paragraph` blocks. On docs where this triggers
28
+ * (~20% of F-series fixtures; 2 of 3 CCEP templates), the probe's
29
+ * structural hash diverges from the envelope's → cache is rejected →
30
+ * safe fallback to the full-parse open path. Plan B warm-cache opt-in
31
+ * is "clean docs only" under this probe.
32
+ *
33
+ * Future improvement: refine the probe to detect `<w:sdt>`,
34
+ * `<w:drawing w:anchor>`, and `<mc:AlternateContent>` inside top-level
35
+ * paragraphs and classify them accordingly. Deferred unless real-world
36
+ * hit rates prove insufficient.
37
+ *
38
+ * **Cost budget.** <30 ms on extra-large CCEP (~2.7 MB document.xml).
39
+ * Single regex walk, O(bytes). No DOM, no full XML parse.
40
+ *
41
+ * **Fidelity gate.** `test/io/parse-block-structure.test.ts` compares
42
+ * probe output against full-parse blockIds on representative fixtures
43
+ * (F01/F02/F05/F48 for paragraph + table patterns, plus a clean CCEP
44
+ * template). Docs with opaque-promoting features are covered by a
45
+ * separate "safe fallback" test rather than the strict match.
46
+ */
47
+
48
+ export interface BlockStructureProbe {
49
+ readonly kind: "paragraph" | "table";
50
+ readonly blockId: string;
51
+ }
52
+
53
+ const BODY_RE = /<w:body\b[^>]*>([\s\S]*?)<\/w:body>/u;
54
+ const TAG_RE = /<(\/?)w:(p|tbl)\b[^>]*?(\/?)>/gu;
55
+
56
+ export function parseBlockStructure(documentXml: string): BlockStructureProbe[] {
57
+ const bodyMatch = BODY_RE.exec(documentXml);
58
+ if (!bodyMatch) return [];
59
+ const body = bodyMatch[1] ?? "";
60
+
61
+ const results: BlockStructureProbe[] = [];
62
+ let paragraphCounter = 0;
63
+ let tableCounter = 0;
64
+ let depth = 0;
65
+
66
+ TAG_RE.lastIndex = 0;
67
+ let match: RegExpExecArray | null;
68
+ while ((match = TAG_RE.exec(body)) !== null) {
69
+ const closing = match[1] === "/";
70
+ const tag = match[2] as "p" | "tbl";
71
+ const selfClose = match[3] === "/";
72
+
73
+ if (closing) {
74
+ depth -= 1;
75
+ continue;
76
+ }
77
+
78
+ if (depth === 0) {
79
+ if (tag === "p") {
80
+ results.push({ kind: "paragraph", blockId: `paragraph-${paragraphCounter}` });
81
+ } else {
82
+ results.push({ kind: "table", blockId: `table-${tableCounter}` });
83
+ }
84
+ }
85
+
86
+ // Global counter bumps (all depths, including top-level).
87
+ if (tag === "p") {
88
+ paragraphCounter += 1;
89
+ } else {
90
+ tableCounter += 1;
91
+ }
92
+
93
+ if (!selfClose) {
94
+ depth += 1;
95
+ }
96
+ }
97
+
98
+ return results;
99
+ }
@@ -11,12 +11,22 @@
11
11
 
12
12
  import type { OpcRelationship } from "./part-manifest.ts";
13
13
  import { normalizePartPath, resolveRelationshipTarget } from "./part-manifest.ts";
14
+ import { parseChartSpace } from "./chart/parse-chart-space.ts";
15
+ import type { ChartModel } from "./chart/types.ts";
14
16
 
15
17
  export interface InlineMediaPart {
16
18
  path: string;
17
19
  contentType: string;
18
20
  }
19
21
 
22
+ /**
23
+ * Callback that resolves a chart relationship id (the `r:id` on a
24
+ * `<c:chart>` reference) to the chart-part XML body. Returning undefined
25
+ * skips ChartModel population — the drawing still parses as a
26
+ * `ParsedChartContent` with `rawXml`, just without `parsedData`.
27
+ */
28
+ export type ChartPartLookup = (rId: string) => string | undefined;
29
+
20
30
  export interface ParsedChartContent {
21
31
  type: "chart_preview";
22
32
  /** Media ID of the fallback preview image, if one is present in mc:Fallback. */
@@ -25,6 +35,17 @@ export interface ParsedChartContent {
25
35
  previewPackagePartName?: string;
26
36
  /** MIME type of the preview media (e.g. `image/png`, `image/svg+xml`). */
27
37
  previewContentType?: string;
38
+ /**
39
+ * Stage 1 typed chart model, when the chart part XML resolved and
40
+ * parsed cleanly. Undefined when no chart-part lookup was supplied, the
41
+ * lookup returned undefined, or `parseChartSpace` threw / returned an
42
+ * `UnsupportedChartModel` with reason="parse-error".
43
+ *
44
+ * A successful `UnsupportedChartModel{reason: "not-yet-implemented"}`
45
+ * IS attached — the renderer decides whether to fall back; preserve-
46
+ * only rawXml always survives export.
47
+ */
48
+ parsedData?: ChartModel;
28
49
  /** Original drawing XML slice for lossless round-trip export. */
29
50
  rawXml: string;
30
51
  }
@@ -60,6 +81,7 @@ export function parseComplexContentXml(
60
81
  relationships: readonly OpcRelationship[],
61
82
  mediaParts: ReadonlyMap<string, InlineMediaPart> = new Map(),
62
83
  sourcePartPath = "/word/document.xml",
84
+ chartPartLookup?: ChartPartLookup,
63
85
  ): ParsedComplexContent | null {
64
86
  const root = parseXml(drawingXml);
65
87
  const relationshipMap = new Map(relationships.map((r) => [r.id, r]));
@@ -67,7 +89,14 @@ export function parseComplexContentXml(
67
89
  // Look for mc:AlternateContent at any depth
68
90
  const altContent = findFirstDescendant(root, "AlternateContent");
69
91
  if (altContent) {
70
- return parseAlternateContent(altContent, drawingXml, relationshipMap, mediaParts, sourcePartPath);
92
+ return parseAlternateContent(
93
+ altContent,
94
+ drawingXml,
95
+ relationshipMap,
96
+ mediaParts,
97
+ sourcePartPath,
98
+ chartPartLookup,
99
+ );
71
100
  }
72
101
 
73
102
  // No mc:AlternateContent — look for direct graphic data
@@ -78,7 +107,13 @@ export function parseComplexContentXml(
78
107
 
79
108
  const uri = graphicData.attributes.uri ?? graphicData.attributes["uri"] ?? "";
80
109
  if (isChartUri(uri)) {
81
- return { type: "chart_preview", rawXml: drawingXml };
110
+ const parsedData = maybeParseChart(root, chartPartLookup);
111
+ const node: ParsedChartContent = {
112
+ type: "chart_preview",
113
+ ...(parsedData ? { parsedData } : {}),
114
+ rawXml: drawingXml,
115
+ };
116
+ return node;
82
117
  }
83
118
  if (isSmartArtUri(uri)) {
84
119
  return { type: "smartart_preview", rawXml: drawingXml };
@@ -87,12 +122,43 @@ export function parseComplexContentXml(
87
122
  return null;
88
123
  }
89
124
 
125
+ /**
126
+ * Attempt to parse the referenced chart part into a ChartModel.
127
+ *
128
+ * Walks the drawing for a `<c:chart r:id="…"/>` reference, hands the id to
129
+ * the lookup callback, and if the callback returns chart-part XML,
130
+ * invokes `parseChartSpace`. Returns undefined on any failure — the
131
+ * caller still emits a valid `ParsedChartContent` with `rawXml`, just
132
+ * without `parsedData`.
133
+ */
134
+ function maybeParseChart(
135
+ drawingRoot: XmlElementNode,
136
+ chartPartLookup: ChartPartLookup | undefined,
137
+ ): ChartModel | undefined {
138
+ if (!chartPartLookup) return undefined;
139
+ const chartRef = findFirstDescendant(drawingRoot, "chart");
140
+ if (!chartRef) return undefined;
141
+ const rId =
142
+ chartRef.attributes["r:id"] ??
143
+ chartRef.attributes["id"] ??
144
+ chartRef.attributes["r:embed"];
145
+ if (!rId) return undefined;
146
+ const chartXml = chartPartLookup(rId);
147
+ if (!chartXml) return undefined;
148
+ try {
149
+ return parseChartSpace(chartXml);
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
90
155
  function parseAlternateContent(
91
156
  altContent: XmlElementNode,
92
157
  fullDrawingXml: string,
93
158
  relationshipMap: Map<string, OpcRelationship>,
94
159
  mediaParts: ReadonlyMap<string, InlineMediaPart>,
95
160
  sourcePartPath: string,
161
+ chartPartLookup: ChartPartLookup | undefined,
96
162
  ): ParsedComplexContent | null {
97
163
  const choice = findFirstChild(altContent, "Choice");
98
164
  const fallback = findFirstChild(altContent, "Fallback");
@@ -150,6 +216,28 @@ function parseAlternateContent(
150
216
  }
151
217
  }
152
218
 
219
+ // For chart_preview, try to populate parsedData from the referenced
220
+ // chart part. parseAlternateContent is called with the AlternateContent
221
+ // subtree; the <c:chart> reference typically lives in the Choice branch,
222
+ // so we search from the altContent root (captures both Choice and any
223
+ // nested graphicData paths).
224
+ let parsedData: ChartModel | undefined;
225
+ if (contentType === "chart_preview") {
226
+ parsedData = maybeParseChart(altContent, chartPartLookup);
227
+ }
228
+
229
+ if (contentType === "chart_preview") {
230
+ const node: ParsedChartContent = {
231
+ type: "chart_preview",
232
+ ...(previewMediaId ? { previewMediaId } : {}),
233
+ ...(previewPackagePartName ? { previewPackagePartName } : {}),
234
+ ...(previewContentType ? { previewContentType } : {}),
235
+ ...(parsedData ? { parsedData } : {}),
236
+ rawXml: fullDrawingXml,
237
+ };
238
+ return node;
239
+ }
240
+
153
241
  return {
154
242
  type: contentType,
155
243
  ...(previewMediaId ? { previewMediaId } : {}),
@@ -26,12 +26,13 @@ import type {
26
26
  SectionPageBorders,
27
27
  } from "../../model/canonical-document.ts";
28
28
  import type { OpcRelationship } from "./part-manifest.ts";
29
+ import { SCOPE_MARKER_BOOKMARK_PREFIX } from "./parse-scope-markers.ts";
29
30
  import {
30
31
  parseInlineMediaXml,
31
32
  type InlineMediaPart,
32
33
  } from "./parse-inline-media.ts";
33
34
  import { toCanonicalNumberingInstanceId } from "./parse-numbering.ts";
34
- import { parseComplexContentXml } from "./parse-complex-content.ts";
35
+ import { parseComplexContentXml, type ChartPartLookup } from "./parse-complex-content.ts";
35
36
  import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
36
37
  import { classifyFieldInstruction } from "./parse-fields.ts";
37
38
  import { resolveHighlightColor } from "./highlight-colors.ts";
@@ -66,6 +67,37 @@ import {
66
67
  readTableStyleId as readSharedTableStyleId,
67
68
  readTableWidth as readSharedTableWidth,
68
69
  } from "./parse-tables.ts";
70
+ import {
71
+ buildGrabBagSourceChildFromParsed,
72
+ capturePropertyGrabBag,
73
+ type PropertyGrabBagDescriptor,
74
+ } from "./property-grab-bag.ts";
75
+
76
+ /**
77
+ * Modelled direct children of `<w:sectPr>` that `parseSectionPropertiesFromElement`
78
+ * below dispatches into typed fields on `SectionProperties`. Anything else
79
+ * becomes a grab-bag entry so a parse→serialize round-trip preserves
80
+ * extension-namespace section properties and Word-internal knobs the
81
+ * canonical model doesn't understand. Mirrors Slice 1/2 pPr/rPr coverage.
82
+ */
83
+ const SECT_PR_MODELLED_CHILDREN: ReadonlySet<string> = new Set([
84
+ "cols",
85
+ "docGrid",
86
+ "footerReference",
87
+ "headerReference",
88
+ "lnNumType",
89
+ "pgBorders",
90
+ "pgMar",
91
+ "pgNumType",
92
+ "pgSz",
93
+ "titlePg",
94
+ "type",
95
+ ]);
96
+
97
+ const SECT_PR_GRAB_BAG_DESCRIPTOR: PropertyGrabBagDescriptor = {
98
+ modelledChildNames: SECT_PR_MODELLED_CHILDREN,
99
+ modelledChildAttributes: new Map(),
100
+ };
69
101
 
70
102
  export interface ParsedMainDocument {
71
103
  blocks: ParsedBlockNode[];
@@ -213,6 +245,9 @@ export interface ParsedChartPreviewNode {
213
245
  previewMediaId?: string;
214
246
  previewPackagePartName?: string;
215
247
  previewContentType?: string;
248
+ /** Typed chart data parsed from the c:chartSpace part. See
249
+ * `src/io/ooxml/parse-complex-content.ts` for semantics. */
250
+ parsedData?: import("./chart/types.ts").ChartModel;
216
251
  rawXml: string;
217
252
  }
218
253
 
@@ -429,11 +464,38 @@ interface MarksParseResult {
429
464
  const HYPERLINK_RELATIONSHIP_TYPE =
430
465
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
431
466
 
467
+ /**
468
+ * Request-scoped chart-part lookup. Set by `parseMainDocumentXml` for
469
+ * the duration of a single top-level parse; read by `parseRun` where
470
+ * the `<w:drawing>` → `parseComplexContentXml` call site lives. Using a
471
+ * module variable instead of threading the callback through ~8
472
+ * intermediate function signatures keeps the call sites readable; the
473
+ * try/finally in `parseMainDocumentXml` ensures the variable never
474
+ * leaks across concurrent parses (Node.js is single-threaded; no
475
+ * re-entrancy since the parser is fully synchronous).
476
+ */
477
+ let activeChartPartLookup: ChartPartLookup | undefined;
478
+
432
479
  export function parseMainDocumentXml(
433
480
  xml: string,
434
481
  relationships: readonly OpcRelationship[] = [],
435
482
  mediaParts: ReadonlyMap<string, InlineMediaPart> = new Map(),
436
483
  sourcePartPath = "/word/document.xml",
484
+ chartPartLookup?: ChartPartLookup,
485
+ ): ParsedMainDocument {
486
+ activeChartPartLookup = chartPartLookup;
487
+ try {
488
+ return parseMainDocumentXmlInner(xml, relationships, mediaParts, sourcePartPath);
489
+ } finally {
490
+ activeChartPartLookup = undefined;
491
+ }
492
+ }
493
+
494
+ function parseMainDocumentXmlInner(
495
+ xml: string,
496
+ relationships: readonly OpcRelationship[],
497
+ mediaParts: ReadonlyMap<string, InlineMediaPart>,
498
+ sourcePartPath: string,
437
499
  ): ParsedMainDocument {
438
500
  const root = parseXml(xml);
439
501
  const documentElement = findChildElement(root, "document");
@@ -457,9 +519,91 @@ export function parseMainDocumentXml(
457
519
  }
458
520
  }
459
521
 
522
+ rewriteScopeMarkerBookmarks(blocks);
523
+
460
524
  return { blocks, finalSectionProperties };
461
525
  }
462
526
 
527
+ /**
528
+ * S1 — post-process the parsed block tree in place, converting bookmark
529
+ * pairs whose `name` starts with `bw:scope:` into `scope_marker_*` inline
530
+ * nodes. The `bookmarkId` is used to pair start+end; the `scopeId` is
531
+ * taken from the name after the prefix. Unmatched bookmarks (start without
532
+ * end or vice versa) stay as regular bookmarks — S1 markers are always
533
+ * emitted in pairs on export, so an orphan implies upstream corruption
534
+ * that we preserve rather than drop.
535
+ */
536
+ function rewriteScopeMarkerBookmarks(blocks: ParsedBlockNode[]): void {
537
+ const scopeBookmarkIds = new Map<string, string>();
538
+
539
+ const scanForStarts = (nodes: readonly { type?: string; [key: string]: unknown }[]): void => {
540
+ for (const node of nodes) {
541
+ if (!node || typeof node !== "object") continue;
542
+ if (node.type === "bookmark_start") {
543
+ const name = (node as { name?: string }).name ?? "";
544
+ if (name.startsWith(SCOPE_MARKER_BOOKMARK_PREFIX)) {
545
+ const bkId = (node as { bookmarkId?: string }).bookmarkId ?? "";
546
+ const scopeId = name.slice(SCOPE_MARKER_BOOKMARK_PREFIX.length);
547
+ if (bkId && scopeId) {
548
+ scopeBookmarkIds.set(bkId, scopeId);
549
+ }
550
+ }
551
+ }
552
+ const children = (node as { children?: unknown }).children;
553
+ if (Array.isArray(children)) scanForStarts(children);
554
+ const rows = (node as { rows?: unknown }).rows;
555
+ if (Array.isArray(rows)) scanForStarts(rows);
556
+ const cells = (node as { cells?: unknown }).cells;
557
+ if (Array.isArray(cells)) scanForStarts(cells);
558
+ }
559
+ };
560
+
561
+ const rewriteInPlace = (nodes: { type?: string; [key: string]: unknown }[]): void => {
562
+ for (let i = 0; i < nodes.length; i += 1) {
563
+ const node = nodes[i]!;
564
+ if (!node || typeof node !== "object") continue;
565
+
566
+ if (node.type === "bookmark_start") {
567
+ const bkId = (node as { bookmarkId?: string }).bookmarkId ?? "";
568
+ const scopeId = scopeBookmarkIds.get(bkId);
569
+ if (scopeId !== undefined) {
570
+ nodes[i] = { type: "scope_marker_start", scopeId } as typeof node;
571
+ continue;
572
+ }
573
+ }
574
+
575
+ if (node.type === "bookmark_end") {
576
+ const bkId = (node as { bookmarkId?: string }).bookmarkId ?? "";
577
+ const scopeId = scopeBookmarkIds.get(bkId);
578
+ if (scopeId !== undefined) {
579
+ nodes[i] = { type: "scope_marker_end", scopeId } as typeof node;
580
+ continue;
581
+ }
582
+ }
583
+
584
+ const children = (node as { children?: unknown }).children;
585
+ if (Array.isArray(children)) {
586
+ rewriteInPlace(children as { type?: string; [key: string]: unknown }[]);
587
+ }
588
+ const rows = (node as { rows?: unknown }).rows;
589
+ if (Array.isArray(rows)) {
590
+ rewriteInPlace(rows as { type?: string; [key: string]: unknown }[]);
591
+ }
592
+ const cells = (node as { cells?: unknown }).cells;
593
+ if (Array.isArray(cells)) {
594
+ rewriteInPlace(cells as { type?: string; [key: string]: unknown }[]);
595
+ }
596
+ }
597
+ };
598
+
599
+ // Two passes: collect all scope-prefixed start IDs, then rewrite both
600
+ // start + end occurrences. Pairing by id — scope_marker_end may appear
601
+ // in a later paragraph than its matching scope_marker_start.
602
+ scanForStarts(blocks as unknown as readonly { [key: string]: unknown }[]);
603
+ if (scopeBookmarkIds.size === 0) return;
604
+ rewriteInPlace(blocks as unknown as { [key: string]: unknown }[]);
605
+ }
606
+
463
607
  function parseBodyChild(
464
608
  node: XmlElementNode,
465
609
  sourceXml: string,
@@ -1911,6 +2055,7 @@ function parseRun(
1911
2055
  relationships,
1912
2056
  mediaParts,
1913
2057
  sourcePartPath,
2058
+ activeChartPartLookup,
1914
2059
  );
1915
2060
  if (complexContent) {
1916
2061
  result.push(complexContent);
@@ -3056,6 +3201,16 @@ export function parseSectionPropertiesFromElement(
3056
3201
  }
3057
3202
  }
3058
3203
 
3204
+ // Grab-bag capture for unmodelled <w:sectPr> children (O2 Slice 4).
3205
+ const sourceChildren = node.children
3206
+ .filter((child): child is XmlElementNode => child.type === "element")
3207
+ .map((child) => buildGrabBagSourceChildFromParsed(child));
3208
+ const unknown = capturePropertyGrabBag(
3209
+ sourceChildren,
3210
+ SECT_PR_GRAB_BAG_DESCRIPTOR,
3211
+ );
3212
+ if (unknown) props.unknownPropertyChildren = unknown;
3213
+
3059
3214
  return props;
3060
3215
  }
3061
3216
 
@@ -17,6 +17,42 @@ import type {
17
17
  import { readRunProperties } from "./parse-run-formatting.ts";
18
18
  import { findChildOptional, localName, readIntAttr, readIntVal, readOnOff } from "./xml-attr-helpers.ts";
19
19
  import type { XmlElementNode } from "./xml-element.ts";
20
+ import {
21
+ buildGrabBagSourceChildFromParsed,
22
+ capturePropertyGrabBag,
23
+ type PropertyGrabBagDescriptor,
24
+ } from "./property-grab-bag.ts";
25
+
26
+ /**
27
+ * Modelled direct children of `<w:pPr>` that `readParagraphProperties` below
28
+ * dispatches into typed fields on `CanonicalParagraphFormatting`. Anything
29
+ * else becomes a grab-bag entry so a parse→serialize round-trip preserves
30
+ * extension-namespace properties like `<w15:collapsed>` or Word-internal
31
+ * knobs like `<w:kinsoku>` that our canonical model doesn't understand.
32
+ */
33
+ const PPR_MODELLED_CHILDREN: ReadonlySet<string> = new Set([
34
+ "spacing",
35
+ "ind",
36
+ "jc",
37
+ "pBdr",
38
+ "shd",
39
+ "tabs",
40
+ "keepNext",
41
+ "keepLines",
42
+ "widowControl",
43
+ "pageBreakBefore",
44
+ "contextualSpacing",
45
+ "bidi",
46
+ "suppressLineNumbers",
47
+ "suppressAutoHyphens",
48
+ "outlineLvl",
49
+ "rPr",
50
+ ]);
51
+
52
+ const PPR_GRAB_BAG_DESCRIPTOR: PropertyGrabBagDescriptor = {
53
+ modelledChildNames: PPR_MODELLED_CHILDREN,
54
+ modelledChildAttributes: new Map(),
55
+ };
20
56
 
21
57
  function readSpacing(node: XmlElementNode): ParagraphSpacing | undefined {
22
58
  const out: ParagraphSpacing = {};
@@ -184,5 +220,15 @@ export function readParagraphProperties(
184
220
  const markRpr = readRunProperties(rPrNode);
185
221
  if (markRpr) out.paragraphMarkRunProperties = markRpr;
186
222
 
223
+ // Capture any unmodelled direct children of <w:pPr> so a parse→serialize
224
+ // round-trip does not silently drop extension-namespace properties
225
+ // (w15:collapsed, w16cex:..., w:kinsoku, etc.). See Lane 3 O2 plan +
226
+ // src/io/ooxml/property-grab-bag.ts for the pattern.
227
+ const sourceChildren = node.children
228
+ .filter((child): child is XmlElementNode => child.type === "element")
229
+ .map((child) => buildGrabBagSourceChildFromParsed(child));
230
+ const unknown = capturePropertyGrabBag(sourceChildren, PPR_GRAB_BAG_DESCRIPTOR);
231
+ if (unknown) out.unknownPropertyChildren = unknown;
232
+
187
233
  return Object.keys(out).length > 0 ? out : undefined;
188
234
  }
@@ -1,6 +1,42 @@
1
1
  import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
2
2
  import { findChildOptional, readIntVal, readOnOff, readStringAttr } from "./xml-attr-helpers.ts";
3
3
  import type { XmlElementNode } from "./xml-element.ts";
4
+ import {
5
+ buildGrabBagSourceChildFromParsed,
6
+ capturePropertyGrabBag,
7
+ type PropertyGrabBagDescriptor,
8
+ } from "./property-grab-bag.ts";
9
+
10
+ /**
11
+ * Modelled direct children of `<w:rPr>` that `readRunProperties` dispatches
12
+ * into typed fields on `CanonicalRunFormatting`. Anything else goes through
13
+ * the grab-bag helper so a parse→serialize round-trip preserves extension-
14
+ * namespace properties (e.g. `<w14:textOutline>`, `<w:em>`, `<w:kern>`).
15
+ */
16
+ const RPR_MODELLED_CHILDREN: ReadonlySet<string> = new Set([
17
+ "b",
18
+ "i",
19
+ "strike",
20
+ "dstrike",
21
+ "vanish",
22
+ "caps",
23
+ "smallCaps",
24
+ "u",
25
+ "vertAlign",
26
+ "rFonts",
27
+ "sz",
28
+ "szCs",
29
+ "color",
30
+ "highlight",
31
+ "spacing",
32
+ "rStyle",
33
+ "lang",
34
+ ]);
35
+
36
+ const RPR_GRAB_BAG_DESCRIPTOR: PropertyGrabBagDescriptor = {
37
+ modelledChildNames: RPR_MODELLED_CHILDREN,
38
+ modelledChildAttributes: new Map(),
39
+ };
4
40
 
5
41
  /**
6
42
  * Read `<w:rPr>` (run properties) into a `CanonicalRunFormatting` value.
@@ -101,8 +137,14 @@ export function readRunProperties(
101
137
  const val = color.attributes["w:val"] ?? color.attributes["val"];
102
138
  const theme =
103
139
  color.attributes["w:themeColor"] ?? color.attributes["themeColor"];
140
+ const tint =
141
+ color.attributes["w:themeTint"] ?? color.attributes["themeTint"];
142
+ const shade =
143
+ color.attributes["w:themeShade"] ?? color.attributes["themeShade"];
104
144
  if (val) rPr.colorHex = val;
105
145
  if (theme) rPr.colorThemeSlot = theme;
146
+ if (tint) rPr.colorThemeTint = tint;
147
+ if (shade) rPr.colorThemeShade = shade;
106
148
  }
107
149
 
108
150
  const highlight = findChildOptional(node, "highlight");
@@ -125,5 +167,12 @@ export function readRunProperties(
125
167
  if (val) rPr.languageCode = val;
126
168
  }
127
169
 
170
+ // Grab-bag capture: unmodelled <w:rPr> children survive round-trip.
171
+ const sourceChildren = node.children
172
+ .filter((child): child is XmlElementNode => child.type === "element")
173
+ .map((child) => buildGrabBagSourceChildFromParsed(child));
174
+ const unknown = capturePropertyGrabBag(sourceChildren, RPR_GRAB_BAG_DESCRIPTOR);
175
+ if (unknown) rPr.unknownPropertyChildren = unknown;
176
+
128
177
  return Object.keys(rPr).length > 0 ? rPr : undefined;
129
178
  }