diff --git a/packages/layout-engine/pm-adapter/src/constants.ts b/packages/layout-engine/pm-adapter/src/constants.ts index 7b1b42ba6f..90825ad358 100644 --- a/packages/layout-engine/pm-adapter/src/constants.ts +++ b/packages/layout-engine/pm-adapter/src/constants.ts @@ -112,6 +112,7 @@ export const ATOMIC_INLINE_TYPES = new Set([ 'passthroughInline', 'bookmarkEnd', 'fieldAnnotation', + 'documentStatField', ]); /** diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.test.ts new file mode 100644 index 0000000000..e23e22b85d --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.test.ts @@ -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 }); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.ts new file mode 100644 index 0000000000..38e1ccc741 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.ts @@ -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; + 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; +} diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 91228be8e6..7e34f4be2f 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -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'; @@ -859,6 +860,9 @@ const INLINE_CONVERTERS_REGISTRY: Record = { sequenceField: { inlineConverter: sequenceFieldNodeToRun, }, + documentStatField: { + inlineConverter: documentStatFieldNodeToRun, + }, citation: { inlineConverter: citationNodeToRun, }, diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 0d449780db..939984ff10 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -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'; @@ -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, @@ -1148,6 +1165,7 @@ class SuperConverter { editor, fieldsHighlightColor, preserveSdtWrappers, + statFieldCacheMap, }); // Keep convertedXml's document part in sync with the current export tree @@ -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]); @@ -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); @@ -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, @@ -1276,6 +1314,7 @@ class SuperConverter { isHeaderFooter, fieldsHighlightColor, preserveSdtWrappers, + statFieldCacheMap: resolvedCacheMap, }); return { result, params }; @@ -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]; diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index ef5288efbb..0aa20a5117 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -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'; @@ -205,6 +206,7 @@ export function exportSchemaToJson(params) { authorityEntry: sdAuthorityEntryTranslator, tableOfAuthorities: sdTableOfAuthoritiesTranslator, sequenceField: sdSequenceFieldTranslator, + documentStatField: sdDocumentStatFieldTranslator, tableOfContents: sdTableOfContentsTranslator, index: sdIndexTranslator, indexEntry: sdIndexEntryTranslator, diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js new file mode 100644 index 0000000000..041c90d23e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js @@ -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]; +} diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js new file mode 100644 index 0000000000..41e6d7c0f4 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { preProcessDocumentStatInstruction } from './document-stat-preprocessor.js'; + +describe('document-stat-preprocessor', () => { + it('creates an sd:documentStatField node with the instruction attribute', () => { + const result = preProcessDocumentStatInstruction([], 'NUMWORDS'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('sd:documentStatField'); + expect(result[0].attributes.instruction).toBe('NUMWORDS'); + }); + + it('preserves content nodes in the elements array', () => { + const contentNodes = [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '42' }] }], + }, + ]; + + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMCHARS'); + + expect(result[0].name).toBe('sd:documentStatField'); + expect(result[0].attributes.instruction).toBe('NUMCHARS'); + // Content nodes should be included + expect(result[0].elements).toBeDefined(); + }); + + it('extracts rPr from content nodes (priority 1)', () => { + const rPrNode = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; + const contentNodes = [ + { name: 'w:r', elements: [rPrNode, { name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }, + ]; + + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS'); + + expect(result[0].elements).toContain(rPrNode); + }); + + it('falls back to fieldRunRPr when no content rPr exists (priority 2)', () => { + const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }]; + + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', fieldRPr); + + expect(result[0].elements).toContain(fieldRPr); + }); + + it('ignores fieldRunRPr that is not w:rPr', () => { + const notRPr = { name: 'w:other', elements: [] }; + const contentNodes = []; + + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', notRPr); + + expect(result[0].elements).not.toContain(notRPr); + }); + + it('handles instruction with switches (e.g. NUMWORDS \\* MERGEFORMAT)', () => { + const result = preProcessDocumentStatInstruction([], 'NUMWORDS \\* MERGEFORMAT'); + + expect(result[0].attributes.instruction).toBe('NUMWORDS \\* MERGEFORMAT'); + }); + + it('uses 5th param fieldRunRPr when 3rd param is docx (body pipeline)', () => { + const docx = { 'word/document.xml': {} }; + const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; + const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }]; + + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', docx, null, fieldRPr); + + expect(result[0].elements[0]).toBe(fieldRPr); + }); + + it('falls back to 3rd param w:rPr when 5th param is null (header/footer pipeline)', () => { + const rPrFromThirdParam = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '5' }] }] }]; + + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMCHARS', rPrFromThirdParam, null, null); + + expect(result[0].elements[0]).toBe(rPrFromThirdParam); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js index 078e7d34cb..a194d26baf 100644 --- a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js @@ -14,6 +14,7 @@ import { preProcessCitationInstruction } from './citation-preprocessor.js'; import { preProcessBibliographyInstruction } from './bibliography-preprocessor.js'; import { preProcessTaInstruction } from './ta-preprocessor.js'; import { preProcessToaInstruction } from './toa-preprocessor.js'; +import { preProcessDocumentStatInstruction } from './document-stat-preprocessor.js'; /** * @callback InstructionPreProcessor @@ -35,6 +36,9 @@ export const getInstructionPreProcessor = (instruction) => { return preProcessPageInstruction; case 'NUMPAGES': return preProcessNumPagesInstruction; + case 'NUMWORDS': + case 'NUMCHARS': + return preProcessDocumentStatInstruction; case 'PAGEREF': return preProcessPageRefInstruction; case 'HYPERLINK': diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index 1be9f4c65f..d99b9a9dfd 100644 --- a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -11,8 +11,16 @@ export function preProcessNumPagesInstruction(nodesToCombine, _instrText, fieldR const totalPageNumNode = { name: 'sd:totalPageNumber', type: 'element', + attributes: {}, }; + // Extract the cached display text from content nodes so the encoder can + // preserve it for the NUMPAGES fallback path (headless / no-pagination). + const cachedText = extractCachedText(nodesToCombine); + if (cachedText) { + totalPageNumNode.attributes.importedCachedText = cachedText; + } + // First, try to get rPr from content nodes (between separate and end) // This is the original behavior and takes priority if content exists with styling let foundContentRPr = false; @@ -33,3 +41,20 @@ export function preProcessNumPagesInstruction(nodesToCombine, _instrText, fieldR return [totalPageNumNode]; } + +/** + * Extracts cached display text from content runs (between separate and end). + * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodes + * @returns {string} + */ +function extractCachedText(nodes) { + const texts = []; + for (const node of nodes) { + const textEl = node.elements?.find((el) => el.name === 'w:t'); + if (textEl) { + const text = textEl.elements?.[0]?.text ?? ''; + if (text) texts.push(text); + } + } + return texts.join(''); +} diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index 3b68b9c03f..dbde3e45ac 100644 --- a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -9,12 +9,9 @@ describe('preProcessNumPagesInstruction', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); - expect(result).toEqual([ - { - name: 'sd:totalPageNumber', - type: 'element', - }, - ]); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('sd:totalPageNumber'); + expect(result[0].type).toBe('element'); }); it('should extract rPr from nodes', () => { @@ -29,19 +26,11 @@ describe('preProcessNumPagesInstruction', () => { ]; const instruction = 'NUMPAGES'; const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); - expect(result).toEqual([ - { - name: 'sd:totalPageNumber', - type: 'element', - elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], - }, - ]); + expect(result[0].elements).toEqual([{ name: 'w:rPr', elements: [{ name: 'w:b' }] }]); }); it('should use fieldRunRPr when content nodes have no rPr', () => { - // This tests the case where NUMPAGES field has styling on begin/instrText/separate nodes - // but no content between separate and end - const nodesToCombine = []; // Empty - no content between separate and end + const nodesToCombine = []; const instruction = 'NUMPAGES'; const fieldRunRPr = { name: 'w:rPr', @@ -52,17 +41,10 @@ describe('preProcessNumPagesInstruction', () => { ], }; const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); - expect(result).toEqual([ - { - name: 'sd:totalPageNumber', - type: 'element', - elements: [fieldRunRPr], - }, - ]); + expect(result[0].elements).toEqual([fieldRunRPr]); }); it('should prefer content node rPr over fieldRunRPr', () => { - // Content between separate and end takes priority over field sequence styling const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const nodesToCombine = [ { @@ -76,26 +58,34 @@ describe('preProcessNumPagesInstruction', () => { elements: [{ name: 'w:b' }], }; const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); - expect(result).toEqual([ - { - name: 'sd:totalPageNumber', - type: 'element', - elements: [contentRPr], - }, - ]); + expect(result[0].elements).toEqual([contentRPr]); }); it('should ignore invalid fieldRunRPr (not a w:rPr node)', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; - // Pass something that's not a w:rPr node const invalidRPr = { name: 'w:r', elements: [] }; const result = preProcessNumPagesInstruction(nodesToCombine, instruction, invalidRPr); - expect(result).toEqual([ + expect(result[0].elements).toBeUndefined(); + }); + + it('should extract cached text from content nodes into importedCachedText', () => { + const nodesToCombine = [ { - name: 'sd:totalPageNumber', - type: 'element', + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:noProof' }] }, + { name: 'w:t', elements: [{ type: 'text', text: '3' }] }, + ], }, - ]); + ]; + const instruction = 'NUMPAGES'; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, null); + expect(result[0].attributes.importedCachedText).toBe('3'); + }); + + it('should not set importedCachedText when no content text exists', () => { + const result = preProcessNumPagesInstruction([], 'NUMPAGES', null); + expect(result[0].attributes.importedCachedText).toBeUndefined(); }); }); diff --git a/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.js index e6f21ef847..3cb85fb42a 100644 --- a/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -32,6 +32,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const processedNodes = []; let collectedNodesStack = []; let rawCollectedNodesStack = []; + let fieldRunRPrStack = []; let currentFieldStack = []; let unpairedEnd = null; let collecting = false; @@ -45,12 +46,14 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { if (collecting) { const collectedNodes = collectedNodesStack.pop().filter((n) => n !== null); const rawCollectedNodes = rawCollectedNodesStack.pop().filter((n) => n !== null); + const fieldRunRPr = fieldRunRPrStack.pop() ?? null; const currentField = currentFieldStack.pop(); const combinedResult = _processCombinedNodesForFldChar( collectedNodes, currentField.instrText.trim(), docx, currentField.instructionTokens, + fieldRunRPr, ); const outputNodes = combinedResult.handled ? combinedResult.nodes : rawCollectedNodes; if (collectedNodesStack.length === 0) { @@ -141,6 +144,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { rawCollectedNodesStack.push(rawStack); rawNodeSourceTokens.set(rawNode, rawSourceToken); capturedRawNodes.add(rawNode); + fieldRunRPrStack.push(extractFieldRunRPr(node)); currentFieldStack.push({ instrText: '', instructionTokens: [], afterSeparate: false }); return; } @@ -152,6 +156,10 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const instructionTokens = extractInstructionTokensFromNode(node); if (instructionTokens.length > 0) { captureRawNodeForCurrentField(rawNode, capturedRawNodes, rawSourceToken); + const fieldRunRPr = extractFieldRunRPr(node); + if (fieldRunRPr) { + fieldRunRPrStack[fieldRunRPrStack.length - 1] = fieldRunRPr; + } currentField.instructionTokens.push(...instructionTokens); const instrTextValue = instrTextEl?.elements?.[0]?.text; if (instrTextValue != null) { @@ -175,6 +183,10 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { } else if (fldType === 'separate') { if (collecting) { captureRawNodeForCurrentField(rawNode, capturedRawNodes, rawSourceToken); + const fieldRunRPr = extractFieldRunRPr(node); + if (fieldRunRPr) { + fieldRunRPrStack[fieldRunRPrStack.length - 1] = fieldRunRPr; + } const currentField = currentFieldStack[currentFieldStack.length - 1]; if (currentField) { currentField.afterSeparate = true; @@ -256,17 +268,37 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { * @param {OpenXmlNode[]} [nodesToCombine=[]] - The nodes to combine. * @param {string} instrText - The instruction text associated with the field. * @param {import('../v2/docxHelper').ParsedDocx} [docx] - The docx object. - * @returns {OpenXmlNode[]} The processed nodes. + * @param {Array<{type: string, text?: string}>} [instructionTokens] - Raw instruction tokens. + * @param {OpenXmlNode | null} [fieldRunRPr] - The w:rPr captured from field sequence runs. + * @returns {{ nodes: OpenXmlNode[], handled: boolean }} The processed nodes and whether a preprocessor handled them. */ -const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, instructionTokens) => { +const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, instructionTokens, fieldRunRPr) => { const instructionType = instrText.trim().split(' ')[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { - return { nodes: instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens), handled: true }; + return { + nodes: instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), + handled: true, + }; } return { nodes: nodesToCombine, handled: false }; }; +/** + * Returns a styled w:rPr node from a field-sequence run, or null when none exists. + * We only keep non-empty rPr nodes so empty formatting stubs do not mask later runs. + * + * @param {OpenXmlNode} node + * @returns {OpenXmlNode | null} + */ +const extractFieldRunRPr = (node) => { + const rPrNode = node?.elements?.find((el) => el.name === 'w:rPr'); + if (!rPrNode?.elements?.length) { + return null; + } + return rPrNode; +}; + /** * @typedef {Object} InstructionToken * @property {'text' | 'tab'} type - The token type diff --git a/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 1f223ca819..9ccc1f4f60 100644 --- a/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -384,4 +384,42 @@ describe('preProcessNodesForFldChar', () => { expect(processedNodes[0].name).toBe('sd:indexEntry'); expect(processedNodes[0].attributes.instruction).toBe('XE "Term"'); }); + + it('passes field-sequence rPr into body NUMWORDS fields when cached-result runs have no styling', () => { + const nodes = [ + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + { name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }, + ], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + { name: 'w:instrText', elements: [{ type: 'text', text: 'NUMWORDS' }] }, + ], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + { name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }, + ], + }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '12' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ]; + + const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:documentStatField'); + expect(processedNodes[0].attributes.instruction).toBe('NUMWORDS'); + expect(processedNodes[0].elements?.[0]).toEqual({ + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js index b22f515ffc..0367a738ed 100644 --- a/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -3,6 +3,7 @@ */ import { preProcessPageInstruction } from './fld-preprocessors/page-preprocessor.js'; import { preProcessNumPagesInstruction } from './fld-preprocessors/num-pages-preprocessor.js'; +import { preProcessDocumentStatInstruction } from './fld-preprocessors/document-stat-preprocessor.js'; const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); @@ -48,9 +49,8 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { const instrAttr = node.attributes?.['w:instr'] || ''; const fieldType = instrAttr.trim().split(/\s+/)[0]; - if (fieldType === 'PAGE' || fieldType === 'NUMPAGES') { - const preprocessor = fieldType === 'PAGE' ? preProcessPageInstruction : preProcessNumPagesInstruction; - + const fldSimplePreprocessor = getHeaderFooterFieldPreprocessor(fieldType); + if (fldSimplePreprocessor) { // Extract rPr from child elements (content nodes inside fldSimple) const contentNodes = node.elements || []; let fieldRunRPr = null; @@ -62,7 +62,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } - const processedField = preprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); + const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); processedNodes.push(...processedField); i++; continue; @@ -90,9 +90,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // Scan ahead to find the field type and end marker const fieldInfo = scanFieldSequence(nodes, i); - if (fieldInfo && (fieldInfo.fieldType === 'PAGE' || fieldInfo.fieldType === 'NUMPAGES')) { - // Process PAGE or NUMPAGES fields - const preprocessor = fieldInfo.fieldType === 'PAGE' ? preProcessPageInstruction : preProcessNumPagesInstruction; + const complexFieldPreprocessor = fieldInfo ? getHeaderFooterFieldPreprocessor(fieldInfo.fieldType) : null; + if (fieldInfo && complexFieldPreprocessor) { + const preprocessor = complexFieldPreprocessor; // Collect nodes between separate and end for the preprocessor // Also pass the captured rPr from field sequence nodes (begin, instrText, separate) @@ -215,6 +215,27 @@ function scanFieldSequence(nodes, beginIndex) { }; } +/** + * Returns the appropriate preprocessor for fields recognized in headers/footers, + * or null for unrecognized field types. + * + * @param {string} fieldType - The uppercase field type keyword (e.g. "PAGE", "NUMWORDS"). + * @returns {Function | null} + */ +function getHeaderFooterFieldPreprocessor(fieldType) { + switch (fieldType) { + case 'PAGE': + return preProcessPageInstruction; + case 'NUMPAGES': + return preProcessNumPagesInstruction; + case 'NUMWORDS': + case 'NUMCHARS': + return preProcessDocumentStatInstruction; + default: + return null; + } +} + /** * Checks if an rPr node has significant styling beyond just rStyle references. * Significant styling includes fonts, sizes, bold, italic, colors, etc. diff --git a/packages/super-editor/src/core/super-converter/v2/importer/documentStatFieldImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/documentStatFieldImporter.js new file mode 100644 index 0000000000..3ecafc14f2 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/documentStatFieldImporter.js @@ -0,0 +1,10 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator as documentStatFieldTranslator } from '../../v3/handlers/sd/documentStatField/index.js'; + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const documentStatFieldHandlerEntity = generateV2HandlerEntity( + 'documentStatFieldHandler', + documentStatFieldTranslator, +); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 5818fb88e4..6dfc3f9d0c 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -15,6 +15,7 @@ import { bookmarkStartNodeHandlerEntity } from './bookmarkStartImporter.js'; import { bookmarkEndNodeHandlerEntity } from './bookmarkEndImporter.js'; import { alternateChoiceHandler } from './alternateChoiceImporter.js'; import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumberImporter.js'; +import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; @@ -241,6 +242,7 @@ export const defaultNodeListHandler = () => { indexEntryHandlerEntity, autoPageHandlerEntity, autoTotalPageCountEntity, + documentStatFieldHandlerEntity, pageReferenceEntity, permStartHandlerEntity, permEndHandlerEntity, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/index.js index 5d0e38416f..da72ff0a59 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/index.js @@ -13,6 +13,7 @@ import { translator as sd_authorityEntry_translator } from './sd/authorityEntry/ import { translator as sd_tableOfAuthorities_translator } from './sd/tableOfAuthorities/tableOfAuthorities-translator.js'; import { translator as sd_autoPageNumber_translator } from './sd/autoPageNumber/autoPageNumber-translator.js'; import { translator as sd_totalPageNumber_translator } from './sd/totalPageNumber/totalPageNumber-translator.js'; +import { translator as sd_documentStatField_translator } from './sd/documentStatField/documentStatField-translator.js'; import { translator as w_abstractNum_translator } from './w/abstractNum/abstractNum-translator.js'; import { translator as w_abstractNumId_translator } from './w/abstractNumId/abstractNumId-translator.js'; import { translator as w_adjustRightInd_translator } from './w/adjustRightInd/adjustRightInd-translator.js'; @@ -222,6 +223,7 @@ const translatorList = Array.from( sd_tableOfAuthorities_translator, sd_autoPageNumber_translator, sd_totalPageNumber_translator, + sd_documentStatField_translator, w_abstractNum_translator, w_abstractNumId_translator, w_adjustRightInd_translator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/build-complex-field-runs.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/build-complex-field-runs.js new file mode 100644 index 0000000000..95c2d34006 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/build-complex-field-runs.js @@ -0,0 +1,70 @@ +// @ts-check + +/** + * Shared OOXML complex-field run builder. + * + * Both totalPageNumber and documentStatField translators emit the same + * five-run structure (begin → instrText → separate → cached result → end). + * This helper eliminates that duplication. + */ + +/** + * Builds the standard OOXML 5-run complex field structure. + * + * @param {Object} options + * @param {string} options.instruction - Field instruction (e.g. 'NUMPAGES', 'NUMWORDS') + * @param {string} options.cachedText - Cached result text to embed between separate/end + * @param {Array} options.outputMarks - Serialized w:rPr elements for each run + * @param {boolean} options.dirty - Whether to mark the field as w:dirty + * @returns {Array} Five w:r OOXML elements + */ +export function buildComplexFieldRuns({ instruction, cachedText, outputMarks, dirty }) { + const beginAttrs = { 'w:fldCharType': 'begin' }; + if (dirty) beginAttrs['w:dirty'] = 'true'; + + return [ + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: outputMarks }, + { name: 'w:fldChar', attributes: beginAttrs }, + ], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: outputMarks }, + { + name: 'w:instrText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: ` ${instruction}` }], + }, + ], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: outputMarks }, + { name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }, + ], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: outputMarks }, + { + name: 'w:t', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: cachedText }], + }, + ], + }, + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: outputMarks }, + { name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }, + ], + }, + ]; +} diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/build-complex-field-runs.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/build-complex-field-runs.test.js new file mode 100644 index 0000000000..c26049ccbe --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/build-complex-field-runs.test.js @@ -0,0 +1,73 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { buildComplexFieldRuns } from './build-complex-field-runs.js'; + +describe('buildComplexFieldRuns', () => { + it('produces a 5-run structure with begin, instrText, separate, cached result, and end', () => { + const result = buildComplexFieldRuns({ + instruction: 'NUMWORDS', + cachedText: '42', + outputMarks: [], + dirty: false, + }); + + expect(result).toHaveLength(5); + expect(result[0].elements[1].attributes['w:fldCharType']).toBe('begin'); + expect(result[1].elements[1].elements[0].text).toBe(' NUMWORDS'); + expect(result[2].elements[1].attributes['w:fldCharType']).toBe('separate'); + expect(result[3].elements[1].elements[0].text).toBe('42'); + expect(result[4].elements[1].attributes['w:fldCharType']).toBe('end'); + }); + + it('sets w:dirty on the begin run when dirty is true', () => { + const result = buildComplexFieldRuns({ + instruction: 'NUMPAGES', + cachedText: '3', + outputMarks: [], + dirty: true, + }); + + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + 'w:dirty': 'true', + }); + }); + + it('omits w:dirty on the begin run when dirty is false', () => { + const result = buildComplexFieldRuns({ + instruction: 'NUMPAGES', + cachedText: '3', + outputMarks: [], + dirty: false, + }); + + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + }); + }); + + it('applies outputMarks as w:rPr on every run', () => { + const marks = [{ name: 'w:b' }, { name: 'w:i' }]; + const result = buildComplexFieldRuns({ + instruction: 'NUMCHARS', + cachedText: '100', + outputMarks: marks, + dirty: false, + }); + + for (const run of result) { + expect(run.elements[0]).toEqual({ name: 'w:rPr', elements: marks }); + } + }); + + it('handles empty cachedText', () => { + const result = buildComplexFieldRuns({ + instruction: 'NUMPAGES', + cachedText: '', + outputMarks: [], + dirty: false, + }); + + expect(result[3].elements[1].elements[0].text).toBe(''); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/documentStatField/documentStatField-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/documentStatField/documentStatField-translator.js new file mode 100644 index 0000000000..7792084792 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/documentStatField/documentStatField-translator.js @@ -0,0 +1,126 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { processOutputMarks } from '../../../../exporter.js'; +import { parseMarks } from './../../../../v2/importer/markImporter.js'; +import { buildComplexFieldRuns } from '../build-complex-field-runs.js'; + +/** @type {import('@translator').XmlNodeName} */ +const XML_NODE_NAME = 'sd:documentStatField'; + +/** @type {import('@translator').SuperDocNodeOrKeyName} */ +const SD_NODE_NAME = 'documentStatField'; + +/** + * Encode a node as a SuperDoc documentStatField node. + * + * Extracts the instruction attribute and the cached display text from child + * content runs. Marks are collected from the first w:rPr found in child elements. + * + * @param {import('@translator').SCEncoderConfig} [params] + * @returns {import('@translator').SCEncoderResult} + */ +const encode = (params) => { + const { nodes = [] } = params || {}; + const node = nodes[0]; + + const instruction = node.attributes?.instruction || ''; + const resolvedText = extractCachedText(node.elements || []); + + const rPr = node.elements?.find((el) => el.name === 'w:rPr'); + const marks = parseMarks(rPr || { elements: [] }); + + return { + type: SD_NODE_NAME, + attrs: { + instruction, + resolvedText, + marksAsAttrs: marks, + }, + }; +}; + +/** + * Decode the documentStatField node back into OOXML complex field structure. + * + * Emits the standard 5-run complex field pattern: + * begin → instrText → separate → cached result run → end + * + * The cached result text comes from the export-preparation cache map context + * (if available), falling back to the node's `resolvedText` attr. + * + * @param {import('@translator').SCDecoderConfig} params + * @returns {import('@translator').SCDecoderResult[]} + */ +const decode = (params) => { + const { node } = params; + + const instruction = node.attrs?.instruction || ''; + const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const cachedText = resolveCachedText(params.statFieldCacheMap, node); + + // Fields with uninterpreted switches are marked dirty so Word re-evaluates. + const dirty = hasUninterpretedSwitches(instruction); + + return buildComplexFieldRuns({ instruction, cachedText, outputMarks, dirty }); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extracts cached display text from the content runs between separate and end + * that were preserved during import. + * + * @param {import('../../../../v2/types/index.js').OpenXmlNode[]} elements + * @returns {string} + */ +function extractCachedText(elements) { + const texts = []; + for (const el of elements) { + if (el.name === 'w:rPr') continue; + const textNode = el.elements?.find((child) => child.name === 'w:t'); + if (textNode) { + const text = textNode.elements?.[0]?.text ?? ''; + texts.push(text); + } + } + return texts.join(''); +} + +/** + * Resolves the cached text to write in the export. + * Prefers the export-preparation cache map (keyed by field type), + * falls back to the node's resolvedText attr. + */ +function resolveCachedText(cacheMap, node) { + if (cacheMap) { + const instruction = node.attrs?.instruction || ''; + const fieldType = instruction.trim().split(/\s+/)[0]?.toUpperCase(); + if (fieldType && cacheMap.has?.(fieldType)) { + return cacheMap.get(fieldType); + } + } + return node.attrs?.resolvedText ?? ''; +} + +/** + * Returns true if the instruction contains formatting switches that SuperDoc + * does not interpret. When true, the field must be marked w:dirty so Word + * re-evaluates on open. + */ +function hasUninterpretedSwitches(instruction) { + return /\\[*#@]/.test(instruction); +} + +/** @type {import('@translator').NodeTranslatorConfig} */ +export const config = { + xmlName: XML_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAME, + type: NodeTranslator.translatorTypes.NODE, + encode, + decode, +}; + +/** @type {import('@translator').NodeTranslator} */ +export const translator = NodeTranslator.from(config); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/documentStatField/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/documentStatField/index.js new file mode 100644 index 0000000000..a56173cc60 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/documentStatField/index.js @@ -0,0 +1 @@ +export * from './documentStatField-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index 9e22c67157..60b9dd9f27 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -2,6 +2,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { parseMarks } from './../../../../v2/importer/markImporter.js'; +import { buildComplexFieldRuns } from '../build-complex-field-runs.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:totalPageNumber'; @@ -20,10 +21,16 @@ const encode = (params) => { const rPr = node.elements?.find((el) => el.name === 'w:rPr'); const marks = parseMarks(rPr || { elements: [] }); + + // Preserve the imported cached text so the export fallback path can write + // a meaningful value when pagination is unavailable. + const importedCachedText = node.attributes?.importedCachedText || null; + const processedNode = { type: 'total-page-number', attrs: { marksAsAttrs: marks, + importedCachedText, }, }; @@ -39,76 +46,47 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); - const translated = [ - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: outputMarks, - }, - { - name: 'w:fldChar', - attributes: { - 'w:fldCharType': 'begin', - }, - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: outputMarks, - }, - { - name: 'w:instrText', - attributes: { 'xml:space': 'preserve' }, - elements: [ - { - type: 'text', - text: ' NUMPAGES', - }, - ], - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: outputMarks, - }, - { - name: 'w:fldChar', - attributes: { - 'w:fldCharType': 'separate', - }, - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: outputMarks, - }, - { - name: 'w:fldChar', - attributes: { - 'w:fldCharType': 'end', - }, - }, - ], - }, - ]; + const cachedText = resolveCachedPageCount(params, node); + + // Only mark dirty when the cache map does NOT contain a fresh page count. + // When pagination is active, the cache map has NUMPAGES and the value is + // trustworthy — Word should display it as-is without prompting. + const hasFreshPageCount = params.statFieldCacheMap?.has?.('NUMPAGES'); + const dirty = !hasFreshPageCount; - return translated; + return buildComplexFieldRuns({ instruction: 'NUMPAGES', cachedText, outputMarks, dirty }); }; +/** + * Resolves the cached page count text for export. + * + * Priority chain: + * 1. Export-preparation cache map (freshest, computed at export time) + * 2. resolvedText attr (set by explicit F9 field update) + * 3. importedCachedText attr (preserved from import for headless scenarios) + * 4. Node text content (total-page-number stores value as text children) + */ +function resolveCachedPageCount(params, node) { + const cacheMap = params.statFieldCacheMap; + if (cacheMap?.has?.('NUMPAGES')) { + return String(cacheMap.get('NUMPAGES')); + } + + if (node.attrs?.resolvedText) { + return String(node.attrs.resolvedText); + } + + if (node.attrs?.importedCachedText) { + return String(node.attrs.importedCachedText); + } + + const textContent = node.content + ?.filter((n) => n.type === 'text') + .map((n) => n.text || '') + .join(''); + return textContent || ''; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index 73ce675cb4..3f0f042be1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -13,6 +13,11 @@ vi.mock('./../../../../v2/importer/markImporter.js', () => ({ parseMarks: vi.fn(() => []), })); +vi.mock('../build-complex-field-runs.js', async (importOriginal) => { + const actual = await importOriginal(); + return actual; +}); + describe('sd:totalPageNumber translator', () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,10 +60,27 @@ describe('sd:totalPageNumber translator', () => { type: 'total-page-number', attrs: { marksAsAttrs: marks, + importedCachedText: null, }, }); }); + it('preserves importedCachedText from preprocessor attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:totalPageNumber', + attributes: { importedCachedText: '5' }, + elements: [], + }, + ], + }); + + expect(result.attrs.importedCachedText).toBe('5'); + }); + it('falls back to an empty rPr object when run properties are missing', () => { config.encode({ nodes: [ @@ -75,101 +97,89 @@ describe('sd:totalPageNumber translator', () => { }); describe('decode', () => { - it('decodes a total-page-number node back into the NUMPAGES field structure', () => { - const processedMarks = [{ name: 'w:u' }]; - vi.mocked(processOutputMarks).mockReturnValue(processedMarks); + it('marks NUMPAGES dirty when no cache map is provided (non-paginated)', () => { + vi.mocked(processOutputMarks).mockReturnValue([{ name: 'w:u' }]); const node = { type: 'total-page-number', - attrs: { - marksAsAttrs: [{ type: 'underline' }], - }, + attrs: { marksAsAttrs: [{ type: 'underline' }] }, + content: [{ type: 'text', text: '5' }], }; const result = config.decode({ node }); - expect(processOutputMarks).toHaveBeenCalledTimes(1); - expect(processOutputMarks).toHaveBeenCalledWith(node.attrs.marksAsAttrs); - expect(result).toEqual([ - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: processedMarks, - }, - { - name: 'w:fldChar', - attributes: { - 'w:fldCharType': 'begin', - }, - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: processedMarks, - }, - { - name: 'w:instrText', - attributes: { - 'xml:space': 'preserve', - }, - elements: [ - { - type: 'text', - text: ' NUMPAGES', - }, - ], - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: processedMarks, - }, - { - name: 'w:fldChar', - attributes: { - 'w:fldCharType': 'separate', - }, - }, - ], - }, - { - name: 'w:r', - elements: [ - { - name: 'w:rPr', - elements: processedMarks, - }, - { - name: 'w:fldChar', - attributes: { - 'w:fldCharType': 'end', - }, - }, - ], - }, - ]); + expect(result).toHaveLength(5); + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + 'w:dirty': 'true', + }); + // Cached text from node content + expect(result[3].elements[1].elements[0].text).toBe('5'); + }); + + it('omits w:dirty when cache map has a fresh NUMPAGES value (paginated)', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const cacheMap = new Map([['NUMPAGES', '12']]); + const node = { + type: 'total-page-number', + attrs: {}, + content: [{ type: 'text', text: '5' }], + }; + + const result = config.decode({ node, statFieldCacheMap: cacheMap }); + + expect(result).toHaveLength(5); + // Begin run should NOT have w:dirty + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + }); + // Cached text should come from the cache map, not node content + expect(result[3].elements[1].elements[0].text).toBe('12'); + }); + + it('falls back to resolvedText when cache map is absent', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const node = { + type: 'total-page-number', + attrs: { resolvedText: '8', importedCachedText: '3' }, + }; + + const result = config.decode({ node }); + + // resolvedText takes priority over importedCachedText + expect(result[3].elements[1].elements[0].text).toBe('8'); }); - it('passes an empty marks array to processOutputMarks when marks are missing', () => { - config.decode({ + it('falls back to importedCachedText when no resolvedText or cache map', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const node = { + type: 'total-page-number', + attrs: { importedCachedText: '3' }, + }; + + const result = config.decode({ node }); + + expect(result[3].elements[1].elements[0].text).toBe('3'); + }); + + it('produces a valid 5-run structure with empty text when all fallbacks are empty', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ node: { type: 'total-page-number', attrs: {}, }, }); - expect(processOutputMarks).toHaveBeenCalledTimes(1); - expect(processOutputMarks).toHaveBeenCalledWith([]); + expect(result).toHaveLength(5); + expect(result[0].elements[1].attributes['w:fldCharType']).toBe('begin'); + expect(result[3].elements[1].name).toBe('w:t'); + expect(result[3].elements[1].elements[0].text).toBe(''); + expect(result[4].elements[1].attributes['w:fldCharType']).toBe('end'); }); }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js b/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js index 6915bd6ab1..e085162635 100644 --- a/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/node-translator/node-translator.js @@ -34,6 +34,7 @@ export const TranslatorTypes = Object.freeze({ * @property {Comment[]} [comments] * @property {'external' | 'clean'} [commentsExportType] * @property {any[]} [exportedCommentDefs] + * @property {Map} [statFieldCacheMap] * @property {Record} [extraParams] * @property {import('../../../Editor.js').Editor} [editor] */ diff --git a/packages/super-editor/src/document-api-adapters/document-settings.ts b/packages/super-editor/src/document-api-adapters/document-settings.ts index ffca7ce639..9c984746bc 100644 --- a/packages/super-editor/src/document-api-adapters/document-settings.ts +++ b/packages/super-editor/src/document-api-adapters/document-settings.ts @@ -119,3 +119,44 @@ export function setOddEvenHeadersFooters(settingsRoot: XmlElement, enabled: bool const hasFlag = hasOddEvenHeadersFooters(settingsRoot); return hadFlag !== hasFlag; } + +// ────────────────────────────────────────────────────────────────────────────── +// w:updateFields +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Reads the `w:updateFields` flag from settings.xml. + * Returns true when the element is present with `w:val="true"` (or `"1"`). + */ +export function hasUpdateFields(settingsRoot: XmlElement): boolean { + const el = settingsRoot.elements?.find((entry) => entry.name === 'w:updateFields'); + if (!el) return false; + const val = (el.attributes as Record | undefined)?.['w:val']; + return val === 'true' || val === '1' || val === true; +} + +/** + * Sets the `w:updateFields` flag in settings.xml. + * Creates the element if absent, updates its value if present. + * Only upserts the targeted element — all other settings are preserved. + */ +export function setUpdateFields(settingsRoot: XmlElement, enabled: boolean): void { + const elements = ensureSettingsRootElements(settingsRoot); + const idx = elements.findIndex((entry) => entry.name === 'w:updateFields'); + + if (enabled) { + const newEl: XmlElement = { + type: 'element', + name: 'w:updateFields', + attributes: { 'w:val': 'true' }, + elements: [], + }; + if (idx !== -1) { + elements[idx] = newEl; + } else { + elements.push(newEl); + } + } else if (idx !== -1) { + elements.splice(idx, 1); + } +} diff --git a/packages/super-editor/src/document-api-adapters/document-stat-field-import.integration.test.ts b/packages/super-editor/src/document-api-adapters/document-stat-field-import.integration.test.ts new file mode 100644 index 0000000000..429c18550b --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/document-stat-field-import.integration.test.ts @@ -0,0 +1,110 @@ +/* @vitest-environment jsdom */ + +/** + * Integration test proving that w:fldSimple NUMWORDS and NUMCHARS fields + * import into documentStatField PM nodes (not dropped, not passthrough). + * + * Also verifies NUMPAGES imports as total-page-number with importedCachedText. + */ + +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import type { Editor } from '../core/Editor.js'; + +type LoadedDocData = Awaited>; + +describe('document stat field import integration', () => { + let docData: LoadedDocData; + let editor: Editor | undefined; + + beforeAll(async () => { + docData = await loadTestDataForEditorTests('numwords.docx'); + }); + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + function createEditor(): Editor { + const result = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + }); + return result.editor; + } + + /** Collect all nodes of a given type from the document. */ + function findNodesByType(ed: Editor, typeName: string): Array<{ pos: number; attrs: Record }> { + const results: Array<{ pos: number; attrs: Record }> = []; + ed.state.doc.descendants((node, pos) => { + if (node.type.name === typeName) { + results.push({ pos, attrs: node.attrs as Record }); + } + return true; + }); + return results; + } + + it('imports w:fldSimple NUMWORDS as a documentStatField node', () => { + editor = createEditor(); + const statFields = findNodesByType(editor, 'documentStatField'); + const numwordsFields = statFields.filter((f) => { + const instruction = (f.attrs.instruction as string) ?? ''; + return instruction.trim().split(/\s+/)[0]?.toUpperCase() === 'NUMWORDS'; + }); + + expect(numwordsFields).toHaveLength(1); + expect(numwordsFields[0].attrs.resolvedText).toBe('12'); + }); + + it('imports w:fldSimple NUMCHARS as a documentStatField node', () => { + editor = createEditor(); + const statFields = findNodesByType(editor, 'documentStatField'); + const numcharsFields = statFields.filter((f) => { + const instruction = (f.attrs.instruction as string) ?? ''; + return instruction.trim().split(/\s+/)[0]?.toUpperCase() === 'NUMCHARS'; + }); + + expect(numcharsFields).toHaveLength(1); + expect(numcharsFields[0].attrs.resolvedText).toBe('41'); + }); + + it('imports w:fldSimple NUMPAGES as a total-page-number node with importedCachedText', () => { + editor = createEditor(); + const numPagesFields = findNodesByType(editor, 'total-page-number'); + + expect(numPagesFields).toHaveLength(1); + expect(numPagesFields[0].attrs.importedCachedText).toBe('3'); + }); + + it('surfaces all three field types via fields.list', () => { + editor = createEditor(); + const listResult = editor.doc.fields.list({}); + const items = listResult?.items ?? []; + const fieldTypes = items.map((item: any) => { + const domain = item?.domain ?? item; + return domain?.fieldType; + }); + + expect(fieldTypes).toContain('NUMWORDS'); + expect(fieldTypes).toContain('NUMCHARS'); + expect(fieldTypes).toContain('NUMPAGES'); + }); + + it('reports the imported cached value for NUMPAGES via fields.list resolvedText', () => { + editor = createEditor(); + const listResult = editor.doc.fields.list({}); + const items = listResult?.items ?? []; + const numPagesItem = items.find((item: any) => { + const domain = item?.domain ?? item; + return domain?.fieldType === 'NUMPAGES'; + }); + + const resolvedText = numPagesItem?.domain?.resolvedText ?? numPagesItem?.resolvedText ?? ''; + expect(resolvedText).toBe('3'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/app-properties.test.ts b/packages/super-editor/src/document-api-adapters/helpers/app-properties.test.ts new file mode 100644 index 0000000000..a5fc344016 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/app-properties.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { writeAppStatistics, readAppStatistic } from './app-properties.js'; +import type { WordStatistics } from './word-statistics.js'; + +function makeStats(overrides: Partial = {}): WordStatistics { + return { + words: 100, + characters: 500, + charactersWithSpaces: 600, + pages: 3, + ...overrides, + }; +} + +function makeAppXml(): Record { + return { + 'docProps/app.xml': { + elements: [ + { + type: 'element', + name: 'Properties', + attributes: { xmlns: 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties' }, + elements: [ + { type: 'element', name: 'Application', elements: [{ type: 'text', text: 'Microsoft Office Word' }] }, + { type: 'element', name: 'Template', elements: [{ type: 'text', text: 'Normal.dotm' }] }, + { type: 'element', name: 'TotalTime', elements: [{ type: 'text', text: '33' }] }, + { type: 'element', name: 'Words', elements: [{ type: 'text', text: '50' }] }, + ], + }, + ], + }, + }; +} + +describe('app-properties', () => { + describe('writeAppStatistics', () => { + it('upserts Words, Characters, CharactersWithSpaces, and Pages', () => { + const xml = makeAppXml(); + writeAppStatistics(xml, makeStats()); + + expect(readAppStatistic(xml, 'Words')).toBe('100'); + expect(readAppStatistic(xml, 'Characters')).toBe('500'); + expect(readAppStatistic(xml, 'CharactersWithSpaces')).toBe('600'); + expect(readAppStatistic(xml, 'Pages')).toBe('3'); + }); + + it('preserves unrelated elements', () => { + const xml = makeAppXml(); + writeAppStatistics(xml, makeStats()); + + expect(readAppStatistic(xml, 'Application')).toBe('Microsoft Office Word'); + expect(readAppStatistic(xml, 'Template')).toBe('Normal.dotm'); + expect(readAppStatistic(xml, 'TotalTime')).toBe('33'); + }); + + it('updates existing Words value in place', () => { + const xml = makeAppXml(); + expect(readAppStatistic(xml, 'Words')).toBe('50'); + + writeAppStatistics(xml, makeStats({ words: 200 })); + expect(readAppStatistic(xml, 'Words')).toBe('200'); + }); + + it('skips Pages when pagination is inactive', () => { + const xml = makeAppXml(); + writeAppStatistics(xml, makeStats({ pages: undefined })); + + // Pages should not be written + expect(readAppStatistic(xml, 'Pages')).toBeNull(); + // Other stats should still be written + expect(readAppStatistic(xml, 'Words')).toBe('100'); + }); + + it('creates app.xml when it does not exist', () => { + const xml: Record = {}; + writeAppStatistics(xml, makeStats()); + + expect(readAppStatistic(xml, 'Words')).toBe('100'); + expect(readAppStatistic(xml, 'Characters')).toBe('500'); + }); + }); + + describe('readAppStatistic', () => { + it('returns null for missing elements', () => { + const xml = makeAppXml(); + expect(readAppStatistic(xml, 'NonExistent')).toBeNull(); + }); + + it('returns null when app.xml is absent', () => { + expect(readAppStatistic({}, 'Words')).toBeNull(); + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/app-properties.ts b/packages/super-editor/src/document-api-adapters/helpers/app-properties.ts new file mode 100644 index 0000000000..87384bc1d1 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/app-properties.ts @@ -0,0 +1,130 @@ +/** + * docProps/app.xml writer — upserts document-statistic elements. + * + * Only touches the targeted statistic elements (`Pages`, `Words`, `Characters`, + * `CharactersWithSpaces`). All other elements in app.xml are preserved. + */ + +import type { WordStatistics } from './word-statistics.js'; + +interface XmlElement { + type?: string; + name?: string; + attributes?: Record; + elements?: XmlElement[]; + text?: string; +} + +const APP_XML_PATH = 'docProps/app.xml'; + +/** + * The extended-properties namespace used by docProps/app.xml. + */ +const EP_NAMESPACE = 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties'; + +/** + * Upserts word-statistic values into a parsed app.xml structure. + * + * If the given `convertedXml` does not contain an app.xml part, one is + * created with a minimal `Properties` root. Existing elements not listed + * here are left untouched. + * + * @param convertedXml - The mutable map of part paths → parsed XML trees. + * @param stats - Fresh statistics from the Word-statistics helper. + */ +export function writeAppStatistics(convertedXml: Record, stats: WordStatistics): void { + const propertiesRoot = ensureAppPropertiesRoot(convertedXml); + const elements = ensureElements(propertiesRoot); + + upsertSimpleElement(elements, 'Words', String(stats.words)); + upsertSimpleElement(elements, 'Characters', String(stats.characters)); + upsertSimpleElement(elements, 'CharactersWithSpaces', String(stats.charactersWithSpaces)); + + // Only write Pages if a value is available (pagination may be inactive). + if (stats.pages != null) { + upsertSimpleElement(elements, 'Pages', String(stats.pages)); + } +} + +/** + * Reads a statistic value from docProps/app.xml. + * Returns the text content of the named element, or null if not found. + */ +export function readAppStatistic(convertedXml: Record, tagName: string): string | null { + const part = convertedXml[APP_XML_PATH] as XmlElement | undefined; + if (!part) return null; + + const root = findPropertiesRoot(part); + if (!root?.elements) return null; + + const el = root.elements.find((e) => e.name === tagName); + if (!el?.elements?.[0]) return null; + + return el.elements[0].text ?? null; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function findPropertiesRoot(part: XmlElement): XmlElement | null { + if (part.name === 'Properties') return part; + if (!Array.isArray(part.elements)) return null; + return part.elements.find((e) => e.name === 'Properties') ?? null; +} + +function ensureAppPropertiesRoot(convertedXml: Record): XmlElement { + let part = convertedXml[APP_XML_PATH] as XmlElement | undefined; + + if (!part) { + part = { + elements: [ + { + type: 'element', + name: 'Properties', + attributes: { xmlns: EP_NAMESPACE }, + elements: [], + }, + ], + }; + convertedXml[APP_XML_PATH] = part; + } + + const root = findPropertiesRoot(part); + if (root) return root; + + // Fallback: create root element + const newRoot: XmlElement = { + type: 'element', + name: 'Properties', + attributes: { xmlns: EP_NAMESPACE }, + elements: [], + }; + if (!Array.isArray(part.elements)) part.elements = []; + part.elements.push(newRoot); + return newRoot; +} + +function ensureElements(root: XmlElement): XmlElement[] { + if (!Array.isArray(root.elements)) root.elements = []; + return root.elements; +} + +/** + * Upserts a simple text element (`value`) in the given + * elements array. If the element already exists, its text is updated in place. + */ +function upsertSimpleElement(elements: XmlElement[], tagName: string, value: string): void { + const idx = elements.findIndex((e) => e.name === tagName); + const newEl: XmlElement = { + type: 'element', + name: tagName, + elements: [{ type: 'text', text: value }], + }; + + if (idx !== -1) { + elements[idx] = newEl; + } else { + elements.push(newEl); + } +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/field-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/field-resolver.ts index 9c6f600410..d05c1a8344 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/field-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/field-resolver.ts @@ -45,14 +45,51 @@ const FIELD_NODE_TYPES = new Set([ 'sequenceField', 'tableOfAuthorities', 'authorityEntry', + 'documentStatField', ]); +/** + * Node types that represent fields but derive their instruction synthetically + * rather than from an `instruction` attribute. + */ +const SYNTHETIC_FIELD_NODE_TYPES: Record = { + 'total-page-number': { fieldType: 'NUMPAGES', instruction: 'NUMPAGES' }, +}; + export function findAllFields(doc: ProseMirrorNode): ResolvedField[] { const results: ResolvedField[] = []; const blockOccurrenceCounters = new Map(); doc.descendants((node, pos) => { - if (!FIELD_NODE_TYPES.has(node.type.name) && !node.attrs?.instruction) { + const typeName = node.type.name; + + // Check for synthetic field node types (e.g. total-page-number → NUMPAGES) + const synthetic = SYNTHETIC_FIELD_NODE_TYPES[typeName]; + if (synthetic) { + const blockId = resolveParentBlockId(doc, pos); + const counter = blockOccurrenceCounters.get(blockId) ?? 0; + blockOccurrenceCounters.set(blockId, counter + 1); + + // Priority: text content (live NodeView value) → resolvedText (F9 update) + // → importedCachedText (original import fallback). + const textContent = node.textContent ?? ''; + const resolvedAttr = (node.attrs?.resolvedText as string) ?? ''; + const importedCached = (node.attrs?.importedCachedText as string) ?? ''; + const resolvedText = textContent || resolvedAttr || importedCached; + + results.push({ + pos, + blockId, + occurrenceIndex: counter, + nestingDepth: 0, + instruction: synthetic.instruction, + fieldType: synthetic.fieldType, + resolvedText, + }); + return true; + } + + if (!FIELD_NODE_TYPES.has(typeName) && !node.attrs?.instruction) { return true; } @@ -82,6 +119,33 @@ export function findAllFields(doc: ProseMirrorNode): ResolvedField[] { return results; } +/** + * Returns the subset of document fields whose positions intersect a range. + * + * For a collapsed selection (from === to) the field immediately at or + * adjacent to the cursor is matched — mirroring Word's "caret on a field" + * semantics. For a range selection, all fields overlapping [from, to) are + * returned. + */ +export function findFieldsInRange(doc: ProseMirrorNode, from: number, to: number): ResolvedField[] { + const allFields = findAllFields(doc); + const isCollapsed = from === to; + + return allFields.filter((field) => { + const node = doc.nodeAt(field.pos); + if (!node) return false; + const fieldEnd = field.pos + node.nodeSize; + + if (isCollapsed) { + // Match when cursor sits at either edge of the field node + return field.pos <= from && fieldEnd >= from; + } + + // Standard range overlap: field starts before range ends AND ends after range starts + return field.pos < to && fieldEnd > from; + }); +} + export function resolveFieldTarget(doc: ProseMirrorNode, target: FieldAddress): ResolvedField { const all = findAllFields(doc); const found = all.find( diff --git a/packages/super-editor/src/document-api-adapters/helpers/refresh-stat-fields.test.ts b/packages/super-editor/src/document-api-adapters/helpers/refresh-stat-fields.test.ts new file mode 100644 index 0000000000..cd40bd6579 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/refresh-stat-fields.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./word-statistics.js', async (importOriginal) => { + const original = (await importOriginal()) as typeof import('./word-statistics.js'); + return { + ...original, + getWordStatistics: vi.fn(), + resolveMainBodyEditor: vi.fn((editor) => editor), + }; +}); + +import { refreshAllStatFields } from './refresh-stat-fields.js'; +import { getWordStatistics, resolveMainBodyEditor } from './word-statistics.js'; + +describe('refreshAllStatFields', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps NUMCHARS to characters excluding spaces', () => { + vi.mocked(getWordStatistics).mockReturnValue({ + words: 11, + characters: 19, + charactersWithSpaces: 22, + pages: 3, + }); + + const editor = { state: { doc: {} } } as any; + const cache = refreshAllStatFields(editor); + + expect(resolveMainBodyEditor).toHaveBeenCalledWith(editor); + expect(cache.get('NUMWORDS')).toBe('11'); + expect(cache.get('NUMCHARS')).toBe('19'); + expect(cache.get('NUMPAGES')).toBe('3'); + }); + + it('omits NUMPAGES when pagination is unavailable', () => { + vi.mocked(getWordStatistics).mockReturnValue({ + words: 11, + characters: 19, + charactersWithSpaces: 22, + pages: undefined, + }); + + const cache = refreshAllStatFields({ state: { doc: {} } } as any); + + expect(cache.get('NUMPAGES')).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/refresh-stat-fields.ts b/packages/super-editor/src/document-api-adapters/helpers/refresh-stat-fields.ts new file mode 100644 index 0000000000..acfbe73f13 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/refresh-stat-fields.ts @@ -0,0 +1,40 @@ +/** + * Internal export-preparation helper for document statistic fields. + * + * Computes fresh field values keyed by field type. All fields of the same + * type display the same document-level value (e.g. every NUMWORDS field + * shows the same word count), so the cache map is keyed by field type, + * not by position. + * + * This cache map is consumed by translators during export — the live PM + * document is never mutated by this helper. + */ + +import type { Editor } from '../../core/Editor.js'; +import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor } from './word-statistics.js'; + +/** Maps uppercase field type (e.g. 'NUMWORDS') to its fresh string value. */ +export type StatFieldCacheMap = Map; + +/** + * Computes fresh cached values for all document-statistic field types. + * Returns a map from field type → fresh display value. + * + * Always resolves to the main body editor before computing stats, so this + * function is safe to call with a header/footer sub-editor — it will still + * return document-level counts. + */ +export function refreshAllStatFields(editor: Editor): StatFieldCacheMap { + const cacheMap: StatFieldCacheMap = new Map(); + const mainEditor = resolveMainBodyEditor(editor); + const stats = getWordStatistics(mainEditor); + + for (const fieldType of ['NUMWORDS', 'NUMCHARS', 'NUMPAGES'] as const) { + const freshValue = resolveDocumentStatFieldValue(fieldType, stats); + if (freshValue != null) { + cacheMap.set(fieldType, freshValue); + } + } + + return cacheMap; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/word-statistics.test.ts b/packages/super-editor/src/document-api-adapters/helpers/word-statistics.test.ts new file mode 100644 index 0000000000..32680f15ba --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/word-statistics.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies before importing the module under test +vi.mock('../get-text-adapter.js', () => ({ + getTextAdapter: vi.fn(() => ''), +})); + +vi.mock('./live-document-counts.js', async (importOriginal) => { + const original = (await importOriginal()) as any; + return { + ...original, + countWordsFromText: original.countWordsFromText, + countPages: vi.fn(() => undefined), + }; +}); + +import { getWordStatistics, resolveDocumentStatFieldValue } from './word-statistics.js'; +import { getTextAdapter } from '../get-text-adapter.js'; +import { countPages } from './live-document-counts.js'; + +function mockEditor(): any { + return { state: { doc: {} } }; +} + +describe('word-statistics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('computes words from the text projection', () => { + vi.mocked(getTextAdapter).mockReturnValue('Hello world test'); + const stats = getWordStatistics(mockEditor()); + expect(stats.words).toBe(3); + }); + + it('computes characters excluding spaces', () => { + vi.mocked(getTextAdapter).mockReturnValue('Hello world'); + const stats = getWordStatistics(mockEditor()); + // "Helloworld" = 10 (no spaces) + expect(stats.characters).toBe(10); + }); + + it('computes characters with spaces (excluding newlines)', () => { + vi.mocked(getTextAdapter).mockReturnValue('Hello world\nTest'); + const stats = getWordStatistics(mockEditor()); + // "Hello worldTest" = 15 (newline excluded, space included) + expect(stats.charactersWithSpaces).toBe(15); + }); + + it('returns pages from the layout engine', () => { + vi.mocked(getTextAdapter).mockReturnValue('text'); + vi.mocked(countPages).mockReturnValue(5); + const stats = getWordStatistics(mockEditor()); + expect(stats.pages).toBe(5); + }); + + it('returns undefined pages when pagination is inactive', () => { + vi.mocked(getTextAdapter).mockReturnValue('text'); + vi.mocked(countPages).mockReturnValue(undefined); + const stats = getWordStatistics(mockEditor()); + expect(stats.pages).toBeUndefined(); + }); + + it('handles empty documents', () => { + vi.mocked(getTextAdapter).mockReturnValue(''); + const stats = getWordStatistics(mockEditor()); + expect(stats.words).toBe(0); + expect(stats.characters).toBe(0); + expect(stats.charactersWithSpaces).toBe(0); + }); + + it('handles multi-paragraph text with block separators', () => { + // Text projection uses '\n' as block separator + vi.mocked(getTextAdapter).mockReturnValue('First paragraph\nSecond paragraph\nThird'); + const stats = getWordStatistics(mockEditor()); + expect(stats.words).toBe(5); + // Characters excluding all whitespace + expect(stats.characters).toBe('Firstparagraph'.length + 'Secondparagraph'.length + 'Third'.length); + // Characters with spaces but not newlines + expect(stats.charactersWithSpaces).toBe('First paragraph'.length + 'Second paragraph'.length + 'Third'.length); + }); + + it('maps NUMCHARS to the characters metric', () => { + const stats = { + words: 12, + characters: 34, + charactersWithSpaces: 40, + pages: 2, + }; + + expect(resolveDocumentStatFieldValue('NUMWORDS', stats)).toBe('12'); + expect(resolveDocumentStatFieldValue('NUMCHARS', stats)).toBe('34'); + expect(resolveDocumentStatFieldValue('NUMPAGES', stats)).toBe('2'); + }); + + it('returns null for unknown field types and unavailable NUMPAGES', () => { + const stats = { + words: 12, + characters: 34, + charactersWithSpaces: 40, + pages: undefined, + }; + + expect(resolveDocumentStatFieldValue('NUMPAGES', stats)).toBeNull(); + expect(resolveDocumentStatFieldValue('AUTHOR', stats)).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/word-statistics.ts b/packages/super-editor/src/document-api-adapters/helpers/word-statistics.ts new file mode 100644 index 0000000000..e8564c4fce --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/word-statistics.ts @@ -0,0 +1,108 @@ +/** + * Word-compatible document statistics helper. + * + * Provides counts that align with Word's NUMWORDS, NUMCHARS, and NUMPAGES + * field semantics. Reuses the existing text projection and word-counting + * infrastructure from live-document-counts.ts — only character-counting + * formulas are new. + * + * Count scope: main document body only (headers/footers, footnotes, and + * field display text are already excluded by the text projection). + * + * **Important**: When called from a header/footer sub-editor, the caller + * must pass the main body editor via `mainBodyEditor` so that counts + * reflect the document body, not the header/footer text. + */ + +import type { Editor } from '../../core/Editor.js'; +import { getTextAdapter } from '../get-text-adapter.js'; +import { countWordsFromText, countPages } from './live-document-counts.js'; + +export interface WordStatistics { + /** Word count — matches NUMWORDS / ap:Words semantics. */ + words: number; + /** Character count excluding spaces — matches ap:Characters / NUMCHARS semantics. */ + characters: number; + /** Character count including spaces — matches ap:CharactersWithSpaces. */ + charactersWithSpaces: number; + /** Page count from the layout engine (undefined when pagination is inactive). */ + pages: number | undefined; +} + +/** + * Computes Word-compatible document statistics. + * + * @param editor - The editor to compute stats from. For header/footer + * sub-editors this should be the **main body editor**, not the sub-editor, + * because stat fields always display document-level counts. + */ +export function getWordStatistics(editor: Editor): WordStatistics { + const text = getTextAdapter(editor, {}); + + return { + words: countWordsFromText(text), + characters: countCharactersExcludingSpaces(text), + charactersWithSpaces: countCharactersWithSpaces(text), + pages: countPages(editor), + }; +} + +/** + * Resolves the live display value for a document-statistic field. + * + * Word's NUMCHARS field reads from the `Characters` metric (excluding + * spaces), not from `CharactersWithSpaces`. + */ +export function resolveDocumentStatFieldValue(fieldType: string, stats: WordStatistics): string | null { + switch (fieldType) { + case 'NUMWORDS': + return String(stats.words); + case 'NUMCHARS': + return String(stats.characters); + case 'NUMPAGES': + return stats.pages != null ? String(stats.pages) : null; + default: + return null; + } +} + +/** + * Resolves the correct editor for computing document-level statistics. + * + * If the given editor is a header/footer sub-editor, returns the parent + * (main body) editor. Otherwise returns the editor itself. + */ +export function resolveMainBodyEditor(editor: Editor): Editor { + const parentEditor = (editor as any).options?.parentEditor; + return parentEditor ?? editor; +} + +/** + * Counts characters excluding whitespace — approximates Word's ap:Characters. + * + * Word's "Characters" count excludes spaces but includes punctuation. + * The text projection uses '\n' as block and leaf separators, which should + * not count as characters. + * + * **Semantics note**: This formula (`text.replace(/\s/g, '').length`) is an + * approximation. The exact Word counting rules are not publicly documented + * and may vary by Word version. The formula should be verified against + * Word-authored fixtures and adjusted if drift is detected. + */ +function countCharactersExcludingSpaces(text: string): number { + return text.replace(/\s/g, '').length; +} + +/** + * Counts characters with spaces — approximates Word's ap:CharactersWithSpaces. + * + * Word's "CharactersWithSpaces" counts all visible characters plus spaces, + * but not paragraph marks. The text projection inserts '\n' between blocks, + * which we exclude. + * + * **Semantics note**: Same caveat as above — the formula should be verified + * against Word-authored fixtures. + */ +function countCharactersWithSpaces(text: string): number { + return text.replace(/\n/g, '').length; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/field-wrappers.ts index adaa136480..8367870665 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/field-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/field-wrappers.ts @@ -28,6 +28,7 @@ import { executeDomainCommand } from './plan-wrappers.js'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; import { DocumentApiAdapterError } from '../errors.js'; +import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor } from '../helpers/word-statistics.js'; // --------------------------------------------------------------------------- // Result helpers @@ -75,6 +76,9 @@ export function fieldsGetWrapper(editor: Editor, input: FieldGetInput): FieldInf // Mutation operations // --------------------------------------------------------------------------- +/** Field types that use the documentStatField node representation. */ +const DOCUMENT_STAT_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS']); + export function fieldsInsertWrapper( editor: Editor, input: FieldInsertInput, @@ -95,8 +99,105 @@ export function fieldsInsertWrapper( if (options?.dryRun) return fieldSuccess(address); - // Find a field node type in the schema that accepts an instruction attribute. - // sequenceField is the generic raw-field container. + const fieldType = extractFieldType(input.instruction); + const resolved = resolveInlineInsertPosition(editor, input.at, 'fields.insert'); + + // Route insertion by field type + if (DOCUMENT_STAT_FIELD_TYPES.has(fieldType)) { + return insertDocumentStatField(editor, input, resolved, options); + } + + if (fieldType === 'NUMPAGES') { + return insertNumPagesField(editor, resolved, options); + } + + return insertRawField(editor, input, resolved, options); +} + +function insertDocumentStatField( + editor: Editor, + input: FieldInsertInput, + resolved: { from: number }, + options?: MutationOptions, +): FieldMutationResult { + const nodeType = editor.schema.nodes.documentStatField; + if (!nodeType) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'fields.insert: documentStatField node type not in schema.', + ); + } + + // Stat fields always display document-level counts. When the editor is a + // header/footer sub-editor, resolve the main body editor for correct scope. + const statsEditor = resolveMainBodyEditor(editor); + const stats = getWordStatistics(statsEditor); + const fieldType = extractFieldType(input.instruction); + const initialValue = resolveDocumentStatFieldValue(fieldType, stats) ?? ''; + + const receipt = executeDomainCommand( + editor, + (): boolean => { + const node = nodeType.create({ + instruction: input.instruction, + resolvedText: initialValue, + sdBlockId: `field-${Date.now()}`, + }); + const { tr } = editor.state; + tr.insert(resolved.from, node); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Insert produced no change.'); + return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from)); +} + +function insertNumPagesField( + editor: Editor, + resolved: { from: number }, + options?: MutationOptions, +): FieldMutationResult { + // NUMPAGES insertion is restricted to headers/footers (existing product restriction). + const isHeaderOrFooter = Boolean((editor as any).options?.isHeaderOrFooter); + if (!isHeaderOrFooter) { + return fieldFailure('INVALID_INPUT', 'fields.insert: NUMPAGES insertion is only supported in headers/footers.'); + } + + const nodeType = editor.schema.nodes['total-page-number']; + if (!nodeType) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'fields.insert: total-page-number node type not in schema.', + ); + } + + const receipt = executeDomainCommand( + editor, + (): boolean => { + const node = nodeType.create({}); + const { tr } = editor.state; + tr.insert(resolved.from, node); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Insert produced no change.'); + return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from)); +} + +function insertRawField( + editor: Editor, + input: FieldInsertInput, + resolved: { from: number }, + options?: MutationOptions, +): FieldMutationResult { const fieldNodeType = editor.schema.nodes.sequenceField; if (!fieldNodeType) { throw new DocumentApiAdapterError( @@ -105,8 +206,6 @@ export function fieldsInsertWrapper( ); } - const resolved = resolveInlineInsertPosition(editor, input.at, 'fields.insert'); - const receipt = executeDomainCommand( editor, (): boolean => { @@ -128,7 +227,6 @@ export function fieldsInsertWrapper( ); if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Insert produced no change.'); - return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from)); } @@ -149,16 +247,27 @@ export function fieldsRebuildWrapper( if (options?.dryRun) return fieldSuccess(address); - // Rebuild triggers re-evaluation by touching the node's attrs (sets a dirty - // flag so the layout engine will recalculate the field result on next pass). + const node = editor.state.doc.nodeAt(resolved.pos); + if (!node) return fieldFailure('TARGET_NOT_FOUND', 'Node not found at resolved position.'); + + // Dispatch to the appropriate rebuild strategy based on node type + if (node.type.name === 'documentStatField') { + return rebuildDocumentStatField(editor, resolved, address, options); + } + + if (node.type.name === 'total-page-number') { + return rebuildTotalPageNumber(editor, resolved, address, options); + } + + // Default: clear resolvedNumber to force re-evaluation (sequence fields, etc.) const receipt = executeDomainCommand( editor, () => { const { tr } = editor.state; - const node = tr.doc.nodeAt(resolved.pos); - if (!node) return false; + const currentNode = tr.doc.nodeAt(resolved.pos); + if (!currentNode) return false; tr.setNodeMarkup(resolved.pos, undefined, { - ...node.attrs, + ...currentNode.attrs, resolvedNumber: '', // clear cached result to force re-evaluation }); editor.dispatch(tr); @@ -172,6 +281,87 @@ export function fieldsRebuildWrapper( return fieldSuccess(address); } +/** + * Rebuilds a documentStatField by recomputing its value from the Word-statistics helper. + */ +function rebuildDocumentStatField( + editor: Editor, + resolved: { pos: number }, + address: FieldAddress, + options?: MutationOptions, +): FieldMutationResult { + // Stat fields always display document-level counts, not sub-editor counts. + const statsEditor = resolveMainBodyEditor(editor); + const stats = getWordStatistics(statsEditor); + const node = editor.state.doc.nodeAt(resolved.pos); + if (!node) return fieldFailure('TARGET_NOT_FOUND', 'Node not found.'); + + const fieldType = extractFieldType((node.attrs?.instruction as string) ?? ''); + const freshValue = resolveDocumentStatFieldValue(fieldType, stats) ?? ''; + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + const currentNode = tr.doc.nodeAt(resolved.pos); + if (!currentNode) return false; + tr.setNodeMarkup(resolved.pos, undefined, { + ...currentNode.attrs, + resolvedText: freshValue, + }); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Rebuild produced no change.'); + return fieldSuccess(address); +} + +/** + * Rebuilds a total-page-number field by writing the current page count + * into both resolvedText and the node's text content. + * + * When pagination is unavailable, the cached value is the best we have — + * return success without modifying the node. + */ +function rebuildTotalPageNumber( + editor: Editor, + resolved: { pos: number }, + address: FieldAddress, + options?: MutationOptions, +): FieldMutationResult { + const statsEditor = resolveMainBodyEditor(editor); + const stats = getWordStatistics(statsEditor); + + if (stats.pages == null) return fieldSuccess(address); + + const freshValue = String(stats.pages); + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + const currentNode = tr.doc.nodeAt(resolved.pos); + if (!currentNode) return false; + + // Replace the entire node to keep text content and resolvedText in sync. + const textChild = freshValue ? editor.schema.text(freshValue) : null; + const newNode = currentNode.type.create({ ...currentNode.attrs, resolvedText: freshValue }, textChild); + tr.replaceWith(resolved.pos, resolved.pos + currentNode.nodeSize, newNode); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Rebuild produced no change.'); + return fieldSuccess(address); +} + export function fieldsRemoveWrapper( editor: Editor, input: FieldRemoveInput, diff --git a/packages/super-editor/src/extensions/document-stat-field/document-stat-field.js b/packages/super-editor/src/extensions/document-stat-field/document-stat-field.js new file mode 100644 index 0000000000..31a49df8d1 --- /dev/null +++ b/packages/super-editor/src/extensions/document-stat-field/document-stat-field.js @@ -0,0 +1,62 @@ +import { Node, Attribute } from '@core/index.js'; + +/** + * Inline atom node representing a Word document-statistic field (NUMWORDS, NUMCHARS). + * + * The field type is derived at runtime from the first token of `instruction`. + * `resolvedText` holds the cached display value — seeded from the imported + * OOXML cached result and later updated in place by `fields.rebuild`. + */ +export const DocumentStatField = Node.create({ + name: 'documentStatField', + + group: 'inline', + + inline: true, + + atom: true, + + selectable: false, + + draggable: false, + + addOptions() { + return { + htmlAttributes: { + contenteditable: false, + 'data-id': 'document-stat-field', + 'aria-label': 'Document statistic field', + }, + }; + }, + + addAttributes() { + return { + instruction: { + default: '', + rendered: false, + }, + resolvedText: { + default: '', + rendered: false, + }, + sdBlockId: { + default: null, + rendered: false, + }, + marksAsAttrs: { + default: null, + rendered: false, + }, + }; + }, + + parseDOM() { + return [{ tag: 'span[data-id="document-stat-field"]' }]; + }, + + renderDOM({ node, htmlAttributes }) { + const text = node.attrs.resolvedText || '0'; + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), text]; + }, +}); diff --git a/packages/super-editor/src/extensions/document-stat-field/index.js b/packages/super-editor/src/extensions/document-stat-field/index.js new file mode 100644 index 0000000000..0455cbec18 --- /dev/null +++ b/packages/super-editor/src/extensions/document-stat-field/index.js @@ -0,0 +1 @@ +export { DocumentStatField } from './document-stat-field.js'; diff --git a/packages/super-editor/src/extensions/field-update/field-update.js b/packages/super-editor/src/extensions/field-update/field-update.js new file mode 100644 index 0000000000..77fe8f9282 --- /dev/null +++ b/packages/super-editor/src/extensions/field-update/field-update.js @@ -0,0 +1,93 @@ +import { Extension } from '@core/index.js'; +import { findFieldsInRange } from '../../document-api-adapters/helpers/field-resolver.js'; +import { + getWordStatistics, + resolveDocumentStatFieldValue, + resolveMainBodyEditor, +} from '../../document-api-adapters/helpers/word-statistics.js'; + +/** Field types eligible for value updates via F9. */ +const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES']); + +/** + * @module FieldUpdate + * @sidebarTitle Field Update + * @shortcut F9 | updateFieldsInSelection | Update fields in selection + */ +export const FieldUpdate = Extension.create({ + name: 'fieldUpdate', + + addCommands() { + return { + /** + * Update all field values intersecting the current selection. + * + * Mirrors Word's F9 semantics: + * - Collapsed selection: updates the single field at the cursor + * - Range selection: updates all fields intersecting the range + * - Select-all then F9: updates every field in the document + * + * @category Command + * @returns {Function} ProseMirror command function + * @example + * editor.commands.updateFieldsInSelection() + */ + updateFieldsInSelection: + () => + ({ editor, state, dispatch }) => { + const { from, to } = state.selection; + const fields = findFieldsInRange(state.doc, from, to); + + const updatable = fields.filter((f) => UPDATABLE_FIELD_TYPES.has(f.fieldType)); + if (updatable.length === 0) return false; + + const mainEditor = resolveMainBodyEditor(editor); + const stats = getWordStatistics(mainEditor); + + const tr = state.tr; + let changed = false; + + // Process in reverse position order so earlier positions stay valid + // as we apply setNodeMarkup (which replaces nodes in-place). + const sorted = [...updatable].sort((a, b) => b.pos - a.pos); + + for (const field of sorted) { + const freshValue = resolveDocumentStatFieldValue(field.fieldType, stats); + if (freshValue == null) continue; + + const node = tr.doc.nodeAt(field.pos); + if (!node) continue; + + if (node.type.name === 'total-page-number') { + // total-page-number stores its display value as a text child, + // not just an attr. Replace the entire node so both the text + // content and resolvedText stay in sync. + const textChild = freshValue ? state.schema.text(freshValue) : null; + const newNode = node.type.create({ ...node.attrs, resolvedText: freshValue }, textChild); + tr.replaceWith(field.pos, field.pos + node.nodeSize, newNode); + changed = true; + } else { + const currentValue = (node.attrs?.resolvedText ?? '').toString(); + if (currentValue === freshValue) continue; + + tr.setNodeMarkup(field.pos, undefined, { + ...node.attrs, + resolvedText: freshValue, + }); + changed = true; + } + } + + if (!changed) return false; + if (dispatch) dispatch(tr); + return true; + }, + }; + }, + + addShortcuts() { + return { + F9: () => this.editor.commands.updateFieldsInSelection(), + }; + }, +}); diff --git a/packages/super-editor/src/extensions/field-update/field-update.test.js b/packages/super-editor/src/extensions/field-update/field-update.test.js new file mode 100644 index 0000000000..c621d7b2cd --- /dev/null +++ b/packages/super-editor/src/extensions/field-update/field-update.test.js @@ -0,0 +1,109 @@ +/* @vitest-environment jsdom */ + +/** + * Tests for the FieldUpdate extension's updateFieldsInSelection command. + * + * Uses the numwords.docx fixture which contains NUMWORDS, NUMCHARS, and + * NUMPAGES fields with known imported values. + */ + +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { getWordStatistics } from '../../document-api-adapters/helpers/word-statistics.js'; + +describe('FieldUpdate extension', () => { + let docData; + let editor; + + beforeAll(async () => { + docData = await loadTestDataForEditorTests('numwords.docx'); + }); + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + function createEditor() { + const result = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + }); + return result.editor; + } + + function findNodesByType(ed, typeName) { + const results = []; + ed.state.doc.descendants((node, pos) => { + if (node.type.name === typeName) { + results.push({ pos, node, attrs: node.attrs }); + } + return true; + }); + return results; + } + + it('exposes updateFieldsInSelection as a command', () => { + editor = createEditor(); + expect(typeof editor.commands.updateFieldsInSelection).toBe('function'); + }); + + it('updates documentStatField resolvedText when selection covers the field', () => { + editor = createEditor(); + + const before = findNodesByType(editor, 'documentStatField'); + expect(before.length).toBeGreaterThan(0); + + const originalValue = before[0].attrs.resolvedText; + + // Select the entire document, then run the command + editor.commands.selectAll(); + const result = editor.commands.updateFieldsInSelection(); + + expect(result).toBe(true); + + // After update, the resolvedText should be recomputed from current document stats. + // The exact value depends on the fixture's word count, but the command should succeed. + const after = findNodesByType(editor, 'documentStatField'); + expect(after.length).toBe(before.length); + + // The resolved value should be a numeric string + const numwordsField = after.find((f) => { + const instr = (f.attrs.instruction ?? '').trim().split(/\s+/)[0]?.toUpperCase(); + return instr === 'NUMWORDS'; + }); + expect(numwordsField).toBeTruthy(); + expect(Number(numwordsField.attrs.resolvedText)).toBeGreaterThan(0); + }); + + it('returns false when no updatable fields are in the selection', () => { + editor = createEditor(); + + // Set a collapsed selection at position 1 (likely inside the first paragraph text, + // not adjacent to any field) + editor.commands.setTextSelection(1); + const result = editor.commands.updateFieldsInSelection(); + + expect(result).toBe(false); + }); + + it('updates NUMCHARS field to a numeric string', () => { + editor = createEditor(); + const expectedValue = String(getWordStatistics(editor).characters); + + editor.commands.selectAll(); + editor.commands.updateFieldsInSelection(); + + const statFields = findNodesByType(editor, 'documentStatField'); + const numcharsField = statFields.find((f) => { + const instr = (f.attrs.instruction ?? '').trim().split(/\s+/)[0]?.toUpperCase(); + return instr === 'NUMCHARS'; + }); + + expect(numcharsField).toBeTruthy(); + expect(numcharsField.attrs.resolvedText).toBe(expectedValue); + }); +}); diff --git a/packages/super-editor/src/extensions/field-update/index.js b/packages/super-editor/src/extensions/field-update/index.js new file mode 100644 index 0000000000..9ed21d2368 --- /dev/null +++ b/packages/super-editor/src/extensions/field-update/index.js @@ -0,0 +1 @@ +export { FieldUpdate } from './field-update.js'; diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index a4f681e8ea..820dbbfb0a 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -55,6 +55,8 @@ import { IndexEntry } from './index-entry/index.js'; import { TableOfContentsEntry } from './table-of-contents-entry/index.js'; import { CrossReference } from './cross-reference/index.js'; import { SequenceField } from './sequence-field/index.js'; +import { DocumentStatField } from './document-stat-field/index.js'; +import { FieldUpdate } from './field-update/index.js'; import { Citation } from './citation/index.js'; import { Bibliography } from './bibliography/index.js'; import { AuthorityEntry } from './authority-entry/index.js'; @@ -198,6 +200,8 @@ const getStarterExtensions = () => { TableOfContentsEntry, CrossReference, SequenceField, + DocumentStatField, + FieldUpdate, Citation, Bibliography, AuthorityEntry, @@ -307,6 +311,8 @@ export { PermissionRanges, CrossReference, SequenceField, + DocumentStatField, + FieldUpdate, Citation, Bibliography, AuthorityEntry, diff --git a/packages/super-editor/src/extensions/page-number/page-number.js b/packages/super-editor/src/extensions/page-number/page-number.js index 35c4242efa..a989a5f2f1 100644 --- a/packages/super-editor/src/extensions/page-number/page-number.js +++ b/packages/super-editor/src/extensions/page-number/page-number.js @@ -154,6 +154,25 @@ export const TotalPageCount = Node.create({ default: null, rendered: false, }, + /** + * Preserves the imported OOXML cached field result for NUMPAGES. + * Used as a fallback when pagination is unavailable (headless context) + * so the export can write the original cached value instead of empty text. + */ + importedCachedText: { + default: null, + rendered: false, + }, + /** + * Cached display value set by an explicit field update (F9). + * Sits between the export cache map and importedCachedText in the + * export fallback chain, giving the user's last F9 result priority + * over the original imported value. + */ + resolvedText: { + default: null, + rendered: false, + }, }; }, @@ -270,6 +289,7 @@ export class AutoPageNumberNodeView { #scheduleUpdateNodeStyle(pos, marks) { setTimeout(() => { + if (!this.editor?.state) return; // editor may have been destroyed const { state } = this.editor; const { dispatch } = this.view; diff --git a/packages/super-editor/src/tests/data/numwords.docx b/packages/super-editor/src/tests/data/numwords.docx new file mode 100644 index 0000000000..ae4f4795aa Binary files /dev/null and b/packages/super-editor/src/tests/data/numwords.docx differ diff --git a/tests/doc-api-stories/tests/fields/fixtures/numwords.docx b/tests/doc-api-stories/tests/fields/fixtures/numwords.docx new file mode 100644 index 0000000000..ae4f4795aa Binary files /dev/null and b/tests/doc-api-stories/tests/fields/fixtures/numwords.docx differ diff --git a/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts b/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts new file mode 100644 index 0000000000..c6d52d8402 --- /dev/null +++ b/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts @@ -0,0 +1,296 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +const FIXTURE_DOC = path.resolve(import.meta.dirname, 'fixtures', 'numwords.docx'); + +// --------------------------------------------------------------------------- +// OOXML inspection helpers (local to this story) +// --------------------------------------------------------------------------- + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +/** Extracts all field instruction texts from a document.xml string. */ +function extractFieldInstructions(documentXml: string): string[] { + const matches = [...documentXml.matchAll(/]*>([^<]*)<\/w:instrText>/g)]; + return matches.map((m) => m[1].trim()); +} + +/** Extracts text elements (w:t) from field cached result runs. */ +function extractCachedFieldResults(documentXml: string): string[] { + // Find all w:t elements that appear between w:fldChar separate and end + const results: string[] = []; + const fieldRegex = /]*w:fldCharType="separate"[^>]*\/?>[\s\S]*?]*w:fldCharType="end"/g; + + for (const match of documentXml.matchAll(fieldRegex)) { + const segment = match[0]; + const textMatches = [...segment.matchAll(/]*>([^<]*)<\/w:t>/g)]; + for (const tm of textMatches) { + results.push(tm[1]); + } + } + return results; +} + +/** Checks whether w:updateFields is present in settings.xml. */ +function hasUpdateFields(settingsXml: string): boolean { + return /]*w:val="true"/.test(settingsXml); +} + +/** Extracts a simple element's text value from app.xml. */ +function extractAppStat(appXml: string, tagName: string): string | null { + const match = appXml.match(new RegExp(`<${tagName}>([^<]*)`)); + return match?.[1] ?? null; +} + +/** Checks for w:dirty attribute on fldChar begin elements. */ +function hasDirtyField(documentXml: string): boolean { + return /w:dirty="true"/.test(documentXml); +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +// --------------------------------------------------------------------------- +// Story tests +// --------------------------------------------------------------------------- + +describe('word-stat-fields roundtrip', () => { + const { client, copyDoc, outPath } = useStoryHarness('fields/word-stat-fields-roundtrip', { + preserveResults: true, + }); + + const api = client as any; + + async function openSession(docPath: string, sessionId: string) { + await api.doc.open({ filePath: docPath, sessionId }); + } + + async function saveSession(sessionId: string, savePath: string) { + await api.doc.save({ sessionId, filePath: savePath }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Phase A & B — Import baseline + field discovery + // ───────────────────────────────────────────────────────────────────────── + + it('imports NUMWORDS and NUMCHARS as semantic fields via fields.list', async () => { + const docPath = await copyDoc(FIXTURE_DOC, 'phase-a-source.docx'); + const sessionId = sid('phase-a'); + await openSession(docPath, sessionId); + + const listResult = await api.doc.fields.list({ sessionId }); + const items = unwrap(listResult)?.items ?? listResult?.items ?? []; + + // The fixture has NUMWORDS, NUMCHARS, and NUMPAGES fields + const fieldTypes = items.map((item: any) => { + const domain = item?.domain ?? item; + return domain?.fieldType; + }); + + expect(fieldTypes).toContain('NUMWORDS'); + expect(fieldTypes).toContain('NUMCHARS'); + expect(fieldTypes).toContain('NUMPAGES'); + + await api.doc.close({ sessionId, discard: true }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Phase C — OOXML export baseline + // ───────────────────────────────────────────────────────────────────────── + + it('exports stat fields as native OOXML complex fields with cached results', async () => { + const docPath = await copyDoc(FIXTURE_DOC, 'phase-c-source.docx'); + const sessionId = sid('phase-c'); + const savedPath = outPath('phase-c-exported.docx'); + + await openSession(docPath, sessionId); + await saveSession(sessionId, savedPath); + + // Inspect exported document.xml + const documentXml = await readDocxPart(savedPath, 'word/document.xml'); + + // Should contain field instructions for our stat fields + const instructions = extractFieldInstructions(documentXml); + const hasNumwords = instructions.some((instr) => instr.includes('NUMWORDS')); + const hasNumchars = instructions.some((instr) => instr.includes('NUMCHARS')); + const hasNumpages = instructions.some((instr) => instr.includes('NUMPAGES')); + + expect(hasNumwords).toBe(true); + expect(hasNumchars).toBe(true); + expect(hasNumpages).toBe(true); + + // Should have fldChar structure (complex fields, not fldSimple) + expect(documentXml).toContain('w:fldCharType="begin"'); + expect(documentXml).toContain('w:fldCharType="separate"'); + expect(documentXml).toContain('w:fldCharType="end"'); + + // Should have cached result runs between separate and end + const cachedResults = extractCachedFieldResults(documentXml); + expect(cachedResults.length).toBeGreaterThanOrEqual(3); + + // Inspect docProps/app.xml — stat values should be present and consistent + const appXml = await readDocxPart(savedPath, 'docProps/app.xml'); + const wordsValue = extractAppStat(appXml, 'Words'); + const charsValue = extractAppStat(appXml, 'Characters'); + const charsWithSpaces = extractAppStat(appXml, 'CharactersWithSpaces'); + + // All stat values must be numeric and positive + expect(wordsValue).toBeTruthy(); + expect(Number(wordsValue)).toBeGreaterThan(0); + expect(charsValue).toBeTruthy(); + expect(Number(charsValue)).toBeGreaterThan(0); + expect(charsWithSpaces).toBeTruthy(); + expect(Number(charsWithSpaces)).toBeGreaterThan(0); + + // Characters (no spaces) must be ≤ CharactersWithSpaces (internal consistency) + expect(Number(charsValue)).toBeLessThanOrEqual(Number(charsWithSpaces)); + + // The NUMWORDS cached result in the field should match the app.xml Words value + // (both are computed from the same helper during export) + const numwordsCachedResult = cachedResults.find((r) => r && /^\d+$/.test(r.trim())); + if (numwordsCachedResult) { + expect(wordsValue).toBe(numwordsCachedResult.trim()); + } + + // Dirty-flag policy: NUMWORDS and NUMCHARS should NOT be dirty (no + // uninterpreted switches). NUMPAGES may or may not be dirty depending + // on whether pagination was available in the test environment. + // We verify the structural invariant rather than a blanket dirty check. + const settingsXml = await readDocxPart(savedPath, 'word/settings.xml').catch(() => ''); + if (settingsXml) { + expect(settingsXml).toContain('w:settings'); + } + + await api.doc.close({ sessionId, discard: true }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Phase D — Update after edit + // ───────────────────────────────────────────────────────────────────────── + + it('rebuilds stat field values after inserting text', async () => { + const docPath = await copyDoc(FIXTURE_DOC, 'phase-d-source.docx'); + const sessionId = sid('phase-d'); + + await openSession(docPath, sessionId); + + // Get initial field list + const initialList = await api.doc.fields.list({ sessionId }); + const initialItems = unwrap(initialList)?.items ?? initialList?.items ?? []; + const numwordsField = initialItems.find((item: any) => { + const domain = item?.domain ?? item; + return domain?.fieldType === 'NUMWORDS'; + }); + + expect(numwordsField).toBeTruthy(); + + const initialResolvedText = numwordsField?.domain?.resolvedText ?? numwordsField?.resolvedText ?? ''; + + // Append text to change the word count + await api.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: 'These extra words change the count significantly', + }); + + // Rebuild the NUMWORDS field + const address = numwordsField?.domain?.address ?? numwordsField?.address; + if (address) { + await api.doc.fields.rebuild({ sessionId, target: address }); + + // Check the value changed + const updatedList = await api.doc.fields.list({ sessionId }); + const updatedItems = unwrap(updatedList)?.items ?? updatedList?.items ?? []; + const updatedNumwords = updatedItems.find((item: any) => { + const domain = item?.domain ?? item; + return domain?.fieldType === 'NUMWORDS'; + }); + + const updatedResolvedText = updatedNumwords?.domain?.resolvedText ?? updatedNumwords?.resolvedText ?? ''; + + // After adding words, the count should be different from the original + expect(updatedResolvedText).not.toBe(initialResolvedText); + } + + // Save and re-inspect OOXML + const savedPath = outPath('phase-d-exported.docx'); + await saveSession(sessionId, savedPath); + + const documentXml = await readDocxPart(savedPath, 'word/document.xml'); + const instructions = extractFieldInstructions(documentXml); + expect(instructions.some((instr) => instr.includes('NUMWORDS'))).toBe(true); + + await api.doc.close({ sessionId, discard: true }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Phase E — Reopen roundtrip + // ───────────────────────────────────────────────────────────────────────── + + it('reimports exported fields semantically on reopen', async () => { + const docPath = await copyDoc(FIXTURE_DOC, 'phase-e-source.docx'); + const firstSessionId = sid('phase-e-first'); + const firstSavedPath = outPath('phase-e-first-export.docx'); + + await openSession(docPath, firstSessionId); + await saveSession(firstSessionId, firstSavedPath); + await api.doc.close({ sessionId: firstSessionId, discard: true }); + + // Reopen the exported file + const secondSessionId = sid('phase-e-second'); + await openSession(firstSavedPath, secondSessionId); + + const listResult = await api.doc.fields.list({ sessionId: secondSessionId }); + const items = unwrap(listResult)?.items ?? listResult?.items ?? []; + + const fieldTypes = items.map((item: any) => { + const domain = item?.domain ?? item; + return domain?.fieldType; + }); + + // Fields should still be discoverable after roundtrip + expect(fieldTypes).toContain('NUMWORDS'); + expect(fieldTypes).toContain('NUMCHARS'); + expect(fieldTypes).toContain('NUMPAGES'); + + await api.doc.close({ sessionId: secondSessionId, discard: true }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Targeted semantic tests + // ───────────────────────────────────────────────────────────────────────── + + it('preserves unrelated docProps/app.xml elements across export', async () => { + const docPath = await copyDoc(FIXTURE_DOC, 'appxml-preservation-source.docx'); + const sessionId = sid('appxml-pres'); + const savedPath = outPath('appxml-preservation-exported.docx'); + + await openSession(docPath, sessionId); + await saveSession(sessionId, savedPath); + + const appXml = await readDocxPart(savedPath, 'docProps/app.xml'); + + // The original fixture has Application, Template, TotalTime, etc. + // These should survive export. + expect(appXml).toContain('Application'); + expect(appXml).toContain('Template'); + + await api.doc.close({ sessionId, discard: true }); + }); +});