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
1 change: 1 addition & 0 deletions packages/layout-engine/pm-adapter/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const ATOMIC_INLINE_TYPES = new Set([
'passthroughInline',
'bookmarkEnd',
'fieldAnnotation',
'documentStatField',
]);

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import type { PMMark, PMNode, PositionMap } from '../../types.js';
import type { TextRun } from '@superdoc/contracts';
import { documentStatFieldNodeToRun } from './document-stat-field.js';
import { textNodeToRun } from './text-run.js';

vi.mock('./text-run.js', () => ({
textNodeToRun: vi.fn(() => ({ text: '0', fontFamily: 'Arial', fontSize: 16 }) satisfies TextRun),
}));

describe('documentStatFieldNodeToRun', () => {
it('passes marksAsAttrs through to the synthetic text node', () => {
const marksAsAttrs: PMMark[] = [{ type: 'bold' }, { type: 'italic' }];
const node = {
type: 'documentStatField',
attrs: {
resolvedText: '17',
marksAsAttrs,
},
marks: [],
} as unknown as PMNode;

const positions: PositionMap = new WeakMap();
positions.set(node, { start: 10, end: 11 });

const run = documentStatFieldNodeToRun({
node,
positions,
defaultFont: 'Arial',
defaultSize: 16,
inheritedMarks: [],
hyperlinkConfig: { enableRichHyperlinks: false },
themeColors: undefined,
enableComments: false,
runProperties: undefined,
converterContext: undefined,
sdtMetadata: undefined,
});

expect(textNodeToRun).toHaveBeenCalledWith(
expect.objectContaining({
node: expect.objectContaining({
type: 'text',
text: '17',
attrs: { marksAsAttrs },
}),
}),
);
expect(run).toMatchObject({ pmStart: 10, pmEnd: 11 });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { TextRun } from '@superdoc/contracts';
import type { PMNode } from '../../types.js';
import { textNodeToRun } from './text-run.js';
import type { InlineConverterParams } from './common.js';

/**
* Converts a documentStatField PM node to a TextRun with the resolved display text.
*/
export function documentStatFieldNodeToRun(params: InlineConverterParams): TextRun | null {
const { node, positions, sdtMetadata } = params;

const attrs = (node.attrs ?? {}) as Record<string, unknown>;
const resolvedText = (attrs.resolvedText as string) || '0';
const marksAsAttrs = Array.isArray(attrs.marksAsAttrs) ? attrs.marksAsAttrs : undefined;

const run = textNodeToRun({
...params,
node: {
type: 'text',
text: resolvedText,
marks: [...(node.marks ?? [])],
...(marksAsAttrs ? { attrs: { marksAsAttrs } } : {}),
} as PMNode,
});

const pos = positions.get(node);
if (pos) {
run.pmStart = pos.start;
run.pmEnd = pos.end;
}

if (sdtMetadata) {
run.sdt = sdtMetadata;
}

return run;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { tokenNodeToRun } from './inline-converters/generic-token.js';
import { imageNodeToRun } from './inline-converters/image.js';
import { crossReferenceNodeToRun } from './inline-converters/cross-reference.js';
import { sequenceFieldNodeToRun } from './inline-converters/sequence-field.js';
import { documentStatFieldNodeToRun } from './inline-converters/document-stat-field.js';
import { citationNodeToRun } from './inline-converters/citation.js';
import { authorityEntryNodeToRun } from './inline-converters/authority-entry.js';
import { lineBreakNodeToRun } from './inline-converters/line-break.js';
Expand Down Expand Up @@ -859,6 +860,9 @@ const INLINE_CONVERTERS_REGISTRY: Record<string, InlineConverterSpec> = {
sequenceField: {
inlineConverter: sequenceFieldNodeToRun,
},
documentStatField: {
inlineConverter: documentStatFieldNodeToRun,
},
citation: {
inlineConverter: citationNodeToRun,
},
Expand Down
110 changes: 110 additions & 0 deletions packages/super-editor/src/core/super-converter/SuperConverter.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
prepareCommentsXmlFilesForExport,
} 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 { 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';
import { DocxHelpers } from './docx-helpers/index.js';
import { mergeRelationshipElements } from './relationship-helpers.js';
Expand Down Expand Up @@ -1138,6 +1142,19 @@ class SuperConverter {
getCommentDefinition(c, index, commentsWithParaIds, editor),
);

// Compute the stat-field cache map once from the main body editor.
// This same map is reused for header/footer exports so all parts
// see document-level counts, not sub-editor-local counts.
let statFieldCacheMap;
try {
if (editor) {
statFieldCacheMap = refreshAllStatFields(editor);
}
} catch {
// Non-critical — translators will fall back to node attrs
}
this._currentStatFieldCacheMap = statFieldCacheMap;

const { result, params } = this.exportToXmlJson({
data: jsonData,
editorSchema,
Expand All @@ -1148,6 +1165,7 @@ class SuperConverter {
editor,
fieldsHighlightColor,
preserveSdtWrappers,
statFieldCacheMap,
});

// Keep convertedXml's document part in sync with the current export tree
Expand Down Expand Up @@ -1214,6 +1232,7 @@ class SuperConverter {
}

const headFootRels = this.#exportProcessHeadersFooters({ isFinalDoc });
this._currentStatFieldCacheMap = undefined; // cleanup after export cycle

// Update the rels table
this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...footnotesRels, ...headFootRels]);
Expand All @@ -1239,6 +1258,9 @@ class SuperConverter {
SuperConverter.setStoredCustomProperty(this.convertedXml, 'DocumentGuid', this.documentGuid, true);
}

// Flush document statistics into app.xml and settings.xml.
this.#exportStatFieldMetadata(editor);

// Update the numbering.xml
this.#exportNumberingFile(params);

Expand All @@ -1256,9 +1278,25 @@ class SuperConverter {
isHeaderFooter = false,
fieldsHighlightColor = null,
preserveSdtWrappers = false,
statFieldCacheMap = undefined,
}) {
const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body');

// Use the pre-computed cache map (from exportToDocx) when available.
// This ensures header/footer exports use main-body statistics, not
// sub-editor-local counts. Falls back to computing from the current
// editor for standalone calls.
let resolvedCacheMap = statFieldCacheMap ?? this._currentStatFieldCacheMap;
if (!resolvedCacheMap) {
try {
if (editor) {
resolvedCacheMap = refreshAllStatFields(editor);
}
} catch {
// Non-critical — translators will fall back to node attrs
}
}

const [result, params] = exportSchemaToJson({
node: data,
bodyNode,
Expand All @@ -1276,6 +1314,7 @@ class SuperConverter {
isHeaderFooter,
fieldsHighlightColor,
preserveSdtWrappers,
statFieldCacheMap: resolvedCacheMap,
});

return { result, params };
Expand All @@ -1285,6 +1324,77 @@ class SuperConverter {
return getBibliographyPartExportPaths(this.bibliographyPart);
}

/**
* Writes document-statistic metadata into docProps/app.xml and
* word/settings.xml as part of the export pipeline.
*
* Only upserts targeted elements — all unrelated metadata is preserved.
*/
#exportStatFieldMetadata(editor) {
if (!editor) return;

try {
const stats = getWordStatistics(editor);
writeAppStatistics(this.convertedXml, stats);

// Only set w:updateFields when the document actually contains a
// total-page-number node AND pagination is unavailable. This is the
// only scenario where the cached NUMPAGES result is definitively stale.
// Setting w:updateFields unconditionally would cause Word to recalculate
// ALL fields on open (TOC, cross-references, etc.) — a side effect the
// plan explicitly warns against.
const settingsPart = this.convertedXml['word/settings.xml'];
if (settingsPart && stats.pages == null) {
const hasNumPagesNode = this.#anyPartContainsNodeType('total-page-number', editor);
if (hasNumPagesNode) {
const settingsRoot = ensureSettingsRoot(settingsPart);
if (!hasUpdateFields(settingsRoot)) {
setUpdateFields(settingsRoot, true);
}
}
}
} catch {
// Non-critical — export should not fail if stats cannot be computed
}
}

/**
* Checks whether any document part (body + all header/footer editors)
* contains at least one node of the given type.
*/
#anyPartContainsNodeType(typeName, mainEditor) {
// Check main body
if (mainEditor && this.#docContainsNodeType(mainEditor.state.doc, typeName)) {
return true;
}
// Check all header editors
for (const entry of this.headerEditors ?? []) {
if (entry?.editor && this.#docContainsNodeType(entry.editor.state.doc, typeName)) {
return true;
}
}
// Check all footer editors
for (const entry of this.footerEditors ?? []) {
if (entry?.editor && this.#docContainsNodeType(entry.editor.state.doc, typeName)) {
return true;
}
}
return false;
}

#docContainsNodeType(doc, typeName) {
let found = false;
doc.descendants((node) => {
if (found) return false;
if (node.type.name === typeName) {
found = true;
return false;
}
return true;
});
return found;
}

#exportNumberingFile() {
const numberingPath = 'word/numbering.xml';
let numberingXml = this.convertedXml[numberingPath];
Expand Down
2 changes: 2 additions & 0 deletions packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { translator as sdIndexTranslator } from '@converter/v3/handlers/sd/index
import { translator as sdIndexEntryTranslator } from '@converter/v3/handlers/sd/indexEntry';
import { translator as sdAutoPageNumberTranslator } from '@converter/v3/handlers/sd/autoPageNumber';
import { translator as sdTotalPageNumberTranslator } from '@converter/v3/handlers/sd/totalPageNumber';
import { translator as sdDocumentStatFieldTranslator } from '@converter/v3/handlers/sd/documentStatField/documentStatField-translator.js';
import { translator as pictTranslator } from './v3/handlers/w/pict/pict-translator';
import { translateVectorShape, translateShapeGroup } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers';
import { translator as wTextTranslator } from '@converter/v3/handlers/w/t';
Expand Down Expand Up @@ -205,6 +206,7 @@ export function exportSchemaToJson(params) {
authorityEntry: sdAuthorityEntryTranslator,
tableOfAuthorities: sdTableOfAuthoritiesTranslator,
sequenceField: sdSequenceFieldTranslator,
documentStatField: sdDocumentStatFieldTranslator,
tableOfContents: sdTableOfContentsTranslator,
index: sdIndexTranslator,
indexEntry: sdIndexEntryTranslator,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Processes NUMWORDS and NUMCHARS instructions into `sd:documentStatField` nodes.
*
* Follows the same pattern as page-preprocessor.js / num-pages-preprocessor.js:
* captures rPr from content nodes first, falls back to field-sequence rPr.
*
* @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end.
* @param {string} instrText The full instruction string (e.g. "NUMWORDS" or "NUMCHARS \* MERGEFORMAT").
* @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] In the generic body pipeline this position still carries `docx`; in header/footer standalone processing it carries the captured w:rPr.
* @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the generic body pipeline, or a legacy w:rPr position in alternate callers.
* @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes for complex body fields.
* @returns {import('../../v2/types/index.js').OpenXmlNode[]}
*/
export function preProcessDocumentStatInstruction(
nodesToCombine,
instrText,
_docxOrFieldRunRPr = null,
instructionTokensOrFieldRunRPr = null,
fieldRunRPr = null,
) {
const effectiveFieldRunRPr =
fieldRunRPr ??
(instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ??
(_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null);
const statFieldNode = {
name: 'sd:documentStatField',
type: 'element',
attributes: {
instruction: instrText,
},
elements: [...nodesToCombine],
};

// Priority 1: Extract rPr from content nodes (between separate and end)
let foundContentRPr = false;
nodesToCombine.forEach((n) => {
const rPrNode = n.elements?.find((el) => el.name === 'w:rPr');
if (rPrNode) {
if (!statFieldNode.elements) statFieldNode.elements = [];
// Prepend rPr so the translator can find it
statFieldNode.elements = [rPrNode, ...nodesToCombine];
foundContentRPr = true;
}
});

// Priority 2: Use rPr from field sequence if content has none
if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') {
statFieldNode.elements = [effectiveFieldRunRPr, ...nodesToCombine];
}

return [statFieldNode];
}
Loading
Loading