@beyondwork/docx-react-component 1.0.36 → 1.0.37

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 (64) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +83 -0
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +173 -11
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/surface-projection.ts +1 -0
  44. package/src/runtime/text-ack-range.ts +49 -0
  45. package/src/ui/WordReviewEditor.tsx +15 -0
  46. package/src/ui/editor-runtime-boundary.ts +10 -1
  47. package/src/ui/editor-surface-controller.tsx +3 -0
  48. package/src/ui/headless/chrome-registry.ts +235 -0
  49. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  50. package/src/ui/headless/selection-tool-context.ts +2 -0
  51. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  52. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  53. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  54. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  55. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  56. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  57. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  58. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  61. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  62. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  63. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  64. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -3,6 +3,7 @@ import {
3
3
  isSyntheticDocxNullAbstractDefinition,
4
4
  isSyntheticDocxNullNumberingInstance,
5
5
  } from "../ooxml/numbering-sentinels.ts";
6
+ import { twip } from "./twip.ts";
6
7
 
7
8
  export const WORD_NUMBERING_CONTENT_TYPE =
8
9
  "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
@@ -48,14 +49,18 @@ export function serializeParagraphNumberingProperties(
48
49
  return "";
49
50
  }
50
51
 
51
- return `<w:numPr><w:ilvl w:val="${numbering.level}"/><w:numId w:val="${escapeAttribute(
52
- stripCanonicalPrefix(numbering.numberingInstanceId, "num:"),
52
+ return `<w:numPr><w:ilvl w:val="${clampIlvl(numbering.level)}"/><w:numId w:val="${escapeAttribute(
53
+ clampNonNegativeIdString(
54
+ stripCanonicalPrefix(numbering.numberingInstanceId, "num:"),
55
+ ),
53
56
  )}"/></w:numPr>`;
54
57
  }
55
58
 
56
59
  function serializeAbstractDefinition(definition: NumberingCatalog["abstractDefinitions"][string]): string {
57
60
  const abstractNumId = escapeAttribute(
58
- stripCanonicalPrefix(definition.abstractNumberingId, "abstract-num:"),
61
+ clampNonNegativeIdString(
62
+ stripCanonicalPrefix(definition.abstractNumberingId, "abstract-num:"),
63
+ ),
59
64
  );
60
65
  const levels = [...definition.levels]
61
66
  .sort((left, right) => left.level - right.level)
@@ -69,7 +74,10 @@ function serializeLevel(
69
74
  level: NumberingCatalog["abstractDefinitions"][string]["levels"][number],
70
75
  serializedLevel = level.level,
71
76
  ): string {
72
- const start = level.startAt !== undefined ? `<w:start w:val="${level.startAt}"/>` : "";
77
+ const start =
78
+ level.startAt !== undefined
79
+ ? `<w:start w:val="${clampStart(level.startAt)}"/>`
80
+ : "";
73
81
  const paragraphStyle = level.paragraphStyleId
74
82
  ? `<w:pStyle w:val="${escapeAttribute(level.paragraphStyleId)}"/>`
75
83
  : "";
@@ -80,7 +88,7 @@ function serializeLevel(
80
88
  : "";
81
89
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
82
90
 
83
- return `<w:lvl w:ilvl="${serializedLevel}">${start}<w:numFmt w:val="${escapeAttribute(
91
+ return `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${start}<w:numFmt w:val="${escapeAttribute(
84
92
  level.format,
85
93
  )}"/><w:lvlText w:val="${escapeAttribute(level.text)}"/>${paragraphStyle}${isLegal}${suffix}${justification}${paragraphProperties}</w:lvl>`;
86
94
  }
@@ -93,7 +101,10 @@ function serializeLevelOverride(
93
101
  return "";
94
102
  }
95
103
 
96
- const start = level.startAt !== undefined ? `<w:start w:val="${level.startAt}"/>` : "";
104
+ const start =
105
+ level.startAt !== undefined
106
+ ? `<w:start w:val="${clampStart(level.startAt)}"/>`
107
+ : "";
97
108
  const format = level.format ? `<w:numFmt w:val="${escapeAttribute(level.format)}"/>` : "";
98
109
  const text = level.text !== undefined
99
110
  ? `<w:lvlText w:val="${escapeAttribute(level.text)}"/>`
@@ -101,12 +112,17 @@ function serializeLevelOverride(
101
112
  const paragraphStyle = level.paragraphStyleId
102
113
  ? `<w:pStyle w:val="${escapeAttribute(level.paragraphStyleId)}"/>`
103
114
  : "";
115
+ // ST_OnOff element (A.3):
116
+ // - true → <w:isLgl/>.
117
+ // - false → <w:isLgl w:val="false"/> (explicit override of a parent
118
+ // abstract level that declared legal numbering on).
119
+ // - undefined → omit.
104
120
  const isLegal =
105
- level.isLegalNumbering === undefined
106
- ? ""
107
- : level.isLegalNumbering
108
- ? "<w:isLgl/>"
109
- : `<w:isLgl w:val="0"/>`;
121
+ level.isLegalNumbering === true
122
+ ? "<w:isLgl/>"
123
+ : level.isLegalNumbering === false
124
+ ? `<w:isLgl w:val="false"/>`
125
+ : "";
110
126
  const suffix = level.suffix ? `<w:suff w:val="${escapeAttribute(level.suffix)}"/>` : "";
111
127
  const justification = level.paragraphGeometry?.justification
112
128
  ? `<w:lvlJc w:val="${escapeAttribute(level.paragraphGeometry.justification)}"/>`
@@ -114,13 +130,19 @@ function serializeLevelOverride(
114
130
  const paragraphProperties = serializeLevelParagraphGeometry(level.paragraphGeometry);
115
131
  const body = `${start}${format}${text}${paragraphStyle}${isLegal}${suffix}${justification}${paragraphProperties}`;
116
132
 
117
- return body.length > 0 ? `<w:lvl w:ilvl="${serializedLevel}">${body}</w:lvl>` : "";
133
+ return body.length > 0
134
+ ? `<w:lvl w:ilvl="${clampIlvl(serializedLevel)}">${body}</w:lvl>`
135
+ : "";
118
136
  }
119
137
 
120
138
  function serializeInstance(instance: NumberingCatalog["instances"][string]): string {
121
- const numId = escapeAttribute(stripCanonicalPrefix(instance.numberingInstanceId, "num:"));
139
+ const numId = escapeAttribute(
140
+ clampNonNegativeIdString(stripCanonicalPrefix(instance.numberingInstanceId, "num:")),
141
+ );
122
142
  const abstractNumId = escapeAttribute(
123
- stripCanonicalPrefix(instance.abstractNumberingId, "abstract-num:"),
143
+ clampNonNegativeIdString(
144
+ stripCanonicalPrefix(instance.abstractNumberingId, "abstract-num:"),
145
+ ),
124
146
  );
125
147
  const overrides = [...instance.overrides]
126
148
  .sort((left, right) => left.level - right.level)
@@ -132,11 +154,13 @@ function serializeInstance(instance: NumberingCatalog["instances"][string]): str
132
154
 
133
155
  function serializeOverride(override: NumberingCatalog["instances"][string]["overrides"][number]): string {
134
156
  const startOverride =
135
- override.startAt !== undefined ? `<w:startOverride w:val="${override.startAt}"/>` : "";
157
+ override.startAt !== undefined
158
+ ? `<w:startOverride w:val="${clampStart(override.startAt)}"/>`
159
+ : "";
136
160
  const levelDefinition = override.levelDefinition
137
161
  ? serializeLevelOverride(override.levelDefinition, override.level)
138
162
  : "";
139
- return `<w:lvlOverride w:ilvl="${override.level}">${startOverride}${levelDefinition}</w:lvlOverride>`;
163
+ return `<w:lvlOverride w:ilvl="${clampIlvl(override.level)}">${startOverride}${levelDefinition}</w:lvlOverride>`;
140
164
  }
141
165
 
142
166
  function serializeLevelParagraphGeometry(
@@ -150,13 +174,13 @@ function serializeLevelParagraphGeometry(
150
174
  if (paragraphGeometry.spacing) {
151
175
  const attrs: string[] = [];
152
176
  if (paragraphGeometry.spacing.before !== undefined) {
153
- attrs.push(`w:before="${paragraphGeometry.spacing.before}"`);
177
+ attrs.push(`w:before="${twip(paragraphGeometry.spacing.before)}"`);
154
178
  }
155
179
  if (paragraphGeometry.spacing.after !== undefined) {
156
- attrs.push(`w:after="${paragraphGeometry.spacing.after}"`);
180
+ attrs.push(`w:after="${twip(paragraphGeometry.spacing.after)}"`);
157
181
  }
158
182
  if (paragraphGeometry.spacing.line !== undefined) {
159
- attrs.push(`w:line="${paragraphGeometry.spacing.line}"`);
183
+ attrs.push(`w:line="${twip(paragraphGeometry.spacing.line)}"`);
160
184
  }
161
185
  if (paragraphGeometry.spacing.lineRule !== undefined) {
162
186
  attrs.push(`w:lineRule="${paragraphGeometry.spacing.lineRule}"`);
@@ -169,16 +193,16 @@ function serializeLevelParagraphGeometry(
169
193
  if (paragraphGeometry.indentation) {
170
194
  const attrs: string[] = [];
171
195
  if (paragraphGeometry.indentation.left !== undefined) {
172
- attrs.push(`w:left="${paragraphGeometry.indentation.left}"`);
196
+ attrs.push(`w:left="${twip(paragraphGeometry.indentation.left)}"`);
173
197
  }
174
198
  if (paragraphGeometry.indentation.right !== undefined) {
175
- attrs.push(`w:right="${paragraphGeometry.indentation.right}"`);
199
+ attrs.push(`w:right="${twip(paragraphGeometry.indentation.right)}"`);
176
200
  }
177
201
  if (paragraphGeometry.indentation.firstLine !== undefined) {
178
- attrs.push(`w:firstLine="${paragraphGeometry.indentation.firstLine}"`);
202
+ attrs.push(`w:firstLine="${twip(paragraphGeometry.indentation.firstLine)}"`);
179
203
  }
180
204
  if (paragraphGeometry.indentation.hanging !== undefined) {
181
- attrs.push(`w:hanging="${paragraphGeometry.indentation.hanging}"`);
205
+ attrs.push(`w:hanging="${twip(paragraphGeometry.indentation.hanging)}"`);
182
206
  }
183
207
  if (attrs.length > 0) {
184
208
  children.push(`<w:ind ${attrs.join(" ")}/>`);
@@ -189,7 +213,7 @@ function serializeLevelParagraphGeometry(
189
213
  const tabsXml = paragraphGeometry.tabStops.map((tab) => {
190
214
  const leader = tab.leader === "middleDot" ? "middledot" : tab.leader;
191
215
  const leaderAttr = leader ? ` w:leader="${leader}"` : "";
192
- return `<w:tab w:val="${tab.align}" w:pos="${tab.position}"${leaderAttr}/>`;
216
+ return `<w:tab w:val="${tab.align}" w:pos="${twip(tab.position)}"${leaderAttr}/>`;
193
217
  }).join("");
194
218
  children.push(`<w:tabs>${tabsXml}</w:tabs>`);
195
219
  }
@@ -197,6 +221,73 @@ function serializeLevelParagraphGeometry(
197
221
  return children.length > 0 ? `<w:pPr>${children.join("")}</w:pPr>` : "";
198
222
  }
199
223
 
224
+ /**
225
+ * ST_DecimalNumberOrPercent / ST_DecimalNumber based ilvl/numId/abstractNumId
226
+ * attributes are integer-typed. Ilvl is bounded 0-8 by the schema.
227
+ * Negative ids violate MinInclusive ≥ 0 and make the validator fail.
228
+ * These clampers normalise at the authoring edge (A.6 MinInclusive clamp).
229
+ */
230
+ function clampIlvl(value: number | undefined): number {
231
+ if (value === undefined || !Number.isFinite(value)) return 0;
232
+ const rounded = Math.round(value);
233
+ if (rounded < 0) {
234
+ warnClamp("w:ilvl", rounded, 0);
235
+ return 0;
236
+ }
237
+ if (rounded > 8) {
238
+ warnClamp("w:ilvl", rounded, 8);
239
+ return 8;
240
+ }
241
+ return rounded;
242
+ }
243
+
244
+ function clampStart(value: number | undefined): number {
245
+ if (value === undefined || !Number.isFinite(value)) return 1;
246
+ const rounded = Math.round(value);
247
+ // w:start MinInclusive per the schema is 0 (0 is legal for Word-created
248
+ // lists that begin with a 0-based custom format). Negative values fail.
249
+ if (rounded < 0) {
250
+ warnClamp("w:start", rounded, 0);
251
+ return 0;
252
+ }
253
+ return rounded;
254
+ }
255
+
256
+ /**
257
+ * Clamp a stringified id attribute (w:numId / w:abstractNumId) to the
258
+ * schema's MinInclusive=0. Accepts the canonical id string (produced by
259
+ * `stripCanonicalPrefix`) and returns a string safe to embed as a value.
260
+ * If the string is non-numeric or positive it passes through unchanged.
261
+ */
262
+ function clampNonNegativeIdString(value: string): string {
263
+ const parsed = Number.parseInt(value, 10);
264
+ if (!Number.isFinite(parsed)) return value;
265
+ if (parsed < 0) {
266
+ warnClamp("w:numId/w:abstractNumId", parsed, 0);
267
+ return "0";
268
+ }
269
+ return value;
270
+ }
271
+
272
+ let warnedClamps = 0;
273
+ const WARNED_CLAMPS_MAX = 20;
274
+ function warnClamp(attr: string, from: number, to: number): void {
275
+ if (warnedClamps >= WARNED_CLAMPS_MAX) return;
276
+ warnedClamps += 1;
277
+ // Only warn outside of production builds. The test runner shows these
278
+ // via console.warn automatically; in production this is a silent no-op
279
+ // so the Buffer cost is zero. Read NODE_ENV through globalThis so the
280
+ // production tsconfig (which excludes @types/node) type-checks cleanly.
281
+ const proc = (globalThis as unknown as {
282
+ process?: { env?: Record<string, string | undefined> };
283
+ }).process;
284
+ if (proc?.env?.NODE_ENV !== "production") {
285
+ console.warn(
286
+ `[numbering] clamped ${attr} ${from} -> ${to} at MinInclusive (A.6 guard)`,
287
+ );
288
+ }
289
+ }
290
+
200
291
  function compareSerializedIds(left: string, right: string): number {
201
292
  return stripKnownPrefix(left).localeCompare(stripKnownPrefix(right), "en", { numeric: true });
202
293
  }
@@ -10,13 +10,14 @@ import type {
10
10
  ParsedTableRow,
11
11
  ParsedTableWidth,
12
12
  } from "../ooxml/parse-tables.ts";
13
+ import { twip } from "./twip.ts";
13
14
 
14
15
  export function serializeTable(table: ParsedTable): string {
15
16
  const propertiesXml = table.propertiesXml ?? buildTablePropertiesXml(table);
16
17
  const gridXml =
17
18
  table.gridColumns.length > 0
18
19
  ? `<w:tblGrid>${table.gridColumns
19
- .map((width) => `<w:gridCol w:w="${width}"/>`)
20
+ .map((width) => `<w:gridCol w:w="${twip(width)}"/>`)
20
21
  .join("")}</w:tblGrid>`
21
22
  : "";
22
23
  const rowsXml = table.rows.map(serializeRow).join("");
@@ -65,7 +66,7 @@ function buildRowPropertiesXml(row: ParsedTableRow): string {
65
66
  const children: string[] = [];
66
67
  if (row.height !== undefined) {
67
68
  const hRuleAttr = row.heightRule ? ` w:hRule="${row.heightRule}"` : "";
68
- children.push(`<w:trHeight w:val="${row.height}"${hRuleAttr}/>`);
69
+ children.push(`<w:trHeight w:val="${twip(row.height)}"${hRuleAttr}/>`);
69
70
  }
70
71
  if (row.isHeader) {
71
72
  children.push(`<w:tblHeader/>`);
@@ -83,7 +84,7 @@ function ensureCellProperties(cell: ParsedTableCell): string {
83
84
  children.push(serializeWidth("tcW", cell.width));
84
85
  }
85
86
  if (cell.gridSpan && cell.gridSpan > 1) {
86
- children.push(`<w:gridSpan w:val="${cell.gridSpan}"/>`);
87
+ children.push(`<w:gridSpan w:val="${twip(cell.gridSpan)}"/>`);
87
88
  }
88
89
  if (cell.verticalMerge) {
89
90
  children.push(
@@ -107,14 +108,14 @@ function ensureCellProperties(cell: ParsedTableCell): string {
107
108
  }
108
109
 
109
110
  function serializeWidth(element: string, width: ParsedTableWidth): string {
110
- return `<w:${element} w:w="${width.value}" w:type="${width.type}"/>`;
111
+ return `<w:${element} w:w="${twip(width.value)}" w:type="${width.type}"/>`;
111
112
  }
112
113
 
113
114
  function serializeBorderSpec(element: string, spec: ParsedBorderSpec): string {
114
115
  const attrs: string[] = [];
115
116
  if (spec.value) attrs.push(`w:val="${spec.value}"`);
116
- if (spec.size !== undefined) attrs.push(`w:sz="${spec.size}"`);
117
- if (spec.space !== undefined) attrs.push(`w:space="${spec.space}"`);
117
+ if (spec.size !== undefined) attrs.push(`w:sz="${twip(spec.size)}"`);
118
+ if (spec.space !== undefined) attrs.push(`w:space="${twip(spec.space)}"`);
118
119
  if (spec.color) attrs.push(`w:color="${spec.color}"`);
119
120
  const attrsStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
120
121
  return `<w:${element}${attrsStr}/>`;
@@ -149,7 +150,8 @@ function serializeTableLook(tblLook: ParsedTableLook): string {
149
150
  ] as const) {
150
151
  const value = tblLook[key];
151
152
  if (value !== undefined) {
152
- attrs.push(`${attr}="${value ? "1" : "0"}"`);
153
+ // ST_OnOff (A.3): emit "true"/"false", never "1"/"0".
154
+ attrs.push(`${attr}="${value ? "true" : "false"}"`);
153
155
  }
154
156
  }
155
157
  return attrs.length > 0 ? `<w:tblLook ${attrs.join(" ")}/>` : "";
@@ -166,9 +168,9 @@ function serializeCellShading(shading: ParsedCellShading): string {
166
168
 
167
169
  function serializeTableCellMargins(margins: ParsedCellMargins): string {
168
170
  const parts: string[] = [];
169
- if (margins.top !== undefined) parts.push(`<w:top w:w="${margins.top}" w:type="dxa"/>`);
170
- if (margins.left !== undefined) parts.push(`<w:left w:w="${margins.left}" w:type="dxa"/>`);
171
- if (margins.bottom !== undefined) parts.push(`<w:bottom w:w="${margins.bottom}" w:type="dxa"/>`);
172
- if (margins.right !== undefined) parts.push(`<w:right w:w="${margins.right}" w:type="dxa"/>`);
171
+ if (margins.top !== undefined) parts.push(`<w:top w:w="${twip(margins.top)}" w:type="dxa"/>`);
172
+ if (margins.left !== undefined) parts.push(`<w:left w:w="${twip(margins.left)}" w:type="dxa"/>`);
173
+ if (margins.bottom !== undefined) parts.push(`<w:bottom w:w="${twip(margins.bottom)}" w:type="dxa"/>`);
174
+ if (margins.right !== undefined) parts.push(`<w:right w:w="${twip(margins.right)}" w:type="dxa"/>`);
173
175
  return parts.join("");
174
176
  }
@@ -1,3 +1,5 @@
1
+ import { twip } from "./twip.ts";
2
+
1
3
  interface TableWidthLike {
2
4
  value: number;
3
5
  type: string;
@@ -196,23 +198,35 @@ function buildTablePropertiesInnerXml(table: TablePropertiesLike): string {
196
198
  function buildTableRowPropertiesInnerXml(row: TableRowPropertiesLike): string {
197
199
  const children: string[] = [];
198
200
  if (row.gridBefore !== undefined) {
199
- children.push(`<w:gridBefore w:val="${row.gridBefore}"/>`);
201
+ children.push(`<w:gridBefore w:val="${twip(row.gridBefore)}"/>`);
200
202
  }
201
203
  if (row.widthBefore) {
202
- children.push(`<w:wBefore w:w="${row.widthBefore.value}" w:type="${row.widthBefore.type}"/>`);
204
+ children.push(
205
+ `<w:wBefore w:w="${twip(row.widthBefore.value)}" w:type="${row.widthBefore.type}"/>`,
206
+ );
203
207
  }
204
208
  if (row.gridAfter !== undefined) {
205
- children.push(`<w:gridAfter w:val="${row.gridAfter}"/>`);
209
+ children.push(`<w:gridAfter w:val="${twip(row.gridAfter)}"/>`);
206
210
  }
207
211
  if (row.widthAfter) {
208
- children.push(`<w:wAfter w:w="${row.widthAfter.value}" w:type="${row.widthAfter.type}"/>`);
212
+ children.push(
213
+ `<w:wAfter w:w="${twip(row.widthAfter.value)}" w:type="${row.widthAfter.type}"/>`,
214
+ );
209
215
  }
210
216
  if (row.height !== undefined) {
211
217
  const hRuleAttr = row.heightRule ? ` w:hRule="${escapeAttribute(row.heightRule)}"` : "";
212
- children.push(`<w:trHeight w:val="${row.height}"${hRuleAttr}/>`);
218
+ children.push(`<w:trHeight w:val="${twip(row.height)}"${hRuleAttr}/>`);
213
219
  }
214
- if (row.isHeader !== undefined) {
215
- children.push(row.isHeader ? `<w:tblHeader/>` : `<w:tblHeader w:val="0"/>`);
220
+ // ST_OnOff element (A.3):
221
+ // - true → <w:tblHeader/> (element-only form = implicit true).
222
+ // - false → <w:tblHeader w:val="false"/> (explicit override — canonical
223
+ // carries an explicit false because the source distinguished it
224
+ // from "missing" at import time; never emit "0"/"1" per A.3).
225
+ // - undefined → omit (no element).
226
+ if (row.isHeader === true) {
227
+ children.push(`<w:tblHeader/>`);
228
+ } else if (row.isHeader === false) {
229
+ children.push(`<w:tblHeader w:val="false"/>`);
216
230
  }
217
231
  return children.join("");
218
232
  }
@@ -223,7 +237,7 @@ function buildTableCellPropertiesInnerXml(cell: TableCellPropertiesLike): string
223
237
  children.push(serializeWidth("tcW", cell.width));
224
238
  }
225
239
  if (cell.gridSpan && cell.gridSpan > 1) {
226
- children.push(`<w:gridSpan w:val="${cell.gridSpan}"/>`);
240
+ children.push(`<w:gridSpan w:val="${twip(cell.gridSpan)}"/>`);
227
241
  }
228
242
  if (cell.verticalMerge) {
229
243
  children.push(
@@ -251,7 +265,9 @@ function buildTableCellPropertiesInnerXml(cell: TableCellPropertiesLike): string
251
265
  }
252
266
 
253
267
  function serializeWidth(elementName: "tblW" | "tcW", width: TableWidthLike): string {
254
- return `<w:${elementName} w:w="${width.value}" w:type="${escapeAttribute(width.type)}"/>`;
268
+ // OOXML allows w:w to be percentage (pct) or twentieths-of-a-percent too, but
269
+ // both are integer-typed in the schema. Always round at the authoring edge.
270
+ return `<w:${elementName} w:w="${twip(width.value)}" w:type="${escapeAttribute(width.type)}"/>`;
255
271
  }
256
272
 
257
273
  function serializeBorders(borders: TableBordersLike): string {
@@ -265,18 +281,18 @@ function serializeBorders(borders: TableBordersLike): string {
265
281
  function serializeBorderSpec(elementName: string, border: BorderSpecLike): string {
266
282
  const attrs: string[] = [];
267
283
  if (border.value) attrs.push(`w:val="${escapeAttribute(border.value)}"`);
268
- if (border.size !== undefined) attrs.push(`w:sz="${border.size}"`);
269
- if (border.space !== undefined) attrs.push(`w:space="${border.space}"`);
284
+ if (border.size !== undefined) attrs.push(`w:sz="${twip(border.size)}"`);
285
+ if (border.space !== undefined) attrs.push(`w:space="${twip(border.space)}"`);
270
286
  if (border.color) attrs.push(`w:color="${escapeAttribute(border.color)}"`);
271
287
  return attrs.length > 0 ? `<w:${elementName} ${attrs.join(" ")}/>` : "";
272
288
  }
273
289
 
274
290
  function serializeTableCellMargins(margins: TableCellMarginsLike): string {
275
291
  const parts: string[] = [];
276
- if (margins.top !== undefined) parts.push(`<w:top w:w="${margins.top}" w:type="dxa"/>`);
277
- if (margins.left !== undefined) parts.push(`<w:left w:w="${margins.left}" w:type="dxa"/>`);
278
- if (margins.bottom !== undefined) parts.push(`<w:bottom w:w="${margins.bottom}" w:type="dxa"/>`);
279
- if (margins.right !== undefined) parts.push(`<w:right w:w="${margins.right}" w:type="dxa"/>`);
292
+ if (margins.top !== undefined) parts.push(`<w:top w:w="${twip(margins.top)}" w:type="dxa"/>`);
293
+ if (margins.left !== undefined) parts.push(`<w:left w:w="${twip(margins.left)}" w:type="dxa"/>`);
294
+ if (margins.bottom !== undefined) parts.push(`<w:bottom w:w="${twip(margins.bottom)}" w:type="dxa"/>`);
295
+ if (margins.right !== undefined) parts.push(`<w:right w:w="${twip(margins.right)}" w:type="dxa"/>`);
280
296
  return parts.join("");
281
297
  }
282
298
 
@@ -295,7 +311,10 @@ function serializeTableLook(tblLook: TableLookLike): string {
295
311
  ] as const) {
296
312
  const value = tblLook[key];
297
313
  if (value !== undefined) {
298
- attrs.push(`${attr}="${value ? "1" : "0"}"`);
314
+ // tblLook individual attributes are ST_OnOff per ECMA-376 2nd Ed.
315
+ // Emit the string form ("true"/"false"); the "1"/"0" form is rejected
316
+ // by DocumentFormat.OpenXml's enumeration validator.
317
+ attrs.push(`${attr}="${value ? "true" : "false"}"`);
299
318
  }
300
319
  }
301
320
  return attrs.length > 0 ? `<w:tblLook ${attrs.join(" ")}/>` : "";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Integer-twip guard for OOXML numeric attributes.
3
+ *
4
+ * OOXML twip-based attributes (w:ind.left, w:pgSz.w, w:tblW.w, w:tcW.w,
5
+ * w:gridCol.w, w:trHeight.val, border w:sz, tab-stop w:pos, etc.) are
6
+ * schema-typed as ST_TwipsMeasure / ST_SignedTwipsMeasure / ST_MeasurementOrPercent
7
+ * or plain xsd:int variants. The OpenXML SDK validates these as Int32. Emitting
8
+ * a fractional value like "566.9999999999999" triggers the SDK
9
+ * "invalid 'Int32' value" finding, even when the numeric result is only off
10
+ * by float-rounding noise.
11
+ *
12
+ * These helpers normalise every numeric attribute write through `Math.round`
13
+ * and reject non-finite inputs at the authoring boundary so the serializer
14
+ * cannot accidentally emit NaN, Infinity, or fractional values.
15
+ *
16
+ * Callers should route through:
17
+ * twip(value) — optional numeric: undefined passes through; returns integer.
18
+ * requireTwip(value) — mandatory numeric: throws on non-finite input.
19
+ *
20
+ * Source:
21
+ * docs/plans/close-render-fidelity.md §2 A.2
22
+ */
23
+
24
+ /**
25
+ * Normalise a numeric twip attribute to an integer. Returns undefined when
26
+ * the input is undefined, preserving existing "omit attribute" semantics.
27
+ * Throws when the numeric input is NaN or non-finite — this indicates a
28
+ * bug in whichever authoring path produced the value and must never reach
29
+ * the serialized XML buffer.
30
+ */
31
+ export function twip(value: number | undefined | null): number | undefined {
32
+ if (value === undefined || value === null) return undefined;
33
+ if (!Number.isFinite(value)) {
34
+ throw new RangeError(
35
+ `twip(): expected finite number, received ${String(value)}`,
36
+ );
37
+ }
38
+ return Math.round(value);
39
+ }
40
+
41
+ /**
42
+ * Normalise a mandatory numeric twip attribute to an integer. Throws when
43
+ * the input is not a finite number.
44
+ */
45
+ export function requireTwip(value: number): number {
46
+ if (!Number.isFinite(value)) {
47
+ throw new RangeError(
48
+ `requireTwip(): expected finite number, received ${String(value)}`,
49
+ );
50
+ }
51
+ return Math.round(value);
52
+ }
53
+
54
+ /**
55
+ * Parse a numeric attribute from an OOXML document. Accepts whatever the
56
+ * raw string carries — Word sometimes emits floats from scaling math — and
57
+ * returns either an integer or `undefined` when the input is missing /
58
+ * unparseable. This symmetric helper is used on the parser side so the
59
+ * canonical model never holds a fractional twip.
60
+ */
61
+ export function parseTwipAttribute(raw: string | undefined): number | undefined {
62
+ if (raw === undefined || raw === null || raw === "") return undefined;
63
+ const parsed = Number.parseFloat(raw);
64
+ if (!Number.isFinite(parsed)) return undefined;
65
+ return Math.round(parsed);
66
+ }
@@ -182,6 +182,11 @@ function normalizeParagraph(
182
182
  ...(paragraph.bidi ? { bidi: paragraph.bidi } : {}),
183
183
  ...(paragraph.borders ? { borders: paragraph.borders } : {}),
184
184
  ...(paragraph.shading ? { shading: paragraph.shading } : {}),
185
+ // A.7: preserve w14:paraId / w14:textId across import → export so
186
+ // downstream tools that diff documents by paragraph id stay stable.
187
+ ...(paragraph.wordExtensionIds
188
+ ? { wordExtensionIds: paragraph.wordExtensionIds }
189
+ : {}),
185
190
  children,
186
191
  };
187
192
  }
@@ -546,7 +546,8 @@ function parseRunProperties(rElement: XmlElementNode): TextMark[] {
546
546
  }
547
547
  case "color": {
548
548
  const colorVal = child.attributes["w:val"] ?? child.attributes.val;
549
- if (colorVal && colorVal !== "auto") marks.push({ type: "textColor", color: colorVal });
549
+ // A.9: preserve "auto" verbatim for round-trip.
550
+ if (colorVal) marks.push({ type: "textColor", color: colorVal });
550
551
  break;
551
552
  }
552
553
  case "smallCaps":
@@ -689,7 +689,8 @@ function parseRunProperties(rElement: XmlElementNode): TextMark[] {
689
689
  }
690
690
  case "color": {
691
691
  const colorVal = child.attributes["w:val"] ?? child.attributes.val;
692
- if (colorVal && colorVal !== "auto") {
692
+ // A.9: preserve "auto" verbatim for round-trip.
693
+ if (colorVal) {
693
694
  marks.push({ type: "textColor", color: colorVal });
694
695
  }
695
696
  break;
@@ -92,6 +92,11 @@ export interface ParsedParagraphNode {
92
92
  bidi?: boolean;
93
93
  suppressLineNumbers?: boolean;
94
94
  cnfStyle?: string;
95
+ /** A.7: preserved w14 extension ids (paraId/textId). */
96
+ wordExtensionIds?: {
97
+ paraId?: string;
98
+ textId?: string;
99
+ };
95
100
  sectionProperties?: SectionProperties;
96
101
  sectionPropertiesXml?: string;
97
102
  children: ParsedInlineNode[];
@@ -493,6 +498,17 @@ function parseBodyChild(
493
498
  let sectionProperties: SectionProperties | undefined;
494
499
  let sectionPropertiesXml: string | undefined;
495
500
  let paragraphSupported = true;
501
+ // A.7: capture w14:paraId / w14:textId from the <w:p> element attributes
502
+ // so round-trip export can re-emit them unchanged.
503
+ const paraId = node.attributes["w14:paraId"] ?? node.attributes.paraId;
504
+ const textId = node.attributes["w14:textId"] ?? node.attributes.textId;
505
+ const wordExtensionIds: ParsedParagraphNode["wordExtensionIds"] | undefined =
506
+ paraId || textId
507
+ ? {
508
+ ...(paraId ? { paraId: paraId.toUpperCase() } : {}),
509
+ ...(textId ? { textId: textId.toUpperCase() } : {}),
510
+ }
511
+ : undefined;
496
512
  const children: ParsedInlineNode[] = [];
497
513
  let activeComplexField: {
498
514
  instruction: string;
@@ -709,6 +725,7 @@ function parseBodyChild(
709
725
  ...(bidi ? { bidi } : {}),
710
726
  ...(suppressLineNumbers ? { suppressLineNumbers } : {}),
711
727
  ...(cnfStyle ? { cnfStyle } : {}),
728
+ ...(wordExtensionIds ? { wordExtensionIds } : {}),
712
729
  ...(sectionProperties ? { sectionProperties } : {}),
713
730
  ...(sectionPropertiesXml ? { sectionPropertiesXml } : {}),
714
731
  children,
@@ -2242,7 +2259,10 @@ function readRunMarks(node: XmlElementNode, sourceXml: string): MarksParseResult
2242
2259
  );
2243
2260
  if (colorNode) {
2244
2261
  const colorVal = colorNode.attributes["w:val"] ?? colorNode.attributes.val;
2245
- if (colorVal && colorVal !== "auto") {
2262
+ // A.9: preserve the literal "auto" marker so the serializer can round-trip
2263
+ // it back. The canonical colour string is free-form; downstream paint
2264
+ // paths treat "auto" as "inherit from theme/style".
2265
+ if (colorVal) {
2246
2266
  marks.push({ type: "textColor", color: colorVal });
2247
2267
  }
2248
2268
  }
@@ -254,6 +254,84 @@ export function resolveBookmarkFieldDependencies(
254
254
  return deps;
255
255
  }
256
256
 
257
+ /**
258
+ * A.4 — detect duplicate bookmark ids in the canonical document.
259
+ *
260
+ * Walks the document in order and records every bookmarkStart. The first
261
+ * occurrence of each id keeps its original value; later occurrences are
262
+ * scheduled for re-keying to a fresh monotonically-increasing id above
263
+ * `max(existing numeric ids)` so newly-allocated ids never collide with
264
+ * any surviving original.
265
+ *
266
+ * Returns a plan the caller can use to mutate both bookmark nodes and
267
+ * every reference to them (PAGEREF / REF field instructions, hyperlink
268
+ * w:anchor, TOC entries). Each assignment is keyed by the start node's
269
+ * ordinal position so callers can walk the document a second time and
270
+ * apply the new id deterministically.
271
+ */
272
+ export interface BookmarkRekeyAssignment {
273
+ /** Ordinal position of the bookmark_start within the pre-order walk. */
274
+ ordinal: number;
275
+ /** Original bookmark id as emitted by the source. */
276
+ oldId: string;
277
+ /** New id when a re-key was scheduled; undefined when the id is kept. */
278
+ newId?: string;
279
+ }
280
+
281
+ export interface BookmarkRekeyPlan {
282
+ assignments: readonly BookmarkRekeyAssignment[];
283
+ /** All ids that had ≥2 occurrences in the source. */
284
+ duplicatedIds: readonly string[];
285
+ /** New max id allocated by the plan (advisory for allocators). */
286
+ nextId: number;
287
+ }
288
+
289
+ export function detectDuplicateBookmarkIds(
290
+ document: Pick<CanonicalDocument, "content"> | DocumentNode,
291
+ ): BookmarkRekeyPlan {
292
+ const root = "content" in document ? document.content : document;
293
+ const seenStarts = new Set<string>();
294
+ const duplicatedIds = new Set<string>();
295
+ let maxNumericId = 0;
296
+
297
+ const bookmarkStarts: Array<BookmarkStartNode> = [];
298
+
299
+ walkDocument(root, (node) => {
300
+ if (node.type === "bookmark_start") {
301
+ bookmarkStarts.push(node);
302
+ const parsed = Number.parseInt(node.bookmarkId, 10);
303
+ if (Number.isFinite(parsed)) {
304
+ maxNumericId = Math.max(maxNumericId, parsed);
305
+ }
306
+ }
307
+ });
308
+
309
+ const assignments: BookmarkRekeyAssignment[] = [];
310
+ let nextId = maxNumericId + 1;
311
+ for (let ordinal = 0; ordinal < bookmarkStarts.length; ordinal += 1) {
312
+ const start = bookmarkStarts[ordinal]!;
313
+ if (!seenStarts.has(start.bookmarkId)) {
314
+ seenStarts.add(start.bookmarkId);
315
+ assignments.push({ ordinal, oldId: start.bookmarkId });
316
+ continue;
317
+ }
318
+ duplicatedIds.add(start.bookmarkId);
319
+ const fresh = String(nextId);
320
+ nextId += 1;
321
+ assignments.push({
322
+ ordinal,
323
+ oldId: start.bookmarkId,
324
+ newId: fresh,
325
+ });
326
+ }
327
+
328
+ return {
329
+ assignments,
330
+ duplicatedIds: [...duplicatedIds].sort(),
331
+ nextId,
332
+ };
333
+ }
334
+
257
335
  function walkDocument(node: DocumentNode, visit: (node: DocumentNode) => void): void {
258
336
  visit(node);
259
337