@beyondwork/docx-react-component 1.0.35 → 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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +84 -1
- package/src/core/commands/index.ts +19 -2
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +178 -16
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/session-capabilities.ts +7 -4
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 ===
|
|
106
|
-
? ""
|
|
107
|
-
: level.isLegalNumbering
|
|
108
|
-
?
|
|
109
|
-
:
|
|
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
|
|
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(
|
|
139
|
+
const numId = escapeAttribute(
|
|
140
|
+
clampNonNegativeIdString(stripCanonicalPrefix(instance.numberingInstanceId, "num:")),
|
|
141
|
+
);
|
|
122
142
|
const abstractNumId = escapeAttribute(
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/legal/bookmarks.ts
CHANGED
|
@@ -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
|
|