@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,421 @@
1
+ /**
2
+ * Serialize a `DocumentSettings` to an OOXML `<w:settings>` document.
3
+ *
4
+ * Two modes:
5
+ *
6
+ * 1. **Synthesis** (`serializeSettingsXml(settings)`): emit a complete
7
+ * `<w:settings>` document from canonical fields only. Drops every
8
+ * unmodelled top-level child (`<w:defaultTabStop>`, etc.) — so this
9
+ * mode is safe ONLY for synthetic / green-field documents that never
10
+ * had those fields to begin with.
11
+ *
12
+ * 2. **Graft** (`serializeSettingsXml(settings, sourceXml)`): walk the
13
+ * source's top-level children and decide per child:
14
+ * - **Modelled child whose source-derived canonical projection
15
+ * equals the current canonical projection** → keep source bytes
16
+ * verbatim (byte-preservation). This preserves multi-line
17
+ * whitespace inside `<w:compat>`, source child ordering inside
18
+ * `<w:compat>`, unknown attributes on modelled elements (e.g.
19
+ * `<w:zoom w:percent="120%" w:newAttr="..."/>`), and empty
20
+ * self-closing forms like `<w:compat/>`.
21
+ * - **Modelled child that canonical mutated** → rebuild from
22
+ * synthesis. This is where unknown attributes on the mutated
23
+ * element are dropped (documented limitation: the canonical
24
+ * model only carries the attributes the parser captured).
25
+ * - **Modelled child that canonical removed** → drop element AND
26
+ * its leading interstitial whitespace.
27
+ * - **Unmodelled child** → preserve source bytes verbatim.
28
+ * Modelled fields canonical has but source lacks are appended at
29
+ * the end of the body.
30
+ *
31
+ * Critical invariant proven by the CCEP fixture sweep in
32
+ * `test/io/parse-settings-ccep.test.ts`: when canonical equals
33
+ * `parseSettingsXml(sourceXml)`, the grafted output is byte-identical
34
+ * to `sourceXml`. The sweep dynamically discovers every fixture under
35
+ * `test/fixtures/docx/F*.docx` that ships a `/word/settings.xml` part
36
+ * and asserts the no-op round-trip plus an `unmodelledSettingsChildren`
37
+ * symmetry check.
38
+ *
39
+ * Known limitation: a canonical mutation on a modelled element rebuilds
40
+ * the element from the typed fields, dropping any unknown attributes the
41
+ * source carried. The trade-off is acceptable because the alternative
42
+ * (preserving unknown attrs across mutations) would require the parser
43
+ * to track raw attribute bags. For the no-mutation case, byte-
44
+ * preservation kicks in and unknown attrs survive.
45
+ *
46
+ * **WIRED INTO THE EXPORT PIPELINE** as of Lane 3 O4 Phase 3c. The
47
+ * `exportDocxEditorSession` flow in `src/io/docx-session.ts` adds
48
+ * `/word/settings.xml` to `ownedOutputPaths` whenever the source
49
+ * package carried one (or canonical surfaces `subParts.settings`)
50
+ * and routes the bytes through `serializeSettingsXml(settings,
51
+ * sourceXml)`. The `sourceXml` argument comes from
52
+ * `state.sourceSettingsXml`, captured at import time. The CCEP
53
+ * integration test at `test/io/ccep-roundtrip-settings.test.ts`
54
+ * proves both the byte-reuse short-circuit AND the forced-reserialize
55
+ * path (`DOCX_VALIDATOR_FORCE_REGEN=1`) keep settings.xml byte-clean
56
+ * across the full settings-bearing CCEP fixture set.
57
+ *
58
+ * Authority for emit shape: ECMA-376 §17.15 (settings.xml schema) plus
59
+ * the LibreOffice settings serializer at
60
+ * vendor/libreoffice/sw/source/writerfilter/dmapper/SettingsTable.cxx for
61
+ * compatSetting triple emission.
62
+ */
63
+
64
+ import type {
65
+ CompatSetting,
66
+ DocumentSettings,
67
+ } from "../../model/canonical-document.ts";
68
+ import {
69
+ parseSettingsXml,
70
+ ROOT_COMPAT_FLAG_NAMES,
71
+ } from "../ooxml/parse-settings.ts";
72
+ import {
73
+ parseSettingsBlueprint,
74
+ type SettingsBlueprintChild,
75
+ } from "../ooxml/parse-settings-blueprint.ts";
76
+ import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
77
+
78
+ export const WORD_SETTINGS_CONTENT_TYPE =
79
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
80
+
81
+ const WORDPROCESSINGML_2006_MAIN_NS =
82
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
83
+
84
+ /**
85
+ * Render a complete `<w:settings>` XML document from canonical
86
+ * `DocumentSettings`. The output is the standard XML declaration plus a
87
+ * `<w:settings>` root carrying every modelled field.
88
+ *
89
+ * Emit order is OOXML-schema-friendly to maximize Word's tolerance:
90
+ * 1. <w:evenAndOddHeaders>
91
+ * 2. <w:zoom>
92
+ * 3. root-level compat-adjacent flags (e.g. <w:doNotEmbedSmartTags/>)
93
+ * 4. <w:compat> wrapping flags then compatSetting triples
94
+ * 5. <w:themeFontLang>
95
+ *
96
+ * Insertion order of `compatSettings` array entries and `compatFlags` /
97
+ * `rootCompatFlags` / `themeFontLang` keys is preserved so a byte-stable
98
+ * diff is achievable on round-trip when caller-supplied data is itself
99
+ * stable.
100
+ */
101
+ export function serializeSettingsXml(
102
+ settings: DocumentSettings,
103
+ sourceXml?: string,
104
+ ): string {
105
+ if (sourceXml !== undefined) {
106
+ return graftSettingsXml(settings, sourceXml);
107
+ }
108
+ return synthesizeSettingsXml(settings);
109
+ }
110
+
111
+ function synthesizeSettingsXml(settings: DocumentSettings): string {
112
+ const parts: string[] = [];
113
+ parts.push(emitEvenAndOddHeaders(settings));
114
+ parts.push(emitZoom(settings));
115
+ parts.push(emitRootCompatFlags(settings));
116
+ parts.push(emitCompatBlock(settings));
117
+ parts.push(emitThemeFontLang(settings));
118
+
119
+ const body = parts.filter((p) => p.length > 0).join("");
120
+ return [
121
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
122
+ `<w:settings xmlns:w="${WORDPROCESSINGML_2006_MAIN_NS}">${body}</w:settings>`,
123
+ ].join("");
124
+ }
125
+
126
+ /**
127
+ * Graft canonical fields onto a source settings.xml. Walks the source's
128
+ * top-level children: replaces modelled names with canonical re-emission
129
+ * (or removes them when canonical drops the field), keeps unmodelled
130
+ * children byte-identical to the source. Modelled fields the canonical
131
+ * has but the source lacks are appended at the end of the body.
132
+ *
133
+ * Critical invariant: when canonical equals `parseSettingsXml(sourceXml)`,
134
+ * the output is byte-identical to `sourceXml`. This is the no-edit
135
+ * round-trip guarantee that lets the export pipeline take ownership of
136
+ * `/word/settings.xml` without introducing validator regressions.
137
+ */
138
+ function graftSettingsXml(
139
+ settings: DocumentSettings,
140
+ sourceXml: string,
141
+ ): string {
142
+ const blueprint = parseSettingsBlueprint(sourceXml);
143
+
144
+ // Track which top-level modelled names we already emitted in place so we
145
+ // don't double-emit them in the append-at-end pass.
146
+ const emittedTopLevel = new Set<string>();
147
+
148
+ // Mutable copy of canonical rootCompatFlags so we can "consume" entries as
149
+ // we replace them in place; what's left at the end gets appended.
150
+ const pendingRootFlags = new Map<string, boolean>(
151
+ Object.entries(settings.rootCompatFlags ?? {}),
152
+ );
153
+
154
+ const childParts: string[] = [];
155
+ for (const child of blueprint.topLevelChildren) {
156
+ const replacement = computeChildReplacement(
157
+ settings,
158
+ child,
159
+ pendingRootFlags,
160
+ );
161
+ // Mark modelled names as encountered in source so the append-at-end pass
162
+ // does not duplicate them. Root flags are tracked via pendingRootFlags
163
+ // (computeChildReplacement deletes them as it handles each one), so we
164
+ // only need to track the four named modelled top-level elements here.
165
+ if (
166
+ replacement.kind !== "drop" &&
167
+ MODELLED_TOP_LEVEL_NAMES.has(child.localName)
168
+ ) {
169
+ emittedTopLevel.add(child.localName);
170
+ }
171
+ if (replacement.kind === "keep") {
172
+ childParts.push(child.interstitialBefore + child.rawXml);
173
+ continue;
174
+ }
175
+ if (replacement.kind === "drop") {
176
+ // Skip both raw and interstitial — leaving interstitial would create
177
+ // dangling whitespace.
178
+ continue;
179
+ }
180
+ // replacement.kind === "replace"
181
+ childParts.push(child.interstitialBefore + replacement.xml);
182
+ }
183
+
184
+ // Append modelled elements canonical has that source did NOT.
185
+ const appendedParts: string[] = [];
186
+ if (
187
+ !emittedTopLevel.has("evenAndOddHeaders") &&
188
+ settings.evenAndOddHeaders !== undefined
189
+ ) {
190
+ appendedParts.push(emitEvenAndOddHeaders(settings));
191
+ }
192
+ if (
193
+ !emittedTopLevel.has("zoom") &&
194
+ settings.zoomLevel !== undefined
195
+ ) {
196
+ appendedParts.push(emitZoom(settings));
197
+ }
198
+ // Any rootCompatFlags entries that didn't have a source counterpart.
199
+ for (const [name, value] of pendingRootFlags) {
200
+ appendedParts.push(emitOnOffElement(name, value));
201
+ }
202
+ if (!emittedTopLevel.has("compat")) {
203
+ const compatXml = emitCompatBlock(settings);
204
+ if (compatXml.length > 0) appendedParts.push(compatXml);
205
+ }
206
+ if (
207
+ !emittedTopLevel.has("themeFontLang") &&
208
+ settings.themeFontLang !== undefined
209
+ ) {
210
+ appendedParts.push(emitThemeFontLang(settings));
211
+ }
212
+
213
+ return (
214
+ blueprint.prelude +
215
+ blueprint.settingsOpenTag +
216
+ childParts.join("") +
217
+ appendedParts.join("") +
218
+ blueprint.trailingWhitespace +
219
+ blueprint.settingsCloseTag
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Modelled top-level <w:settings> children that the graft loop tracks via
225
+ * `emittedTopLevel`. Root compat flags (`<w:doNotEmbedSmartTags/>`, etc.)
226
+ * are NOT in this set — they're tracked via `pendingRootFlags` because
227
+ * canonical can carry an arbitrary set of them.
228
+ */
229
+ const MODELLED_TOP_LEVEL_NAMES: ReadonlySet<string> = new Set([
230
+ "evenAndOddHeaders",
231
+ "zoom",
232
+ "compat",
233
+ "themeFontLang",
234
+ ]);
235
+
236
+ type ChildReplacement =
237
+ | { kind: "keep" }
238
+ | { kind: "drop" }
239
+ | { kind: "replace"; xml: string };
240
+
241
+ /**
242
+ * Decide what graft does with a single top-level source child.
243
+ *
244
+ * Byte-preservation precedence: for any modelled child whose source-derived
245
+ * canonical projection equals the current canonical projection, return
246
+ * `{ kind: "keep" }` so the source rawXml survives untouched. This preserves
247
+ *
248
+ * - multi-line whitespace inside `<w:compat>` (reviewer C2(a))
249
+ * - source child ordering inside `<w:compat>` (reviewer C2(b))
250
+ * - unknown attributes on modelled elements like
251
+ * `<w:compatSetting w:name=… w:uri=… w:val=… w:futureAttr=…/>` or
252
+ * `<w:zoom w:percent="120%" w:newAttr=…/>` (reviewer C2(c))
253
+ * - empty self-closing `<w:compat/>` source (reviewer Risk #6)
254
+ *
255
+ * Only when canonical genuinely mutates a modelled field does the byte
256
+ * form get rebuilt from synthesis. The trade-off is documented: a
257
+ * mutation drops unknown attributes on the mutated element, because the
258
+ * canonical model only carries the attributes the parser captured.
259
+ */
260
+ function computeChildReplacement(
261
+ settings: DocumentSettings,
262
+ child: SettingsBlueprintChild,
263
+ pendingRootFlags: Map<string, boolean>,
264
+ ): ChildReplacement {
265
+ switch (child.localName) {
266
+ case "evenAndOddHeaders": {
267
+ if (
268
+ emitEvenAndOddHeaders(parseModelledChild(child.rawXml)) ===
269
+ emitEvenAndOddHeaders(settings)
270
+ ) {
271
+ return { kind: "keep" };
272
+ }
273
+ const xml = emitEvenAndOddHeaders(settings);
274
+ return xml.length > 0 ? { kind: "replace", xml } : { kind: "drop" };
275
+ }
276
+ case "zoom": {
277
+ if (
278
+ emitZoom(parseModelledChild(child.rawXml)) === emitZoom(settings)
279
+ ) {
280
+ return { kind: "keep" };
281
+ }
282
+ const xml = emitZoom(settings);
283
+ return xml.length > 0 ? { kind: "replace", xml } : { kind: "drop" };
284
+ }
285
+ case "compat": {
286
+ if (
287
+ emitCompatBlock(parseModelledChild(child.rawXml)) ===
288
+ emitCompatBlock(settings)
289
+ ) {
290
+ return { kind: "keep" };
291
+ }
292
+ const xml = emitCompatBlock(settings);
293
+ return xml.length > 0 ? { kind: "replace", xml } : { kind: "drop" };
294
+ }
295
+ case "themeFontLang": {
296
+ if (
297
+ emitThemeFontLang(parseModelledChild(child.rawXml)) ===
298
+ emitThemeFontLang(settings)
299
+ ) {
300
+ return { kind: "keep" };
301
+ }
302
+ const xml = emitThemeFontLang(settings);
303
+ return xml.length > 0 ? { kind: "replace", xml } : { kind: "drop" };
304
+ }
305
+ }
306
+ // Root compat flag?
307
+ if (ROOT_COMPAT_FLAG_NAMES.has(child.localName)) {
308
+ if (pendingRootFlags.has(child.localName)) {
309
+ const value = pendingRootFlags.get(child.localName)!;
310
+ pendingRootFlags.delete(child.localName);
311
+ // Byte-preserve when canonical value matches the source-encoded value.
312
+ const sourceParsed = parseModelledChild(child.rawXml);
313
+ if (sourceParsed.rootCompatFlags?.[child.localName] === value) {
314
+ return { kind: "keep" };
315
+ }
316
+ return { kind: "replace", xml: emitOnOffElement(child.localName, value) };
317
+ }
318
+ // Canonical dropped it.
319
+ return { kind: "drop" };
320
+ }
321
+ // Unmodelled — keep verbatim.
322
+ return { kind: "keep" };
323
+ }
324
+
325
+ /**
326
+ * Re-parse a single top-level source child element by wrapping it in a
327
+ * synthetic `<w:settings>` envelope and feeding it through the canonical
328
+ * parser. Used to compare source-derived canonical projection against the
329
+ * caller-supplied canonical for byte-preservation eligibility.
330
+ *
331
+ * Tiny string allocation per child; fine for settings.xml-sized inputs.
332
+ */
333
+ function parseModelledChild(rawXml: string): DocumentSettings {
334
+ const wrapped =
335
+ `<w:settings xmlns:w="${WORDPROCESSINGML_2006_MAIN_NS}">` +
336
+ rawXml +
337
+ `</w:settings>`;
338
+ return parseSettingsXml(wrapped);
339
+ }
340
+
341
+ function emitEvenAndOddHeaders(settings: DocumentSettings): string {
342
+ if (settings.evenAndOddHeaders === undefined) return "";
343
+ if (settings.evenAndOddHeaders) {
344
+ return `<w:evenAndOddHeaders/>`;
345
+ }
346
+ return `<w:evenAndOddHeaders w:val="false"/>`;
347
+ }
348
+
349
+ function emitZoom(settings: DocumentSettings): string {
350
+ const { zoomLevel } = settings;
351
+ if (zoomLevel === undefined) return "";
352
+ if (zoomLevel === "pageWidth") {
353
+ return `<w:zoom w:val="bestFit"/>`;
354
+ }
355
+ if (zoomLevel === "onePage") {
356
+ return `<w:zoom w:val="fullPage"/>`;
357
+ }
358
+ if (typeof zoomLevel === "number" && Number.isFinite(zoomLevel)) {
359
+ return `<w:zoom w:percent="${Math.round(zoomLevel)}"/>`;
360
+ }
361
+ return "";
362
+ }
363
+
364
+ function emitRootCompatFlags(settings: DocumentSettings): string {
365
+ const flags = settings.rootCompatFlags;
366
+ if (!flags) return "";
367
+ return Object.entries(flags)
368
+ .map(([name, value]) => emitOnOffElement(name, value))
369
+ .join("");
370
+ }
371
+
372
+ function emitCompatBlock(settings: DocumentSettings): string {
373
+ const flags = settings.compatFlags;
374
+ const triples = settings.compatSettings;
375
+ const hasFlags = flags && Object.keys(flags).length > 0;
376
+ const hasTriples = triples && triples.length > 0;
377
+ if (!hasFlags && !hasTriples) return "";
378
+
379
+ const flagXml = hasFlags
380
+ ? Object.entries(flags!)
381
+ .map(([name, value]) => emitOnOffElement(name, value))
382
+ .join("")
383
+ : "";
384
+ const triplesXml = hasTriples
385
+ ? triples!.map((cs) => emitCompatSetting(cs)).join("")
386
+ : "";
387
+
388
+ return `<w:compat>${flagXml}${triplesXml}</w:compat>`;
389
+ }
390
+
391
+ function emitCompatSetting(cs: CompatSetting): string {
392
+ return (
393
+ `<w:compatSetting` +
394
+ ` w:name="${escapeXmlAttribute(cs.name)}"` +
395
+ ` w:uri="${escapeXmlAttribute(cs.uri)}"` +
396
+ ` w:val="${escapeXmlAttribute(cs.value)}"` +
397
+ `/>`
398
+ );
399
+ }
400
+
401
+ function emitThemeFontLang(settings: DocumentSettings): string {
402
+ const lang = settings.themeFontLang;
403
+ if (!lang) return "";
404
+ const attrs = Object.entries(lang)
405
+ .map(([name, value]) => ` ${name}="${escapeXmlAttribute(value)}"`)
406
+ .join("");
407
+ if (attrs.length === 0) {
408
+ return `<w:themeFontLang/>`;
409
+ }
410
+ return `<w:themeFontLang${attrs}/>`;
411
+ }
412
+
413
+ /**
414
+ * Emit a ST_OnOff element. true → bare self-closing tag; false → explicit
415
+ * `w:val="false"` so the parser doesn't infer the default-true. Symmetric
416
+ * with `readOnOffValue` in `parse-settings.ts`.
417
+ */
418
+ function emitOnOffElement(localName: string, value: boolean): string {
419
+ if (value) return `<w:${localName}/>`;
420
+ return `<w:${localName} w:val="false"/>`;
421
+ }
@@ -73,6 +73,10 @@ function buildParagraphStyleXml(
73
73
  const nextEl = style.nextStyle
74
74
  ? `<w:next w:val="${escXml(style.nextStyle)}"/>`
75
75
  : "";
76
+ // ECMA-376 §17.7 emit order: name → basedOn → next → link → ...
77
+ const linkEl = style.linkedStyleId
78
+ ? `<w:link w:val="${escXml(style.linkedStyleId)}"/>`
79
+ : "";
76
80
 
77
81
  // Build pPr: may contain numPr (from numbering) and any canonical formatting.
78
82
  // We reconstruct the pPr children in canonical order:
@@ -95,6 +99,7 @@ function buildParagraphStyleXml(
95
99
  nameEl +
96
100
  basedOnEl +
97
101
  nextEl +
102
+ linkEl +
98
103
  pPrBodyXml +
99
104
  rPrXml +
100
105
  `</w:style>`
@@ -175,12 +180,17 @@ function buildCharacterStyleXml(
175
180
  const basedOnEl = style.basedOn
176
181
  ? `<w:basedOn w:val="${escXml(style.basedOn)}"/>`
177
182
  : "";
183
+ // ECMA-376 §17.7 emit order: name → basedOn → link → ... → rPr
184
+ const linkEl = style.linkedStyleId
185
+ ? `<w:link w:val="${escXml(style.linkedStyleId)}"/>`
186
+ : "";
178
187
  const rPrXml = buildRunPropertiesXml(style.runProperties);
179
188
 
180
189
  return (
181
190
  `<w:style w:type="character" w:styleId="${escXml(style.styleId)}"${defaultAttr}>` +
182
191
  nameEl +
183
192
  basedOnEl +
193
+ linkEl +
184
194
  rPrXml +
185
195
  `</w:style>`
186
196
  );
@@ -482,6 +482,7 @@ function normalizeInlineChildren(
482
482
  normalized.push({
483
483
  type: "chart_preview",
484
484
  ...(node.previewMediaId ? { previewMediaId: node.previewMediaId } : {}),
485
+ ...(node.parsedData ? { parsedData: node.parsedData } : {}),
485
486
  rawXml: node.rawXml,
486
487
  });
487
488
  state.cursor += 1;