@beyondwork/docx-react-component 1.0.101 → 1.0.102

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.101",
4
+ "version": "1.0.102",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -40,10 +40,22 @@ export interface AppPropertiesStats {
40
40
  words?: number;
41
41
  /** Character count. */
42
42
  characters?: number;
43
+ /** Character count including spaces. */
44
+ charactersWithSpaces?: number;
43
45
  /** Line count. */
44
46
  lines?: number;
45
47
  /** Paragraph count. */
46
48
  paragraphs?: number;
49
+ /** Editing time in minutes. */
50
+ totalTime?: number;
51
+ /** Source template name. */
52
+ template?: string;
53
+ /** Source company property. */
54
+ company?: string;
55
+ /** Source manager property. */
56
+ manager?: string;
57
+ /** Document security flag. */
58
+ docSecurity?: number;
47
59
  /** App-version string override; defaults to the package version. */
48
60
  appVersion?: string;
49
61
  /** Application identifier override; defaults to package.json name@version. */
@@ -58,19 +70,31 @@ export function buildAppPropertiesXml(
58
70
  const pages = stats.pages ?? 0;
59
71
  const words = stats.words ?? 0;
60
72
  const characters = stats.characters ?? 0;
73
+ const charactersWithSpaces = stats.charactersWithSpaces;
61
74
  const lines = stats.lines ?? 0;
62
75
  const paragraphs = stats.paragraphs ?? 0;
76
+ const totalTime = stats.totalTime;
63
77
 
64
78
  return [
65
79
  `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
66
80
  `<Properties xmlns="${APP_PROPERTIES_NAMESPACE}" xmlns:vt="${APP_PROPERTIES_VT_NAMESPACE}">`,
67
81
  ` <Application>${escapeXml(application)}</Application>`,
68
82
  ` <AppVersion>${escapeXml(appVersion)}</AppVersion>`,
83
+ ...(stats.template ? [` <Template>${escapeXml(stats.template)}</Template>`] : []),
69
84
  ` <Pages>${Math.max(0, Math.round(pages))}</Pages>`,
70
85
  ` <Words>${Math.max(0, Math.round(words))}</Words>`,
71
86
  ` <Characters>${Math.max(0, Math.round(characters))}</Characters>`,
87
+ ...(charactersWithSpaces !== undefined
88
+ ? [` <CharactersWithSpaces>${Math.max(0, Math.round(charactersWithSpaces))}</CharactersWithSpaces>`]
89
+ : []),
72
90
  ` <Lines>${Math.max(0, Math.round(lines))}</Lines>`,
73
91
  ` <Paragraphs>${Math.max(0, Math.round(paragraphs))}</Paragraphs>`,
92
+ ...(totalTime !== undefined ? [` <TotalTime>${Math.max(0, Math.round(totalTime))}</TotalTime>`] : []),
93
+ ...(stats.company ? [` <Company>${escapeXml(stats.company)}</Company>`] : []),
94
+ ...(stats.manager ? [` <Manager>${escapeXml(stats.manager)}</Manager>`] : []),
95
+ ...(stats.docSecurity !== undefined
96
+ ? [` <DocSecurity>${Math.max(0, Math.round(stats.docSecurity))}</DocSecurity>`]
97
+ : []),
74
98
  `</Properties>`,
75
99
  ].join("\n");
76
100
  }
@@ -0,0 +1,298 @@
1
+ import type { DocumentMetadata } from "../../model/canonical-document.ts";
2
+ import {
3
+ findFirstChild,
4
+ localName,
5
+ parseXml,
6
+ type XmlElementNode,
7
+ type XmlNode,
8
+ } from "./_mini-xml.ts";
9
+
10
+ export interface CorePropertiesView {
11
+ readonly title?: string;
12
+ readonly subject?: string;
13
+ readonly description?: string;
14
+ readonly creator?: string;
15
+ readonly language?: string;
16
+ readonly keywords?: string;
17
+ readonly category?: string;
18
+ readonly lastModifiedBy?: string;
19
+ readonly contentStatus?: string;
20
+ readonly revision?: string;
21
+ readonly version?: string;
22
+ readonly createdUtc?: string;
23
+ readonly modifiedUtc?: string;
24
+ }
25
+
26
+ export interface AppPropertiesView {
27
+ readonly application?: string;
28
+ readonly appVersion?: string;
29
+ readonly template?: string;
30
+ readonly pages?: number;
31
+ readonly words?: number;
32
+ readonly characters?: number;
33
+ readonly charactersWithSpaces?: number;
34
+ readonly totalTime?: number;
35
+ readonly company?: string;
36
+ readonly manager?: string;
37
+ readonly docSecurity?: number;
38
+ }
39
+
40
+ export interface CustomPropertyValue {
41
+ readonly name: string;
42
+ readonly value: string;
43
+ readonly valueKind: string;
44
+ }
45
+
46
+ export interface DocpropsView {
47
+ readonly coreProperties: CorePropertiesView | null;
48
+ readonly appProperties: AppPropertiesView | null;
49
+ readonly customProperties: ReadonlyArray<CustomPropertyValue>;
50
+ readonly sourcePresence: {
51
+ readonly core: boolean;
52
+ readonly app: boolean;
53
+ readonly custom: boolean;
54
+ };
55
+ readonly parseErrors: ReadonlyArray<{
56
+ readonly part: "core" | "app" | "custom";
57
+ readonly message: string;
58
+ }>;
59
+ }
60
+
61
+ export interface DocpropsParts {
62
+ readonly core?: Uint8Array;
63
+ readonly app?: Uint8Array;
64
+ readonly custom?: Uint8Array;
65
+ }
66
+
67
+ export interface OpcPartMap {
68
+ get(path: string): { bytes: Uint8Array } | undefined;
69
+ }
70
+
71
+ const CORE_PART = "/docProps/core.xml";
72
+ const APP_PART = "/docProps/app.xml";
73
+ const CUSTOM_PART = "/docProps/custom.xml";
74
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: false });
75
+
76
+ export function parseDocprops(parts: DocpropsParts): DocpropsView {
77
+ const parseErrors: { part: "core" | "app" | "custom"; message: string }[] = [];
78
+
79
+ let coreProperties: CorePropertiesView | null = null;
80
+ if (parts.core) {
81
+ try {
82
+ coreProperties = parseCoreXml(decodeXmlBytes(parts.core));
83
+ } catch (err) {
84
+ parseErrors.push({
85
+ part: "core",
86
+ message: err instanceof Error ? err.message : String(err),
87
+ });
88
+ }
89
+ }
90
+
91
+ let appProperties: AppPropertiesView | null = null;
92
+ if (parts.app) {
93
+ try {
94
+ appProperties = parseAppXml(decodeXmlBytes(parts.app));
95
+ } catch (err) {
96
+ parseErrors.push({
97
+ part: "app",
98
+ message: err instanceof Error ? err.message : String(err),
99
+ });
100
+ }
101
+ }
102
+
103
+ let customProperties: ReadonlyArray<CustomPropertyValue> = [];
104
+ if (parts.custom) {
105
+ try {
106
+ customProperties = parseCustomXml(decodeXmlBytes(parts.custom));
107
+ } catch (err) {
108
+ parseErrors.push({
109
+ part: "custom",
110
+ message: err instanceof Error ? err.message : String(err),
111
+ });
112
+ }
113
+ }
114
+
115
+ return {
116
+ coreProperties,
117
+ appProperties,
118
+ customProperties,
119
+ sourcePresence: {
120
+ core: Boolean(parts.core),
121
+ app: Boolean(parts.app),
122
+ custom: Boolean(parts.custom),
123
+ },
124
+ parseErrors,
125
+ };
126
+ }
127
+
128
+ export function parseDocpropsFromOpcParts(parts: OpcPartMap): DocpropsView {
129
+ const core = parts.get(CORE_PART);
130
+ const app = parts.get(APP_PART);
131
+ const custom = parts.get(CUSTOM_PART);
132
+ return parseDocprops({
133
+ ...(core ? { core: core.bytes } : {}),
134
+ ...(app ? { app: app.bytes } : {}),
135
+ ...(custom ? { custom: custom.bytes } : {}),
136
+ });
137
+ }
138
+
139
+ export function parseDocumentMetadataFromOpcParts(parts: OpcPartMap): DocumentMetadata {
140
+ return docpropsToDocumentMetadata(parseDocpropsFromOpcParts(parts));
141
+ }
142
+
143
+ export function docpropsToDocumentMetadata(view: DocpropsView): DocumentMetadata {
144
+ const core = view.coreProperties;
145
+ const app = view.appProperties;
146
+ const appProperties = app ? compactObject({
147
+ application: app.application,
148
+ appVersion: app.appVersion,
149
+ template: app.template,
150
+ pages: app.pages,
151
+ words: app.words,
152
+ characters: app.characters,
153
+ charactersWithSpaces: app.charactersWithSpaces,
154
+ totalTime: app.totalTime,
155
+ company: app.company,
156
+ manager: app.manager,
157
+ docSecurity: app.docSecurity,
158
+ }) : undefined;
159
+ return {
160
+ ...(core?.title ? { title: core.title } : {}),
161
+ ...(core?.subject ? { subject: core.subject } : {}),
162
+ ...(core?.description ? { description: core.description } : {}),
163
+ ...(core?.creator ? { creator: core.creator } : {}),
164
+ ...(core?.language ? { language: core.language } : {}),
165
+ ...(parseKeywords(core?.keywords) ? { keywords: parseKeywords(core?.keywords) } : {}),
166
+ ...(core?.category ? { category: core.category } : {}),
167
+ ...(core?.lastModifiedBy ? { lastModifiedBy: core.lastModifiedBy } : {}),
168
+ ...(core?.contentStatus ? { contentStatus: core.contentStatus } : {}),
169
+ ...(core?.revision ? { revision: core.revision } : {}),
170
+ ...(core?.version ? { version: core.version } : {}),
171
+ ...(core?.createdUtc ? { createdUtc: core.createdUtc } : {}),
172
+ ...(core?.modifiedUtc ? { modifiedUtc: core.modifiedUtc } : {}),
173
+ ...(appProperties && Object.keys(appProperties).length > 0 ? { appProperties } : {}),
174
+ customProperties: Object.fromEntries(
175
+ view.customProperties.map((property) => [property.name, property.value]),
176
+ ),
177
+ };
178
+ }
179
+
180
+ function parseCoreXml(xml: string): CorePropertiesView {
181
+ const parsed = parseXml(xml);
182
+ const root = documentElement(parsed) ?? parsed;
183
+ return {
184
+ title: textOfFirstChild(root, "title"),
185
+ subject: textOfFirstChild(root, "subject"),
186
+ description: textOfFirstChild(root, "description"),
187
+ creator: textOfFirstChild(root, "creator"),
188
+ language: textOfFirstChild(root, "language"),
189
+ keywords: textOfFirstChild(root, "keywords"),
190
+ category: textOfFirstChild(root, "category"),
191
+ lastModifiedBy: textOfFirstChild(root, "lastModifiedBy"),
192
+ contentStatus: textOfFirstChild(root, "contentStatus"),
193
+ revision: textOfFirstChild(root, "revision"),
194
+ version: textOfFirstChild(root, "version"),
195
+ createdUtc: textOfFirstChild(root, "created"),
196
+ modifiedUtc: textOfFirstChild(root, "modified"),
197
+ };
198
+ }
199
+
200
+ function parseAppXml(xml: string): AppPropertiesView {
201
+ const parsed = parseXml(xml);
202
+ const root = documentElement(parsed) ?? parsed;
203
+ return {
204
+ application: textOfFirstChild(root, "Application"),
205
+ appVersion: textOfFirstChild(root, "AppVersion"),
206
+ template: textOfFirstChild(root, "Template"),
207
+ pages: parseNonNegativeInt(textOfFirstChild(root, "Pages")),
208
+ words: parseNonNegativeInt(textOfFirstChild(root, "Words")),
209
+ characters: parseNonNegativeInt(textOfFirstChild(root, "Characters")),
210
+ charactersWithSpaces: parseNonNegativeInt(textOfFirstChild(root, "CharactersWithSpaces")),
211
+ totalTime: parseNonNegativeInt(textOfFirstChild(root, "TotalTime")),
212
+ company: textOfFirstChild(root, "Company"),
213
+ manager: textOfFirstChild(root, "Manager"),
214
+ docSecurity: parseNonNegativeInt(textOfFirstChild(root, "DocSecurity")),
215
+ };
216
+ }
217
+
218
+ function parseCustomXml(xml: string): CustomPropertyValue[] {
219
+ const parsed = parseXml(xml);
220
+ const root = documentElement(parsed) ?? parsed;
221
+ const out: CustomPropertyValue[] = [];
222
+ for (const child of root.children as XmlNode[]) {
223
+ if (child.type !== "element" || localName(child.name) !== "property") {
224
+ continue;
225
+ }
226
+ const name = child.attributes.name;
227
+ if (typeof name !== "string" || name.length === 0) {
228
+ continue;
229
+ }
230
+ const valueNode = firstElementChild(child);
231
+ if (!valueNode) {
232
+ continue;
233
+ }
234
+ out.push({
235
+ name,
236
+ value: extractChildText(valueNode).trim(),
237
+ valueKind: localName(valueNode.name),
238
+ });
239
+ }
240
+ return out;
241
+ }
242
+
243
+ function decodeXmlBytes(bytes: Uint8Array): string {
244
+ return UTF8_DECODER.decode(bytes);
245
+ }
246
+
247
+ function documentElement(root: XmlElementNode): XmlElementNode | undefined {
248
+ for (const child of root.children) {
249
+ if (child.type === "element") return child;
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ function textOfFirstChild(node: XmlElementNode, name: string): string | undefined {
255
+ const child = findFirstChild(node, name);
256
+ if (!child) return undefined;
257
+ const raw = extractChildText(child).trim();
258
+ return raw.length === 0 ? undefined : raw;
259
+ }
260
+
261
+ function extractChildText(node: XmlElementNode): string {
262
+ let out = "";
263
+ for (const child of node.children) {
264
+ if (child.type === "text") {
265
+ out += child.text;
266
+ } else {
267
+ out += extractChildText(child);
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+
273
+ function firstElementChild(node: XmlElementNode): XmlElementNode | undefined {
274
+ for (const child of node.children) {
275
+ if (child.type === "element") return child;
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ function parseNonNegativeInt(raw: string | undefined): number | undefined {
281
+ if (raw === undefined) return undefined;
282
+ const n = Number.parseInt(raw, 10);
283
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
284
+ }
285
+
286
+ function parseKeywords(raw: string | undefined): string[] | undefined {
287
+ if (!raw) return undefined;
288
+ const keywords = raw.split(",").map((entry) => entry.trim()).filter(Boolean);
289
+ return keywords.length > 0 ? keywords : undefined;
290
+ }
291
+
292
+ function compactObject<T extends Record<string, string | number | undefined>>(
293
+ value: T,
294
+ ): { [K in keyof T]?: Exclude<T[K], undefined> } {
295
+ return Object.fromEntries(
296
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
297
+ ) as { [K in keyof T]?: Exclude<T[K], undefined> };
298
+ }
@@ -189,13 +189,35 @@ export interface DocumentMetadata {
189
189
  title?: string;
190
190
  subject?: string;
191
191
  description?: string;
192
+ creator?: string;
192
193
  language?: string;
193
194
  keywords?: string[];
194
195
  category?: string;
196
+ lastModifiedBy?: string;
197
+ contentStatus?: string;
198
+ revision?: string;
199
+ version?: string;
200
+ createdUtc?: string;
201
+ modifiedUtc?: string;
195
202
  importMode?: string;
203
+ appProperties?: DocumentAppProperties;
196
204
  customProperties: Record<string, string>;
197
205
  }
198
206
 
207
+ export interface DocumentAppProperties {
208
+ application?: string;
209
+ appVersion?: string;
210
+ template?: string;
211
+ pages?: number;
212
+ words?: number;
213
+ characters?: number;
214
+ charactersWithSpaces?: number;
215
+ totalTime?: number;
216
+ company?: string;
217
+ manager?: string;
218
+ docSecurity?: number;
219
+ }
220
+
199
221
  export interface StylesCatalog {
200
222
  paragraphs: Record<string, ParagraphStyleDefinition>;
201
223
  characters: Record<string, CharacterStyleDefinition>;
@@ -2408,6 +2430,9 @@ export function repairCanonicalDocumentEnvelope(
2408
2430
  });
2409
2431
  const record = isPlainRecord(value) ? value : {};
2410
2432
  const metadata = isPlainRecord(record.metadata) ? record.metadata : {};
2433
+ const metadataRest = { ...metadata };
2434
+ delete metadataRest.customProperties;
2435
+ delete metadataRest.appProperties;
2411
2436
  const customProperties = isPlainRecord(metadata.customProperties)
2412
2437
  ? (Object.fromEntries(
2413
2438
  Object.entries(metadata.customProperties).filter(
@@ -2415,6 +2440,15 @@ export function repairCanonicalDocumentEnvelope(
2415
2440
  ),
2416
2441
  ) as Record<string, string>)
2417
2442
  : {};
2443
+ const appProperties = isPlainRecord(metadata.appProperties)
2444
+ ? (Object.fromEntries(
2445
+ Object.entries(metadata.appProperties).filter(
2446
+ ([, entry]) =>
2447
+ typeof entry === "string" ||
2448
+ (typeof entry === "number" && Number.isFinite(entry) && entry >= 0),
2449
+ ),
2450
+ ) as DocumentAppProperties)
2451
+ : undefined;
2418
2452
 
2419
2453
  const envelope: Mutable<CanonicalDocument> = {
2420
2454
  ...base,
@@ -2430,7 +2464,8 @@ export function repairCanonicalDocumentEnvelope(
2430
2464
  : base.updatedAt,
2431
2465
  metadata: {
2432
2466
  ...base.metadata,
2433
- ...metadata,
2467
+ ...metadataRest,
2468
+ ...(appProperties !== undefined ? { appProperties } : {}),
2434
2469
  customProperties,
2435
2470
  } as CanonicalDocument["metadata"],
2436
2471
  styles:
@@ -2614,6 +2649,74 @@ function validateMetadata(
2614
2649
  return;
2615
2650
  }
2616
2651
 
2652
+ for (const field of [
2653
+ "title",
2654
+ "subject",
2655
+ "description",
2656
+ "creator",
2657
+ "language",
2658
+ "category",
2659
+ "lastModifiedBy",
2660
+ "contentStatus",
2661
+ "revision",
2662
+ "version",
2663
+ "createdUtc",
2664
+ "modifiedUtc",
2665
+ "importMode",
2666
+ ]) {
2667
+ if (record[field] !== undefined && typeof record[field] !== "string") {
2668
+ issues.push({
2669
+ path: `${path}.${field}`,
2670
+ message: "metadata string fields must be strings when present.",
2671
+ });
2672
+ }
2673
+ }
2674
+
2675
+ if (
2676
+ record.keywords !== undefined &&
2677
+ (!Array.isArray(record.keywords) ||
2678
+ record.keywords.some((entry) => typeof entry !== "string"))
2679
+ ) {
2680
+ issues.push({
2681
+ path: `${path}.keywords`,
2682
+ message: "metadata keywords must be an array of strings when present.",
2683
+ });
2684
+ }
2685
+
2686
+ if (record.appProperties !== undefined) {
2687
+ const appProperties = asPlainObject(record.appProperties, `${path}.appProperties`, issues);
2688
+ if (appProperties) {
2689
+ for (const field of ["application", "appVersion", "template", "company", "manager"]) {
2690
+ if (appProperties[field] !== undefined && typeof appProperties[field] !== "string") {
2691
+ issues.push({
2692
+ path: `${path}.appProperties.${field}`,
2693
+ message: "appProperties string fields must be strings when present.",
2694
+ });
2695
+ }
2696
+ }
2697
+ for (const field of [
2698
+ "pages",
2699
+ "words",
2700
+ "characters",
2701
+ "charactersWithSpaces",
2702
+ "totalTime",
2703
+ "docSecurity",
2704
+ ]) {
2705
+ if (
2706
+ appProperties[field] !== undefined &&
2707
+ (typeof appProperties[field] !== "number" ||
2708
+ !Number.isFinite(appProperties[field]) ||
2709
+ appProperties[field] < 0)
2710
+ ) {
2711
+ issues.push({
2712
+ path: `${path}.appProperties.${field}`,
2713
+ message: "appProperties numeric fields must be finite non-negative numbers when present.",
2714
+ });
2715
+ }
2716
+ }
2717
+ }
2718
+ }
2719
+
2617
2720
  const customProperties = asPlainObject(record.customProperties, `${path}.customProperties`, issues);
2618
2721
  if (!customProperties) {
2619
2722
  return;
@@ -136,7 +136,7 @@ export function ensureHostMetadataParts(
136
136
  if (!corePropertiesPart || corePropertiesPart.contentType !== CORE_PROPERTIES_CONTENT_TYPE) {
137
137
  exportSession.replaceOwnedPart({
138
138
  path: CORE_PROPERTIES_PART_PATH,
139
- bytes: corePropertiesPart?.bytes ?? new TextEncoder().encode(buildCorePropertiesXml(document)),
139
+ bytes: new TextEncoder().encode(buildCorePropertiesXml(document)),
140
140
  contentType: CORE_PROPERTIES_CONTENT_TYPE,
141
141
  compression: corePropertiesPart?.compression,
142
142
  });
@@ -146,7 +146,7 @@ export function ensureHostMetadataParts(
146
146
  if (!appPropertiesPart || appPropertiesPart.contentType !== APP_PROPERTIES_CONTENT_TYPE) {
147
147
  exportSession.replaceOwnedPart({
148
148
  path: APP_PROPERTIES_PART_PATH,
149
- bytes: appPropertiesPart?.bytes ?? new TextEncoder().encode(buildAppPropertiesXml()),
149
+ bytes: new TextEncoder().encode(buildAppPropertiesXml(document.metadata.appProperties)),
150
150
  contentType: APP_PROPERTIES_CONTENT_TYPE,
151
151
  compression: appPropertiesPart?.compression,
152
152
  });
@@ -305,11 +305,16 @@ export function buildCorePropertiesXml(document: CanonicalDocumentEnvelope): str
305
305
  xmlNode("dc:title", metadata.title),
306
306
  xmlNode("dc:subject", metadata.subject),
307
307
  xmlNode("dc:description", metadata.description),
308
+ xmlNode("dc:creator", metadata.creator),
308
309
  xmlNode("dc:language", metadata.language),
309
310
  xmlNode("cp:keywords", keywords),
310
311
  xmlNode("cp:category", metadata.category),
311
- xmlNode('dcterms:created xsi:type="dcterms:W3CDTF"', document.createdAt),
312
- xmlNode('dcterms:modified xsi:type="dcterms:W3CDTF"', document.updatedAt),
312
+ xmlNode("cp:lastModifiedBy", metadata.lastModifiedBy),
313
+ xmlNode("cp:contentStatus", metadata.contentStatus),
314
+ xmlNode("cp:revision", metadata.revision),
315
+ xmlNode("cp:version", metadata.version),
316
+ xmlNode('dcterms:created xsi:type="dcterms:W3CDTF"', metadata.createdUtc ?? document.createdAt),
317
+ xmlNode('dcterms:modified xsi:type="dcterms:W3CDTF"', metadata.modifiedUtc ?? document.updatedAt),
313
318
  ].filter((line): line is string => Boolean(line));
314
319
 
315
320
  return [
@@ -83,6 +83,7 @@ import {
83
83
  import { toEditorSessionState } from "../import/canonical-assembly.ts";
84
84
  import {
85
85
  serializeCanonicalDocumentForExport,
86
+ serializeNumberingCatalogForExport,
86
87
  } from "../shared/session-utils.ts";
87
88
  import { withDocumentRelatedParts } from "../import/package-parts.ts";
88
89
  import {
@@ -290,6 +291,14 @@ export async function runStatefulExport(
290
291
  state.sourcePeoplePartPath ?? PEOPLE_PART_PATH;
291
292
  const numberingPartPath =
292
293
  state.sourceNumberingPartPath ?? NUMBERING_PART_PATH;
294
+ const sourceNumberingPart = state.sourceNumberingPartPath
295
+ ? state.sourcePackage.parts.get(numberingPartPath)
296
+ : undefined;
297
+ const numberingSignatureMatch =
298
+ serializeNumberingCatalogForExport(currentDocument.numbering as NumberingCatalog) ===
299
+ state.initialNumberingSignature;
300
+ const canReuseSourceNumberingPart =
301
+ numberingSignatureMatch && sourceNumberingPart !== undefined;
293
302
  const serializedNumberingXml = hasSerializableNumberingEntries(
294
303
  currentDocument.numbering as NumberingCatalog,
295
304
  )
@@ -303,6 +312,7 @@ export async function runStatefulExport(
303
312
  partPath: numberingPartPath,
304
313
  existingRelationshipId: state.sourceNumberingRelationshipId,
305
314
  include:
315
+ canReuseSourceNumberingPart ||
306
316
  Boolean(serializedNumberingXml) ||
307
317
  Boolean(state.sourceNumberingPartPath),
308
318
  },
@@ -423,16 +433,22 @@ export async function runStatefulExport(
423
433
  relationships: nextRelationships,
424
434
  });
425
435
 
426
- if (serializedNumberingXml || state.sourceNumberingPartPath) {
436
+ if (canReuseSourceNumberingPart || serializedNumberingXml || state.sourceNumberingPartPath) {
437
+ const numberingBytes =
438
+ canReuseSourceNumberingPart && sourceNumberingPart
439
+ ? sourceNumberingPart.bytes
440
+ : new TextEncoder().encode(
441
+ serializedNumberingXml ??
442
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"></w:numbering>`,
443
+ );
444
+
427
445
  exportSession.replaceOwnedPart({
428
446
  path: numberingPartPath,
429
- bytes: new TextEncoder().encode(
430
- serializedNumberingXml ??
431
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"></w:numbering>`,
432
- ),
447
+ bytes: numberingBytes,
433
448
  contentType:
434
- state.sourcePackage.parts.get(numberingPartPath)?.contentType ??
449
+ sourceNumberingPart?.contentType ??
435
450
  WORD_NUMBERING_CONTENT_TYPE,
451
+ compression: sourceNumberingPart?.compression,
436
452
  });
437
453
  }
438
454
 
@@ -133,6 +133,7 @@ export function createImportedCanonicalDocument(input: {
133
133
  subParts?: SubPartsCatalog;
134
134
  parsedStyles?: ParseStylesResult;
135
135
  fontTable?: CanonicalDocument["fontTable"];
136
+ metadata?: CanonicalDocument["metadata"];
136
137
  preservation: CanonicalDocument["preservation"];
137
138
  diagnostics: CanonicalDocument["diagnostics"];
138
139
  review: CanonicalDocument["review"];
@@ -166,9 +167,7 @@ export function createImportedCanonicalDocument(input: {
166
167
  docId: createCanonicalDocumentId(input.documentId),
167
168
  createdAt: input.timestamp,
168
169
  updatedAt: input.timestamp,
169
- metadata: {
170
- customProperties: {},
171
- },
170
+ metadata: input.metadata ?? { customProperties: {} },
172
171
  styles,
173
172
  numbering,
174
173
  media: input.media,
@@ -252,7 +252,8 @@ export interface LoadedDocxEditorSession {
252
252
  * State captured at import time that the export pipeline consumes to
253
253
  * round-trip the package. Carries source bytes, the OPC package
254
254
  * snapshot, per-part relationships, preserved-part manifest, and the
255
- * initial canonical signature used for fast-path byte reuse.
255
+ * initial canonical signatures used for fast-path byte reuse and
256
+ * preserve-first per-part export decisions.
256
257
  *
257
258
  * Load path builds this; `exportDocxEditorSession` consumes it via
258
259
  * the closure captured at open time.
@@ -299,6 +300,7 @@ export interface ImportedDocxState {
299
300
  preservedCommentDefinitions: readonly ImportedCommentDefinition[];
300
301
  blockingCommentDiagnostics: readonly CommentImportDiagnostic[];
301
302
  initialCanonicalSignature: string;
303
+ initialNumberingSignature: string;
302
304
  sourceSubPartPaths: {
303
305
  headers: Array<{ partPath: string; relationshipId: string }>;
304
306
  footers: Array<{ partPath: string; relationshipId: string }>;
@@ -97,6 +97,7 @@ import {
97
97
  } from "../../io/ooxml/parse-revisions.ts";
98
98
  import { parseCommentsFromOoxml } from "../../io/ooxml/parse-comments.ts";
99
99
  import { parseNumberingXml } from "../../io/ooxml/parse-numbering.ts";
100
+ import { parseDocumentMetadataFromOpcParts } from "../../io/ooxml/docprops.ts";
100
101
  import {
101
102
  parseHeaderFooterReferences,
102
103
  parseHeaderXml,
@@ -131,6 +132,7 @@ import {
131
132
  decodeUtf8,
132
133
  extractDocumentRootAttributes,
133
134
  serializeCanonicalDocumentForExport,
135
+ serializeNumberingCatalogForExport,
134
136
  toUint8Array,
135
137
  } from "../shared/session-utils.ts";
136
138
  import {
@@ -285,6 +287,7 @@ export async function loadDocxSessionAsync(
285
287
  }),
286
288
  );
287
289
  }
290
+ const packageMetadata = parseDocumentMetadataFromOpcParts(sourcePackage.parts);
288
291
  stages.emit("opc");
289
292
  await scheduler.yield();
290
293
  const embeddedWorkflowPayload = parseWorkflowPayloadEnvelopeFromPackage(sourcePackage);
@@ -1018,6 +1021,7 @@ export async function loadDocxSessionAsync(
1018
1021
  subParts,
1019
1022
  parsedStyles,
1020
1023
  fontTable: parsedFontTable,
1024
+ metadata: packageMetadata,
1021
1025
  preservation: buildImportPreservation(normalizedDocument.preservation, sourcePackage, [
1022
1026
  mainDocumentPath,
1023
1027
  numberingPartPath,
@@ -1152,6 +1156,9 @@ export async function loadDocxSessionAsync(
1152
1156
  BLOCKING_COMMENT_DIAGNOSTIC_CODES.has(diagnostic.code),
1153
1157
  ),
1154
1158
  initialCanonicalSignature: serializeCanonicalDocumentForExport(document),
1159
+ initialNumberingSignature: serializeNumberingCatalogForExport(
1160
+ document.numbering as _NumberingCatalog,
1161
+ ),
1155
1162
  sourceSubPartPaths: {
1156
1163
  headers: sourceHeaderPaths,
1157
1164
  footers: sourceFooterPaths,
@@ -1229,6 +1236,7 @@ export function loadDocxSessionSync(
1229
1236
  }),
1230
1237
  );
1231
1238
  }
1239
+ const packageMetadata = parseDocumentMetadataFromOpcParts(sourcePackage.parts);
1232
1240
  stages.emit("opc");
1233
1241
  const embeddedWorkflowPayload = parseWorkflowPayloadEnvelopeFromPackage(sourcePackage);
1234
1242
  const embeddedWorkflowMetadata = embeddedWorkflowPayload?.workflowMetadata;
@@ -1697,6 +1705,7 @@ export function loadDocxSessionSync(
1697
1705
  subParts,
1698
1706
  parsedStyles,
1699
1707
  fontTable: parsedFontTable,
1708
+ metadata: packageMetadata,
1700
1709
  preservation: buildImportPreservation(normalizedDocument.preservation, sourcePackage, [
1701
1710
  mainDocumentPath,
1702
1711
  numberingPartPath,
@@ -1808,6 +1817,9 @@ export function loadDocxSessionSync(
1808
1817
  BLOCKING_COMMENT_DIAGNOSTIC_CODES.has(diagnostic.code),
1809
1818
  ),
1810
1819
  initialCanonicalSignature: serializeCanonicalDocumentForExport(document),
1820
+ initialNumberingSignature: serializeNumberingCatalogForExport(
1821
+ document.numbering as _NumberingCatalog,
1822
+ ),
1811
1823
  sourceSubPartPaths: {
1812
1824
  headers: sourceHeaderPaths,
1813
1825
  footers: sourceFooterPaths,
@@ -0,0 +1,405 @@
1
+ import type { OpcPackage } from "../../io/opc/package-reader.ts";
2
+ import { readOpcPackage } from "../../io/opc/package-reader.ts";
3
+ import { sha256Hex } from "../../io/source-package-provenance.ts";
4
+ import {
5
+ normalizePartPath,
6
+ resolveRelationshipTarget,
7
+ type OpcPackagePart,
8
+ type OpcRelationship,
9
+ } from "../../io/ooxml/part-manifest.ts";
10
+ import { parseXml } from "../../io/ooxml/xml-parser.ts";
11
+ import type { XmlElementNode } from "../../io/ooxml/xml-element.ts";
12
+ import { localName } from "../../io/ooxml/xml-attr-helpers.ts";
13
+ import {
14
+ resolveMainDocumentPartPath,
15
+ } from "./part-discovery.ts";
16
+
17
+ export interface SourcePackageElementCounts {
18
+ tables: number;
19
+ tableRows: number;
20
+ tableCells: number;
21
+ anchors: number;
22
+ inlineDrawings: number;
23
+ drawings: number;
24
+ vmlShapes: number;
25
+ contentControls: number;
26
+ fieldMarkers: number;
27
+ fieldInstructions: number;
28
+ simpleFields: number;
29
+ bookmarks: number;
30
+ footnotes: number;
31
+ endnotes: number;
32
+ }
33
+
34
+ export interface SourcePackagePartEvidence {
35
+ path: string;
36
+ storyKind: SourcePackageStoryKind;
37
+ contentType: string | null;
38
+ byteLength: number;
39
+ relationshipCount: number;
40
+ counts: SourcePackageElementCounts;
41
+ }
42
+
43
+ export interface SourcePackageRelationshipEvidence {
44
+ sourcePartPath: string | null;
45
+ relationshipId: string;
46
+ relationshipType: string;
47
+ targetMode: OpcRelationship["targetMode"];
48
+ target: string;
49
+ resolvedTarget: string;
50
+ targetExists: boolean;
51
+ }
52
+
53
+ export interface SourcePackageEvidence {
54
+ schemaVersion: 1;
55
+ docId: string;
56
+ sourceLabel?: string;
57
+ sha256Hex: string;
58
+ sourceByteLength: number;
59
+ mainDocumentPartPath?: string;
60
+ partCount: number;
61
+ contentPartCount: number;
62
+ relationshipCount: number;
63
+ internalRelationshipCount: number;
64
+ externalRelationshipCount: number;
65
+ brokenInternalRelationshipCount: number;
66
+ counts: SourcePackageElementCounts & {
67
+ headers: number;
68
+ footers: number;
69
+ mediaParts: number;
70
+ imageParts: number;
71
+ chartParts: number;
72
+ embeddedParts: number;
73
+ customXmlParts: number;
74
+ commentsParts: number;
75
+ numberingParts: number;
76
+ settingsParts: number;
77
+ themeParts: number;
78
+ };
79
+ parts: SourcePackagePartEvidence[];
80
+ relationships: SourcePackageRelationshipEvidence[];
81
+ parseErrors: Array<{ partPath: string; message: string }>;
82
+ }
83
+
84
+ export type SourcePackageStoryKind =
85
+ | "main-document"
86
+ | "header"
87
+ | "footer"
88
+ | "footnotes"
89
+ | "endnotes"
90
+ | "comments"
91
+ | "numbering"
92
+ | "settings"
93
+ | "styles"
94
+ | "font-table"
95
+ | "theme"
96
+ | "chart"
97
+ | "media"
98
+ | "embedded"
99
+ | "custom-xml"
100
+ | "relationships"
101
+ | "content-types"
102
+ | "other";
103
+
104
+ const ZERO_COUNTS: SourcePackageElementCounts = {
105
+ tables: 0,
106
+ tableRows: 0,
107
+ tableCells: 0,
108
+ anchors: 0,
109
+ inlineDrawings: 0,
110
+ drawings: 0,
111
+ vmlShapes: 0,
112
+ contentControls: 0,
113
+ fieldMarkers: 0,
114
+ fieldInstructions: 0,
115
+ simpleFields: 0,
116
+ bookmarks: 0,
117
+ footnotes: 0,
118
+ endnotes: 0,
119
+ };
120
+
121
+ export function createSourcePackageEvidenceFromBytes(
122
+ input: {
123
+ bytes: Uint8Array | ArrayBuffer;
124
+ docId: string;
125
+ sourceLabel?: string;
126
+ },
127
+ ): SourcePackageEvidence {
128
+ const bytes = input.bytes instanceof Uint8Array
129
+ ? input.bytes
130
+ : new Uint8Array(input.bytes);
131
+ return createSourcePackageEvidence(readOpcPackage(bytes), {
132
+ docId: input.docId,
133
+ sourceLabel: input.sourceLabel,
134
+ sha256Hex: sha256Hex(bytes),
135
+ });
136
+ }
137
+
138
+ export function createSourcePackageEvidence(
139
+ sourcePackage: OpcPackage,
140
+ input: {
141
+ docId: string;
142
+ sourceLabel?: string;
143
+ sha256Hex?: string;
144
+ },
145
+ ): SourcePackageEvidence {
146
+ const mainDocumentPartPath = resolveMainDocumentPartPath(sourcePackage);
147
+ const parseErrors: SourcePackageEvidence["parseErrors"] = [];
148
+ const parts = [...sourcePackage.parts.values()]
149
+ .sort((left, right) => left.path.localeCompare(right.path))
150
+ .map((part) => summarizePart(part, mainDocumentPartPath, parseErrors));
151
+ const relationships = collectRelationships(sourcePackage);
152
+ const relationshipCount = relationships.length;
153
+ const internalRelationshipCount = relationships.filter(
154
+ (relationship) => relationship.targetMode === "internal",
155
+ ).length;
156
+ const externalRelationshipCount = relationshipCount - internalRelationshipCount;
157
+ const brokenInternalRelationshipCount = relationships.filter(
158
+ (relationship) =>
159
+ relationship.targetMode === "internal" && !relationship.targetExists,
160
+ ).length;
161
+ const contentParts = parts.filter(
162
+ (part) => part.storyKind !== "relationships" && part.storyKind !== "content-types",
163
+ );
164
+ const aggregateCounts = aggregateElementCounts(parts.map((part) => part.counts));
165
+
166
+ return {
167
+ schemaVersion: 1,
168
+ docId: input.docId,
169
+ ...(input.sourceLabel !== undefined ? { sourceLabel: input.sourceLabel } : {}),
170
+ sha256Hex: input.sha256Hex ?? "",
171
+ sourceByteLength: sourcePackage.sourceByteLength,
172
+ ...(mainDocumentPartPath !== undefined ? { mainDocumentPartPath } : {}),
173
+ partCount: sourcePackage.parts.size,
174
+ contentPartCount: contentParts.length,
175
+ relationshipCount,
176
+ internalRelationshipCount,
177
+ externalRelationshipCount,
178
+ brokenInternalRelationshipCount,
179
+ counts: {
180
+ ...aggregateCounts,
181
+ headers: countParts(parts, "header"),
182
+ footers: countParts(parts, "footer"),
183
+ mediaParts: countParts(parts, "media"),
184
+ imageParts: parts.filter((part) => part.contentType?.startsWith("image/")).length,
185
+ chartParts: countParts(parts, "chart"),
186
+ embeddedParts: countParts(parts, "embedded"),
187
+ customXmlParts: countParts(parts, "custom-xml"),
188
+ commentsParts: countParts(parts, "comments"),
189
+ numberingParts: countParts(parts, "numbering"),
190
+ settingsParts: countParts(parts, "settings"),
191
+ themeParts: countParts(parts, "theme"),
192
+ },
193
+ parts,
194
+ relationships,
195
+ parseErrors,
196
+ };
197
+ }
198
+
199
+ function summarizePart(
200
+ part: OpcPackagePart,
201
+ mainDocumentPartPath: string | undefined,
202
+ parseErrors: SourcePackageEvidence["parseErrors"],
203
+ ): SourcePackagePartEvidence {
204
+ const storyKind = classifyPart(part, mainDocumentPartPath);
205
+ return {
206
+ path: part.path,
207
+ storyKind,
208
+ contentType: part.contentType,
209
+ byteLength: part.bytes.byteLength,
210
+ relationshipCount: part.relationships.length,
211
+ counts: countPartElements(part, storyKind, parseErrors),
212
+ };
213
+ }
214
+
215
+ function countPartElements(
216
+ part: OpcPackagePart,
217
+ storyKind: SourcePackageStoryKind,
218
+ parseErrors: SourcePackageEvidence["parseErrors"],
219
+ ): SourcePackageElementCounts {
220
+ if (!shouldParseXmlPart(part, storyKind)) {
221
+ return { ...ZERO_COUNTS };
222
+ }
223
+
224
+ try {
225
+ return countElements(parseXml(new TextDecoder("utf-8").decode(part.bytes)));
226
+ } catch (error) {
227
+ parseErrors.push({
228
+ partPath: part.path,
229
+ message: error instanceof Error ? error.message : String(error),
230
+ });
231
+ return { ...ZERO_COUNTS };
232
+ }
233
+ }
234
+
235
+ function shouldParseXmlPart(
236
+ part: OpcPackagePart,
237
+ storyKind: SourcePackageStoryKind,
238
+ ): boolean {
239
+ if (part.surfaceKind !== "content") {
240
+ return false;
241
+ }
242
+ if (storyKind === "media" || storyKind === "embedded") {
243
+ return false;
244
+ }
245
+ return (
246
+ part.contentType?.includes("xml") === true ||
247
+ part.path.endsWith(".xml")
248
+ );
249
+ }
250
+
251
+ function countElements(root: XmlElementNode): SourcePackageElementCounts {
252
+ const counts = { ...ZERO_COUNTS };
253
+ const visit = (node: XmlElementNode): void => {
254
+ const name = localName(node.name);
255
+ switch (name) {
256
+ case "tbl":
257
+ counts.tables += 1;
258
+ break;
259
+ case "tr":
260
+ counts.tableRows += 1;
261
+ break;
262
+ case "tc":
263
+ counts.tableCells += 1;
264
+ break;
265
+ case "anchor":
266
+ counts.anchors += 1;
267
+ break;
268
+ case "inline":
269
+ counts.inlineDrawings += 1;
270
+ break;
271
+ case "drawing":
272
+ counts.drawings += 1;
273
+ break;
274
+ case "shape":
275
+ counts.vmlShapes += 1;
276
+ break;
277
+ case "sdt":
278
+ counts.contentControls += 1;
279
+ break;
280
+ case "fldChar":
281
+ counts.fieldMarkers += 1;
282
+ break;
283
+ case "instrText":
284
+ counts.fieldInstructions += 1;
285
+ break;
286
+ case "fldSimple":
287
+ counts.simpleFields += 1;
288
+ break;
289
+ case "bookmarkStart":
290
+ counts.bookmarks += 1;
291
+ break;
292
+ case "footnote":
293
+ counts.footnotes += 1;
294
+ break;
295
+ case "endnote":
296
+ counts.endnotes += 1;
297
+ break;
298
+ }
299
+
300
+ for (const child of node.children) {
301
+ if (child.type === "element") {
302
+ visit(child);
303
+ }
304
+ }
305
+ };
306
+
307
+ visit(root);
308
+ return counts;
309
+ }
310
+
311
+ function collectRelationships(
312
+ sourcePackage: OpcPackage,
313
+ ): SourcePackageRelationshipEvidence[] {
314
+ const relationships: SourcePackageRelationshipEvidence[] = [];
315
+ const pushRelationship = (
316
+ sourcePartPath: string | null,
317
+ relationship: OpcRelationship,
318
+ ): void => {
319
+ const resolvedTarget =
320
+ relationship.targetMode === "internal"
321
+ ? resolveRelationshipTarget(sourcePartPath, relationship)
322
+ : relationship.target;
323
+ relationships.push({
324
+ sourcePartPath,
325
+ relationshipId: relationship.id,
326
+ relationshipType: relationship.type,
327
+ targetMode: relationship.targetMode,
328
+ target: relationship.target,
329
+ resolvedTarget,
330
+ targetExists:
331
+ relationship.targetMode === "external" ||
332
+ sourcePackage.parts.has(normalizePartPath(resolvedTarget)),
333
+ });
334
+ };
335
+
336
+ for (const relationship of sourcePackage.manifest.packageRelationships) {
337
+ pushRelationship(null, relationship);
338
+ }
339
+ for (const part of sourcePackage.parts.values()) {
340
+ for (const relationship of part.relationships) {
341
+ pushRelationship(part.path, relationship);
342
+ }
343
+ }
344
+
345
+ return relationships.sort((left, right) => {
346
+ const leftKey = `${left.sourcePartPath ?? ""}:${left.relationshipId}`;
347
+ const rightKey = `${right.sourcePartPath ?? ""}:${right.relationshipId}`;
348
+ return leftKey.localeCompare(rightKey);
349
+ });
350
+ }
351
+
352
+ function classifyPart(
353
+ part: OpcPackagePart,
354
+ mainDocumentPartPath: string | undefined,
355
+ ): SourcePackageStoryKind {
356
+ const path = part.path;
357
+ if (part.surfaceKind === "relationships") return "relationships";
358
+ if (part.surfaceKind === "content-types") return "content-types";
359
+ if (mainDocumentPartPath && path === mainDocumentPartPath) return "main-document";
360
+ if (/^\/word\/header\d*\.xml$/u.test(path)) return "header";
361
+ if (/^\/word\/footer\d*\.xml$/u.test(path)) return "footer";
362
+ if (path === "/word/footnotes.xml") return "footnotes";
363
+ if (path === "/word/endnotes.xml") return "endnotes";
364
+ if (path.startsWith("/word/comments")) return "comments";
365
+ if (path === "/word/numbering.xml") return "numbering";
366
+ if (path === "/word/settings.xml") return "settings";
367
+ if (path === "/word/styles.xml") return "styles";
368
+ if (path === "/word/fontTable.xml") return "font-table";
369
+ if (path.startsWith("/word/theme/")) return "theme";
370
+ if (path.startsWith("/word/charts/")) return "chart";
371
+ if (path.startsWith("/word/media/")) return "media";
372
+ if (path.startsWith("/word/embeddings/")) return "embedded";
373
+ if (path.startsWith("/customXml/") && path.endsWith(".xml")) return "custom-xml";
374
+ return "other";
375
+ }
376
+
377
+ function aggregateElementCounts(
378
+ countsList: SourcePackageElementCounts[],
379
+ ): SourcePackageElementCounts {
380
+ const aggregate = { ...ZERO_COUNTS };
381
+ for (const counts of countsList) {
382
+ aggregate.tables += counts.tables;
383
+ aggregate.tableRows += counts.tableRows;
384
+ aggregate.tableCells += counts.tableCells;
385
+ aggregate.anchors += counts.anchors;
386
+ aggregate.inlineDrawings += counts.inlineDrawings;
387
+ aggregate.drawings += counts.drawings;
388
+ aggregate.vmlShapes += counts.vmlShapes;
389
+ aggregate.contentControls += counts.contentControls;
390
+ aggregate.fieldMarkers += counts.fieldMarkers;
391
+ aggregate.fieldInstructions += counts.fieldInstructions;
392
+ aggregate.simpleFields += counts.simpleFields;
393
+ aggregate.bookmarks += counts.bookmarks;
394
+ aggregate.footnotes += counts.footnotes;
395
+ aggregate.endnotes += counts.endnotes;
396
+ }
397
+ return aggregate;
398
+ }
399
+
400
+ function countParts(
401
+ parts: SourcePackagePartEvidence[],
402
+ storyKind: SourcePackageStoryKind,
403
+ ): number {
404
+ return parts.filter((part) => part.storyKind === storyKind).length;
405
+ }
@@ -16,6 +16,9 @@
16
16
  * signature wrapper that zeroes out `docId` / `createdAt` /
17
17
  * `updatedAt` so structural comparison doesn't falsely register
18
18
  * semantic drift.
19
+ * - `serializeNumberingCatalogForExport` — numbering-only signature
20
+ * used by export to preserve source numbering bytes when unrelated
21
+ * document edits force a package rewrite.
19
22
  *
20
23
  * P6-clean: imports only from `src/model/**` + `src/api/**`.
21
24
  */
@@ -80,3 +83,9 @@ export function serializeCanonicalDocumentForExport(
80
83
  updatedAt: "__export__",
81
84
  });
82
85
  }
86
+
87
+ export function serializeNumberingCatalogForExport(
88
+ numbering: NumberingCatalog,
89
+ ): string {
90
+ return createCanonicalDocumentSignature(numbering);
91
+ }