@beyondwork/docx-react-component 1.0.48 → 1.0.50

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 (53) hide show
  1. package/README.md +19 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +103 -12
  4. package/src/core/commands/index.ts +30 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-comments.ts +50 -5
  12. package/src/io/export/serialize-main-document.ts +9 -0
  13. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  14. package/src/io/export/serialize-run-formatting.ts +10 -1
  15. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  16. package/src/io/ooxml/chart/color-palette.ts +101 -0
  17. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  18. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  19. package/src/io/ooxml/chart/parse-series.ts +76 -11
  20. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  21. package/src/io/ooxml/chart/types.ts +30 -11
  22. package/src/io/ooxml/parse-complex-content.ts +6 -3
  23. package/src/io/ooxml/parse-main-document.ts +41 -0
  24. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  25. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  26. package/src/io/ooxml/property-grab-bag.ts +211 -0
  27. package/src/io/paste/word-clipboard.ts +114 -0
  28. package/src/model/canonical-document.ts +69 -3
  29. package/src/runtime/collab/index.ts +7 -0
  30. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  31. package/src/runtime/collab/workflow-shared.ts +247 -0
  32. package/src/runtime/document-locations.ts +1 -9
  33. package/src/runtime/document-outline.ts +1 -9
  34. package/src/runtime/document-runtime.ts +98 -50
  35. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  36. package/src/runtime/layout/layout-engine-version.ts +11 -1
  37. package/src/runtime/layout/public-facet.ts +5 -12
  38. package/src/runtime/render/render-frame-types.ts +14 -0
  39. package/src/runtime/render/render-kernel.ts +40 -2
  40. package/src/runtime/structure-ops/fragment-insert.ts +134 -0
  41. package/src/runtime/surface-projection.ts +94 -36
  42. package/src/runtime/theme-color-resolver.ts +188 -0
  43. package/src/runtime/workflow-markup.ts +7 -18
  44. package/src/ui/WordReviewEditor.tsx +22 -4
  45. package/src/ui/editor-runtime-boundary.ts +37 -0
  46. package/src/ui/headless/selection-helpers.ts +10 -23
  47. package/src/ui/unsupported-previews-policy.ts +23 -0
  48. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  49. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  50. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
  51. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  52. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  53. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -13,7 +13,8 @@ import {
13
13
  type TextStory,
14
14
  } from "../schema/text-schema.ts";
15
15
  import { createEditorSurfaceSnapshot } from "../../runtime/surface-projection.ts";
16
- import type { DocumentRootNode, ParagraphNode, SdtNode, TableNode } from "../../model/canonical-document.ts";
16
+ import type { DocumentRootNode, ParagraphNode, SdtNode, TableNode, TextMark } from "../../model/canonical-document.ts";
17
+ import type { TextFormattingDirective } from "../../api/public-types.ts";
17
18
 
18
19
  export type TextInsertion =
19
20
  | {
@@ -38,6 +39,13 @@ export type TextTransactionIntent =
38
39
  to: number;
39
40
  };
40
41
  insertion: TextInsertion[];
42
+ /**
43
+ * I7 — optional formatting directive governing which `marks` the inserted text
44
+ * units carry. Resolved against `story` + `normalizedRange` inside
45
+ * `applyLinearTextTransaction`. `undefined` (or `{ mode: "paragraph-default" }`)
46
+ * preserves pre-I7 behavior (no inherited run marks).
47
+ */
48
+ formatting?: TextFormattingDirective;
41
49
  }
42
50
  | {
43
51
  type: "delete_backward";
@@ -117,7 +125,8 @@ function applyLinearTextTransaction(
117
125
  ): TextTransactionResult {
118
126
  const story = parseTextStory(document.content);
119
127
  const normalizedRange = resolveRange(selection, story.size, intent);
120
- const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from);
128
+ const resolvedMarks = resolveMarksForInsertion(intent, story, normalizedRange);
129
+ const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from, resolvedMarks);
121
130
 
122
131
  // `normalizedRange.{from,to}` are logical positions (scope markers = 0 width,
123
132
  // matching surface-projection). Translate to unit-array indices so scope
@@ -779,6 +788,7 @@ function createInsertionUnits(
779
788
  intent: TextTransactionIntent,
780
789
  story: TextStory,
781
790
  position: number,
791
+ marks: TextMark[] | undefined,
782
792
  ): StoryUnit[] {
783
793
  if (intent.type !== "replace") {
784
794
  return [];
@@ -792,6 +802,7 @@ function createInsertionUnits(
792
802
  return Array.from(entry.text).map<StoryUnit>((character) => ({
793
803
  kind: "text",
794
804
  value: character,
805
+ ...(marks && marks.length > 0 ? { marks: marks.map((mark) => ({ ...mark })) } : {}),
795
806
  }));
796
807
  case "tab":
797
808
  return [{ kind: "tab" }];
@@ -808,6 +819,79 @@ function createInsertionUnits(
808
819
  });
809
820
  }
810
821
 
822
+ /**
823
+ * I7 — given a formatting directive, produce the `marks` array that inserted text
824
+ * units should carry. Returns `undefined` for the paragraph-default path (no marks).
825
+ *
826
+ * - `paragraph-default` / no directive → `undefined`.
827
+ * - `explicit` → caller-supplied marks verbatim.
828
+ * - `match-replaced-range`:
829
+ * - If the range is collapsed, use the marks of the text unit immediately left of
830
+ * the caret (Word-matching behavior for empty-range inserts).
831
+ * - Otherwise walk text units in `[from, to)`. If every text unit shares the same
832
+ * marks (by type), use them. Mixed → fall back to paragraph-default (`undefined`).
833
+ */
834
+ function resolveMarksForInsertion(
835
+ intent: TextTransactionIntent,
836
+ story: TextStory,
837
+ range: { from: number; to: number },
838
+ ): TextMark[] | undefined {
839
+ if (intent.type !== "replace") {
840
+ return undefined;
841
+ }
842
+ const directive = intent.formatting;
843
+ if (!directive || directive.mode === "paragraph-default") {
844
+ return undefined;
845
+ }
846
+ if (directive.mode === "explicit") {
847
+ return directive.marks.map((mark) => ({ ...mark }));
848
+ }
849
+
850
+ // match-replaced-range
851
+ const unitFrom = logicalPositionToUnitIndex(story.units, range.from, "before");
852
+ const unitTo = logicalPositionToUnitIndex(story.units, range.to, "after");
853
+
854
+ if (range.from === range.to) {
855
+ // Empty range — inherit from the text unit immediately left of the caret.
856
+ for (let i = unitFrom - 1; i >= 0; i -= 1) {
857
+ const unit = story.units[i];
858
+ if (unit?.kind === "text") {
859
+ return unit.marks ? unit.marks.map((mark) => ({ ...mark })) : undefined;
860
+ }
861
+ if (unit?.kind === "paragraph_break") {
862
+ break;
863
+ }
864
+ }
865
+ return undefined;
866
+ }
867
+
868
+ const textUnits: Array<{ marks?: TextMark[] }> = [];
869
+ for (let i = unitFrom; i < unitTo; i += 1) {
870
+ const unit = story.units[i];
871
+ if (unit?.kind === "text") {
872
+ textUnits.push(unit);
873
+ }
874
+ }
875
+ if (textUnits.length === 0) {
876
+ return undefined;
877
+ }
878
+ const firstMarks = textUnits[0].marks;
879
+ const uniform = textUnits.every((unit) => marksAreEqual(firstMarks, unit.marks));
880
+ if (!uniform) {
881
+ return undefined;
882
+ }
883
+ return firstMarks ? firstMarks.map((mark) => ({ ...mark })) : undefined;
884
+ }
885
+
886
+ function marksAreEqual(left: TextMark[] | undefined, right: TextMark[] | undefined): boolean {
887
+ if (!left && !right) return true;
888
+ if (!left || !right) return false;
889
+ if (left.length !== right.length) return false;
890
+ const sortedLeft = [...left].map((mark) => mark.type).sort();
891
+ const sortedRight = [...right].map((mark) => mark.type).sort();
892
+ return sortedLeft.every((type, index) => type === sortedRight[index]);
893
+ }
894
+
811
895
  function resolveParagraphPropertiesAtPosition(
812
896
  story: TextStory,
813
897
  position: number,
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @internal HARNESS-ONLY debug-ports token module.
3
+ *
4
+ * This module is **not listed in `package.json#exports`**, so
5
+ * downstream consumers of `@beyondwork/docx-react-component` cannot
6
+ * import it via any public entry point. The in-repo harness at
7
+ * `services/react-word-editor/` reaches it via a relative path
8
+ * (`../../../src/internal/harness-debug-ports`); that is the only
9
+ * supported caller.
10
+ *
11
+ * ## Why this exists
12
+ *
13
+ * It replaces the former public `showUnsupportedObjectPreviews?: boolean`
14
+ * prop on `WordReviewEditorProps`, which regressed to `true` three
15
+ * times through merges (PRs #124, #131, #160). Each regression leaked
16
+ * preserve-only preview chrome (charts, SmartArt, shapes, WordArt,
17
+ * VML, "N preserve-only features detected" banner, lock-callouts)
18
+ * into consumer applications.
19
+ *
20
+ * ## What the new shape buys us
21
+ *
22
+ * 1. **Type-level**: the `HarnessDebugPorts` brand uses a `unique
23
+ * symbol` key that cannot be obtained outside this file, so
24
+ * downstream TypeScript consumers cannot structurally construct a
25
+ * valid token.
26
+ * 2. **Module-level**: this file is not in the package's export map.
27
+ * External consumers would have to reach into the installed
28
+ * package's internals via a non-guaranteed path to find it.
29
+ * 3. **Runtime gate**: even if a caller does manage to invoke
30
+ * `__createHarnessDebugPorts`, the factory checks for a harness
31
+ * environment marker (`globalThis.__DOCX_REACT_COMPONENT_HARNESS__`)
32
+ * that only the harness sets via `__markHarnessEnvironment()`.
33
+ * In a consumer app the flag is false; the returned token's
34
+ * permissions are all `false` regardless of input.
35
+ * 4. **Intent assertion**: the factory input requires a literal
36
+ * `confirmHarnessAuthor: "I-am-the-harness-dev-drawer"` string as
37
+ * a self-documenting "I know what I'm doing" gate.
38
+ *
39
+ * Flipping any of (1)–(4) is the regression this module is designed
40
+ * to prevent. The invariant test at
41
+ * `test/ui/unsupported-previews-invariant.test.ts` locks them all.
42
+ */
43
+
44
+ // NOT exported. The symbol's identity is what makes the brand
45
+ // unobtainable from outside this module: `typeof` on a const symbol
46
+ // gives TypeScript a `unique symbol` type that's only satisfiable
47
+ // by the same `const` binding. Consumers that don't import this
48
+ // module cannot satisfy the `[harnessDebugPortsBrand]: true` field.
49
+ const harnessDebugPortsBrand = Symbol("harnessDebugPortsBrand");
50
+
51
+ /**
52
+ * Opaque, brand-typed token passed through the internal
53
+ * `__harnessDebugPorts` editor prop. Cannot be constructed
54
+ * structurally — the brand key is a module-local `unique symbol`.
55
+ *
56
+ * @internal HARNESS-ONLY.
57
+ */
58
+ export interface HarnessDebugPorts {
59
+ readonly [harnessDebugPortsBrand]: true;
60
+ /**
61
+ * Sanitized. Only `true` when the token factory confirmed
62
+ * harness-env + debug-mode + literal author assertion.
63
+ */
64
+ readonly unsupportedObjectPreviews: boolean;
65
+ }
66
+
67
+ const HARNESS_GLOBAL_KEY = "__DOCX_REACT_COMPONENT_HARNESS__" as const;
68
+
69
+ /**
70
+ * Input to the harness-debug-ports token factory.
71
+ *
72
+ * @internal
73
+ */
74
+ export interface HarnessDebugPortsInput {
75
+ /**
76
+ * Self-documenting author gate. Must be the literal string
77
+ * `"I-am-the-harness-dev-drawer"`. Any other value fails the
78
+ * runtime check and the token's flags are all `false`.
79
+ */
80
+ readonly confirmHarnessAuthor: "I-am-the-harness-dev-drawer";
81
+ /**
82
+ * Harness dev-drawer debug-mode toggle. Even if `true`, the factory
83
+ * refuses to honor the request unless the harness environment is
84
+ * active (`__markHarnessEnvironment()` was called).
85
+ */
86
+ readonly debugMode: boolean;
87
+ /**
88
+ * Requested preview-rendering permission. Honored only when all
89
+ * runtime checks pass.
90
+ */
91
+ readonly unsupportedObjectPreviews: boolean;
92
+ }
93
+
94
+ /**
95
+ * Build a harness-debug-ports token.
96
+ *
97
+ * Runtime gate: the returned token's `unsupportedObjectPreviews`
98
+ * field is `true` **only** when all of the following hold:
99
+ *
100
+ * - `__markHarnessEnvironment()` was called previously (sets the
101
+ * `__DOCX_REACT_COMPONENT_HARNESS__` global to `true`);
102
+ * - `input.confirmHarnessAuthor === "I-am-the-harness-dev-drawer"`;
103
+ * - `input.debugMode === true`;
104
+ * - `input.unsupportedObjectPreviews === true`.
105
+ *
106
+ * Any check failure produces a fully-disabled token (same brand, all
107
+ * flags `false`). The editor component reads the token's flag
108
+ * through `readHarnessDebugPortsFlag()` so downstream rendering code
109
+ * sees a plain `boolean`.
110
+ *
111
+ * @internal HARNESS-ONLY.
112
+ */
113
+ export function __createHarnessDebugPorts(
114
+ input: HarnessDebugPortsInput,
115
+ ): HarnessDebugPorts {
116
+ const harnessActive =
117
+ typeof globalThis !== "undefined" &&
118
+ (globalThis as Record<string, unknown>)[HARNESS_GLOBAL_KEY] === true;
119
+ const authorConfirmed =
120
+ input.confirmHarnessAuthor === "I-am-the-harness-dev-drawer";
121
+ const allow =
122
+ harnessActive && authorConfirmed && input.debugMode === true;
123
+ return Object.freeze({
124
+ [harnessDebugPortsBrand]: true as const,
125
+ unsupportedObjectPreviews:
126
+ allow && input.unsupportedObjectPreviews === true,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Mark the current JavaScript realm as a harness. MUST be called
132
+ * once, synchronously, before the first editor render so the token
133
+ * factory can confirm the environment.
134
+ *
135
+ * @internal HARNESS-ONLY.
136
+ */
137
+ export function __markHarnessEnvironment(): void {
138
+ if (typeof globalThis !== "undefined") {
139
+ (globalThis as Record<string, unknown>)[HARNESS_GLOBAL_KEY] = true;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Read a sanitized flag from a token. Always returns `false` for
145
+ * `undefined` tokens (consumer code paths).
146
+ *
147
+ * @internal
148
+ */
149
+ export function readHarnessDebugPortsFlag(
150
+ token: HarnessDebugPorts | undefined,
151
+ flag: "unsupportedObjectPreviews",
152
+ ): boolean {
153
+ if (!token) return false;
154
+ return token[flag] === true;
155
+ }
156
+
157
+ /**
158
+ * Test-only: reset the harness environment flag. Exported so the
159
+ * invariant/factory tests can assert the "consumer app" path
160
+ * (factory called without `__markHarnessEnvironment`).
161
+ *
162
+ * @internal TEST-ONLY.
163
+ */
164
+ export function __resetHarnessEnvironmentForTests(): void {
165
+ if (typeof globalThis !== "undefined") {
166
+ delete (globalThis as Record<string, unknown>)[HARNESS_GLOBAL_KEY];
167
+ }
168
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  InlineNode,
25
25
  MediaItem,
26
26
  ParagraphNode,
27
+ ResolvedTheme,
27
28
  } from "../model/canonical-document.ts";
28
29
  import type {
29
30
  ChartPreviewResolveParams,
@@ -31,11 +32,34 @@ import type {
31
32
  } from "../api/public-types.ts";
32
33
  import type { OpcPackage } from "./opc/package-reader.ts";
33
34
  import { normalizePartPath, resolveRelationshipTarget } from "./ooxml/part-manifest.ts";
35
+ import { parseThemeXml, resolveTheme } from "./ooxml/parse-theme.ts";
36
+
37
+ /**
38
+ * Parse and resolve the workbook theme once, tolerating malformed XML.
39
+ * An unparseable theme must NOT abort the import — fall back to
40
+ * undefined so downstream renderers use the fallback palette.
41
+ */
42
+ function tryParseTheme(themeXml: string): ResolvedTheme | undefined {
43
+ try {
44
+ return resolveTheme(parseThemeXml(themeXml));
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
34
49
 
35
50
  interface ResolveContext {
36
51
  readonly package: OpcPackage;
37
52
  readonly adapter: EditorHostAdapter;
38
53
  readonly themeXml: string | undefined;
54
+ /**
55
+ * Once-per-import cache of the workbook theme. Stage 2 wiring:
56
+ * in-tree renderers consume `parsedTheme` via
57
+ * `composeSeriesColor(model, parsedTheme, seriesIdx)` to resolve a
58
+ * series palette entry into a concrete sRGB string without re-parsing
59
+ * theme XML on every render. Undefined when the document has no
60
+ * theme1.xml or when parseThemeXml threw.
61
+ */
62
+ readonly parsedTheme: ResolvedTheme | undefined;
39
63
  /** Monotonic counter so we can generate unique media ids across one run. */
40
64
  seq: number;
41
65
  }
@@ -63,8 +87,15 @@ export async function resolveChartPreviewsForDocument(
63
87
  if (pending.length === 0) return doc;
64
88
 
65
89
  const themeXml = extractPartTextFromPackage(pkg, "/word/theme/theme1.xml");
90
+ const parsedTheme = themeXml ? tryParseTheme(themeXml) : undefined;
66
91
  const renderer = adapter.renderChartPreview;
67
- const ctx: ResolveContext = { package: pkg, adapter, themeXml, seq: 0 };
92
+ const ctx: ResolveContext = {
93
+ package: pkg,
94
+ adapter,
95
+ themeXml,
96
+ parsedTheme,
97
+ seq: 0,
98
+ };
68
99
 
69
100
  const resolutions = await Promise.all(
70
101
  pending.map(async (entry) => {
@@ -760,11 +760,56 @@ function walkInlineNodeForBoundaries(
760
760
  return;
761
761
  }
762
762
  case "t": {
763
- const text = node.children
764
- .filter((child): child is XmlTextNode => child.type === "text")
765
- .map((child) => child.text)
766
- .join("");
767
- setCursor(getCursor() + Array.from(text).length);
763
+ // O8 fix: emit a boundary entry for every interior code-point position
764
+ // so comment anchors landing mid-run resolve to a real source byte
765
+ // offset instead of silently dropping into skippedCommentIds. The walk
766
+ // advances through the raw XML source between each text child's start
767
+ // and end, treating XML entities (&amp; &lt; &gt; &quot; &apos; &#N;
768
+ // &#xN;) as a single code point whose byte cursor skips the whole
769
+ // entity. Surrogate pairs collapse to one code point to match
770
+ // Array.from(text).iteration used by the existing cursor math.
771
+ let cursor = getCursor();
772
+ for (const child of node.children) {
773
+ if (child.type !== "text") {
774
+ continue;
775
+ }
776
+ let byte = child.start;
777
+ const end = child.end;
778
+ while (byte < end) {
779
+ if (!boundaries.has(cursor)) {
780
+ // Preserve the outer <w:r>-entry at runStart: it maps cursor to
781
+ // node.start (between runs, a valid insertion point), whereas the
782
+ // interior byte here would point inside <w:t> where inserting
783
+ // commentRangeStart/End would corrupt the text node.
784
+ boundaries.set(cursor, byte);
785
+ }
786
+ const ch = sourceXml.charCodeAt(byte);
787
+ if (ch === 0x26 /* & */) {
788
+ const semi = sourceXml.indexOf(";", byte + 1);
789
+ if (semi !== -1 && semi < end) {
790
+ byte = semi + 1;
791
+ } else {
792
+ byte += 1;
793
+ }
794
+ } else if (ch >= 0xd800 && ch <= 0xdbff && byte + 1 < end) {
795
+ // High surrogate followed by low surrogate = 1 code point / 2 units.
796
+ byte += 2;
797
+ } else {
798
+ byte += 1;
799
+ }
800
+ cursor += 1;
801
+ }
802
+ // Trailing boundary: map the cursor immediately after the last
803
+ // character to the byte just before </w:t>, so an anchor at the right
804
+ // edge of this text node resolves inside the run. The outer <w:r>
805
+ // close will overwrite nothing here because it runs after the inner
806
+ // walk and uses boundaries.set (last-write-wins is fine: node.end
807
+ // points to the same logical insertion point between runs).
808
+ if (!boundaries.has(cursor)) {
809
+ boundaries.set(cursor, end);
810
+ }
811
+ }
812
+ setCursor(cursor);
768
813
  return;
769
814
  }
770
815
  case "tab":
@@ -26,6 +26,7 @@ import {
26
26
  } from "./table-properties-xml.ts";
27
27
  import { twip } from "./twip.ts";
28
28
  import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
29
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
29
30
 
30
31
  const HYPERLINK_RELATIONSHIP_TYPE =
31
32
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
@@ -1607,6 +1608,14 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1607
1608
  }
1608
1609
  }
1609
1610
 
1611
+ // O2 Slice 4 grab-bag: unmodelled sectPr children preserved verbatim
1612
+ // from import. Emitted last so the modelled body stays ECMA-376 canonical
1613
+ // and extension-namespace properties land at the tail of <w:sectPr>.
1614
+ const grabBagXml = emitPropertyGrabBag(props.unknownPropertyChildren);
1615
+ if (grabBagXml.length > 0) {
1616
+ children.push(grabBagXml);
1617
+ }
1618
+
1610
1619
  if (children.length === 0) {
1611
1620
  return "<w:sectPr/>";
1612
1621
  }
@@ -12,6 +12,7 @@ import type {
12
12
  ParagraphSpacing,
13
13
  TabStop,
14
14
  } from "../../model/canonical-document.ts";
15
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
15
16
  import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
16
17
 
17
18
  function escXml(value: string): string {
@@ -147,6 +148,13 @@ export function buildParagraphPropertiesXml(
147
148
  if (markXml) parts.push(markXml);
148
149
  }
149
150
 
151
+ // 16. Grab-bag: unmodelled pPr children preserved verbatim from import.
152
+ // Emitted last so the typed emit order above stays ECMA-376 canonical and
153
+ // any extension-namespace properties land after the modelled set. Word
154
+ // tolerates extra trailing children inside <w:pPr> better than it
155
+ // tolerates interleaving them with the typed set.
156
+ parts.push(emitPropertyGrabBag(pPr.unknownPropertyChildren));
157
+
150
158
  const body = parts.filter(Boolean).join("");
151
159
  return body.length > 0 ? `<w:pPr>${body}</w:pPr>` : "";
152
160
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
11
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
11
12
 
12
13
  function escXml(value: string): string {
13
14
  return value
@@ -57,10 +58,12 @@ export function buildRunPropertiesXml(rPr: CanonicalRunFormatting | undefined):
57
58
  parts.push(toggleEl("vanish", rPr.vanish));
58
59
 
59
60
  // 8. color
60
- if (rPr.colorHex || rPr.colorThemeSlot) {
61
+ if (rPr.colorHex || rPr.colorThemeSlot || rPr.colorThemeTint || rPr.colorThemeShade) {
61
62
  const attrs: string[] = [];
62
63
  if (rPr.colorHex) attrs.push(`w:val="${escXml(rPr.colorHex)}"`);
63
64
  if (rPr.colorThemeSlot) attrs.push(`w:themeColor="${escXml(rPr.colorThemeSlot)}"`);
65
+ if (rPr.colorThemeTint) attrs.push(`w:themeTint="${escXml(rPr.colorThemeTint)}"`);
66
+ if (rPr.colorThemeShade) attrs.push(`w:themeShade="${escXml(rPr.colorThemeShade)}"`);
64
67
  parts.push(`<w:color ${attrs.join(" ")}/>`);
65
68
  }
66
69
 
@@ -85,6 +88,12 @@ export function buildRunPropertiesXml(rPr: CanonicalRunFormatting | undefined):
85
88
  if (rPr.fontSizeHalfPoints !== undefined) parts.push(`<w:sz w:val="${rPr.fontSizeHalfPoints}"/>`);
86
89
  if (rPr.fontSizeCsHalfPoints !== undefined) parts.push(`<w:szCs w:val="${rPr.fontSizeCsHalfPoints}"/>`);
87
90
 
91
+ // 15. Grab-bag: unmodelled rPr children preserved verbatim from import
92
+ // (extension-namespace properties like <w14:textOutline>, Word-internal
93
+ // knobs like <w:em>, <w:kern>). Emitted last so the typed body stays
94
+ // ECMA-376 canonical and Word tolerates extras at the tail of <w:rPr>.
95
+ parts.push(emitPropertyGrabBag(rPr.unknownPropertyChildren));
96
+
88
97
  const body = parts.filter(Boolean).join("");
89
98
  return body.length > 0 ? `<w:rPr>${body}</w:rPr>` : "";
90
99
  }