@beyondwork/docx-react-component 1.0.38 → 1.0.39

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 (76) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +183 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/io/docx-session.ts +1 -0
  6. package/src/io/export/serialize-numbering.ts +42 -8
  7. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  8. package/src/io/export/serialize-run-formatting.ts +90 -0
  9. package/src/io/export/serialize-styles.ts +212 -0
  10. package/src/io/ooxml/parse-fields.ts +10 -3
  11. package/src/io/ooxml/parse-numbering.ts +41 -1
  12. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  13. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  14. package/src/io/ooxml/parse-styles.ts +31 -0
  15. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  16. package/src/io/ooxml/xml-element.ts +19 -0
  17. package/src/model/canonical-document.ts +83 -3
  18. package/src/runtime/collab/event-types.ts +165 -0
  19. package/src/runtime/collab/index.ts +22 -0
  20. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  22. package/src/runtime/document-runtime.ts +134 -18
  23. package/src/runtime/layout/index.ts +2 -0
  24. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  25. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  26. package/src/runtime/layout/layout-invalidation.ts +14 -5
  27. package/src/runtime/layout/page-graph.ts +36 -0
  28. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  29. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  30. package/src/runtime/layout/project-block-fragments.ts +154 -20
  31. package/src/runtime/layout/public-facet.ts +40 -1
  32. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  33. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  34. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  35. package/src/runtime/layout/table-render-plan.ts +21 -1
  36. package/src/runtime/numbering-prefix.ts +5 -0
  37. package/src/runtime/paragraph-style-resolver.ts +194 -0
  38. package/src/runtime/render/render-kernel.ts +5 -1
  39. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  40. package/src/runtime/surface-projection.ts +129 -9
  41. package/src/runtime/table-schema.ts +11 -0
  42. package/src/ui/WordReviewEditor.tsx +285 -5
  43. package/src/ui/editor-command-bag.ts +4 -0
  44. package/src/ui/editor-runtime-boundary.ts +16 -0
  45. package/src/ui/editor-shell-view.tsx +4 -0
  46. package/src/ui/editor-surface-controller.tsx +9 -1
  47. package/src/ui/headless/chrome-registry.ts +34 -5
  48. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  49. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  50. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  51. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  52. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  53. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  54. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  55. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  56. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  57. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  58. package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
  59. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
  60. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  61. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  62. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  63. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  64. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  65. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  66. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  67. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  68. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  69. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  70. package/src/ui-tailwind/index.ts +1 -5
  71. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  72. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  73. package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
  74. package/src/runtime/collab-review-sync.ts +0 -254
  75. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  76. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -3,6 +3,7 @@ import {
3
3
  isSyntheticDocxNullAbstractDefinition,
4
4
  isSyntheticDocxNullNumberingInstance,
5
5
  } from "../ooxml/numbering-sentinels.ts";
6
+ import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
6
7
  import { twip } from "./twip.ts";
7
8
 
8
9
  export const WORD_NUMBERING_CONTENT_TYPE =
@@ -62,35 +63,61 @@ function serializeAbstractDefinition(definition: NumberingCatalog["abstractDefin
62
63
  stripCanonicalPrefix(definition.abstractNumberingId, "abstract-num:"),
63
64
  ),
64
65
  );
66
+
67
+ // ECMA-376 abstractNum child order: nsid, multiLevelType, tmpl, styleLink,
68
+ // numStyleLink — all before the <w:lvl> children.
69
+ const nsid = definition.nsid
70
+ ? `<w:nsid w:val="${escapeAttribute(definition.nsid)}"/>`
71
+ : "";
72
+ const multiLevelType = definition.multiLevelType
73
+ ? `<w:multiLevelType w:val="${escapeAttribute(definition.multiLevelType)}"/>`
74
+ : "";
75
+ const tmpl = definition.tplc
76
+ ? `<w:tmpl w:val="${escapeAttribute(definition.tplc)}"/>`
77
+ : "";
78
+ const styleLink = definition.styleLink
79
+ ? `<w:styleLink w:val="${escapeAttribute(definition.styleLink)}"/>`
80
+ : "";
81
+ const numStyleLink = definition.numStyleLink
82
+ ? `<w:numStyleLink w:val="${escapeAttribute(definition.numStyleLink)}"/>`
83
+ : "";
84
+
65
85
  const levels = [...definition.levels]
66
86
  .sort((left, right) => left.level - right.level)
67
87
  .map((level) => serializeLevel(level))
68
88
  .join("");
69
89
 
70
- return `<w:abstractNum w:abstractNumId="${abstractNumId}">${levels}</w:abstractNum>`;
90
+ return `<w:abstractNum w:abstractNumId="${abstractNumId}">${nsid}${multiLevelType}${tmpl}${styleLink}${numStyleLink}${levels}</w:abstractNum>`;
71
91
  }
72
92
 
73
93
  function serializeLevel(
74
94
  level: NumberingCatalog["abstractDefinitions"][string]["levels"][number],
75
95
  serializedLevel = level.level,
76
96
  ): string {
97
+ // ECMA-376 canonical lvl child order:
98
+ // start, numFmt, lvlRestart, pStyle, isLgl, suff, lvlText, lvlJc, pPr, rPr
77
99
  const start =
78
100
  level.startAt !== undefined
79
101
  ? `<w:start w:val="${clampStart(level.startAt)}"/>`
80
102
  : "";
103
+ const numFmt = `<w:numFmt w:val="${escapeAttribute(level.format)}"/>`;
104
+ const lvlRestart =
105
+ level.restartAfterLevel !== undefined
106
+ ? `<w:lvlRestart w:val="${level.restartAfterLevel}"/>`
107
+ : "";
81
108
  const paragraphStyle = level.paragraphStyleId
82
109
  ? `<w:pStyle w:val="${escapeAttribute(level.paragraphStyleId)}"/>`
83
110
  : "";
84
111
  const isLegal = level.isLegalNumbering ? "<w:isLgl/>" : "";
85
112
  const suffix = level.suffix ? `<w:suff w:val="${escapeAttribute(level.suffix)}"/>` : "";
113
+ const lvlText = `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`;
86
114
  const justification = level.paragraphGeometry?.justification
87
115
  ? `<w:lvlJc w:val="${escapeAttribute(level.paragraphGeometry.justification)}"/>`
88
116
  : "";
89
117
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
118
+ const runProperties = buildRunPropertiesXml(level.runProperties);
90
119
 
91
- return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}<w:numFmt w:val="${escapeAttribute(
92
- level.format,
93
- )}"/><w:lvlText w:val="${escapeAttribute(level.text)}"/>${paragraphStyle}${isLegal}${suffix}${justification}${paragraphProperties}</w:lvl>`;
120
+ return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}${numFmt}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${lvlText}${justification}${paragraphProperties}${runProperties}</w:lvl>`;
94
121
  }
95
122
 
96
123
  function serializeLevelOverride(
@@ -101,14 +128,17 @@ function serializeLevelOverride(
101
128
  return "";
102
129
  }
103
130
 
131
+ // ECMA-376 canonical lvl child order (override subset):
132
+ // start, numFmt, lvlRestart, pStyle, isLgl, suff, lvlText, lvlJc, pPr, rPr
104
133
  const start =
105
134
  level.startAt !== undefined
106
135
  ? `<w:start w:val="${clampStart(level.startAt)}"/>`
107
136
  : "";
108
137
  const format = level.format ? `<w:numFmt w:val="${escapeAttribute(level.format)}"/>` : "";
109
- const text = level.text !== undefined
110
- ? `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`
111
- : "";
138
+ const lvlRestart =
139
+ level.restartAfterLevel !== undefined
140
+ ? `<w:lvlRestart w:val="${level.restartAfterLevel}"/>`
141
+ : "";
112
142
  const paragraphStyle = level.paragraphStyleId
113
143
  ? `<w:pStyle w:val="${escapeAttribute(level.paragraphStyleId)}"/>`
114
144
  : "";
@@ -124,11 +154,15 @@ function serializeLevelOverride(
124
154
  ? `<w:isLgl w:val="false"/>`
125
155
  : "";
126
156
  const suffix = level.suffix ? `<w:suff w:val="${escapeAttribute(level.suffix)}"/>` : "";
157
+ const text = level.text !== undefined
158
+ ? `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`
159
+ : "";
127
160
  const justification = level.paragraphGeometry?.justification
128
161
  ? `<w:lvlJc w:val="${escapeAttribute(level.paragraphGeometry.justification)}"/>`
129
162
  : "";
130
163
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
131
- const body = `${start}${format}${text}${paragraphStyle}${isLegal}${suffix}${justification}${paragraphProperties}`;
164
+ const runProperties = buildRunPropertiesXml(level.runProperties);
165
+ const body = `${start}${format}${lvlRestart}${paragraphStyle}${isLegal}${suffix}${text}${justification}${paragraphProperties}${runProperties}`;
132
166
 
133
167
  return body.length > 0
134
168
  ? `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${body}</w:lvl>`
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Serialize a `CanonicalParagraphFormatting` back into an OOXML `<w:pPr>` fragment.
3
+ * Returns empty when the input has no fields. Emits elements in ECMA-376 canonical
4
+ * order so the OpenXML SDK validator accepts the output.
5
+ */
6
+
7
+ import type {
8
+ CanonicalParagraphFormatting,
9
+ ParagraphBorders,
10
+ ParagraphIndentation,
11
+ ParagraphShading,
12
+ ParagraphSpacing,
13
+ TabStop,
14
+ } from "../../model/canonical-document.ts";
15
+ import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
16
+
17
+ function escXml(value: string): string {
18
+ return value
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;");
23
+ }
24
+
25
+ function toggleEl(tag: string, value: boolean | undefined): string {
26
+ if (value === undefined) return "";
27
+ return value ? `<w:${tag}/>` : `<w:${tag} w:val="0"/>`;
28
+ }
29
+
30
+ function borderAttrs(b: {
31
+ value?: string;
32
+ size?: number;
33
+ space?: number;
34
+ color?: string;
35
+ }): string {
36
+ const attrs: string[] = [];
37
+ if (b.value) attrs.push(`w:val="${escXml(b.value)}"`);
38
+ if (b.size !== undefined) attrs.push(`w:sz="${b.size}"`);
39
+ if (b.space !== undefined) attrs.push(`w:space="${b.space}"`);
40
+ if (b.color) attrs.push(`w:color="${escXml(b.color)}"`);
41
+ return attrs.join(" ");
42
+ }
43
+
44
+ function buildBordersXml(b: ParagraphBorders | undefined): string {
45
+ if (!b) return "";
46
+ const sides = ["top", "left", "bottom", "right", "between", "bar"] as const;
47
+ const parts: string[] = [];
48
+ for (const side of sides) {
49
+ const spec = b[side];
50
+ if (!spec) continue;
51
+ const attrs = borderAttrs(spec);
52
+ if (attrs) parts.push(`<w:${side} ${attrs}/>`);
53
+ }
54
+ return parts.length > 0 ? `<w:pBdr>${parts.join("")}</w:pBdr>` : "";
55
+ }
56
+
57
+ function buildShadingXml(s: ParagraphShading | undefined): string {
58
+ if (!s) return "";
59
+ const attrs: string[] = [];
60
+ if (s.val) attrs.push(`w:val="${escXml(s.val)}"`);
61
+ if (s.color) attrs.push(`w:color="${escXml(s.color)}"`);
62
+ if (s.fill) attrs.push(`w:fill="${escXml(s.fill)}"`);
63
+ return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
64
+ }
65
+
66
+ function buildTabsXml(tabs: TabStop[] | undefined): string {
67
+ if (!tabs || tabs.length === 0) return "";
68
+ const parts = tabs.map((t) => {
69
+ // Canonical "middleDot" → OOXML "middledot"
70
+ const leader = t.leader === "middleDot" ? "middledot" : t.leader;
71
+ const attrs: string[] = [`w:val="${escXml(t.align)}"`, `w:pos="${t.position}"`];
72
+ if (leader) attrs.push(`w:leader="${escXml(leader)}"`);
73
+ return `<w:tab ${attrs.join(" ")}/>`;
74
+ });
75
+ return `<w:tabs>${parts.join("")}</w:tabs>`;
76
+ }
77
+
78
+ function buildSpacingXml(s: ParagraphSpacing | undefined): string {
79
+ if (!s) return "";
80
+ const attrs: string[] = [];
81
+ if (s.before !== undefined) attrs.push(`w:before="${s.before}"`);
82
+ if (s.after !== undefined) attrs.push(`w:after="${s.after}"`);
83
+ if (s.line !== undefined) attrs.push(`w:line="${s.line}"`);
84
+ if (s.lineRule) attrs.push(`w:lineRule="${escXml(s.lineRule)}"`);
85
+ return attrs.length > 0 ? `<w:spacing ${attrs.join(" ")}/>` : "";
86
+ }
87
+
88
+ function buildIndentXml(i: ParagraphIndentation | undefined): string {
89
+ if (!i) return "";
90
+ const attrs: string[] = [];
91
+ if (i.left !== undefined) attrs.push(`w:left="${i.left}"`);
92
+ if (i.right !== undefined) attrs.push(`w:right="${i.right}"`);
93
+ if (i.firstLine !== undefined) attrs.push(`w:firstLine="${i.firstLine}"`);
94
+ if (i.hanging !== undefined) attrs.push(`w:hanging="${i.hanging}"`);
95
+ return attrs.length > 0 ? `<w:ind ${attrs.join(" ")}/>` : "";
96
+ }
97
+
98
+ export function buildParagraphPropertiesXml(
99
+ pPr: CanonicalParagraphFormatting | undefined,
100
+ ): string {
101
+ if (!pPr) return "";
102
+ const parts: string[] = [];
103
+
104
+ // ECMA-376 canonical pPr child order (subset we model):
105
+ // 1. keepNext, keepLines, pageBreakBefore
106
+ parts.push(toggleEl("keepNext", pPr.keepNext));
107
+ parts.push(toggleEl("keepLines", pPr.keepLines));
108
+ parts.push(toggleEl("pageBreakBefore", pPr.pageBreakBefore));
109
+
110
+ // 4. pBdr
111
+ parts.push(buildBordersXml(pPr.borders));
112
+
113
+ // 5. shd
114
+ parts.push(buildShadingXml(pPr.shading));
115
+
116
+ // 6. tabs
117
+ parts.push(buildTabsXml(pPr.tabStops));
118
+
119
+ // 7. spacing
120
+ parts.push(buildSpacingXml(pPr.spacing));
121
+
122
+ // 8. ind
123
+ parts.push(buildIndentXml(pPr.indentation));
124
+
125
+ // 9. contextualSpacing
126
+ parts.push(toggleEl("contextualSpacing", pPr.contextualSpacing));
127
+
128
+ // 10. widowControl
129
+ parts.push(toggleEl("widowControl", pPr.widowControl));
130
+
131
+ // 11. suppressLineNumbers, suppressAutoHyphens
132
+ parts.push(toggleEl("suppressLineNumbers", pPr.suppressLineNumbers));
133
+ parts.push(toggleEl("suppressAutoHyphens", pPr.suppressAutoHyphens));
134
+
135
+ // 12. bidi
136
+ parts.push(toggleEl("bidi", pPr.bidi));
137
+
138
+ // 13. jc
139
+ if (pPr.alignment) parts.push(`<w:jc w:val="${escXml(pPr.alignment)}"/>`);
140
+
141
+ // 14. outlineLvl
142
+ if (pPr.outlineLevel !== undefined) parts.push(`<w:outlineLvl w:val="${pPr.outlineLevel}"/>`);
143
+
144
+ // 15. rPr (paragraph mark)
145
+ if (pPr.paragraphMarkRunProperties) {
146
+ const markXml = buildRunPropertiesXml(pPr.paragraphMarkRunProperties);
147
+ if (markXml) parts.push(markXml);
148
+ }
149
+
150
+ const body = parts.filter(Boolean).join("");
151
+ return body.length > 0 ? `<w:pPr>${body}</w:pPr>` : "";
152
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Serialize a `CanonicalRunFormatting` back into an OOXML `<w:rPr>` fragment.
3
+ * Returns an empty string when the input has no fields, so callers can safely
4
+ * concatenate without emitting a `<w:rPr/>` husk.
5
+ *
6
+ * Elements are emitted in ECMA-376 canonical order so the OpenXML SDK
7
+ * validator accepts the output.
8
+ */
9
+
10
+ import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
11
+
12
+ function escXml(value: string): string {
13
+ return value
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;");
18
+ }
19
+
20
+ function toggleEl(tag: string, value: boolean | undefined): string {
21
+ if (value === undefined) return "";
22
+ return value ? `<w:${tag}/>` : `<w:${tag} w:val="0"/>`;
23
+ }
24
+
25
+ export function buildRunPropertiesXml(rPr: CanonicalRunFormatting | undefined): string {
26
+ if (!rPr) return "";
27
+ const parts: string[] = [];
28
+
29
+ // 1. rStyle
30
+ if (rPr.characterStyleId) parts.push(`<w:rStyle w:val="${escXml(rPr.characterStyleId)}"/>`);
31
+
32
+ // 2. rFonts
33
+ if (rPr.fontFamilyAscii || rPr.fontFamilyHAnsi || rPr.fontFamilyEastAsia || rPr.fontFamilyCs) {
34
+ const attrs: string[] = [];
35
+ if (rPr.fontFamilyAscii) attrs.push(`w:ascii="${escXml(rPr.fontFamilyAscii)}"`);
36
+ if (rPr.fontFamilyHAnsi) attrs.push(`w:hAnsi="${escXml(rPr.fontFamilyHAnsi)}"`);
37
+ if (rPr.fontFamilyEastAsia) attrs.push(`w:eastAsia="${escXml(rPr.fontFamilyEastAsia)}"`);
38
+ if (rPr.fontFamilyCs) attrs.push(`w:cs="${escXml(rPr.fontFamilyCs)}"`);
39
+ parts.push(`<w:rFonts ${attrs.join(" ")}/>`);
40
+ }
41
+
42
+ // 3. b, bCs (bCs not modeled, skip)
43
+ parts.push(toggleEl("b", rPr.bold));
44
+
45
+ // 4. i, iCs (iCs not modeled, skip)
46
+ parts.push(toggleEl("i", rPr.italic));
47
+
48
+ // 5. caps, smallCaps
49
+ parts.push(toggleEl("caps", rPr.allCaps));
50
+ parts.push(toggleEl("smallCaps", rPr.smallCaps));
51
+
52
+ // 6. strike, dstrike
53
+ parts.push(toggleEl("strike", rPr.strikethrough));
54
+ parts.push(toggleEl("dstrike", rPr.doubleStrikethrough));
55
+
56
+ // 7. vanish
57
+ parts.push(toggleEl("vanish", rPr.vanish));
58
+
59
+ // 8. color
60
+ if (rPr.colorHex || rPr.colorThemeSlot) {
61
+ const attrs: string[] = [];
62
+ if (rPr.colorHex) attrs.push(`w:val="${escXml(rPr.colorHex)}"`);
63
+ if (rPr.colorThemeSlot) attrs.push(`w:themeColor="${escXml(rPr.colorThemeSlot)}"`);
64
+ parts.push(`<w:color ${attrs.join(" ")}/>`);
65
+ }
66
+
67
+ // 9. spacing (character spacing)
68
+ if (rPr.characterSpacingTwips !== undefined) {
69
+ parts.push(`<w:spacing w:val="${rPr.characterSpacingTwips}"/>`);
70
+ }
71
+
72
+ // 10. highlight
73
+ if (rPr.highlight) parts.push(`<w:highlight w:val="${escXml(rPr.highlight)}"/>`);
74
+
75
+ // 11. u (underline)
76
+ if (rPr.underline) parts.push(`<w:u w:val="${escXml(rPr.underline)}"/>`);
77
+
78
+ // 12. vertAlign
79
+ if (rPr.verticalAlign) parts.push(`<w:vertAlign w:val="${escXml(rPr.verticalAlign)}"/>`);
80
+
81
+ // 13. lang
82
+ if (rPr.languageCode) parts.push(`<w:lang w:val="${escXml(rPr.languageCode)}"/>`);
83
+
84
+ // 14. sz, szCs
85
+ if (rPr.fontSizeHalfPoints !== undefined) parts.push(`<w:sz w:val="${rPr.fontSizeHalfPoints}"/>`);
86
+ if (rPr.fontSizeCsHalfPoints !== undefined) parts.push(`<w:szCs w:val="${rPr.fontSizeCsHalfPoints}"/>`);
87
+
88
+ const body = parts.filter(Boolean).join("");
89
+ return body.length > 0 ? `<w:rPr>${body}</w:rPr>` : "";
90
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Serialize a `StylesCatalog` to an OOXML `<w:styles>` document fragment.
3
+ *
4
+ * ⚠️ NOT YET WIRED INTO THE EXPORT PIPELINE. As of the Task 7 commit (bullet-list
5
+ * fidelity Phase 1), the real docx export path (`reattach-preserved-parts.ts`)
6
+ * copies `word/styles.xml` verbatim from the source package — this module is
7
+ * reachable only from tests. That is fine for Phases 2–3 (the cascade resolver
8
+ * reads the parsed catalog fields; it doesn't require re-emission).
9
+ *
10
+ * Before wiring this serializer into `docx-session.ts` / `ownedOutputPaths`, the
11
+ * following gaps MUST be closed — otherwise wiring it in will silently destroy
12
+ * user data:
13
+ *
14
+ * 1. `StylesCatalog.latentStyles` is not emitted. Add a `buildLatentStylesXml`
15
+ * counterpart and call it inside `<w:styles>` after `<w:docDefaults>`.
16
+ * 2. `StylesCatalog.tables` is not emitted. Table styles round-trip through
17
+ * the existing `serialize-table-styles` path today only because styles.xml
18
+ * passes through verbatim; wiring this serializer would drop them.
19
+ * 3. The `<w:style>` metadata fields `<w:aliases>`, `<w:link>`, `<w:autoRedefine>`,
20
+ * `<w:hidden>`, `<w:uiPriority>`, `<w:semiHidden>`, `<w:unhideWhenUsed>`,
21
+ * `<w:qFormat>`, `<w:locked>`, `<w:personal>`, `<w:personal{Compose,Reply}>`,
22
+ * `<w:rsid>`, plus the `<w:customStyle>` attribute, are not modeled on
23
+ * `ParagraphStyleDefinition`/`CharacterStyleDefinition` yet. Extend the
24
+ * canonical model first, parse them, and emit them here.
25
+ * 4. Root-level `<w:styles>` metadata (`<w:docId>`, `<w:rsids>`) is not
26
+ * modeled or emitted.
27
+ *
28
+ * Until those are closed, wiring this in would be a regression.
29
+ */
30
+
31
+ import type { StylesCatalog } from "../../model/canonical-document.ts";
32
+ import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
33
+ import { buildParagraphPropertiesXml } from "./serialize-paragraph-formatting.ts";
34
+
35
+ export const WORD_STYLES_CONTENT_TYPE =
36
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
37
+
38
+ function escXml(value: string): string {
39
+ return value
40
+ .replace(/&/g, "&amp;")
41
+ .replace(/</g, "&lt;")
42
+ .replace(/>/g, "&gt;")
43
+ .replace(/"/g, "&quot;");
44
+ }
45
+
46
+ function buildDocDefaultsXml(catalog: StylesCatalog): string {
47
+ const { docDefaults } = catalog;
48
+ if (!docDefaults) return "";
49
+
50
+ const rPrXml = buildRunPropertiesXml(docDefaults.run);
51
+ const pPrXml = buildParagraphPropertiesXml(docDefaults.paragraph);
52
+
53
+ if (!rPrXml && !pPrXml) return "";
54
+
55
+ const rPrDefault = rPrXml
56
+ ? `<w:rPrDefault>${rPrXml}</w:rPrDefault>`
57
+ : "";
58
+ const pPrDefault = pPrXml
59
+ ? `<w:pPrDefault>${pPrXml}</w:pPrDefault>`
60
+ : "";
61
+
62
+ return `<w:docDefaults>${rPrDefault}${pPrDefault}</w:docDefaults>`;
63
+ }
64
+
65
+ function buildParagraphStyleXml(
66
+ style: StylesCatalog["paragraphs"][string],
67
+ ): string {
68
+ const defaultAttr = style.isDefault ? ` w:default="1"` : "";
69
+ const nameEl = `<w:name w:val="${escXml(style.displayName)}"/>`;
70
+ const basedOnEl = style.basedOn
71
+ ? `<w:basedOn w:val="${escXml(style.basedOn)}"/>`
72
+ : "";
73
+ const nextEl = style.nextStyle
74
+ ? `<w:next w:val="${escXml(style.nextStyle)}"/>`
75
+ : "";
76
+
77
+ // Build pPr: may contain numPr (from numbering) and any canonical formatting.
78
+ // We reconstruct the pPr children in canonical order:
79
+ // pStyle handled externally; numPr; then pPr formatting body (which includes
80
+ // outlineLvl at position 14 in the canonical order).
81
+ const numPrXml = style.numbering
82
+ ? buildStyleNumPrXml(style.numbering)
83
+ : "";
84
+
85
+ // Build pPr body — merge numPr into the formatting pPr.
86
+ // The canonical pPr formatter handles everything except numPr.
87
+ // We inject numPr after keepNext/keepLines/pageBreakBefore, before pBdr
88
+ // (position 3 in ECMA-376 pPr schema order).
89
+ const pPrBodyXml = buildParagraphPropertiesXmlWithNumPr(style.paragraphProperties, numPrXml);
90
+
91
+ const rPrXml = buildRunPropertiesXml(style.runProperties);
92
+
93
+ return (
94
+ `<w:style w:type="paragraph" w:styleId="${escXml(style.styleId)}"${defaultAttr}>` +
95
+ nameEl +
96
+ basedOnEl +
97
+ nextEl +
98
+ pPrBodyXml +
99
+ rPrXml +
100
+ `</w:style>`
101
+ );
102
+ }
103
+
104
+ function buildStyleNumPrXml(
105
+ numbering: NonNullable<StylesCatalog["paragraphs"][string]["numbering"]>,
106
+ ): string {
107
+ // Strip canonical "num:" prefix to get the raw numId value.
108
+ const rawId = numbering.numberingInstanceId.startsWith("num:")
109
+ ? numbering.numberingInstanceId.slice(4)
110
+ : numbering.numberingInstanceId;
111
+ const ilvlEl =
112
+ numbering.level !== undefined
113
+ ? `<w:ilvl w:val="${numbering.level}"/>`
114
+ : "";
115
+ return `<w:numPr>${ilvlEl}<w:numId w:val="${escXml(rawId)}"/></w:numPr>`;
116
+ }
117
+
118
+ /**
119
+ * Build a `<w:pPr>` that folds in an optional `<w:numPr>` at the canonical
120
+ * schema position (after keepNext/keepLines/pageBreakBefore, before pBdr).
121
+ *
122
+ * When there is no canonical paragraph formatting AND no numPr, returns "".
123
+ */
124
+ function buildParagraphPropertiesXmlWithNumPr(
125
+ pPr: StylesCatalog["paragraphs"][string]["paragraphProperties"],
126
+ numPrXml: string,
127
+ ): string {
128
+ if (!numPrXml) {
129
+ // Delegate entirely to the shared formatter.
130
+ return buildParagraphPropertiesXml(pPr);
131
+ }
132
+
133
+ // We need to inject numPr into the correct position.
134
+ // Use the shared formatter's output if pPr exists, then splice numPr in
135
+ // after the toggle booleans (keepNext/keepLines/pageBreakBefore) and
136
+ // before pBdr. The simplest approach: re-build manually with full control.
137
+ if (!pPr) {
138
+ return `<w:pPr>${numPrXml}</w:pPr>`;
139
+ }
140
+
141
+ // Rebuild with the same order as buildParagraphPropertiesXml, inserting numPr
142
+ // after the first three toggles (positions 1-3) per ECMA-376 pPr schema.
143
+ const parts: string[] = [];
144
+ const fn = (tag: string, v: boolean | undefined) =>
145
+ v === undefined ? "" : v ? `<w:${tag}/>` : `<w:${tag} w:val="0"/>`;
146
+
147
+ parts.push(fn("keepNext", pPr.keepNext));
148
+ parts.push(fn("keepLines", pPr.keepLines));
149
+ parts.push(fn("pageBreakBefore", pPr.pageBreakBefore));
150
+ parts.push(numPrXml);
151
+ // Remaining fields delegated through the helper for pBdr, shd, tabs, etc.
152
+ // We do this by generating the full pPr body and stripping the outer <w:pPr> wrapper,
153
+ // then removing any duplicate keepNext/keepLines/pageBreakBefore already emitted.
154
+ const innerXml = buildParagraphPropertiesXml(pPr);
155
+ if (innerXml) {
156
+ // Strip outer <w:pPr>...</w:pPr> wrapper.
157
+ const inner = innerXml.replace(/^<w:pPr>/, "").replace(/<\/w:pPr>$/, "");
158
+ // Remove the three toggles we already emitted above to avoid duplication.
159
+ const deduped = inner
160
+ .replace(/<w:keepNext(?:\s[^>]*)?\/?>/g, "")
161
+ .replace(/<w:keepLines(?:\s[^>]*)?\/?>/g, "")
162
+ .replace(/<w:pageBreakBefore(?:\s[^>]*)?\/?>/g, "");
163
+ parts.push(deduped);
164
+ }
165
+
166
+ const body = parts.filter(Boolean).join("");
167
+ return body.length > 0 ? `<w:pPr>${body}</w:pPr>` : "";
168
+ }
169
+
170
+ function buildCharacterStyleXml(
171
+ style: StylesCatalog["characters"][string],
172
+ ): string {
173
+ const defaultAttr = style.isDefault ? ` w:default="1"` : "";
174
+ const nameEl = `<w:name w:val="${escXml(style.displayName)}"/>`;
175
+ const basedOnEl = style.basedOn
176
+ ? `<w:basedOn w:val="${escXml(style.basedOn)}"/>`
177
+ : "";
178
+ const rPrXml = buildRunPropertiesXml(style.runProperties);
179
+
180
+ return (
181
+ `<w:style w:type="character" w:styleId="${escXml(style.styleId)}"${defaultAttr}>` +
182
+ nameEl +
183
+ basedOnEl +
184
+ rPrXml +
185
+ `</w:style>`
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Serialize a `StylesCatalog` to a `<w:styles>` XML string.
191
+ *
192
+ * Only paragraph and character styles are emitted. Table styles are skipped
193
+ * (they are preserved as opaque package fragments in round-trip workflows).
194
+ */
195
+ export function serializeStylesXml(catalog: StylesCatalog): string {
196
+ const docDefaultsXml = buildDocDefaultsXml(catalog);
197
+
198
+ const paragraphStyles = Object.values(catalog.paragraphs)
199
+ .map((style) => buildParagraphStyleXml(style))
200
+ .join("");
201
+
202
+ const characterStyles = Object.values(catalog.characters)
203
+ .map((style) => buildCharacterStyleXml(style))
204
+ .join("");
205
+
206
+ const body = docDefaultsXml + paragraphStyles + characterStyles;
207
+
208
+ return [
209
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
210
+ `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${body}</w:styles>`,
211
+ ].join("\n");
212
+ }
@@ -215,7 +215,14 @@ import type {
215
215
  const FIELD_FAMILY_PATTERN =
216
216
  /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|MERGEFIELD|IF|SEQ|INDEX|TC|STYLEREF)\b/i;
217
217
 
218
- const SUPPORTED_FAMILIES = new Set<string>(["REF", "PAGEREF", "NOTEREF", "TOC"]);
218
+ const SUPPORTED_FAMILIES = new Set<string>([
219
+ "REF",
220
+ "PAGEREF",
221
+ "NOTEREF",
222
+ "TOC",
223
+ "PAGE",
224
+ "NUMPAGES",
225
+ ]);
219
226
 
220
227
  /**
221
228
  * Classify a field instruction into its field family.
@@ -257,8 +264,8 @@ export function isSupportedFieldFamily(family: FieldFamily): family is Supported
257
264
  * Build a field registry from a canonical document, cataloging every field
258
265
  * instance with its classification, dependency metadata, and refresh status.
259
266
  *
260
- * The registry partitions fields into `supported` (REF, PAGEREF, NOTEREF, TOC)
261
- * and `preserveOnly` (all others) slices.
267
+ * The registry partitions fields into `supported` (REF, PAGEREF, NOTEREF,
268
+ * TOC, PAGE, NUMPAGES) and `preserveOnly` (all others) slices.
262
269
  */
263
270
  export function buildFieldRegistry(
264
271
  document: Pick<CanonicalDocument, "content" | "styles"> & {