@beyondwork/docx-react-component 1.0.100 → 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.100",
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;
@@ -294,6 +294,7 @@ import type {
294
294
  ParagraphNode,
295
295
  SectionProperties,
296
296
  SubPartsCatalog,
297
+ TocCachedEntry,
297
298
  TocRegion,
298
299
  } from "../model/canonical-document.ts";
299
300
  import {
@@ -7550,14 +7551,16 @@ function refreshDocumentTableOfContents(
7550
7551
  : parseTocLevelRange(field.instruction);
7551
7552
  const entries = navigation.headings
7552
7553
  .filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
7553
- .map((heading) => {
7554
+ .map((heading, index) => {
7554
7555
  const bookmarkName = bookmarkNameByOffset.get(heading.offset);
7555
- return {
7556
+ const entry: RuntimeTocEntry = {
7556
7557
  level: heading.level,
7557
7558
  text: heading.text,
7558
7559
  pageIndex: heading.pageIndex,
7559
7560
  ...(bookmarkName ? { bookmarkName } : {}),
7560
7561
  };
7562
+ const cachedPageText = resolveCachedTocPageText(selectedRegion, entry, index);
7563
+ return cachedPageText ? { ...entry, pageText: cachedPageText } : entry;
7561
7564
  });
7562
7565
  if (resultEntries.length === 0) {
7563
7566
  resultEntries = entries;
@@ -7657,6 +7660,7 @@ type RuntimeTocEntry = {
7657
7660
  level: number;
7658
7661
  text: string;
7659
7662
  pageIndex: number;
7663
+ pageText?: string;
7660
7664
  bookmarkName?: string;
7661
7665
  };
7662
7666
 
@@ -7688,11 +7692,54 @@ function buildTocRefreshResult(
7688
7692
  level: entry.level,
7689
7693
  text: entry.text,
7690
7694
  pageIndex: entry.pageIndex,
7695
+ ...(entry.pageText ? { pageText: entry.pageText } : {}),
7691
7696
  source: "generated",
7692
7697
  })),
7693
7698
  };
7694
7699
  }
7695
7700
 
7701
+ function resolveCachedTocPageText(
7702
+ region: TocRegion | undefined,
7703
+ entry: RuntimeTocEntry,
7704
+ index: number,
7705
+ ): string | undefined {
7706
+ if (!region || region.cachedEntries.length === 0) {
7707
+ return undefined;
7708
+ }
7709
+ const indexed = region.cachedEntries[index];
7710
+ if (indexed?.pageText && cachedTocEntryMatchesRuntimeEntry(indexed, entry)) {
7711
+ return indexed.pageText;
7712
+ }
7713
+ if (entry.bookmarkName) {
7714
+ const bookmarkMatch = region.cachedEntries.find(
7715
+ (cached) =>
7716
+ cached.bookmarkName === entry.bookmarkName &&
7717
+ cached.pageText &&
7718
+ cached.level === entry.level,
7719
+ );
7720
+ if (bookmarkMatch?.pageText) {
7721
+ return bookmarkMatch.pageText;
7722
+ }
7723
+ }
7724
+ return undefined;
7725
+ }
7726
+
7727
+ function cachedTocEntryMatchesRuntimeEntry(
7728
+ cached: TocCachedEntry,
7729
+ entry: RuntimeTocEntry,
7730
+ ): boolean {
7731
+ return cached.level === entry.level &&
7732
+ normalizeTocEntryPageReuseText(cached.text) === normalizeTocEntryPageReuseText(entry.text);
7733
+ }
7734
+
7735
+ function normalizeTocEntryPageReuseText(text: string): string {
7736
+ return text
7737
+ .replace(/\s+/gu, " ")
7738
+ .trim()
7739
+ .replace(/^(\d+(?:\.\d+)*\.?)\s+/u, "$1")
7740
+ .toLowerCase();
7741
+ }
7742
+
7696
7743
  function tocPageTextToPageIndex(pageText: string | undefined): number {
7697
7744
  const value = Number.parseInt(pageText ?? "", 10);
7698
7745
  return Number.isFinite(value) && value > 0 ? value - 1 : 0;
@@ -7873,7 +7920,7 @@ function buildTocEntryInlineNodes(
7873
7920
  children.push({ type: "text", text: entry.text });
7874
7921
  }
7875
7922
  children.push({ type: "tab" });
7876
- const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
7923
+ const displayed = entry.pageText ?? resolveDisplayPageNumber?.(entry.pageIndex);
7877
7924
  children.push({
7878
7925
  type: "text",
7879
7926
  text: String(displayed ?? entry.pageIndex + 1),
@@ -8056,7 +8103,7 @@ function buildInlineNodesFromDisplayText(text: string): InlineNode[] {
8056
8103
  * resolver, falls back to the raw `pageIndex + 1` (pre-P5 behavior).
8057
8104
  */
8058
8105
  function buildTocInlineNodes(
8059
- entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; bookmarkName?: string }>,
8106
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; pageText?: string; bookmarkName?: string }>,
8060
8107
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
8061
8108
  ): InlineNode[] {
8062
8109
  const children: InlineNode[] = [];
@@ -8071,7 +8118,7 @@ function buildTocInlineNodes(
8071
8118
  children.push({ type: "text", text: entry.text });
8072
8119
  }
8073
8120
  children.push({ type: "tab" });
8074
- const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
8121
+ const displayed = entry.pageText ?? resolveDisplayPageNumber?.(entry.pageIndex);
8075
8122
  children.push({
8076
8123
  type: "text",
8077
8124
  text: String(displayed ?? entry.pageIndex + 1),
@@ -8085,7 +8132,7 @@ function buildTocInlineNodes(
8085
8132
 
8086
8133
  /** Test-only export of `buildTocInlineNodes` (P5 unit tests). */
8087
8134
  export function __buildTocInlineNodes(
8088
- entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; bookmarkName?: string }>,
8135
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; pageText?: string; bookmarkName?: string }>,
8089
8136
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
8090
8137
  ): InlineNode[] {
8091
8138
  return buildTocInlineNodes(entries, resolveDisplayPageNumber);
@@ -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
+ }