@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -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/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -1,5 +1,10 @@
1
1
  import type { InsertTableOptions } from "../../api/public-types";
2
- import type { ParagraphNode } from "../../model/canonical-document.ts";
2
+ import type {
3
+ DocumentRootNode,
4
+ ParagraphNode,
5
+ ParagraphStyleDefinition,
6
+ StylesCatalog,
7
+ } from "../../model/canonical-document.ts";
3
8
  import {
4
9
  createSelectionSnapshot,
5
10
  type CanonicalDocumentEnvelope,
@@ -18,11 +23,102 @@ import {
18
23
  resolveParagraphScope,
19
24
  type StructuralMutationResult,
20
25
  } from "./structural-helpers.ts";
26
+ import { createEditorSurfaceSnapshot } from "../../runtime/surface-projection.ts";
21
27
 
22
28
  export interface TextCommandContext {
23
29
  timestamp: string;
24
30
  }
25
31
 
32
+ /**
33
+ * Walk the `basedOn` chain of paragraph styles looking for a `nextStyle`
34
+ * definition. Returns the resolved `nextStyle` id, or undefined if none is
35
+ * found. Caps the walk at 32 steps to guard against circular `basedOn` chains.
36
+ */
37
+ function resolveNextStyle(
38
+ styleId: string | undefined,
39
+ catalog: StylesCatalog,
40
+ ): string | undefined {
41
+ if (!styleId) {
42
+ return undefined;
43
+ }
44
+ let current: string | undefined = styleId;
45
+ let steps = 0;
46
+ while (current && steps < 32) {
47
+ steps += 1;
48
+ const def: ParagraphStyleDefinition | undefined = catalog.paragraphs[current];
49
+ if (!def) {
50
+ return undefined;
51
+ }
52
+ if (def.nextStyle && catalog.paragraphs[def.nextStyle]) {
53
+ return def.nextStyle;
54
+ }
55
+ current = def.basedOn;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Given the result document and the head of the new selection (which sits at
62
+ * the start of the newly-created paragraph), locate that paragraph in the
63
+ * top-level `doc` children and return a new document with its `styleId` set to
64
+ * `nextStyleId` and its `numbering` cleared.
65
+ */
66
+ function applyNextStyleToNewParagraph(
67
+ result: TextTransactionResult,
68
+ nextStyleId: string,
69
+ ): TextTransactionResult {
70
+ const root = result.document.content as DocumentRootNode;
71
+ if (!root || root.type !== "doc") {
72
+ return result;
73
+ }
74
+
75
+ const head = result.selection.head;
76
+ const surface = createEditorSurfaceSnapshot(result.document, result.selection);
77
+
78
+ let targetBlockIndex = -1;
79
+ for (let i = 0; i < surface.blocks.length; i += 1) {
80
+ const surfaceBlock = surface.blocks[i];
81
+ if (
82
+ surfaceBlock?.kind === "paragraph" &&
83
+ surfaceBlock.from === head
84
+ ) {
85
+ targetBlockIndex = i;
86
+ break;
87
+ }
88
+ }
89
+
90
+ if (targetBlockIndex === -1) {
91
+ return result;
92
+ }
93
+
94
+ const targetBlock = root.children[targetBlockIndex];
95
+ if (!targetBlock || targetBlock.type !== "paragraph") {
96
+ return result;
97
+ }
98
+
99
+ const { numbering: _numbering, ...restProps } = targetBlock;
100
+ const updatedParagraph: ParagraphNode = {
101
+ ...restProps,
102
+ styleId: nextStyleId,
103
+ children: targetBlock.children,
104
+ };
105
+
106
+ return {
107
+ ...result,
108
+ document: {
109
+ ...result.document,
110
+ content: {
111
+ ...root,
112
+ children: [
113
+ ...root.children.slice(0, targetBlockIndex),
114
+ updatedParagraph,
115
+ ...root.children.slice(targetBlockIndex + 1),
116
+ ],
117
+ },
118
+ },
119
+ };
120
+ }
121
+
26
122
  export function insertText(
27
123
  document: CanonicalDocumentEnvelope,
28
124
  selection: SelectionSnapshot,
@@ -122,7 +218,11 @@ export function splitParagraph(
122
218
  selection: SelectionSnapshot,
123
219
  context: TextCommandContext,
124
220
  ): TextTransactionResult {
125
- return applyTextTransaction(
221
+ // Resolve the current paragraph's styleId before the split so we can look up
222
+ // `nextStyle` from the styles catalog.
223
+ const scope = resolveParagraphScope(document, selection);
224
+
225
+ const result = applyTextTransaction(
126
226
  document,
127
227
  selection,
128
228
  {
@@ -131,6 +231,26 @@ export function splitParagraph(
131
231
  },
132
232
  context,
133
233
  );
234
+
235
+ // Only apply nextStyle for top-level paragraphs; table-cell traversal
236
+ // would require walking into nested blocks which the surface snapshot doesn't expose.
237
+ if (scope?.kind !== "top-level") {
238
+ return result;
239
+ }
240
+
241
+ const originalStyleId = scope.paragraph.styleId;
242
+ const nextStyleId =
243
+ originalStyleId !== undefined
244
+ ? resolveNextStyle(originalStyleId, document.styles)
245
+ : undefined;
246
+
247
+ // If the original paragraph's style specifies a `nextStyle`, apply it to the
248
+ // newly-created paragraph (the one at result.selection.head).
249
+ if (nextStyleId !== undefined) {
250
+ return applyNextStyleToNewParagraph(result, nextStyleId);
251
+ }
252
+
253
+ return result;
134
254
  }
135
255
 
136
256
  export function insertPageBreak(
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export {
7
7
  validateEditorSessionState,
8
8
  EDITOR_SESSION_STATE_VERSION,
9
9
  } from "./api/session-state.ts";
10
+ // R2 — issue metadata id for scope-card-overlay P1.
11
+ export { ISSUE_METADATA_ID } from "./api/public-types.ts";
10
12
  export type {
11
13
  LoadRequest,
12
14
  LoadSourcePolicy,
@@ -104,6 +106,13 @@ export type {
104
106
  WorkflowMetadataDefinition,
105
107
  WorkflowMetadataEntry,
106
108
  WorkflowMetadataSnapshot,
109
+ // R2 — issue metadata (scope-card-overlay P1)
110
+ IssueSeverity,
111
+ IssueMode,
112
+ IssueOwner,
113
+ IssueMetadataValue,
114
+ ScopeIssueAction,
115
+ ScopeCardModel,
107
116
  WorkflowBlockedCommandReason,
108
117
  WorkflowScopeSnapshot,
109
118
  InteractionGuardSnapshot,
@@ -1560,6 +1560,7 @@ function filterValidStyleIds(
1560
1560
  characters: filterRecord(catalog.characters),
1561
1561
  tables: filterRecord(catalog.tables),
1562
1562
  ...(catalog.latentStyles ? { latentStyles: catalog.latentStyles } : {}),
1563
+ ...(catalog.docDefaults ? { docDefaults: catalog.docDefaults } : {}),
1563
1564
  ...(catalog.fromPackage !== undefined ? { fromPackage: catalog.fromPackage } : {}),
1564
1565
  };
1565
1566
  }
@@ -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
+ }