@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 +1 -1
- package/src/io/export/build-app-properties-xml.ts +24 -0
- package/src/io/ooxml/docprops.ts +298 -0
- package/src/model/canonical-document.ts +104 -1
- package/src/session/export/stateful-export-pipeline.ts +9 -4
- package/src/session/export/stateful-export.ts +22 -6
- package/src/session/import/canonical-assembly.ts +2 -3
- package/src/session/import/loader-types.ts +3 -1
- package/src/session/import/loader.ts +12 -0
- package/src/session/import/source-package-evidence.ts +405 -0
- package/src/session/shared/session-utils.ts +9 -0
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.
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
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(
|
|
312
|
-
xmlNode(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|