Skip to content

Commit d06ff4e

Browse files
authored
fix: doc-api story regressions and export app.xml stats (#2478)
* fix: doc-api story regressions and export app.xml stats * fix: child export app.xml stats to use main document counts
1 parent 40e04c2 commit d06ff4e

10 files changed

Lines changed: 336 additions & 225 deletions

File tree

packages/super-editor/src/core/Editor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2934,6 +2934,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
29342934
const numberingData = this.converter.convertedXml['word/numbering.xml'];
29352935
const numbering = this.converter.schemaToXml(numberingData.elements[0]);
29362936

2937+
const appXmlData = this.converter.convertedXml['docProps/app.xml'];
2938+
const appXml = appXmlData?.elements?.[0] ? this.converter.schemaToXml(appXmlData.elements[0]) : null;
2939+
29372940
// Export core.xml (contains dcterms:created timestamp)
29382941
const coreXmlData = this.converter.convertedXml['docProps/core.xml'];
29392942
const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null;
@@ -2946,6 +2949,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
29462949
'word/numbering.xml': String(numbering),
29472950
'word/styles.xml': String(styles),
29482951
...updatedHeadersFooters,
2952+
...(appXml ? { 'docProps/app.xml': String(appXml) } : {}),
29492953
...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}),
29502954
};
29512955

packages/super-editor/src/core/super-converter/SuperConverter.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from './v2/exporter/commentsExporter.js';
2222
import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js';
2323
import { writeAppStatistics } from '../../document-api-adapters/helpers/app-properties.js';
24-
import { getWordStatistics } from '../../document-api-adapters/helpers/word-statistics.js';
24+
import { getWordStatistics, resolveMainBodyEditor } from '../../document-api-adapters/helpers/word-statistics.js';
2525
import { refreshAllStatFields } from '../../document-api-adapters/helpers/refresh-stat-fields.js';
2626
import { ensureSettingsRoot, hasUpdateFields, setUpdateFields } from '../../document-api-adapters/document-settings.js';
2727
import { importFootnoteData, importEndnoteData } from './v2/importer/documentFootnotesImporter.js';
@@ -1334,7 +1334,12 @@ class SuperConverter {
13341334
if (!editor) return;
13351335

13361336
try {
1337-
const stats = getWordStatistics(editor);
1337+
// docProps/app.xml is document-scoped metadata. When export runs from a
1338+
// linked child editor (for example a header/footer editor), compute the
1339+
// statistics from the main body editor so package-level counts stay
1340+
// aligned with Word's document-level stat-field semantics.
1341+
const statsEditor = resolveMainBodyEditor(editor);
1342+
const stats = getWordStatistics(statsEditor);
13381343
writeAppStatistics(this.convertedXml, stats);
13391344

13401345
// Only set w:updateFields when the document actually contains a

packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,31 @@ const TEST_DOC = 'blank-doc.docx';
1717

1818
const CT_CUSTOM = 'application/vnd.openxmlformats-officedocument.custom-properties+xml';
1919
const REL_CUSTOM = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties';
20+
const WORD_STAT_TEXT = 'Alpha beta gamma';
21+
const PARENT_WORD_STAT_TEXT = 'Alpha beta gamma delta';
22+
const CHILD_ONLY_TEXT = 'Header words only';
23+
24+
function readXmlTagValue(xml, tagName) {
25+
const match = xml.match(new RegExp(`<${tagName}>([^<]*)</${tagName}>`));
26+
return match?.[1] ?? null;
27+
}
28+
29+
function readAppStatistics(xml) {
30+
return {
31+
words: readXmlTagValue(xml, 'Words'),
32+
characters: readXmlTagValue(xml, 'Characters'),
33+
charactersWithSpaces: readXmlTagValue(xml, 'CharactersWithSpaces'),
34+
};
35+
}
36+
37+
async function createHeadlessEditor(testDoc = TEST_DOC) {
38+
const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(testDoc);
39+
return initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });
40+
}
2041

2142
describe('OPC package metadata: custom-properties registration', () => {
2243
it('getUpdatedDocs includes correct [Content_Types].xml and _rels/.rels for new custom.xml', async () => {
23-
const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC);
24-
const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });
44+
const { editor } = await createHeadlessEditor();
2545

2646
try {
2747
const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true });
@@ -48,8 +68,7 @@ describe('OPC package metadata: custom-properties registration', () => {
4868
});
4969

5070
it('zipped export includes valid package metadata for new custom.xml', async () => {
51-
const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC);
52-
const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });
71+
const { editor } = await createHeadlessEditor();
5372

5473
try {
5574
const exportedBuffer = await editor.exportDocx({ compression: 'STORE' });
@@ -86,8 +105,7 @@ describe('OPC package metadata: custom-properties registration', () => {
86105
});
87106

88107
it('preserves existing managed registrations without duplication', async () => {
89-
const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC);
90-
const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });
108+
const { editor } = await createHeadlessEditor();
91109

92110
try {
93111
const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true });
@@ -122,4 +140,52 @@ describe('OPC package metadata: custom-properties registration', () => {
122140
editor.destroy();
123141
}
124142
});
143+
144+
it('getUpdatedDocs includes refreshed docProps/app.xml statistics', async () => {
145+
const { editor } = await createHeadlessEditor();
146+
147+
try {
148+
editor.commands.insertContent(WORD_STAT_TEXT);
149+
150+
const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true });
151+
const appXml = updatedDocs['docProps/app.xml'];
152+
153+
expect(appXml).toBeTruthy();
154+
expect(readAppStatistics(appXml)).toEqual({
155+
words: '3',
156+
characters: '14',
157+
charactersWithSpaces: '16',
158+
});
159+
} finally {
160+
editor.destroy();
161+
}
162+
});
163+
164+
it('linked child exports keep docProps/app.xml statistics scoped to the main document', async () => {
165+
const { editor } = await createHeadlessEditor();
166+
let childEditor = null;
167+
168+
try {
169+
editor.commands.insertContent(PARENT_WORD_STAT_TEXT);
170+
171+
childEditor = editor.createChildEditor({
172+
isHeadless: true,
173+
isHeaderOrFooter: true,
174+
});
175+
childEditor.commands.insertContent(CHILD_ONLY_TEXT);
176+
177+
const updatedDocs = await childEditor.exportDocx({ getUpdatedDocs: true });
178+
const appXml = updatedDocs['docProps/app.xml'];
179+
180+
expect(appXml).toBeTruthy();
181+
expect(readAppStatistics(appXml)).toEqual({
182+
words: '4',
183+
characters: '19',
184+
charactersWithSpaces: '22',
185+
});
186+
} finally {
187+
childEditor?.destroy();
188+
editor.destroy();
189+
}
190+
});
125191
});

tests/doc-api-stories/tests/content-controls/all-commands.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const ALL_CONTENT_CONTROL_COMMAND_IDS = [
8484
'contentControls.group.wrap',
8585
'contentControls.group.ungroup',
8686
] as const;
87+
const COMMAND_STORY_TIMEOUT_MS = 60_000;
8788

8889
type ContentControlsCommandId = (typeof ALL_CONTENT_CONTROL_COMMAND_IDS)[number];
8990

@@ -2082,30 +2083,34 @@ describe('document-api story: all content-controls commands', () => {
20822083
});
20832084

20842085
for (const scenario of scenarios) {
2085-
it(`${scenario.operationId}: executes and saves source/result docs`, async () => {
2086-
const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-'));
2086+
it(
2087+
`${scenario.operationId}: executes and saves source/result docs`,
2088+
async () => {
2089+
const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-'));
20872090

2088-
try {
2089-
await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC);
2091+
try {
2092+
await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC);
20902093

2091-
const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null;
2094+
const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null;
20922095

2093-
await saveSource(sessionId, scenario.operationId);
2096+
await saveSource(sessionId, scenario.operationId);
20942097

2095-
const result = await scenario.run(sessionId, fixture);
2098+
const result = await scenario.run(sessionId, fixture);
20962099

2097-
if (READ_OPERATION_IDS.has(scenario.operationId)) {
2098-
assertReadShape(scenario.operationId, result);
2099-
await saveReadOutput(scenario.operationId, result);
2100-
} else {
2101-
assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true);
2102-
}
2100+
if (READ_OPERATION_IDS.has(scenario.operationId)) {
2101+
assertReadShape(scenario.operationId, result);
2102+
await saveReadOutput(scenario.operationId, result);
2103+
} else {
2104+
assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true);
2105+
}
21032106

2104-
await saveResult(sessionId, scenario.operationId);
2105-
} finally {
2106-
await closeSession(sessionId).catch(() => {});
2107-
}
2108-
});
2107+
await saveResult(sessionId, scenario.operationId);
2108+
} finally {
2109+
await closeSession(sessionId).catch(() => {});
2110+
}
2111+
},
2112+
COMMAND_STORY_TIMEOUT_MS,
2113+
);
21092114
}
21102115

21112116
it('writes source/result artifacts for every content-controls command', async () => {

0 commit comments

Comments
 (0)