@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
@@ -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"> & {
@@ -8,6 +8,7 @@ import type {
8
8
  ParagraphIndentation,
9
9
  TabStop,
10
10
  } from "../../model/canonical-document.ts";
11
+ import { readRunProperties } from "./parse-run-formatting.ts";
11
12
 
12
13
  export interface ParsedParagraphNumberingReference {
13
14
  paragraphIndex: number;
@@ -48,9 +49,32 @@ export function parseNumberingXml(xml: string): NumberingCatalog {
48
49
  }
49
50
 
50
51
  const abstractNumberingId = toCanonicalAbstractNumberingId(rawId);
52
+ const nsidEl = findChildElementOptional(child, "nsid");
53
+ const nsid = nsidEl ? (nsidEl.attributes["w:val"] ?? nsidEl.attributes.val) : undefined;
54
+ const mltEl = findChildElementOptional(child, "multiLevelType");
55
+ const mltRaw = mltEl ? (mltEl.attributes["w:val"] ?? mltEl.attributes.val) : undefined;
56
+ const multiLevelType =
57
+ mltRaw === "singleLevel" || mltRaw === "multilevel" || mltRaw === "hybridMultilevel"
58
+ ? mltRaw
59
+ : undefined;
60
+ const tmplEl = findChildElementOptional(child, "tmpl");
61
+ const tplc = tmplEl ? (tmplEl.attributes["w:val"] ?? tmplEl.attributes.val) : undefined;
62
+ const styleLinkEl = findChildElementOptional(child, "styleLink");
63
+ const styleLink = styleLinkEl
64
+ ? (styleLinkEl.attributes["w:val"] ?? styleLinkEl.attributes.val)
65
+ : undefined;
66
+ const numStyleLinkEl = findChildElementOptional(child, "numStyleLink");
67
+ const numStyleLink = numStyleLinkEl
68
+ ? (numStyleLinkEl.attributes["w:val"] ?? numStyleLinkEl.attributes.val)
69
+ : undefined;
51
70
  abstractDefinitions[abstractNumberingId] = {
52
71
  abstractNumberingId,
53
72
  levels: readLevels(child),
73
+ ...(nsid ? { nsid } : {}),
74
+ ...(multiLevelType ? { multiLevelType } : {}),
75
+ ...(tplc ? { tplc } : {}),
76
+ ...(styleLink ? { styleLink } : {}),
77
+ ...(numStyleLink ? { numStyleLink } : {}),
54
78
  };
55
79
  break;
56
80
  }
@@ -219,6 +243,11 @@ function readLevelDefinition(
219
243
  ? "tab"
220
244
  : undefined;
221
245
  const paragraphGeometry = readLevelParagraphGeometry(levelNode);
246
+ const lvlRestartNode = findChildElementOptional(levelNode, "lvlRestart");
247
+ const rawRestart = lvlRestartNode?.attributes["w:val"] ?? lvlRestartNode?.attributes.val;
248
+ const restartAfterLevel = rawRestart !== undefined ? parseInteger(rawRestart) : undefined;
249
+ const rPrNode = findChildElementOptional(levelNode, "rPr");
250
+ const runProperties = readRunProperties(rPrNode);
222
251
 
223
252
  return {
224
253
  level,
@@ -229,6 +258,8 @@ function readLevelDefinition(
229
258
  ...(isLegalNumbering ? { isLegalNumbering: true } : {}),
230
259
  ...(suffix ? { suffix } : {}),
231
260
  ...(paragraphGeometry ? { paragraphGeometry } : {}),
261
+ ...(runProperties ? { runProperties } : {}),
262
+ ...(restartAfterLevel !== undefined ? { restartAfterLevel } : {}),
232
263
  };
233
264
  }
234
265
 
@@ -263,6 +294,11 @@ function readLevelOverrideDefinition(
263
294
  ? "tab"
264
295
  : undefined;
265
296
  const paragraphGeometry = readLevelParagraphGeometry(levelNode);
297
+ const lvlRestartNode = findChildElementOptional(levelNode, "lvlRestart");
298
+ const rawRestart = lvlRestartNode?.attributes["w:val"] ?? lvlRestartNode?.attributes.val;
299
+ const restartAfterLevel = rawRestart !== undefined ? parseInteger(rawRestart) : undefined;
300
+ const rPrNode = findChildElementOptional(levelNode, "rPr");
301
+ const runProperties = readRunProperties(rPrNode);
266
302
 
267
303
  const hasExplicitFields =
268
304
  startAt !== undefined ||
@@ -271,7 +307,9 @@ function readLevelOverrideDefinition(
271
307
  paragraphStyleId !== undefined ||
272
308
  isLegalNumbering !== undefined ||
273
309
  suffix !== undefined ||
274
- paragraphGeometry !== undefined;
310
+ paragraphGeometry !== undefined ||
311
+ runProperties !== undefined ||
312
+ restartAfterLevel !== undefined;
275
313
 
276
314
  if (!hasExplicitFields) {
277
315
  return undefined;
@@ -286,6 +324,8 @@ function readLevelOverrideDefinition(
286
324
  ...(isLegalNumbering !== undefined ? { isLegalNumbering } : {}),
287
325
  ...(suffix ? { suffix } : {}),
288
326
  ...(paragraphGeometry ? { paragraphGeometry } : {}),
327
+ ...(runProperties ? { runProperties } : {}),
328
+ ...(restartAfterLevel !== undefined ? { restartAfterLevel } : {}),
289
329
  };
290
330
  }
291
331
 
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Read `<w:pPr>` (paragraph properties) into a `CanonicalParagraphFormatting` value.
3
+ *
4
+ * Returns `undefined` when the input is `undefined` or when the pPr is empty (no recognized
5
+ * child produces a field). Delegates the paragraph-mark `<w:rPr>` to `readRunProperties`
6
+ * so every rPr in the project is parsed through one code path.
7
+ */
8
+
9
+ import type {
10
+ CanonicalParagraphFormatting,
11
+ ParagraphBorders,
12
+ ParagraphIndentation,
13
+ ParagraphShading,
14
+ ParagraphSpacing,
15
+ TabStop,
16
+ } from "../../model/canonical-document.ts";
17
+ import { readRunProperties } from "./parse-run-formatting.ts";
18
+ import { findChildOptional, localName, readIntAttr, readIntVal, readOnOff } from "./xml-attr-helpers.ts";
19
+ import type { XmlElementNode } from "./xml-element.ts";
20
+
21
+ function readSpacing(node: XmlElementNode): ParagraphSpacing | undefined {
22
+ const out: ParagraphSpacing = {};
23
+ const before = readIntAttr(node, "w:before");
24
+ const after = readIntAttr(node, "w:after");
25
+ const line = readIntAttr(node, "w:line");
26
+ const rule = node.attributes["w:lineRule"] ?? node.attributes.lineRule;
27
+ if (before !== undefined) out.before = before;
28
+ if (after !== undefined) out.after = after;
29
+ if (line !== undefined) out.line = line;
30
+ if (rule) {
31
+ const n = rule.toLowerCase();
32
+ if (n === "auto" || n === "exact") out.lineRule = n;
33
+ else if (n === "atleast") out.lineRule = "atLeast";
34
+ }
35
+ return Object.keys(out).length > 0 ? out : undefined;
36
+ }
37
+
38
+ function readIndent(node: XmlElementNode): ParagraphIndentation | undefined {
39
+ const out: ParagraphIndentation = {};
40
+ const left = readIntAttr(node, "w:left") ?? readIntAttr(node, "w:start");
41
+ const right = readIntAttr(node, "w:right") ?? readIntAttr(node, "w:end");
42
+ const firstLine = readIntAttr(node, "w:firstLine");
43
+ const hanging = readIntAttr(node, "w:hanging");
44
+ if (left !== undefined) out.left = left;
45
+ if (right !== undefined) out.right = right;
46
+ if (firstLine !== undefined) out.firstLine = firstLine;
47
+ if (hanging !== undefined) out.hanging = hanging;
48
+ return Object.keys(out).length > 0 ? out : undefined;
49
+ }
50
+
51
+ function readTabStops(node: XmlElementNode): TabStop[] | undefined {
52
+ const tabs: TabStop[] = [];
53
+ for (const c of node.children) {
54
+ if (c.type !== "element" || localName(c.name) !== "tab") continue;
55
+ const pos = Number.parseInt(c.attributes["w:pos"] ?? c.attributes.pos ?? "", 10);
56
+ if (!Number.isFinite(pos)) continue;
57
+ const val = (c.attributes["w:val"] ?? c.attributes.val ?? "left").toLowerCase();
58
+ const leader = (c.attributes["w:leader"] ?? c.attributes.leader ?? "none").toLowerCase();
59
+ const align: TabStop["align"] =
60
+ val === "center" || val === "right" || val === "decimal" || val === "bar" || val === "num" || val === "clear"
61
+ ? (val as TabStop["align"])
62
+ : "left";
63
+ const mappedLeader: TabStop["leader"] | undefined =
64
+ leader === "dot" || leader === "hyphen" || leader === "underscore" || leader === "heavy"
65
+ ? (leader as TabStop["leader"])
66
+ : leader === "middledot"
67
+ ? "middleDot"
68
+ : undefined;
69
+ tabs.push({
70
+ position: pos,
71
+ align,
72
+ ...(mappedLeader && mappedLeader !== "none" ? { leader: mappedLeader } : {}),
73
+ });
74
+ }
75
+ return tabs.length > 0 ? tabs : undefined;
76
+ }
77
+
78
+ function readBorders(node: XmlElementNode): ParagraphBorders | undefined {
79
+ const out: ParagraphBorders = {};
80
+ const sides = ["top", "bottom", "left", "right", "between", "bar"] as const;
81
+ for (const side of sides) {
82
+ const child = findChildOptional(node, side);
83
+ if (!child) continue;
84
+ const val = child.attributes["w:val"] ?? child.attributes.val;
85
+ const sz = readIntAttr(child, "w:sz");
86
+ const space = readIntAttr(child, "w:space");
87
+ const color = child.attributes["w:color"] ?? child.attributes.color;
88
+ out[side] = {
89
+ ...(val ? { value: val } : {}),
90
+ ...(sz !== undefined ? { size: sz } : {}),
91
+ ...(space !== undefined ? { space } : {}),
92
+ ...(color ? { color } : {}),
93
+ };
94
+ }
95
+ return Object.keys(out).length > 0 ? out : undefined;
96
+ }
97
+
98
+ function readShading(node: XmlElementNode): ParagraphShading | undefined {
99
+ const val = node.attributes["w:val"] ?? node.attributes.val;
100
+ const fill = node.attributes["w:fill"] ?? node.attributes.fill;
101
+ const color = node.attributes["w:color"] ?? node.attributes.color;
102
+ if (!val && !fill && !color) return undefined;
103
+ return {
104
+ ...(val ? { val } : {}),
105
+ ...(fill ? { fill } : {}),
106
+ ...(color ? { color } : {}),
107
+ };
108
+ }
109
+
110
+ export function readParagraphProperties(
111
+ node: XmlElementNode | undefined,
112
+ ): CanonicalParagraphFormatting | undefined {
113
+ if (!node) return undefined;
114
+
115
+ const out: CanonicalParagraphFormatting = {};
116
+
117
+ const spacingNode = findChildOptional(node, "spacing");
118
+ if (spacingNode) {
119
+ const spacing = readSpacing(spacingNode);
120
+ if (spacing) out.spacing = spacing;
121
+ }
122
+
123
+ const indNode = findChildOptional(node, "ind");
124
+ if (indNode) {
125
+ const ind = readIndent(indNode);
126
+ if (ind) out.indentation = ind;
127
+ }
128
+
129
+ const jcNode = findChildOptional(node, "jc");
130
+ if (jcNode) {
131
+ const raw = jcNode.attributes["w:val"] ?? jcNode.attributes.val;
132
+ if (
133
+ raw === "left" ||
134
+ raw === "center" ||
135
+ raw === "right" ||
136
+ raw === "both" ||
137
+ raw === "distribute" ||
138
+ raw === "start" ||
139
+ raw === "end"
140
+ ) {
141
+ out.alignment = raw;
142
+ }
143
+ }
144
+
145
+ const bordersNode = findChildOptional(node, "pBdr");
146
+ if (bordersNode) {
147
+ const borders = readBorders(bordersNode);
148
+ if (borders) out.borders = borders;
149
+ }
150
+
151
+ const shdNode = findChildOptional(node, "shd");
152
+ if (shdNode) {
153
+ const shading = readShading(shdNode);
154
+ if (shading) out.shading = shading;
155
+ }
156
+
157
+ const tabsNode = findChildOptional(node, "tabs");
158
+ if (tabsNode) {
159
+ const tabs = readTabStops(tabsNode);
160
+ if (tabs) out.tabStops = tabs;
161
+ }
162
+
163
+ const keepNext = readOnOff(findChildOptional(node, "keepNext"));
164
+ if (keepNext !== undefined) out.keepNext = keepNext;
165
+ const keepLines = readOnOff(findChildOptional(node, "keepLines"));
166
+ if (keepLines !== undefined) out.keepLines = keepLines;
167
+ const widow = readOnOff(findChildOptional(node, "widowControl"));
168
+ if (widow !== undefined) out.widowControl = widow;
169
+ const pbb = readOnOff(findChildOptional(node, "pageBreakBefore"));
170
+ if (pbb !== undefined) out.pageBreakBefore = pbb;
171
+ const ctx = readOnOff(findChildOptional(node, "contextualSpacing"));
172
+ if (ctx !== undefined) out.contextualSpacing = ctx;
173
+ const bidi = readOnOff(findChildOptional(node, "bidi"));
174
+ if (bidi !== undefined) out.bidi = bidi;
175
+ const suppressLn = readOnOff(findChildOptional(node, "suppressLineNumbers"));
176
+ if (suppressLn !== undefined) out.suppressLineNumbers = suppressLn;
177
+ const suppressAh = readOnOff(findChildOptional(node, "suppressAutoHyphens"));
178
+ if (suppressAh !== undefined) out.suppressAutoHyphens = suppressAh;
179
+
180
+ const outline = readIntVal(findChildOptional(node, "outlineLvl"));
181
+ if (outline !== undefined) out.outlineLevel = outline;
182
+
183
+ const rPrNode = findChildOptional(node, "rPr");
184
+ const markRpr = readRunProperties(rPrNode);
185
+ if (markRpr) out.paragraphMarkRunProperties = markRpr;
186
+
187
+ return Object.keys(out).length > 0 ? out : undefined;
188
+ }
@@ -0,0 +1,129 @@
1
+ import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
2
+ import { findChildOptional, readIntVal, readOnOff, readStringAttr } from "./xml-attr-helpers.ts";
3
+ import type { XmlElementNode } from "./xml-element.ts";
4
+
5
+ /**
6
+ * Read `<w:rPr>` (run properties) into a `CanonicalRunFormatting` value.
7
+ *
8
+ * Returns `undefined` when the input is `undefined` or when the rPr is empty
9
+ * (i.e., no recognized child produces a field). `colorHex` may carry the
10
+ * sentinel `"auto"`; `fontFamily` is the alias of the first-available
11
+ * `fontFamily{Ascii,HAnsi,EastAsia,Cs}`.
12
+ *
13
+ * Caller-side: route every `<w:rPr>` from styles.xml, numbering.xml, and
14
+ * the paragraph mark in document.xml through this helper so the cascade
15
+ * sees the same shape.
16
+ */
17
+ export function readRunProperties(
18
+ node: XmlElementNode | undefined,
19
+ ): CanonicalRunFormatting | undefined {
20
+ if (!node) return undefined;
21
+
22
+ const rPr: CanonicalRunFormatting = {};
23
+
24
+ const bold = readOnOff(findChildOptional(node, "b"));
25
+ if (bold !== undefined) rPr.bold = bold;
26
+
27
+ const italic = readOnOff(findChildOptional(node, "i"));
28
+ if (italic !== undefined) rPr.italic = italic;
29
+
30
+ const strike = readOnOff(findChildOptional(node, "strike"));
31
+ if (strike !== undefined) rPr.strikethrough = strike;
32
+
33
+ const dstrike = readOnOff(findChildOptional(node, "dstrike"));
34
+ if (dstrike !== undefined) rPr.doubleStrikethrough = dstrike;
35
+
36
+ const vanish = readOnOff(findChildOptional(node, "vanish"));
37
+ if (vanish !== undefined) rPr.vanish = vanish;
38
+
39
+ const allCaps = readOnOff(findChildOptional(node, "caps"));
40
+ if (allCaps !== undefined) rPr.allCaps = allCaps;
41
+
42
+ const smallCaps = readOnOff(findChildOptional(node, "smallCaps"));
43
+ if (smallCaps !== undefined) rPr.smallCaps = smallCaps;
44
+
45
+ const u = findChildOptional(node, "u");
46
+ if (u) {
47
+ const val =
48
+ u.attributes["w:val"] ?? u.attributes["val"] ?? "single";
49
+ if (val === "none") {
50
+ rPr.underline = "none";
51
+ } else if (
52
+ val === "single" ||
53
+ val === "double" ||
54
+ val === "thick" ||
55
+ val === "dotted" ||
56
+ val === "dash" ||
57
+ val === "wave"
58
+ ) {
59
+ rPr.underline = val;
60
+ } else {
61
+ // Unknown underline type — coerce to "single"
62
+ rPr.underline = "single";
63
+ }
64
+ }
65
+
66
+ const vertAlign = findChildOptional(node, "vertAlign");
67
+ if (vertAlign) {
68
+ const val =
69
+ vertAlign.attributes["w:val"] ?? vertAlign.attributes["val"];
70
+ if (
71
+ val === "superscript" ||
72
+ val === "subscript" ||
73
+ val === "baseline"
74
+ ) {
75
+ rPr.verticalAlign = val;
76
+ }
77
+ }
78
+
79
+ const rFonts = findChildOptional(node, "rFonts");
80
+ if (rFonts) {
81
+ const ascii = rFonts.attributes["w:ascii"] ?? rFonts.attributes.ascii;
82
+ const hAnsi = rFonts.attributes["w:hAnsi"] ?? rFonts.attributes.hAnsi;
83
+ const eastAsia = rFonts.attributes["w:eastAsia"] ?? rFonts.attributes.eastAsia;
84
+ const cs = rFonts.attributes["w:cs"] ?? rFonts.attributes.cs;
85
+ if (ascii) rPr.fontFamilyAscii = ascii;
86
+ if (hAnsi) rPr.fontFamilyHAnsi = hAnsi;
87
+ if (eastAsia) rPr.fontFamilyEastAsia = eastAsia;
88
+ if (cs) rPr.fontFamilyCs = cs;
89
+ const primary = ascii ?? hAnsi ?? eastAsia ?? cs;
90
+ if (primary) rPr.fontFamily = primary;
91
+ }
92
+
93
+ const sz = readIntVal(findChildOptional(node, "sz"));
94
+ if (sz !== undefined) rPr.fontSizeHalfPoints = sz;
95
+
96
+ const szCs = readIntVal(findChildOptional(node, "szCs"));
97
+ if (szCs !== undefined) rPr.fontSizeCsHalfPoints = szCs;
98
+
99
+ const color = findChildOptional(node, "color");
100
+ if (color) {
101
+ const val = color.attributes["w:val"] ?? color.attributes["val"];
102
+ const theme =
103
+ color.attributes["w:themeColor"] ?? color.attributes["themeColor"];
104
+ if (val) rPr.colorHex = val;
105
+ if (theme) rPr.colorThemeSlot = theme;
106
+ }
107
+
108
+ const highlight = findChildOptional(node, "highlight");
109
+ if (highlight) {
110
+ const val =
111
+ highlight.attributes["w:val"] ?? highlight.attributes["val"];
112
+ if (val) rPr.highlight = val;
113
+ }
114
+
115
+ const spacing = readIntVal(findChildOptional(node, "spacing"));
116
+ if (spacing !== undefined) rPr.characterSpacingTwips = spacing;
117
+
118
+ const rStyleNode = findChildOptional(node, "rStyle");
119
+ const rStyle = rStyleNode ? readStringAttr(rStyleNode, "w:val") : undefined;
120
+ if (rStyle) rPr.characterStyleId = rStyle;
121
+
122
+ const lang = findChildOptional(node, "lang");
123
+ if (lang) {
124
+ const val = lang.attributes["w:val"] ?? lang.attributes["val"];
125
+ if (val) rPr.languageCode = val;
126
+ }
127
+
128
+ return Object.keys(rPr).length > 0 ? rPr : undefined;
129
+ }