From 754cb080f42e4604e42daa2f440066cd2b4c8e84 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 20:37:34 -0700 Subject: [PATCH 1/2] feat(doc-info): add live page counts to doc.info --- .../src/contract/operation-definitions.ts | 4 +- packages/document-api/src/contract/schemas.ts | 1 + packages/document-api/src/types/info.types.ts | 2 + .../helpers/live-document-counts.test.ts | 42 ++++ .../helpers/live-document-counts.ts | 14 ++ .../info-adapter.test.ts | 10 + .../doc-api-stories/tests/info/live-counts.ts | 188 ++++++++++++++++++ 7 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 tests/doc-api-stories/tests/info/live-counts.ts diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 4fae888594..96aefb2af3 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -379,9 +379,9 @@ export const OPERATION_DEFINITIONS = { info: { memberPath: 'info', description: - 'Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities.', + 'Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities.', expectedResult: - 'Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists), document outline, capability flags, and revision.', + 'Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists, and optionally pages when pagination is active), document outline, capability flags, and revision.', requiresDocumentContext: true, metadata: readOperation(), referenceDocPath: 'info.mdx', diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 600ac9cfb3..c44a234f81 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -907,6 +907,7 @@ const documentInfoCountsSchema = objectSchema( trackedChanges: { type: 'integer' }, sdtFields: { type: 'integer' }, lists: { type: 'integer' }, + pages: { type: 'integer' }, }, [ 'words', diff --git a/packages/document-api/src/types/info.types.ts b/packages/document-api/src/types/info.types.ts index 562b505d13..07ff4304b8 100644 --- a/packages/document-api/src/types/info.types.ts +++ b/packages/document-api/src/types/info.types.ts @@ -21,6 +21,8 @@ export interface DocumentInfoCounts { sdtFields: number; /** Count of unique list sequences, not individual list items. */ lists: number; + /** Number of layout pages. Absent when pagination is inactive or layout hasn't completed. */ + pages?: number; } export interface DocumentInfoOutlineItem { diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts index 116a8d657a..1f0212a834 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts @@ -16,6 +16,7 @@ import { countTrackedChanges, countSdtFields, countLists, + countPages, } from './live-document-counts.js'; vi.mock('./index-cache.js', () => ({ @@ -96,6 +97,8 @@ function makeEditor(doc: Record = {}): Editor { } as Editor; } +const EMPTY_EDITOR = makeEditor(); + describe('countWordsFromText', () => { it('counts whitespace-delimited tokens', () => { expect(countWordsFromText('hello world foo')).toBe(3); @@ -341,6 +344,20 @@ describe('countLists', () => { }); }); +describe('countPages', () => { + it('returns page count when currentTotalPages is available', () => { + const editorWithPages = { + ...EMPTY_EDITOR, + currentTotalPages: 5, + } as unknown as Editor; + expect(countPages(editorWithPages)).toBe(5); + }); + + it('returns undefined when no presentationEditor', () => { + expect(countPages(EMPTY_EDITOR)).toBeUndefined(); + }); +}); + describe('getLiveDocumentCounts', () => { beforeEach(() => { getBlockIndexMock.mockReset(); @@ -407,6 +424,31 @@ describe('getLiveDocumentCounts', () => { }); }); + it('includes pages when currentTotalPages is available', () => { + getTextAdapterMock.mockReturnValue('hello'); + getBlockIndexMock.mockReturnValue(makeBlockIndex([])); + getInlineIndexMock.mockReturnValue(makeInlineIndex([])); + groupTrackedChangesMock.mockReturnValue([] as ReturnType); + findAllSdtNodesMock.mockReturnValue([] as ReturnType); + + const editorWithPages = { ...EMPTY_EDITOR, currentTotalPages: 7 } as unknown as Editor; + const result = getLiveDocumentCounts(editorWithPages); + + expect(result.pages).toBe(7); + }); + + it('omits pages key when pagination is inactive', () => { + getTextAdapterMock.mockReturnValue('hello'); + getBlockIndexMock.mockReturnValue(makeBlockIndex([])); + getInlineIndexMock.mockReturnValue(makeInlineIndex([])); + groupTrackedChangesMock.mockReturnValue([] as ReturnType); + findAllSdtNodesMock.mockReturnValue([] as ReturnType); + + const result = getLiveDocumentCounts(EMPTY_EDITOR); + + expect('pages' in result).toBe(false); + }); + it('words and characters derive from the same text projection', () => { const editor = makeEditor(); const text = 'one two three'; diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts index 9ba3fed963..65ff2af834 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts @@ -21,6 +21,8 @@ export interface LiveDocumentCounts { trackedChanges: number; sdtFields: number; lists: number; + /** Page count from the layout engine, if pagination is active. */ + pages?: number; } type LiveDocumentCountsCacheEntry = { @@ -52,6 +54,7 @@ const liveDocumentCountsCache = new WeakMap 0 ? item.path.length - 1 : undefined; } + +/** + * Returns the current page count when pagination is active. + * Delegates to `editor.currentTotalPages`, which returns `undefined` + * when no PresentationEditor exists or layout hasn't completed. + */ +export function countPages(editor: Editor): number | undefined { + return editor.currentTotalPages; +} diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.test.ts b/packages/super-editor/src/document-api-adapters/info-adapter.test.ts index ee306dd8ae..689111635e 100644 --- a/packages/super-editor/src/document-api-adapters/info-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/info-adapter.test.ts @@ -129,6 +129,16 @@ describe('infoAdapter', () => { expect(result.revision).toBe('42'); }); + it('passes through pages count when present', () => { + const countsWithPages = { ...DEFAULT_COUNTS, pages: 12 }; + getLiveDocumentCountsMock.mockReturnValue(countsWithPages); + findLegacyAdapterMock.mockReturnValue(makeFindOutput()); + + const result = infoAdapter({} as Editor, {}); + + expect(result.counts.pages).toBe(12); + }); + it('only calls findLegacyAdapter for heading query (not for counts)', () => { getLiveDocumentCountsMock.mockReturnValue(DEFAULT_COUNTS); findLegacyAdapterMock.mockReturnValue(makeFindOutput()); diff --git a/tests/doc-api-stories/tests/info/live-counts.ts b/tests/doc-api-stories/tests/info/live-counts.ts new file mode 100644 index 0000000000..6079a8722a --- /dev/null +++ b/tests/doc-api-stories/tests/info/live-counts.ts @@ -0,0 +1,188 @@ +/* @vitest-environment happy-dom */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + Editor, + PresentationEditor, + getStarterExtensions, +} from '../../../../packages/superdoc/dist/super-editor.es.js'; + +const FIXTURE_PATH = path.resolve(process.cwd(), 'test-corpus/pagination/longer-header.docx'); +const APPEND_MARKER = 'SD2203-DOC-INFO-STORY'; +const APPENDED_PARAGRAPH = `${APPEND_MARKER} ${Array.from( + { length: 320 }, + (_, index) => + `This appended sentence verifies doc info word character and page counts after mutation block ${index}.`, +).join(' ')}`; + +let originalGetContext: typeof HTMLCanvasElement.prototype.getContext; +let originalCreateObjectURL: typeof URL.createObjectURL | undefined; + +beforeAll(() => { + originalGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function getContextStub(contextId: string) { + if (contextId === '2d') { + return { + font: '', + measureText: (text: string) => ({ + width: text.length * 6, + actualBoundingBoxLeft: 0, + actualBoundingBoxRight: text.length * 6, + }), + } as unknown as CanvasRenderingContext2D; + } + return null; + } as typeof HTMLCanvasElement.prototype.getContext; + + originalCreateObjectURL = URL.createObjectURL; + if (typeof URL.createObjectURL !== 'function') { + URL.createObjectURL = (() => 'blob:mock-font') as typeof URL.createObjectURL; + } +}); + +afterAll(() => { + HTMLCanvasElement.prototype.getContext = originalGetContext; + if (originalCreateObjectURL) { + URL.createObjectURL = originalCreateObjectURL; + } else { + delete (URL as any).createObjectURL; + } +}); + +function countWords(text: string): number { + const matches = text.trim().match(/\S+/g); + return matches ? matches.length : 0; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPageCount( + editor: Editor, + predicate: (pages: number | undefined) => boolean, + label: string, + timeoutMs = 10_000, +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const pages = editor.currentTotalPages; + if (predicate(pages)) { + return pages as number; + } + await sleep(50); + } + + throw new Error(`Timed out waiting for ${label}. Last page count: ${String(editor.currentTotalPages)}`); +} + +async function openPresentationEditor(): Promise<{ host: HTMLDivElement; presentation: PresentationEditor }> { + const fileSource = await readFile(FIXTURE_PATH); + const [content, media, mediaFiles, fonts] = await Editor.loadXmlData(fileSource, true); + + const host = document.createElement('div'); + host.style.width = '900px'; + host.style.minHeight = '1200px'; + document.body.appendChild(host); + + const presentation = new PresentationEditor({ + mode: 'docx', + element: host, + fileSource, + extensions: getStarterExtensions(), + telemetry: { enabled: false }, + documentId: `doc-info-story-${Date.now()}`, + content, + media, + mediaFiles, + fonts, + documentMode: 'editing', + suppressDefaultDocxStyles: true, + }); + + return { host, presentation }; +} + +function expectInfoToMatchTextProjection(info: ReturnType, text: string): void { + expect(info.counts.words).toBe(countWords(text)); + expect(info.counts.characters).toBe(text.length); +} + +describe('document-api story: doc.info live counts on a multi-page document', () => { + it('tracks text-derived counts and pages before and after block mutations', async () => { + const { host, presentation } = await openPresentationEditor(); + + try { + const editor = presentation.editor; + + const initialPages = await waitForPageCount( + editor, + (pages) => typeof pages === 'number' && pages > 1, + 'initial multi-page layout', + ); + const initialInfo = editor.doc.info({}); + const initialText = editor.doc.getText({}); + + expectInfoToMatchTextProjection(initialInfo, initialText); + expect(initialInfo.counts.pages).toBe(initialPages); + expect(initialInfo.counts.paragraphs).toBeGreaterThan(1); + expect(initialInfo.capabilities).toEqual({ + canFind: true, + canGetNode: true, + canComment: true, + canReplace: true, + }); + + const created = editor.doc.create.paragraph({ + at: { kind: 'documentEnd' }, + text: APPENDED_PARAGRAPH, + }); + + expect(created.success).toBe(true); + if (!created.success) { + throw new Error(`create.paragraph failed: ${created.failure.code}`); + } + + const grownPages = await waitForPageCount( + editor, + (pages) => typeof pages === 'number' && pages > initialPages, + 'page-count growth after appending a large paragraph', + ); + const afterInsertInfo = editor.doc.info({}); + const afterInsertText = editor.doc.getText({}); + + expect(afterInsertText).toContain(APPEND_MARKER); + expectInfoToMatchTextProjection(afterInsertInfo, afterInsertText); + expect(afterInsertInfo.counts.pages).toBe(grownPages); + expect(afterInsertInfo.counts.paragraphs).toBe(initialInfo.counts.paragraphs + 1); + expect(afterInsertInfo.counts.words).toBeGreaterThan(initialInfo.counts.words); + expect(afterInsertInfo.counts.characters).toBeGreaterThan(initialInfo.counts.characters); + expect(afterInsertInfo.revision).not.toBe(initialInfo.revision); + + const deleted = editor.doc.blocks.delete({ target: created.paragraph }); + + expect(deleted.success).toBe(true); + expect(deleted.deleted).toEqual(created.paragraph); + + const restoredPages = await waitForPageCount( + editor, + (pages) => pages === initialPages, + 'page-count restoration after deleting the appended paragraph', + ); + const afterDeleteInfo = editor.doc.info({}); + const afterDeleteText = editor.doc.getText({}); + + expect(restoredPages).toBe(initialPages); + expect(afterDeleteText).toBe(initialText); + expectInfoToMatchTextProjection(afterDeleteInfo, afterDeleteText); + expect(afterDeleteInfo.counts).toEqual(initialInfo.counts); + expect(afterDeleteInfo.revision).not.toBe(afterInsertInfo.revision); + } finally { + presentation.destroy(); + host.remove(); + } + }, 30_000); +}); From b44577efb6e5de1ac4b03f6a4f18b476b8524a5e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Mar 2026 20:57:17 -0700 Subject: [PATCH 2/2] fix(doc-info): avoid caching page counts across layout updates --- .../helpers/live-document-counts.test.ts | 30 +++++++++++++++++++ .../helpers/live-document-counts.ts | 22 +++++++------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts index 1f0212a834..bc38312c00 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.test.ts @@ -487,6 +487,36 @@ describe('getLiveDocumentCounts', () => { expect(findAllSdtNodesMock).toHaveBeenCalledOnce(); }); + it('re-reads pages on every call even when the document snapshot cache is reused', () => { + const editor = { + ...makeEditor({ docId: 'snapshot-1' }), + currentTotalPages: undefined, + } as Editor & { currentTotalPages?: number }; + + getTextAdapterMock.mockReturnValue('one two'); + getBlockIndexMock.mockReturnValue(makeBlockIndex([makeBlockCandidate('paragraph')])); + getInlineIndexMock.mockReturnValue(makeInlineIndex([])); + groupTrackedChangesMock.mockReturnValue([{ id: 'tc-1' }] as ReturnType); + findAllSdtNodesMock.mockReturnValue([ + { kind: 'block', pos: 0, node: { attrs: { controlType: 'text' } } }, + ] as ReturnType); + + const beforeLayout = getLiveDocumentCounts(editor); + editor.currentTotalPages = 4; + const afterInitialLayout = getLiveDocumentCounts(editor); + editor.currentTotalPages = 6; + const afterRepagination = getLiveDocumentCounts(editor); + + expect('pages' in beforeLayout).toBe(false); + expect(afterInitialLayout.pages).toBe(4); + expect(afterRepagination.pages).toBe(6); + expect(getTextAdapterMock).toHaveBeenCalledOnce(); + expect(getBlockIndexMock).toHaveBeenCalledOnce(); + expect(getInlineIndexMock).toHaveBeenCalledOnce(); + expect(groupTrackedChangesMock).toHaveBeenCalledOnce(); + expect(findAllSdtNodesMock).toHaveBeenCalledOnce(); + }); + it('invalidates the cache when the editor doc snapshot changes', () => { const editor = makeEditor({ docId: 'snapshot-1' }) as Editor & { state: { doc: Record } }; diff --git a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts index 65ff2af834..5ef52205cd 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/live-document-counts.ts @@ -25,9 +25,11 @@ export interface LiveDocumentCounts { pages?: number; } +type CachedLiveDocumentCounts = Omit; + type LiveDocumentCountsCacheEntry = { doc: Editor['state']['doc']; - counts: LiveDocumentCounts; + counts: CachedLiveDocumentCounts; }; const FIELD_LIKE_SDT_TYPES = new Set(['text', 'date', 'checkbox', 'comboBox', 'dropDownList']); @@ -36,10 +38,11 @@ const liveDocumentCountsCache = new WeakMap