Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2901,6 +2901,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
const numberingData = this.converter.convertedXml['word/numbering.xml'];
const numbering = this.converter.schemaToXml(numberingData.elements[0]);

const appXmlData = this.converter.convertedXml['docProps/app.xml'];
const appXml = appXmlData?.elements?.[0] ? this.converter.schemaToXml(appXmlData.elements[0]) : null;

// Export core.xml (contains dcterms:created timestamp)
const coreXmlData = this.converter.convertedXml['docProps/core.xml'];
const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null;
Expand All @@ -2913,6 +2916,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
'word/numbering.xml': String(numbering),
'word/styles.xml': String(styles),
...updatedHeadersFooters,
...(appXml ? { 'docProps/app.xml': String(appXml) } : {}),
Comment thread
harbournick marked this conversation as resolved.
...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from './v2/exporter/commentsExporter.js';
import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js';
import { writeAppStatistics } from '../../document-api-adapters/helpers/app-properties.js';
import { getWordStatistics } from '../../document-api-adapters/helpers/word-statistics.js';
import { getWordStatistics, resolveMainBodyEditor } from '../../document-api-adapters/helpers/word-statistics.js';
import { refreshAllStatFields } from '../../document-api-adapters/helpers/refresh-stat-fields.js';
import { ensureSettingsRoot, hasUpdateFields, setUpdateFields } from '../../document-api-adapters/document-settings.js';
import { importFootnoteData, importEndnoteData } from './v2/importer/documentFootnotesImporter.js';
Expand Down Expand Up @@ -1334,7 +1334,12 @@ class SuperConverter {
if (!editor) return;

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

// Only set w:updateFields when the document actually contains a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,31 @@ const TEST_DOC = 'blank-doc.docx';

const CT_CUSTOM = 'application/vnd.openxmlformats-officedocument.custom-properties+xml';
const REL_CUSTOM = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties';
const WORD_STAT_TEXT = 'Alpha beta gamma';
const PARENT_WORD_STAT_TEXT = 'Alpha beta gamma delta';
const CHILD_ONLY_TEXT = 'Header words only';

function readXmlTagValue(xml, tagName) {
const match = xml.match(new RegExp(`<${tagName}>([^<]*)</${tagName}>`));
return match?.[1] ?? null;
}

function readAppStatistics(xml) {
return {
words: readXmlTagValue(xml, 'Words'),
characters: readXmlTagValue(xml, 'Characters'),
charactersWithSpaces: readXmlTagValue(xml, 'CharactersWithSpaces'),
};
}

async function createHeadlessEditor(testDoc = TEST_DOC) {
const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(testDoc);
return initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });
}

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

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

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

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

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

try {
const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true });
Expand Down Expand Up @@ -122,4 +140,52 @@ describe('OPC package metadata: custom-properties registration', () => {
editor.destroy();
}
});

it('getUpdatedDocs includes refreshed docProps/app.xml statistics', async () => {
const { editor } = await createHeadlessEditor();

try {
editor.commands.insertContent(WORD_STAT_TEXT);

const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true });
const appXml = updatedDocs['docProps/app.xml'];

expect(appXml).toBeTruthy();
expect(readAppStatistics(appXml)).toEqual({
words: '3',
characters: '14',
charactersWithSpaces: '16',
});
} finally {
editor.destroy();
}
});

it('linked child exports keep docProps/app.xml statistics scoped to the main document', async () => {
const { editor } = await createHeadlessEditor();
let childEditor = null;

try {
editor.commands.insertContent(PARENT_WORD_STAT_TEXT);

childEditor = editor.createChildEditor({
isHeadless: true,
isHeaderOrFooter: true,
});
childEditor.commands.insertContent(CHILD_ONLY_TEXT);

const updatedDocs = await childEditor.exportDocx({ getUpdatedDocs: true });
const appXml = updatedDocs['docProps/app.xml'];

expect(appXml).toBeTruthy();
expect(readAppStatistics(appXml)).toEqual({
words: '4',
characters: '19',
charactersWithSpaces: '22',
});
} finally {
childEditor?.destroy();
editor.destroy();
}
});
});
41 changes: 23 additions & 18 deletions tests/doc-api-stories/tests/content-controls/all-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const ALL_CONTENT_CONTROL_COMMAND_IDS = [
'contentControls.group.wrap',
'contentControls.group.ungroup',
] as const;
const COMMAND_STORY_TIMEOUT_MS = 60_000;

type ContentControlsCommandId = (typeof ALL_CONTENT_CONTROL_COMMAND_IDS)[number];

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

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

try {
await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC);
try {
await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC);

const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null;
const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null;

await saveSource(sessionId, scenario.operationId);
await saveSource(sessionId, scenario.operationId);

const result = await scenario.run(sessionId, fixture);
const result = await scenario.run(sessionId, fixture);

if (READ_OPERATION_IDS.has(scenario.operationId)) {
assertReadShape(scenario.operationId, result);
await saveReadOutput(scenario.operationId, result);
} else {
assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true);
}
if (READ_OPERATION_IDS.has(scenario.operationId)) {
assertReadShape(scenario.operationId, result);
await saveReadOutput(scenario.operationId, result);
} else {
assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true);
}

await saveResult(sessionId, scenario.operationId);
} finally {
await closeSession(sessionId).catch(() => {});
}
});
await saveResult(sessionId, scenario.operationId);
} finally {
await closeSession(sessionId).catch(() => {});
}
},
COMMAND_STORY_TIMEOUT_MS,
);
}

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