From 2099c63c44656fae95ece6a4bac117a53004342b Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 8 Jan 2026 18:56:08 +0200 Subject: [PATCH 1/4] feat: footnotes render --- packages/layout-engine/contracts/src/index.ts | 6 + .../layout-bridge/src/incrementalLayout.ts | 350 ++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 21 +- .../layout-engine/pm-adapter/src/constants.ts | 1 + .../pm-adapter/src/converter-context.d.ts | 6 + .../pm-adapter/src/converter-context.ts | 6 + .../pm-adapter/src/converters/paragraph.ts | 60 +++ .../assets/styles/elements/prosemirror.css | 8 + ...sentationEditor.footnotesPmMarkers.test.ts | 160 ++++++++ .../src/core/PresentationEditor.ts | 271 +++++++++++++- .../core/super-converter/SuperConverter.js | 2 + .../v2/importer/documentFootnotesImporter.js | 115 ++++++ .../v2/importer/docxImporter.js | 8 +- .../v2/importer/footnoteReferenceImporter.js | 8 + .../core/super-converter/v3/handlers/index.js | 2 + .../w/footnoteReference/attributes/index.js | 2 + .../w/footnoteReference/attributes/w-id.js | 30 ++ .../footnoteReference-translator.js | 53 +++ .../src/extensions/footnote/footnote.js | 126 +++++++ .../src/extensions/footnote/index.js | 2 + packages/super-editor/src/extensions/index.js | 3 + .../src/extensions/types/node-attributes.ts | 7 + .../tests/import/footnotesImporter.test.js | 56 +++ 23 files changed, 1290 insertions(+), 13 deletions(-) create mode 100644 packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js create mode 100644 packages/super-editor/src/extensions/footnote/footnote.js create mode 100644 packages/super-editor/src/extensions/footnote/index.js create mode 100644 packages/super-editor/src/tests/import/footnotesImporter.test.js diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 91fcb2410..638cf019d 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1402,6 +1402,12 @@ export type Page = { number: number; fragments: Fragment[]; margins?: PageMargins; + /** + * Extra bottom space reserved on this page for footnotes (in px). + * Used by consumers (e.g. editors/painters) to keep footer hit regions and + * decoration boxes anchored to the real bottom margin while the body shrinks. + */ + footnoteReserved?: number; numberText?: string; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 5e536ccf3..0d791ed05 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -42,6 +42,12 @@ export type IncrementalLayoutResult = { dirty: ReturnType; headers?: HeaderFooterLayoutResult[]; footers?: HeaderFooterLayoutResult[]; + /** + * Extra blocks/measures that should be added to the painter's lookup table. + * Used for rendering non-body fragments injected into the layout (e.g., footnotes). + */ + extraBlocks?: FlowBlock[]; + extraMeasures?: Measure[]; }; export const measureCache = new MeasureCache(); @@ -57,6 +63,115 @@ const perfLog = (...args: unknown[]): void => { console.log(...args); }; +type FootnoteReference = { id: string; pos: number }; +type FootnotesLayoutInput = { + refs: FootnoteReference[]; + blocksById: Map; + gap?: number; + topPadding?: number; + dividerHeight?: number; +}; + +const isFootnotesLayoutInput = (value: unknown): value is FootnotesLayoutInput => { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + if (!Array.isArray(v.refs)) return false; + if (!(v.blocksById instanceof Map)) return false; + return true; +}; + +const getMeasureHeight = (measure: Measure | undefined): number => { + if (!measure) return 0; + if (measure.kind === 'paragraph') return Math.max(0, measure.totalHeight ?? 0); + if (measure.kind === 'image') return Math.max(0, measure.height ?? 0); + if (measure.kind === 'drawing') return Math.max(0, measure.height ?? 0); + if (measure.kind === 'table') return Math.max(0, measure.totalHeight ?? 0); + if (measure.kind === 'list') return Math.max(0, measure.totalHeight ?? 0); + return 0; +}; + +const findPageIndexForPos = (layout: Layout, pos: number): number | null => { + if (!Number.isFinite(pos)) return null; + const fallbackRanges: Array<{ pageIndex: number; minStart: number; maxEnd: number } | null> = []; + for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) { + const page = layout.pages[pageIndex]; + let minStart: number | null = null; + let maxEnd: number | null = null; + for (const fragment of page.fragments) { + const pmStart = (fragment as { pmStart?: number }).pmStart; + const pmEnd = (fragment as { pmEnd?: number }).pmEnd; + if (pmStart == null || pmEnd == null) continue; + if (minStart == null || pmStart < minStart) minStart = pmStart; + if (maxEnd == null || pmEnd > maxEnd) maxEnd = pmEnd; + if (pos >= pmStart && pos <= pmEnd) { + return pageIndex; + } + } + fallbackRanges[pageIndex] = + minStart != null && maxEnd != null ? { pageIndex, minStart, maxEnd } : null; + } + + // Fallback: pick the closest page range when exact containment isn't found. + // This helps when pm ranges are sparse or use slightly different boundary semantics. + let best: { pageIndex: number; distance: number } | null = null; + for (const entry of fallbackRanges) { + if (!entry) continue; + const distance = + pos < entry.minStart ? entry.minStart - pos : pos > entry.maxEnd ? pos - entry.maxEnd : 0; + if (!best || distance < best.distance) { + best = { pageIndex: entry.pageIndex, distance }; + } + } + if (best) return best.pageIndex; + if (layout.pages.length > 0) return layout.pages.length - 1; + return null; +}; + +const assignFootnotesToPages = (layout: Layout, refs: FootnoteReference[]): Map => { + const result = new Map(); + const seenByPage = new Map>(); + for (const ref of refs) { + const pageIndex = findPageIndexForPos(layout, ref.pos); + if (pageIndex == null) continue; + let seen = seenByPage.get(pageIndex); + if (!seen) { + seen = new Set(); + seenByPage.set(pageIndex, seen); + } + if (seen.has(ref.id)) continue; + seen.add(ref.id); + const list = result.get(pageIndex) ?? []; + list.push(ref.id); + result.set(pageIndex, list); + } + return result; +}; + +const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBlock[]): number => { + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const margins = { + right: normalizeMargin(options.margins?.right, DEFAULT_MARGINS.right), + left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), + }; + let width = pageSize.w - (margins.left + margins.right); + + if (blocks && blocks.length > 0) { + for (const block of blocks) { + if (block.kind !== 'sectionBreak') continue; + const sectionPageSize = block.pageSize ?? pageSize; + const sectionMargins = { + right: normalizeMargin(block.margins?.right, margins.right), + left: normalizeMargin(block.margins?.left, margins.left), + }; + const w = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); + if (w > width) width = w; + } + } + + if (!Number.isFinite(width) || width <= 0) return 0; + return width; +}; + /** * Performs incremental layout of document blocks with header/footer support. * @@ -553,6 +668,239 @@ export async function incrementalLayout( }); } + // Footnotes: reserve space per page and inject footnote fragments into the layout. + // 1) Assign footnote refs to pages using the current layout. + // 2) Measure footnote blocks and compute per-page reserved height. + // 3) Relayout with per-page bottom margin reserves, then inject fragments into the reserved band. + let extraBlocks: FlowBlock[] | undefined; + let extraMeasures: Measure[] | undefined; + const footnotesInput = isFootnotesLayoutInput(options.footnotes) ? options.footnotes : null; + if (footnotesInput && footnotesInput.refs.length > 0 && footnotesInput.blocksById.size > 0) { + const gap = typeof footnotesInput.gap === 'number' && Number.isFinite(footnotesInput.gap) ? footnotesInput.gap : 2; + const topPadding = + typeof footnotesInput.topPadding === 'number' && Number.isFinite(footnotesInput.topPadding) + ? footnotesInput.topPadding + : 6; + const dividerHeight = + typeof footnotesInput.dividerHeight === 'number' && Number.isFinite(footnotesInput.dividerHeight) + ? footnotesInput.dividerHeight + : 6; + + const footnoteWidth = resolveFootnoteMeasurementWidth(options, currentBlocks); + if (footnoteWidth > 0) { + const footnoteConstraints = { maxWidth: footnoteWidth, maxHeight: measurementHeight }; + + const measureFootnoteBlocks = async (idsByPage: Map) => { + const needed = new Map(); + idsByPage.forEach((ids) => { + ids.forEach((id) => { + const blocks = footnotesInput.blocksById.get(id) ?? []; + blocks.forEach((block) => { + if (block?.id && !needed.has(block.id)) { + needed.set(block.id, block); + } + }); + }); + }); + + const blocks = Array.from(needed.values()); + const measuresById = new Map(); + await Promise.all( + blocks.map(async (block) => { + const cached = measureCache.get(block, footnoteConstraints.maxWidth, footnoteConstraints.maxHeight); + if (cached) { + measuresById.set(block.id, cached); + return; + } + const measurement = await measureBlock(block, footnoteConstraints); + measureCache.set(block, footnoteConstraints.maxWidth, footnoteConstraints.maxHeight, measurement); + measuresById.set(block.id, measurement); + }), + ); + return { blocks, measuresById }; + }; + + const computeReserves = ( + layoutForPages: Layout, + idsByPage: Map, + measuresById: Map, + ) => { + const reserves: number[] = []; + for (let pageIndex = 0; pageIndex < layoutForPages.pages.length; pageIndex++) { + const ids = idsByPage.get(pageIndex) ?? []; + if (ids.length === 0) { + reserves[pageIndex] = 0; + continue; + } + let height = dividerHeight + topPadding; + ids.forEach((id, idIndex) => { + const blocks = footnotesInput.blocksById.get(id) ?? []; + blocks.forEach((block) => { + height += getMeasureHeight(measuresById.get(block.id)); + }); + if (idIndex < ids.length - 1) { + height += gap; + } + }); + reserves[pageIndex] = Math.max(0, Math.ceil(height)); + } + return reserves; + }; + + const injectFragments = ( + layoutForPages: Layout, + idsByPage: Map, + measuresById: Map, + reservesByPageIndex: number[], + ) => { + const decorativeBlocks: FlowBlock[] = []; + const decorativeMeasures: Measure[] = []; + + for (let pageIndex = 0; pageIndex < layoutForPages.pages.length; pageIndex++) { + const page = layoutForPages.pages[pageIndex]; + page.footnoteReserved = Math.max(0, reservesByPageIndex[pageIndex] ?? 0); + const ids = idsByPage.get(pageIndex) ?? []; + if (ids.length === 0) continue; + if (!page.margins) continue; + + const pageSize = page.size ?? layoutForPages.pageSize; + const contentWidth = pageSize.w - ((page.margins.left ?? 0) + (page.margins.right ?? 0)); + const bandTopY = pageSize.h - (page.margins.bottom ?? 0); + const x = page.margins.left ?? 0; + + // Optional visible separator line (Word-like). Uses a 1px filled rect. + let cursorY = bandTopY; + if (dividerHeight > 0 && contentWidth > 0) { + const separatorId = `footnote-separator-page-${page.number}`; + decorativeBlocks.push({ + kind: 'drawing', + id: separatorId, + drawingKind: 'vectorShape', + geometry: { width: contentWidth, height: dividerHeight }, + shapeKind: 'rect', + fillColor: '#000000', + strokeColor: null, + strokeWidth: 0, + }); + decorativeMeasures.push({ + kind: 'drawing', + drawingKind: 'vectorShape', + width: contentWidth, + height: dividerHeight, + scale: 1, + naturalWidth: contentWidth, + naturalHeight: dividerHeight, + geometry: { width: contentWidth, height: dividerHeight }, + }); + page.fragments.push({ + kind: 'drawing', + blockId: separatorId, + drawingKind: 'vectorShape', + x, + y: cursorY, + width: contentWidth, + height: dividerHeight, + geometry: { width: contentWidth, height: dividerHeight }, + scale: 1, + }); + cursorY += dividerHeight; + } + cursorY += topPadding; + + ids.forEach((id, idIndex) => { + const blocks = footnotesInput.blocksById.get(id) ?? []; + blocks.forEach((block) => { + const measure = measuresById.get(block.id); + if (!measure || measure.kind !== 'paragraph') return; + const linesCount = measure.lines?.length ?? 0; + if (linesCount === 0) return; + page.fragments.push({ + kind: 'para', + blockId: block.id, + fromLine: 0, + toLine: linesCount, + x, + y: cursorY, + width: contentWidth, + }); + cursorY += getMeasureHeight(measure); + }); + if (idIndex < ids.length - 1) { + cursorY += gap; + } + }); + } + + return { decorativeBlocks, decorativeMeasures }; + }; + + // Pass 1: assign + reserve from current layout. + let idsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + let { blocks: measuredFootnoteBlocks, measuresById } = await measureFootnoteBlocks(idsByPage); + let reserves = computeReserves(layout, idsByPage, measuresById); + + // If any reserves, relayout once, then re-assign and inject. + if (reserves.some((h) => h > 0)) { + layout = layoutDocument(currentBlocks, currentMeasures, { + ...options, + footnoteReservedByPageIndex: reserves, + headerContentHeights, + footerContentHeights, + remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => + remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), + }); + + // Pass 2: recompute assignment and reserves for the updated pagination. + idsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + ({ blocks: measuredFootnoteBlocks, measuresById } = await measureFootnoteBlocks(idsByPage)); + reserves = computeReserves(layout, idsByPage, measuresById); + + // Apply final reserves (best-effort second relayout) then inject fragments. + layout = layoutDocument(currentBlocks, currentMeasures, { + ...options, + footnoteReservedByPageIndex: reserves, + headerContentHeights, + footerContentHeights, + remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => + remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), + }); + let finalIdsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + let { blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(finalIdsByPage); + const finalReserves = computeReserves(layout, finalIdsByPage, finalMeasuresById); + let reservesAppliedToLayout = reserves; + const reservesDiffer = + finalReserves.length !== reserves.length || + finalReserves.some((h, i) => (reserves[i] ?? 0) !== h) || + reserves.some((h, i) => (finalReserves[i] ?? 0) !== h); + if (reservesDiffer) { + layout = layoutDocument(currentBlocks, currentMeasures, { + ...options, + footnoteReservedByPageIndex: finalReserves, + headerContentHeights, + footerContentHeights, + remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) => + remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent), + }); + reservesAppliedToLayout = finalReserves; + finalIdsByPage = assignFootnotesToPages(layout, footnotesInput.refs); + ({ blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(finalIdsByPage)); + } + const injected = injectFragments(layout, finalIdsByPage, finalMeasuresById, reservesAppliedToLayout); + + const alignedBlocks: FlowBlock[] = []; + const alignedMeasures: Measure[] = []; + finalBlocks.forEach((block) => { + const measure = finalMeasuresById.get(block.id); + if (!measure) return; + alignedBlocks.push(block); + alignedMeasures.push(measure); + }); + extraBlocks = injected ? alignedBlocks.concat(injected.decorativeBlocks) : alignedBlocks; + extraMeasures = injected ? alignedMeasures.concat(injected.decorativeMeasures) : alignedMeasures; + } + } + } + let headers: HeaderFooterLayoutResult[] | undefined; let footers: HeaderFooterLayoutResult[] | undefined; @@ -626,6 +974,8 @@ export async function incrementalLayout( dirty, headers, footers, + extraBlocks, + extraMeasures, }; } diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 84fe80d43..54b1183a1 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -130,6 +130,19 @@ export type LayoutOptions = { columns?: ColumnLayout; remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure; sectionMetadata?: SectionMetadata[]; + /** + * Extra bottom margin per page index (0-based) reserved for non-body content + * rendered at the bottom of the page (e.g., footnotes). + * + * When provided, the paginator will shrink the body content area on that page by + * increasing the effective bottom margin for that page only. + */ + footnoteReservedByPageIndex?: number[]; + /** + * Optional footnote metadata consumed by higher-level orchestration (e.g. layout-bridge). + * The core layout engine does not interpret this field directly. + */ + footnotes?: unknown; /** * Actual measured header content heights per variant type. * When provided, the layout engine will ensure body content starts below @@ -679,7 +692,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const paginator = createPaginator({ margins: paginatorMargins, getActiveTopMargin: () => activeTopMargin, - getActiveBottomMargin: () => activeBottomMargin, + getActiveBottomMargin: () => { + const reserves = options.footnoteReservedByPageIndex; + const pageIndex = Math.max(0, pageCount - 1); + const reserve = Array.isArray(reserves) ? reserves[pageIndex] : 0; + const reservePx = typeof reserve === 'number' && Number.isFinite(reserve) && reserve > 0 ? reserve : 0; + return activeBottomMargin + reservePx; + }, getActiveHeaderDistance: () => activeHeaderDistance, getActiveFooterDistance: () => activeFooterDistance, getActivePageSize: () => activePageSize, diff --git a/packages/layout-engine/pm-adapter/src/constants.ts b/packages/layout-engine/pm-adapter/src/constants.ts index 663895921..bf485528b 100644 --- a/packages/layout-engine/pm-adapter/src/constants.ts +++ b/packages/layout-engine/pm-adapter/src/constants.ts @@ -135,6 +135,7 @@ export const ATOMIC_INLINE_TYPES = new Set([ 'lineBreak', 'page-number', 'total-page-number', + 'footnoteReference', 'passthroughInline', 'bookmarkEnd', ]); diff --git a/packages/layout-engine/pm-adapter/src/converter-context.d.ts b/packages/layout-engine/pm-adapter/src/converter-context.d.ts index e8cf6fc23..4ede1ec4c 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.d.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.d.ts @@ -22,6 +22,12 @@ export type ConverterContext = { docx?: Record; numbering?: ConverterNumberingContext; linkedStyles?: ConverterLinkedStyle[]; + /** + * Optional mapping from OOXML footnote id -> display number. + * Display numbers are assigned in order of first appearance in the document (1-based), + * matching Word's visible numbering behavior even when ids are non-contiguous or start at 0. + */ + footnoteNumberById?: Record; }; /** * Guard that checks whether the converter context includes DOCX data diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 4f74d7bcb..1b779201e 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -35,6 +35,12 @@ export type ConverterContext = { docx?: Record; numbering?: ConverterNumberingContext; linkedStyles?: ConverterLinkedStyle[]; + /** + * Optional mapping from OOXML footnote id -> display number. + * Display numbers are assigned in order of first appearance in the document (1-based), + * matching Word's visible numbering behavior even when ids are non-contiguous or start at 0. + */ + footnoteNumberById?: Record; /** * Paragraph properties inherited from the containing table's style. * Per OOXML spec, table styles can define pPr that applies to all diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index c44e97adf..8c44f5ed8 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -925,6 +925,33 @@ export function paragraphToFlowBlocks( let tabOrdinal = 0; let suppressedByVanish = false; + const toSuperscriptDigits = (value: unknown): string => { + const map: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + return String(value ?? '') + .split('') + .map((ch) => map[ch] ?? ch) + .join(''); + }; + + const resolveFootnoteDisplayNumber = (id: unknown): unknown => { + const key = id == null ? null : String(id); + if (!key) return null; + const mapping = converterContext?.footnoteNumberById; + const mapped = mapping && typeof mapping === 'object' ? (mapping as Record)[key] : undefined; + return typeof mapped === 'number' && Number.isFinite(mapped) && mapped > 0 ? mapped : null; + }; + const nextId = () => (partIndex === 0 ? baseBlockId : `${baseBlockId}-${partIndex}`); const attachAnchorParagraphId = (block: T, anchorParagraphId: string): T => { const applicableKinds = new Set(['drawing', 'image', 'table']); @@ -983,10 +1010,43 @@ export function paragraphToFlowBlocks( activeRunProperties?: Record | null, activeHidden = false, ) => { + if (node.type === 'footnoteReference') { + const mergedMarks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; + const refPos = positions.get(node); + const id = (node.attrs as Record | undefined)?.id; + const displayId = resolveFootnoteDisplayNumber(id) ?? id ?? '*'; + const displayText = toSuperscriptDigits(displayId); + + const run = textNodeToRun( + { type: 'text', text: displayText } as PMNode, + positions, + defaultFont, + defaultSize, + [], // marks applied after linked styles/base defaults + activeSdt, + hyperlinkConfig, + themeColors, + ); + const inlineStyleId = getInlineStyleId(mergedMarks); + applyRunStyles(run, inlineStyleId, activeRunStyleId); + applyBaseRunDefaults(run, baseRunDefaults, defaultFont, defaultSize); + applyMarksToRun(run, mergedMarks, hyperlinkConfig, themeColors); + + // Copy PM positions from the parent footnoteReference node + if (refPos) { + run.pmStart = refPos.start; + run.pmEnd = refPos.end; + } + + currentRuns.push(run); + return; + } + if (activeHidden && node.type !== 'run') { suppressedByVanish = true; return; } + if (node.type === 'text' && node.text) { // Apply styles in correct priority order: // 1. Create run with defaults (lowest priority) - textNodeToRun with empty marks diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index 2cffa1faa..5b806eed6 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -334,6 +334,14 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Presentation Editor Remote Cursors - end */ +/* Footnotes */ +.sd-footnote-ref { + font-size: 0.75em; + line-height: 1; + vertical-align: super; + user-select: none; +} + /* Image placeholder */ .ProseMirror placeholder { display: inline; diff --git a/packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts new file mode 100644 index 000000000..6c5b2bebe --- /dev/null +++ b/packages/super-editor/src/core/PresentationEditor.footnotesPmMarkers.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PresentationEditor } from './PresentationEditor'; + +let capturedLayoutOptions: any; + +vi.mock('./Editor', () => ({ + Editor: vi.fn().mockImplementation(() => ({ + on: vi.fn(), + off: vi.fn(), + destroy: vi.fn(), + setDocumentMode: vi.fn(), + setOptions: vi.fn(), + getJSON: vi.fn(() => ({ type: 'doc', content: [] })), + isEditable: true, + schema: {}, + state: { + selection: { from: 0, to: 0 }, + doc: { + nodeSize: 100, + content: { size: 100 }, + descendants: vi.fn((cb: (node: any, pos: number) => void) => { + cb({ type: { name: 'footnoteReference' }, attrs: { id: '1' }, nodeSize: 1 }, 10); + }), + }, + }, + view: { dom: document.createElement('div'), hasFocus: vi.fn(() => false) }, + options: { documentId: 'test', element: document.createElement('div'), mediaFiles: {} }, + converter: { + headers: {}, + footers: {}, + headerIds: { default: null, ids: [] }, + footerIds: { default: null, ids: [] }, + footnotes: [{ id: '1', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] }], + }, + storage: { image: { media: {} } }, + })), +})); + +vi.mock('@superdoc/pm-adapter', () => ({ + toFlowBlocks: vi.fn((_: unknown, opts?: any) => { + if (typeof opts?.blockIdPrefix === 'string' && opts.blockIdPrefix.startsWith('footnote-')) { + return { + blocks: [{ kind: 'paragraph', runs: [{ kind: 'text', text: 'Body', pmStart: 5, pmEnd: 9 }] }], + bookmarks: new Map(), + }; + } + return { blocks: [], bookmarks: new Map() }; + }), +})); + +vi.mock('@superdoc/layout-bridge', () => ({ + incrementalLayout: vi.fn(async (...args: any[]) => { + capturedLayoutOptions = args[3]; + return { layout: { pages: [] }, measures: [] }; + }), + selectionToRects: vi.fn(() => []), + clickToPosition: vi.fn(), + getFragmentAtPosition: vi.fn(), + computeLinePmRange: vi.fn(), + measureCharacterX: vi.fn(), + extractIdentifierFromConverter: vi.fn(), + getHeaderFooterType: vi.fn(), + getBucketForPageNumber: vi.fn(), + getBucketRepresentative: vi.fn(), + buildMultiSectionIdentifier: vi.fn(), + getHeaderFooterTypeForSection: vi.fn(), + layoutHeaderFooterWithCache: vi.fn(), + computeDisplayPageNumber: vi.fn(), + findWordBoundaries: vi.fn(), + findParagraphBoundaries: vi.fn(), + createDragHandler: vi.fn(), + PageGeometryHelper: vi.fn(() => ({ + updateLayout: vi.fn(), + getPageIndexAtY: vi.fn(() => 0), + getNearestPageIndex: vi.fn(() => 0), + getPageTop: vi.fn(() => 0), + getPageGap: vi.fn(() => 0), + getLayout: vi.fn(() => ({ pages: [] })), + })), +})); + +vi.mock('@superdoc/painter-dom', () => ({ + createDomPainter: vi.fn(() => ({ + paint: vi.fn(), + destroy: vi.fn(), + setZoom: vi.fn(), + setLayoutMode: vi.fn(), + setProviders: vi.fn(), + setData: vi.fn(), + })), + DOM_CLASS_NAMES: { PAGE: '', FRAGMENT: '', LINE: '', INLINE_SDT_WRAPPER: '', BLOCK_SDT: '', DOCUMENT_SECTION: '' }, +})); + +vi.mock('@superdoc/measuring-dom', () => ({ measureBlock: vi.fn(() => ({ width: 100, height: 100 })) })); + +vi.mock('./header-footer/HeaderFooterRegistry', () => ({ + HeaderFooterEditorManager: vi.fn(() => ({ + createEditor: vi.fn(), + destroyEditor: vi.fn(), + getEditor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + destroy: vi.fn(), + })), + HeaderFooterLayoutAdapter: vi.fn(() => ({ + clear: vi.fn(), + getBatch: vi.fn(() => []), + getBlocksByRId: vi.fn(() => new Map()), + })), +})); + +vi.mock('./header-footer/EditorOverlayManager', () => ({ + EditorOverlayManager: vi.fn(() => ({ + showEditingOverlay: vi.fn(() => ({ success: true, editorHost: document.createElement('div') })), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + })), +})); + +vi.mock('y-prosemirror', () => ({ + ySyncPluginKey: { getState: vi.fn(() => ({ type: {}, binding: { mapping: new Map() } })) }, + absolutePositionToRelativePosition: vi.fn((pos) => ({ type: 'relative', pos })), + relativePositionToAbsolutePosition: vi.fn((relPos) => relPos?.pos ?? null), +})); + +describe('PresentationEditor - footnote number marker PM position', () => { + let editor: PresentationEditor; + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + capturedLayoutOptions = undefined; + }); + + afterEach(() => { + editor?.destroy(); + document.body.removeChild(container); + vi.clearAllMocks(); + }); + + it('adds pmStart/pmEnd to the data-sd-footnote-number marker run', async () => { + editor = new PresentationEditor({ element: container }); + await new Promise((r) => setTimeout(r, 100)); + + const footnotes = capturedLayoutOptions?.footnotes; + expect(footnotes).toBeTruthy(); + const blocks = footnotes.blocksById?.get('1'); + expect(blocks?.[0]?.kind).toBe('paragraph'); + + const markerRun = blocks?.[0]?.runs?.[0]; + expect(markerRun?.dataAttrs?.['data-sd-footnote-number']).toBe('true'); + expect(markerRun?.pmStart).toBe(5); + expect(markerRun?.pmEnd).toBe(6); + }); +}); diff --git a/packages/super-editor/src/core/PresentationEditor.ts b/packages/super-editor/src/core/PresentationEditor.ts index f2e03b367..039c68d4b 100644 --- a/packages/super-editor/src/core/PresentationEditor.ts +++ b/packages/super-editor/src/core/PresentationEditor.ts @@ -283,6 +283,11 @@ interface EditorWithConverter extends Editor { footerIds?: { default?: string; first?: string; even?: string; odd?: string }; createDefaultHeader?: (variant: string) => string; createDefaultFooter?: (variant: string) => string; + footnotes?: Array<{ + id: string; + content?: unknown[]; + text?: string; + }>; }; } @@ -351,6 +356,31 @@ type LayoutState = { anchorMap?: Map; }; +type FootnoteReference = { id: string; pos: number }; +type FootnotesLayoutInput = { + refs: FootnoteReference[]; + blocksById: Map; + gap?: number; + topPadding?: number; + dividerHeight?: number; +}; + +type FootnoteNumberById = Record; + +type ConverterContextForFootnotes = { + docx: unknown; + numbering: unknown; + linkedStyles: unknown; + footnoteNumberById?: FootnoteNumberById; + [key: string]: unknown; +}; + +type BuildFootnotesLayoutInputParams = { + converterContext?: ConverterContextForFootnotes; + themeColors: unknown; + footnoteNumberById?: FootnoteNumberById; +}; + type LayoutMetrics = { durationMs: number; blockCount: number; @@ -4383,13 +4413,41 @@ export class PresentationEditor extends EventEmitter { const sectionMetadata: SectionMetadata[] = []; let blocks: FlowBlock[] | undefined; let bookmarks: Map = new Map(); + let converterContext: + | { docx: unknown; numbering: unknown; linkedStyles: unknown; footnoteNumberById?: Record } + | undefined; try { - const converter = (this.#editor as Editor & { converter?: Record }).converter; - const converterContext = converter + const converterForContext = (this.#editor as Editor & { converter?: Record }).converter; + // Compute visible footnote numbering (1-based) by first appearance in the document. + // This matches Word behavior even when OOXML ids are non-contiguous or start at 0. + const footnoteNumberById: Record = {}; + try { + const seen = new Set(); + let counter = 1; + this.#editor?.state?.doc?.descendants?.((node: any) => { + if (node?.type?.name !== 'footnoteReference') return; + const rawId = node?.attrs?.id; + if (rawId == null) return; + const key = String(rawId); + if (!key || seen.has(key)) return; + seen.add(key); + footnoteNumberById[key] = counter; + counter += 1; + }); + } catch {} + // Expose numbering to node views and layout adapter. + try { + if (converterForContext && typeof converterForContext === 'object') { + (converterForContext as any).footnoteNumberById = footnoteNumberById; + } + } catch {} + + converterContext = converterForContext ? { - docx: converter.convertedXml, - numbering: converter.numbering, - linkedStyles: converter.linkedStyles, + docx: converterForContext.convertedXml, + numbering: converterForContext.numbering, + linkedStyles: converterForContext.linkedStyles, + ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), } : undefined; const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); @@ -4422,7 +4480,13 @@ export class PresentationEditor extends EventEmitter { return; } - const layoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); + const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); + const footnotesLayoutInput = this.#buildFootnotesLayoutInput({ + converterContext, + themeColors: this.#editor?.converter?.themeColors ?? undefined, + footnoteNumberById: converterContext?.footnoteNumberById, + }); + const layoutOptions = footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } : baseLayoutOptions; const previousBlocks = this.#layoutState.blocks; const previousLayout = this.#layoutState.layout; @@ -4430,6 +4494,8 @@ export class PresentationEditor extends EventEmitter { let measures: Measure[]; let headerLayouts: HeaderFooterLayoutResult[] | undefined; let footerLayouts: HeaderFooterLayoutResult[] | undefined; + let extraBlocks: FlowBlock[] | undefined; + let extraMeasures: Measure[] | undefined; const headerFooterInput = this.#buildHeaderFooterInput(); try { const result = await incrementalLayout( @@ -4456,6 +4522,8 @@ export class PresentationEditor extends EventEmitter { } ({ layout, measures } = result); + extraBlocks = Array.isArray(result.extraBlocks) ? result.extraBlocks : undefined; + extraMeasures = Array.isArray(result.extraMeasures) ? result.extraMeasures : undefined; // Add pageGap to layout for hit testing to account for gaps between rendered pages. // Gap depends on virtualization mode and must be non-negative. layout.pageGap = this.#getEffectivePageGap(); @@ -4532,7 +4600,13 @@ export class PresentationEditor extends EventEmitter { footerMeasures.push(...rIdResult.measures); } - // Pass all blocks (main document + headers + footers) to the painter + // Merge any extra lookup blocks (e.g., footnotes injected into page fragments) + if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) { + footerBlocks.push(...extraBlocks); + footerMeasures.push(...extraMeasures); + } + + // Pass all blocks (main document + headers + footers + extras) to the painter painter.setData?.( blocks, measures, @@ -4579,6 +4653,7 @@ export class PresentationEditor extends EventEmitter { this.emit('commentPositions', { positions: commentPositions }); } } + if (this.#telemetryEmitter && metrics) { this.#telemetryEmitter({ type: 'layout', data: { layout, blocks, measures, metrics } }); } @@ -4808,6 +4883,163 @@ export class PresentationEditor extends EventEmitter { }; } + #buildFootnotesLayoutInput({ + converterContext, + themeColors, + footnoteNumberById, + }: { + converterContext: { docx: unknown; numbering: unknown; linkedStyles: unknown; footnoteNumberById?: Record } | undefined; + themeColors: unknown; + footnoteNumberById?: Record; + }): { + refs: Array<{ id: string; pos: number }>; + blocksById: Map; + gap?: number; + topPadding?: number; + dividerHeight?: number; + } | null { + const toSuperscriptDigits = (value: unknown): string => { + const map: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + const str = String(value ?? ''); + return str + .split('') + .map((ch) => map[ch] ?? ch) + .join(''); + }; + + const ensureFootnoteMarker = (blocks: FlowBlock[], id: string): void => { + const displayNumberRaw = footnoteNumberById && typeof footnoteNumberById === 'object' ? footnoteNumberById[id] : undefined; + const displayNumber = + typeof displayNumberRaw === 'number' && Number.isFinite(displayNumberRaw) && displayNumberRaw > 0 + ? displayNumberRaw + : 1; + const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as + | (FlowBlock & { kind: 'paragraph'; runs?: Array> }) + | undefined; + if (!firstParagraph) return; + const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; + const markerText = toSuperscriptDigits(displayNumber); + + const baseRun = runs.find((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + if (dataAttrs?.['data-sd-footnote-number']) return false; + const pmStart = (r as { pmStart?: unknown }).pmStart; + const pmEnd = (r as { pmEnd?: unknown }).pmEnd; + return typeof pmStart === 'number' && Number.isFinite(pmStart) && typeof pmEnd === 'number' && Number.isFinite(pmEnd); + }) as { pmStart: number; pmEnd: number } | undefined; + + const markerPmStart = baseRun?.pmStart ?? null; + const markerPmEnd = + markerPmStart != null + ? baseRun?.pmEnd != null + ? Math.max(markerPmStart, Math.min(baseRun.pmEnd, markerPmStart + markerText.length)) + : markerPmStart + markerText.length + : null; + const alreadyHasMarker = runs.some((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + return Boolean(dataAttrs?.['data-sd-footnote-number']); + }); + if (alreadyHasMarker) { + if (markerPmStart != null && markerPmEnd != null) { + const markerRun = runs.find((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + return Boolean(dataAttrs?.['data-sd-footnote-number']); + }) as { pmStart?: number | null; pmEnd?: number | null } | undefined; + if (markerRun) { + if (markerRun.pmStart == null) markerRun.pmStart = markerPmStart; + if (markerRun.pmEnd == null) markerRun.pmEnd = markerPmEnd; + } + } + return; + } + + const firstTextRun = runs.find((r) => typeof (r as { text?: unknown }).text === 'string') as + | { fontFamily?: unknown; fontSize?: unknown; color?: unknown; text?: unknown } + | undefined; + + const markerRun: Record = { + kind: 'text', + text: markerText, + dataAttrs: { + 'data-sd-footnote-number': 'true', + }, + ...(markerPmStart != null ? { pmStart: markerPmStart } : {}), + ...(markerPmEnd != null ? { pmEnd: markerPmEnd } : {}), + }; + markerRun.fontFamily = typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : 'Arial'; + markerRun.fontSize = typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) ? firstTextRun.fontSize : 12; + if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; + + // Insert marker at the very start. + runs.unshift(markerRun); + + firstParagraph.runs = runs; + }; + + const state = this.#editor?.state; + if (!state) return null; + + const converter = (this.#editor as Partial)?.converter; + const importedFootnotes = Array.isArray(converter?.footnotes) ? converter!.footnotes! : []; + if (importedFootnotes.length === 0) return null; + + const refs: Array<{ id: string; pos: number }> = []; + const idsInUse = new Set(); + state.doc.descendants((node, pos) => { + if (node.type?.name !== 'footnoteReference') return; + const id = node.attrs?.id; + if (id == null) return; + const key = String(id); + const insidePos = Math.min(pos + 1, state.doc.content.size); + refs.push({ id: key, pos: insidePos }); + idsInUse.add(key); + }); + if (refs.length === 0) return null; + + const blocksById = new Map(); + idsInUse.forEach((id) => { + const entry = importedFootnotes.find((f) => String(f?.id) === id); + const content = entry?.content; + if (!Array.isArray(content) || content.length === 0) return; + + try { + const clonedContent = JSON.parse(JSON.stringify(content)); + const footnoteDoc = { type: 'doc', content: clonedContent }; + const result = toFlowBlocks(footnoteDoc, { + blockIdPrefix: `footnote-${id}-`, + enableRichHyperlinks: true, + themeColors: themeColors as never, + converterContext: converterContext as never, + }); + if (result?.blocks?.length) { + ensureFootnoteMarker(result.blocks, id); + blocksById.set(id, result.blocks); + } + } catch {} + }); + + if (blocksById.size === 0) return null; + + return { + refs, + blocksById, + gap: 2, + topPadding: 4, + dividerHeight: 1, + }; + } + #buildHeaderFooterInput() { if (!this.#headerFooterAdapter) { return null; @@ -5098,7 +5330,9 @@ export class PresentationEditor extends EventEmitter { const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; const margins = pageMargins ?? layout.pages[0]?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const box = this.#computeDecorationBox(kind, margins, pageHeight); + const decorationMargins = + kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; + const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); // Use helper to compute metrics with type safety and consistent logic const rawLayoutHeight = rIdLayout.layout.height ?? 0; @@ -5160,12 +5394,13 @@ export class PresentationEditor extends EventEmitter { const pageHeight = page?.size?.h ?? layout.pageSize?.h ?? this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; const margins = pageMargins ?? layout.pages[0]?.margins ?? this.#layoutOptions.margins ?? DEFAULT_MARGINS; - const box = this.#computeDecorationBox(kind, margins, pageHeight); + const decorationMargins = + kind === 'footer' ? this.#stripFootnoteReserveFromBottomMargin(margins, page ?? null) : margins; + const box = this.#computeDecorationBox(kind, decorationMargins, pageHeight); // Use helper to compute metrics with type safety and consistent logic const rawLayoutHeight = variant.layout.height ?? 0; const metrics = this.#computeHeaderFooterMetrics(kind, rawLayoutHeight, box, pageHeight, margins.footer ?? 0); - const fallbackId = this.#headerFooterManager?.getVariantId(kind, headerFooterType); const finalHeaderId = sectionRId ?? fallbackId ?? undefined; @@ -5275,6 +5510,19 @@ export class PresentationEditor extends EventEmitter { } } + #stripFootnoteReserveFromBottomMargin(pageMargins: PageMargins, page?: Page | null): PageMargins { + const reserveRaw = (page as Page | null | undefined)?.footnoteReserved; + const reserve = typeof reserveRaw === 'number' && Number.isFinite(reserveRaw) && reserveRaw > 0 ? reserveRaw : 0; + if (!reserve) return pageMargins; + + const bottomRaw = pageMargins.bottom; + const bottom = typeof bottomRaw === 'number' && Number.isFinite(bottomRaw) ? bottomRaw : 0; + const nextBottom = Math.max(0, bottom - reserve); + if (nextBottom === bottom) return pageMargins; + + return { ...pageMargins, bottom: nextBottom }; + } + /** * Computes the expected header/footer section type for a page based on document configuration. * @@ -5364,7 +5612,8 @@ export class PresentationEditor extends EventEmitter { // Same for footer - always create a hit region const footerPayload = this.#footerDecorationProvider?.(page.number, margins, page); - const footerBox = this.#computeDecorationBox('footer', margins, actualPageHeight); + const footerBoxMargins = this.#stripFootnoteReserveFromBottomMargin(margins, page); + const footerBox = this.#computeDecorationBox('footer', footerBoxMargins, actualPageHeight); this.#footerRegions.set(pageIndex, { kind: 'footer', headerId: footerPayload?.headerId, diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 203207241..25c1272e1 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -184,6 +184,7 @@ class SuperConverter { this.addedMedia = {}; this.comments = []; + this.footnotes = []; this.inlineDocumentFonts = []; // Store custom highlight colors @@ -924,6 +925,7 @@ class SuperConverter { this.pageStyles = result.pageStyles; this.numbering = result.numbering; this.comments = result.comments; + this.footnotes = result.footnotes; this.linkedStyles = result.linkedStyles; this.inlineDocumentFonts = result.inlineDocumentFonts; this.themeColors = result.themeColors ?? null; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js new file mode 100644 index 000000000..30a8185c4 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js @@ -0,0 +1,115 @@ +import { defaultNodeListHandler } from './docxImporter'; + +/** + * Extract plain text from imported ProseMirror JSON nodes. + * @param {Array<{type: string, text?: string, content?: any[]}>} nodes + * @returns {string} + */ +const extractPlainText = (nodes) => { + if (!Array.isArray(nodes) || nodes.length === 0) return ''; + const parts = []; + const walk = (node) => { + if (!node) return; + if (node.type === 'text' && typeof node.text === 'string') { + parts.push(node.text); + return; + } + if (Array.isArray(node.content)) { + node.content.forEach(walk); + } + }; + nodes.forEach(walk); + return parts.join('').replace(/\s+/g, ' ').trim(); +}; + +/** + * Remove w:footnoteRef placeholders from converted footnote content. + * In OOXML footnotes, the first run often includes a w:footnoteRef marker which + * Word uses to render the footnote number. We render numbering ourselves. + * + * @param {Array} nodes + * @returns {Array} + */ +const stripFootnoteMarkerNodes = (nodes) => { + if (!Array.isArray(nodes) || nodes.length === 0) return nodes; + const walk = (list) => { + if (!Array.isArray(list) || list.length === 0) return; + for (let i = list.length - 1; i >= 0; i--) { + const node = list[i]; + if (!node) continue; + if (node.type === 'passthroughInline' && node.attrs?.originalName === 'w:footnoteRef') { + list.splice(i, 1); + continue; + } + if (Array.isArray(node.content)) { + walk(node.content); + } + } + }; + const copy = JSON.parse(JSON.stringify(nodes)); + walk(copy); + return copy; +}; + +/** + * Parse footnotes.xml into SuperDoc-ready footnote entries. + * + * These will be available on converter.footnotes and are used by PresentationEditor + * to build a footnotes panel. + * + * @param {Object} params + * @param {ParsedDocx} params.docx The parsed docx object + * @param {NodeListHandler} [params.nodeListHandler] Optional node list handler (defaults to docxImporter default) + * @param {SuperConverter} params.converter The super converter instance + * @param {Editor} params.editor The editor instance + * @param {Object} [params.numbering] Numbering definitions (optional) + * @returns {Array<{id: string, content: any[], text: string}>} + */ +export function importFootnoteData({ docx, editor, converter, nodeListHandler, numbering } = {}) { + const handler = nodeListHandler || defaultNodeListHandler(); + const footnotes = docx?.['word/footnotes.xml']; + if (!footnotes?.elements?.length) return []; + + const root = footnotes.elements[0]; + const elements = Array.isArray(root?.elements) ? root.elements : []; + const footnoteElements = elements.filter((el) => el?.name === 'w:footnote'); + if (footnoteElements.length === 0) return []; + + const results = []; + const lists = {}; + const inlineDocumentFonts = []; + footnoteElements.forEach((el) => { + const idRaw = el?.attributes?.['w:id']; + if (idRaw === undefined || idRaw === null) return; + const id = String(idRaw); + const idNumber = Number(id); + // Skip special footnotes by explicit type (Word uses these for separators). + const type = el?.attributes?.['w:type']; + if (type === 'separator' || type === 'continuationSeparator') return; + // Be permissive about ids: some producers emit footnotes starting at 0. + // Only skip negative ids (Word uses -1 for separator). + if (!Number.isFinite(idNumber) || idNumber < 0) return; + + const childElements = Array.isArray(el.elements) ? el.elements : []; + const converted = handler.handler({ + nodes: childElements, + nodeListHandler: handler, + docx, + editor, + converter, + numbering, + lists, + inlineDocumentFonts, + path: [el], + }); + + const stripped = stripFootnoteMarkerNodes(converted); + results.push({ + id, + content: stripped, + text: extractPlainText(stripped), + }); + }); + + return results; +} 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 7fd5770ca..394ef057c 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 @@ -18,9 +18,11 @@ import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumbe import { pageReferenceEntity } from './pageReferenceImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; +import { importFootnoteData } from './documentFootnotesImporter.js'; import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js'; import { pruneIgnoredNodes } from './ignoredNodes.js'; import { tabNodeEntityHandler } from './tabImporter.js'; +import { footnoteReferenceHandlerEntity } from './footnoteReferenceImporter.js'; import { tableNodeHandlerEntity } from './tableImporter.js'; import { tableOfContentsHandlerEntity } from './tableOfContentsImporter.js'; import { preProcessNodesForFldChar } from '../../field-references'; @@ -113,13 +115,14 @@ export const createDocumentJson = (docx, converter, editor) => { const contentElements = node.elements?.filter((n) => n.name !== 'w:sectPr') ?? []; const content = pruneIgnoredNodes(contentElements); - const comments = importCommentData({ docx, nodeListHandler, converter, editor }); // Track imported lists const lists = {}; const inlineDocumentFonts = []; const numbering = getNumberingDefinitions(docx); + const comments = importCommentData({ docx, nodeListHandler, converter, editor }); + const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); let parsedContent = nodeListHandler.handler({ nodes: content, nodeListHandler, @@ -159,6 +162,7 @@ export const createDocumentJson = (docx, converter, editor) => { savedTagsToRestore: node, pageStyles: getDocumentStyles(node, docx, converter, editor, numbering), comments, + footnotes, inlineDocumentFonts, linkedStyles: getStyleDefinitions(docx, converter, editor), numbering: getNumberingDefinitions(docx, converter), @@ -185,6 +189,7 @@ export const defaultNodeListHandler = () => { drawingNodeHandlerEntity, trackChangeNodeHandlerEntity, tableNodeHandlerEntity, + footnoteReferenceHandlerEntity, tabNodeEntityHandler, tableOfContentsHandlerEntity, autoPageHandlerEntity, @@ -709,6 +714,7 @@ export function filterOutRootInlineNodes(content = []) { 'commentRangeStart', 'commentRangeEnd', 'commentReference', + 'footnoteReference', 'structuredContent', ]); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js new file mode 100644 index 000000000..677b3340e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/footnoteReferenceImporter.js @@ -0,0 +1,8 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/w/footnoteReference/footnoteReference-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const footnoteReferenceHandlerEntity = generateV2HandlerEntity('footnoteReferenceHandler', translator); + 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 34e137072..814b2e2d2 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 @@ -24,6 +24,7 @@ import { commentRangeStartTranslator as w_commentRangeStart_translator, commentRangeEndTranslator as w_commentRangeEnd_translator, } from './w/commentRange/comment-range-translator.js'; +import { translator as w_footnoteReference_translator } from './w/footnoteReference/footnoteReference-translator.js'; import { translator as w_contextualSpacing } from './w/contextualSpacing/contextualSpacing-translator.js'; import { translator as w_cs } from './w/cs/cs-translator.js'; import { translator as w_del_translator } from './w/del/del-translator.js'; @@ -188,6 +189,7 @@ const translatorList = Array.from( w_em_translator, w_emboss_translator, w_end_translator, + w_footnoteReference_translator, w_fitText_translator, w_framePr_translator, w_gridAfter_translator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js new file mode 100644 index 000000000..ed9254362 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js @@ -0,0 +1,2 @@ +export { attrConfig as idAttrConfig } from './w-id.js'; + diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js new file mode 100644 index 000000000..7a569dd4a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-id.js @@ -0,0 +1,30 @@ +// @ts-check + +/** + * Encoder for the 'w:id' attribute on the element. + * Maps to the 'id' attribute in SuperDoc. + * @param {Record} attributes + * @returns {string|undefined} + */ +export const encode = (attributes) => { + return attributes?.['w:id']; +}; + +/** + * Decoder for the 'id' attribute in SuperDoc. + * Maps to the 'w:id' attribute in OOXML. + * @param {Record} attrs + * @returns {string|undefined} + */ +export const decode = (attrs) => { + return attrs?.id; +}; + +/** @type {import('@translator').AttrConfig} */ +export const attrConfig = Object.freeze({ + xmlName: 'w:id', + sdName: 'id', + encode, + decode, +}); + diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js new file mode 100644 index 000000000..bbdb66c6c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js @@ -0,0 +1,53 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { idAttrConfig } from './attributes/index.js'; + +/** @type {import('@translator').XmlNodeName} */ +const XML_NODE_NAME = 'w:footnoteReference'; + +/** @type {import('@translator').SuperDocNodeOrKeyName} */ +const SD_NODE_NAME = 'footnoteReference'; + +/** + * Encode as an inline atom node. + * @param {import('@translator').SCEncoderConfig} _ + * @param {import('@translator').EncodedAttributes} [encodedAttrs] + * @returns {import('@translator').SCEncoderResult} + */ +const encode = (_, encodedAttrs) => { + const translated = { type: SD_NODE_NAME }; + if (encodedAttrs && Object.keys(encodedAttrs).length > 0) { + translated.attrs = { ...encodedAttrs }; + } + return translated; +}; + +/** + * Decode SuperDoc footnoteReference back to OOXML . + * Note: This element must be emitted inside a by the parent run translator. + * + * @param {import('@translator').SCDecoderConfig} _params + * @param {import('@translator').DecodedAttributes} [decodedAttrs] + * @returns {import('@translator').SCDecoderResult} + */ +const decode = (_params, decodedAttrs) => { + const ref = { name: XML_NODE_NAME }; + if (decodedAttrs && Object.keys(decodedAttrs).length > 0) { + ref.attributes = { ...decodedAttrs }; + } + return ref; +}; + +/** @type {import('@translator').NodeTranslatorConfig} */ +export const config = { + xmlName: XML_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAME, + type: NodeTranslator.translatorTypes.NODE, + encode, + decode, + attributes: [idAttrConfig], +}; + +/** @type {import('@translator').NodeTranslator} */ +export const translator = NodeTranslator.from(config); + diff --git a/packages/super-editor/src/extensions/footnote/footnote.js b/packages/super-editor/src/extensions/footnote/footnote.js new file mode 100644 index 000000000..d05b64447 --- /dev/null +++ b/packages/super-editor/src/extensions/footnote/footnote.js @@ -0,0 +1,126 @@ +import { Node, Attribute } from '@core/index.js'; + +const toSuperscriptDigits = (value) => { + const map = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + }; + return String(value ?? '') + .split('') + .map((ch) => map[ch] ?? ch) + .join(''); +}; + +const resolveFootnoteDisplayNumber = (editor, id) => { + const key = id == null ? null : String(id); + if (!key) return null; + const map = editor?.converter?.footnoteNumberById; + const mapped = map && typeof map === 'object' ? map[key] : undefined; + return typeof mapped === 'number' && Number.isFinite(mapped) && mapped > 0 ? mapped : null; +}; + +export class FootnoteReferenceNodeView { + constructor(node, getPos, decorations, editor, htmlAttributes = {}) { + void decorations; + this.node = node; + this.getPos = getPos; + this.editor = editor; + this.dom = this.#renderDom(node, htmlAttributes); + } + + #renderDom(node, htmlAttributes) { + const el = document.createElement('sup'); + el.className = 'sd-footnote-ref'; + el.setAttribute('contenteditable', 'false'); + el.setAttribute('aria-label', 'Footnote reference'); + + Object.entries(htmlAttributes).forEach(([key, value]) => { + if (value != null && value !== false) { + el.setAttribute(key, String(value)); + } + }); + + const id = node?.attrs?.id; + if (id != null) { + el.setAttribute('data-footnote-id', String(id)); + const display = resolveFootnoteDisplayNumber(this.editor, id) ?? id; + el.textContent = toSuperscriptDigits(display); + } else { + el.textContent = '*'; + } + + return el; + } + + update(node) { + const incomingType = node?.type?.name; + const currentType = this.node?.type?.name; + if (!incomingType || incomingType !== currentType) return false; + this.node = node; + + const id = node?.attrs?.id; + if (id != null) { + this.dom.setAttribute('data-footnote-id', String(id)); + const display = resolveFootnoteDisplayNumber(this.editor, id) ?? id; + this.dom.textContent = toSuperscriptDigits(display); + } else { + this.dom.removeAttribute('data-footnote-id'); + this.dom.textContent = '*'; + } + + return true; + } +} + +export const FootnoteReference = Node.create({ + name: 'footnoteReference', + + group: 'inline', + + inline: true, + + atom: true, + + selectable: false, + + draggable: false, + + addOptions() { + return { + htmlAttributes: { + 'data-footnote-ref': 'true', + }, + }; + }, + + addAttributes() { + return { + id: { + default: null, + }, + }; + }, + + addNodeView() { + return ({ node, editor, getPos, decorations }) => { + const htmlAttributes = this.options.htmlAttributes; + return new FootnoteReferenceNodeView(node, getPos, decorations, editor, htmlAttributes); + }; + }, + + parseDOM() { + return [{ tag: 'sup[data-footnote-id]' }]; + }, + + renderDOM({ htmlAttributes }) { + return ['sup', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes)]; + }, +}); diff --git a/packages/super-editor/src/extensions/footnote/index.js b/packages/super-editor/src/extensions/footnote/index.js new file mode 100644 index 000000000..975681399 --- /dev/null +++ b/packages/super-editor/src/extensions/footnote/index.js @@ -0,0 +1,2 @@ +export * from './footnote.js'; + diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 83d76220f..f2a434bf1 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -26,6 +26,7 @@ import { Run } from './run/index.js'; import { Paragraph } from './paragraph/index.js'; import { Heading } from './heading/index.js'; import { CommentRangeStart, CommentRangeEnd, CommentReference, CommentsMark } from './comment/index.js'; +import { FootnoteReference } from './footnote/index.js'; import { TabNode } from './tab/index.js'; import { LineBreak, HardBreak } from './line-break/index.js'; import { Table } from './table/index.js'; @@ -122,6 +123,7 @@ const getStarterExtensions = () => { CommentRangeStart, CommentRangeEnd, CommentReference, + FootnoteReference, Document, FontFamily, FontSize, @@ -201,6 +203,7 @@ export { CommentRangeStart, CommentRangeEnd, CommentReference, + FootnoteReference, TabNode, LineBreak, HardBreak, diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 4ce7c717c..9699f7d4e 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1045,6 +1045,12 @@ export interface CommentReferenceAttrs extends InlineNodeAttributes { attributes?: Record; } +/** Footnote reference node attributes */ +export interface FootnoteReferenceAttrs extends InlineNodeAttributes { + /** Footnote id from OOXML (w:id) */ + id?: string | null; +} + // ============================================ // MODULE AUGMENTATION // ============================================ @@ -1087,6 +1093,7 @@ declare module '../../core/types/NodeAttributesMap.js' { commentRangeStart: CommentRangeStartAttrs; commentRangeEnd: CommentRangeEndAttrs; commentReference: CommentReferenceAttrs; + footnoteReference: FootnoteReferenceAttrs; // Permissions permStart: PermStartAttrs; diff --git a/packages/super-editor/src/tests/import/footnotesImporter.test.js b/packages/super-editor/src/tests/import/footnotesImporter.test.js new file mode 100644 index 000000000..00f7e2fbc --- /dev/null +++ b/packages/super-editor/src/tests/import/footnotesImporter.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { createDocumentJson } from '@core/super-converter/v2/importer/docxImporter'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; + +const collectNodeTypes = (node, types = []) => { + if (!node) return types; + if (typeof node.type === 'string') types.push(node.type); + const content = Array.isArray(node.content) ? node.content : []; + content.forEach((child) => collectNodeTypes(child, types)); + return types; +}; + +describe('footnotes import', () => { + it('imports w:footnoteReference and loads matching footnotes.xml entry', () => { + const documentXml = + '' + + '' + + '' + + 'Hello' + + '' + + '' + + '' + + ''; + + const footnotesXml = + '' + + '' + + 'Footnote text' + + ''; + + const docx = { + 'word/document.xml': parseXmlToJson(documentXml), + 'word/footnotes.xml': parseXmlToJson(footnotesXml), + }; + + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + const result = createDocumentJson(docx, converter, editor); + expect(result).toBeTruthy(); + + expect(Array.isArray(result.footnotes)).toBe(true); + expect(result.footnotes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + text: 'Footnote text', + }), + ]), + ); + + const types = collectNodeTypes(result.pmDoc); + expect(types).toContain('footnoteReference'); + }); +}); + From 335d90bd6df773880678d62584caa036ba9cae9e Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 9 Jan 2026 17:01:23 +0200 Subject: [PATCH 2/4] fix: ts build issues and idents clean-up --- .../src/core/PresentationEditor.ts | 207 ++++++++---------- .../v2/importer/documentFootnotesImporter.js | 25 +-- .../footnoteReference-translator.js | 2 +- .../tests/import/footnotesImporter.test.js | 30 ++- 4 files changed, 119 insertions(+), 145 deletions(-) diff --git a/packages/super-editor/src/core/PresentationEditor.ts b/packages/super-editor/src/core/PresentationEditor.ts index 039c68d4b..d2cf149aa 100644 --- a/packages/super-editor/src/core/PresentationEditor.ts +++ b/packages/super-editor/src/core/PresentationEditor.ts @@ -129,6 +129,7 @@ import { } from './header-footer/HeaderFooterRegistry.js'; import { EditorOverlayManager } from './header-footer/EditorOverlayManager.js'; import { isInRegisteredSurface } from './uiSurfaceRegistry.js'; +import { ConverterContext } from '@superdoc/pm-adapter/converter-context'; export type PageSize = { w: number; @@ -286,7 +287,6 @@ interface EditorWithConverter extends Editor { footnotes?: Array<{ id: string; content?: unknown[]; - text?: string; }>; }; } @@ -365,22 +365,6 @@ type FootnotesLayoutInput = { dividerHeight?: number; }; -type FootnoteNumberById = Record; - -type ConverterContextForFootnotes = { - docx: unknown; - numbering: unknown; - linkedStyles: unknown; - footnoteNumberById?: FootnoteNumberById; - [key: string]: unknown; -}; - -type BuildFootnotesLayoutInputParams = { - converterContext?: ConverterContextForFootnotes; - themeColors: unknown; - footnoteNumberById?: FootnoteNumberById; -}; - type LayoutMetrics = { durationMs: number; blockCount: number; @@ -4413,11 +4397,9 @@ export class PresentationEditor extends EventEmitter { const sectionMetadata: SectionMetadata[] = []; let blocks: FlowBlock[] | undefined; let bookmarks: Map = new Map(); - let converterContext: - | { docx: unknown; numbering: unknown; linkedStyles: unknown; footnoteNumberById?: Record } - | undefined; + let converterContext: ConverterContext | undefined = undefined; try { - const converterForContext = (this.#editor as Editor & { converter?: Record }).converter; + const converter = (this.#editor as Editor & { converter?: Record }).converter; // Compute visible footnote numbering (1-based) by first appearance in the document. // This matches Word behavior even when OOXML ids are non-contiguous or start at 0. const footnoteNumberById: Record = {}; @@ -4437,16 +4419,16 @@ export class PresentationEditor extends EventEmitter { } catch {} // Expose numbering to node views and layout adapter. try { - if (converterForContext && typeof converterForContext === 'object') { - (converterForContext as any).footnoteNumberById = footnoteNumberById; + if (converter && typeof converter === 'object') { + converter['footnoteNumberById'] = footnoteNumberById; } } catch {} - - converterContext = converterForContext + + converterContext = converter ? { - docx: converterForContext.convertedXml, - numbering: converterForContext.numbering, - linkedStyles: converterForContext.linkedStyles, + docx: converter.convertedXml, + numbering: converter.numbering, + linkedStyles: converter.linkedStyles, ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), } : undefined; @@ -4481,12 +4463,13 @@ export class PresentationEditor extends EventEmitter { } const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata); - const footnotesLayoutInput = this.#buildFootnotesLayoutInput({ - converterContext, - themeColors: this.#editor?.converter?.themeColors ?? undefined, - footnoteNumberById: converterContext?.footnoteNumberById, - }); - const layoutOptions = footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } : baseLayoutOptions; + const footnotesLayoutInput = this.#buildFootnotesLayoutInput({ + converterContext, + themeColors: this.#editor?.converter?.themeColors ?? undefined, + }); + const layoutOptions = footnotesLayoutInput + ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } + : baseLayoutOptions; const previousBlocks = this.#layoutState.blocks; const previousLayout = this.#layoutState.layout; @@ -4886,18 +4869,12 @@ export class PresentationEditor extends EventEmitter { #buildFootnotesLayoutInput({ converterContext, themeColors, - footnoteNumberById, }: { - converterContext: { docx: unknown; numbering: unknown; linkedStyles: unknown; footnoteNumberById?: Record } | undefined; + converterContext: ConverterContext | undefined; themeColors: unknown; - footnoteNumberById?: Record; - }): { - refs: Array<{ id: string; pos: number }>; - blocksById: Map; - gap?: number; - topPadding?: number; - dividerHeight?: number; - } | null { + }): FootnotesLayoutInput | null { + const footnoteNumberById = converterContext?.footnoteNumberById; + const toSuperscriptDigits = (value: unknown): string => { const map: Record = { '0': '⁰', @@ -4916,70 +4893,78 @@ export class PresentationEditor extends EventEmitter { .split('') .map((ch) => map[ch] ?? ch) .join(''); - }; - - const ensureFootnoteMarker = (blocks: FlowBlock[], id: string): void => { - const displayNumberRaw = footnoteNumberById && typeof footnoteNumberById === 'object' ? footnoteNumberById[id] : undefined; - const displayNumber = - typeof displayNumberRaw === 'number' && Number.isFinite(displayNumberRaw) && displayNumberRaw > 0 - ? displayNumberRaw - : 1; - const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as - | (FlowBlock & { kind: 'paragraph'; runs?: Array> }) - | undefined; - if (!firstParagraph) return; - const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; - const markerText = toSuperscriptDigits(displayNumber); - - const baseRun = runs.find((r) => { - const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; - if (dataAttrs?.['data-sd-footnote-number']) return false; - const pmStart = (r as { pmStart?: unknown }).pmStart; - const pmEnd = (r as { pmEnd?: unknown }).pmEnd; - return typeof pmStart === 'number' && Number.isFinite(pmStart) && typeof pmEnd === 'number' && Number.isFinite(pmEnd); - }) as { pmStart: number; pmEnd: number } | undefined; - - const markerPmStart = baseRun?.pmStart ?? null; - const markerPmEnd = - markerPmStart != null - ? baseRun?.pmEnd != null - ? Math.max(markerPmStart, Math.min(baseRun.pmEnd, markerPmStart + markerText.length)) - : markerPmStart + markerText.length - : null; - const alreadyHasMarker = runs.some((r) => { - const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; - return Boolean(dataAttrs?.['data-sd-footnote-number']); - }); - if (alreadyHasMarker) { - if (markerPmStart != null && markerPmEnd != null) { - const markerRun = runs.find((r) => { - const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; - return Boolean(dataAttrs?.['data-sd-footnote-number']); - }) as { pmStart?: number | null; pmEnd?: number | null } | undefined; - if (markerRun) { - if (markerRun.pmStart == null) markerRun.pmStart = markerPmStart; - if (markerRun.pmEnd == null) markerRun.pmEnd = markerPmEnd; - } - } - return; - } - - const firstTextRun = runs.find((r) => typeof (r as { text?: unknown }).text === 'string') as - | { fontFamily?: unknown; fontSize?: unknown; color?: unknown; text?: unknown } - | undefined; - - const markerRun: Record = { - kind: 'text', - text: markerText, - dataAttrs: { - 'data-sd-footnote-number': 'true', - }, - ...(markerPmStart != null ? { pmStart: markerPmStart } : {}), - ...(markerPmEnd != null ? { pmEnd: markerPmEnd } : {}), - }; - markerRun.fontFamily = typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : 'Arial'; - markerRun.fontSize = typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) ? firstTextRun.fontSize : 12; - if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; + }; + + const ensureFootnoteMarker = (blocks: FlowBlock[], id: string): void => { + const displayNumberRaw = + footnoteNumberById && typeof footnoteNumberById === 'object' ? footnoteNumberById[id] : undefined; + const displayNumber = + typeof displayNumberRaw === 'number' && Number.isFinite(displayNumberRaw) && displayNumberRaw > 0 + ? displayNumberRaw + : 1; + const firstParagraph = blocks.find((b) => b?.kind === 'paragraph') as + | (FlowBlock & { kind: 'paragraph'; runs?: Array> }) + | undefined; + if (!firstParagraph) return; + const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : []; + const markerText = toSuperscriptDigits(displayNumber); + + const baseRun = runs.find((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + if (dataAttrs?.['data-sd-footnote-number']) return false; + const pmStart = (r as { pmStart?: unknown }).pmStart; + const pmEnd = (r as { pmEnd?: unknown }).pmEnd; + return ( + typeof pmStart === 'number' && + Number.isFinite(pmStart) && + typeof pmEnd === 'number' && + Number.isFinite(pmEnd) + ); + }) as { pmStart: number; pmEnd: number } | undefined; + + const markerPmStart = baseRun?.pmStart ?? null; + const markerPmEnd = + markerPmStart != null + ? baseRun?.pmEnd != null + ? Math.max(markerPmStart, Math.min(baseRun.pmEnd, markerPmStart + markerText.length)) + : markerPmStart + markerText.length + : null; + + const alreadyHasMarker = runs.some((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + return Boolean(dataAttrs?.['data-sd-footnote-number']); + }); + if (alreadyHasMarker) { + if (markerPmStart != null && markerPmEnd != null) { + const markerRun = runs.find((r) => { + const dataAttrs = (r as { dataAttrs?: Record }).dataAttrs; + return Boolean(dataAttrs?.['data-sd-footnote-number']); + }) as { pmStart?: number | null; pmEnd?: number | null } | undefined; + if (markerRun) { + if (markerRun.pmStart == null) markerRun.pmStart = markerPmStart; + if (markerRun.pmEnd == null) markerRun.pmEnd = markerPmEnd; + } + } + return; + } + + const firstTextRun = runs.find((r) => typeof (r as { text?: unknown }).text === 'string') as + | { fontFamily?: unknown; fontSize?: unknown; color?: unknown; text?: unknown } + | undefined; + + const markerRun: Record = { + kind: 'text', + text: markerText, + dataAttrs: { + 'data-sd-footnote-number': 'true', + }, + ...(markerPmStart != null ? { pmStart: markerPmStart } : {}), + ...(markerPmEnd != null ? { pmEnd: markerPmEnd } : {}), + }; + markerRun.fontFamily = typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : 'Arial'; + markerRun.fontSize = + typeof firstTextRun?.fontSize === 'number' && Number.isFinite(firstTextRun.fontSize) ? firstTextRun.fontSize : 12; + if (firstTextRun?.color != null) markerRun.color = firstTextRun.color; // Insert marker at the very start. runs.unshift(markerRun); @@ -4991,10 +4976,10 @@ export class PresentationEditor extends EventEmitter { if (!state) return null; const converter = (this.#editor as Partial)?.converter; - const importedFootnotes = Array.isArray(converter?.footnotes) ? converter!.footnotes! : []; + const importedFootnotes = Array.isArray(converter?.footnotes) ? converter.footnotes : []; if (importedFootnotes.length === 0) return null; - - const refs: Array<{ id: string; pos: number }> = []; + + const refs: FootnoteReference[] = []; const idsInUse = new Set(); state.doc.descendants((node, pos) => { if (node.type?.name !== 'footnoteReference') return; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js index 30a8185c4..ff82a12f5 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js @@ -1,27 +1,5 @@ import { defaultNodeListHandler } from './docxImporter'; -/** - * Extract plain text from imported ProseMirror JSON nodes. - * @param {Array<{type: string, text?: string, content?: any[]}>} nodes - * @returns {string} - */ -const extractPlainText = (nodes) => { - if (!Array.isArray(nodes) || nodes.length === 0) return ''; - const parts = []; - const walk = (node) => { - if (!node) return; - if (node.type === 'text' && typeof node.text === 'string') { - parts.push(node.text); - return; - } - if (Array.isArray(node.content)) { - node.content.forEach(walk); - } - }; - nodes.forEach(walk); - return parts.join('').replace(/\s+/g, ' ').trim(); -}; - /** * Remove w:footnoteRef placeholders from converted footnote content. * In OOXML footnotes, the first run often includes a w:footnoteRef marker which @@ -63,7 +41,7 @@ const stripFootnoteMarkerNodes = (nodes) => { * @param {SuperConverter} params.converter The super converter instance * @param {Editor} params.editor The editor instance * @param {Object} [params.numbering] Numbering definitions (optional) - * @returns {Array<{id: string, content: any[], text: string}>} + * @returns {Array<{id: string, content: any[]}>} */ export function importFootnoteData({ docx, editor, converter, nodeListHandler, numbering } = {}) { const handler = nodeListHandler || defaultNodeListHandler(); @@ -107,7 +85,6 @@ export function importFootnoteData({ docx, editor, converter, nodeListHandler, n results.push({ id, content: stripped, - text: extractPlainText(stripped), }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js index bbdb66c6c..43346d8b0 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js @@ -31,7 +31,7 @@ const encode = (_, encodedAttrs) => { * @returns {import('@translator').SCDecoderResult} */ const decode = (_params, decodedAttrs) => { - const ref = { name: XML_NODE_NAME }; + const ref = { name: XML_NODE_NAME, elements: [] }; if (decodedAttrs && Object.keys(decodedAttrs).length > 0) { ref.attributes = { ...decodedAttrs }; } diff --git a/packages/super-editor/src/tests/import/footnotesImporter.test.js b/packages/super-editor/src/tests/import/footnotesImporter.test.js index 00f7e2fbc..ac1c8152a 100644 --- a/packages/super-editor/src/tests/import/footnotesImporter.test.js +++ b/packages/super-editor/src/tests/import/footnotesImporter.test.js @@ -10,6 +10,23 @@ const collectNodeTypes = (node, types = []) => { return types; }; +const extractPlainText = (nodes) => { + if (!Array.isArray(nodes) || nodes.length === 0) return ''; + const parts = []; + const walk = (node) => { + if (!node) return; + if (node.type === 'text' && typeof node.text === 'string') { + parts.push(node.text); + return; + } + if (Array.isArray(node.content)) { + node.content.forEach(walk); + } + }; + nodes.forEach(walk); + return parts.join('').replace(/\s+/g, ' ').trim(); +}; + describe('footnotes import', () => { it('imports w:footnoteReference and loads matching footnotes.xml entry', () => { const documentXml = @@ -40,17 +57,12 @@ describe('footnotes import', () => { expect(result).toBeTruthy(); expect(Array.isArray(result.footnotes)).toBe(true); - expect(result.footnotes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: '1', - text: 'Footnote text', - }), - ]), - ); + const footnote = result.footnotes.find((f) => f?.id === '1'); + expect(footnote).toBeTruthy(); + expect(Array.isArray(footnote.content)).toBe(true); + expect(extractPlainText(footnote.content)).toBe('Footnote text'); const types = collectNodeTypes(result.pmDoc); expect(types).toContain('footnoteReference'); }); }); - From cc8944a69c73aa7d7f9943bdbbef7df702a6f9a5 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 9 Jan 2026 18:02:58 +0200 Subject: [PATCH 3/4] fix: missing type import --- .../layout-engine/layout-bridge/src/incrementalLayout.ts | 6 ++++-- packages/layout-engine/pm-adapter/src/index.ts | 1 + packages/super-editor/src/core/PresentationEditor.ts | 3 +-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 0d791ed05..b0018c922 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -164,7 +164,7 @@ const resolveFootnoteMeasurementWidth = (options: LayoutOptions, blocks?: FlowBl left: normalizeMargin(block.margins?.left, margins.left), }; const w = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); - if (w > width) width = w; + if (w > 0 && w < width) width = w; } } @@ -764,7 +764,9 @@ export async function incrementalLayout( if (!page.margins) continue; const pageSize = page.size ?? layoutForPages.pageSize; - const contentWidth = pageSize.w - ((page.margins.left ?? 0) + (page.margins.right ?? 0)); + const pageContentWidth = pageSize.w - ((page.margins.left ?? 0) + (page.margins.right ?? 0)); + const contentWidth = Math.min(pageContentWidth, footnoteWidth); + if (!Number.isFinite(contentWidth) || contentWidth <= 0) continue; const bandTopY = pageSize.h - (page.margins.bottom ?? 0); const x = page.margins.left ?? 0; diff --git a/packages/layout-engine/pm-adapter/src/index.ts b/packages/layout-engine/pm-adapter/src/index.ts index 8007713c1..2d14f7c31 100644 --- a/packages/layout-engine/pm-adapter/src/index.ts +++ b/packages/layout-engine/pm-adapter/src/index.ts @@ -31,6 +31,7 @@ export type { PMDocumentMap, BatchAdapterOptions, FlowBlocksResult, + ConverterContext } from './types.js'; // Re-export enum as value diff --git a/packages/super-editor/src/core/PresentationEditor.ts b/packages/super-editor/src/core/PresentationEditor.ts index d2cf149aa..f0023ba58 100644 --- a/packages/super-editor/src/core/PresentationEditor.ts +++ b/packages/super-editor/src/core/PresentationEditor.ts @@ -66,7 +66,7 @@ import { initHeaderFooterRegistry as initHeaderFooterRegistryFromHelper } from ' import { decodeRPrFromMarks } from './super-converter/styles.js'; import { halfPointToPoints } from './super-converter/helpers.js'; import { layoutPerRIdHeaderFooters as layoutPerRIdHeaderFootersFromHelper } from './header-footer/HeaderFooterPerRidLayout.js'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks, ConverterContext } from '@superdoc/pm-adapter'; import { incrementalLayout, selectionToRects, @@ -129,7 +129,6 @@ import { } from './header-footer/HeaderFooterRegistry.js'; import { EditorOverlayManager } from './header-footer/EditorOverlayManager.js'; import { isInRegisteredSurface } from './uiSurfaceRegistry.js'; -import { ConverterContext } from '@superdoc/pm-adapter/converter-context'; export type PageSize = { w: number; From fb614d15867cef0f44c32d79aa6d184d836db4fe Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 13 Jan 2026 10:52:27 -0800 Subject: [PATCH 4/4] fix: additions for footnote export, add round trip and other tests --- .../layout-engine/layout-bridge/src/index.ts | 2 +- packages/super-editor/src/core/DocxZipper.js | 13 + packages/super-editor/src/core/Editor.ts | 14 + .../core/super-converter/SuperConverter.js | 17 +- .../super-converter/exporter-docx-defs.js | 55 ++ .../src/core/super-converter/exporter.js | 9 + .../v2/exporter/footnotesExporter.js | 211 ++++++ .../v2/importer/documentFootnotesImporter.js | 24 +- .../v2/importer/docxImporter.js | 61 ++ .../w/footnoteReference/attributes/index.js | 2 +- .../attributes/w-customMarkFollows.js | 35 + .../footnoteReference-translator.js | 5 +- .../w/hyperlink/hyperlink-translator.js | 14 +- .../src/extensions/footnote/footnote.js | 23 +- .../src/extensions/types/node-attributes.ts | 2 + .../src/tests/data/basic-footnotes.docx | Bin 0 -> 17152 bytes .../import-export/footnotes-roundtrip.test.js | 700 ++++++++++++++++++ 17 files changed, 1162 insertions(+), 25 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-customMarkFollows.js create mode 100644 packages/super-editor/src/tests/data/basic-footnotes.docx create mode 100644 packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 81f7d5251..7353d0399 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -51,7 +51,7 @@ export type { HeaderFooterBatch, DigitBucket } from './layoutHeaderFooter'; export { findWordBoundaries, findParagraphBoundaries } from './text-boundaries'; export type { BoundaryRange } from './text-boundaries'; export { incrementalLayout, measureCache } from './incrementalLayout'; -export type { HeaderFooterLayoutResult } from './incrementalLayout'; +export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout'; // Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering export { computeDisplayPageNumber, type DisplayPageInfo } from '@superdoc/layout-engine'; export { remeasureParagraph } from './remeasure'; diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 6bae9ab26..018ceb96c 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -143,6 +143,9 @@ class DocxZipper { ); const hasFile = (filename) => { + if (updatedDocs && Object.prototype.hasOwnProperty.call(updatedDocs, filename)) { + return true; + } if (!docx?.files) return false; if (!fromJson) return Boolean(docx.files[filename]); if (Array.isArray(docx.files)) return docx.files.some((file) => file.name === filename); @@ -169,6 +172,16 @@ class DocxZipper { if (!hasCommentsExtensible) typesString += commentsExtendedDef; } + // Update for footnotes + const hasFootnotes = types.elements?.some( + (el) => el.name === 'Override' && el.attributes.PartName === '/word/footnotes.xml', + ); + + if (hasFile('word/footnotes.xml')) { + const footnotesDef = ``; + if (!hasFootnotes) typesString += footnotesDef; + } + const partNames = new Set(additionalPartNames); if (docx?.files) { if (fromJson && Array.isArray(docx.files)) { diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 0a4426fa7..8c5bbbf48 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -2411,6 +2411,12 @@ export class Editor extends EventEmitter { : null; const rels = this.converter.schemaToXml(this.converter.convertedXml['word/_rels/document.xml.rels'].elements[0]); + const footnotesData = this.converter.convertedXml['word/footnotes.xml']; + const footnotesXml = footnotesData?.elements?.[0] ? this.converter.schemaToXml(footnotesData.elements[0]) : null; + const footnotesRelsData = this.converter.convertedXml['word/_rels/footnotes.xml.rels']; + const footnotesRelsXml = footnotesRelsData?.elements?.[0] + ? this.converter.schemaToXml(footnotesRelsData.elements[0]) + : null; const media = this.converter.addedMedia; @@ -2441,6 +2447,14 @@ export class Editor extends EventEmitter { updatedDocs['word/settings.xml'] = String(customSettings); } + if (footnotesXml) { + updatedDocs['word/footnotes.xml'] = String(footnotesXml); + } + + if (footnotesRelsXml) { + updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml); + } + if (comments.length) { const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]); const commentsExtendedXml = this.converter.schemaToXml( diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 25c1272e1..8966ec2f6 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -11,6 +11,7 @@ import { prepareCommentParaIds, prepareCommentsXmlFilesForExport, } from './v2/exporter/commentsExporter.js'; +import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js'; import { DocxHelpers } from './docx-helpers/index.js'; import { mergeRelationshipElements } from './relationship-helpers.js'; @@ -185,6 +186,7 @@ class SuperConverter { this.addedMedia = {}; this.comments = []; this.footnotes = []; + this.footnoteProperties = null; this.inlineDocumentFonts = []; // Store custom highlight colors @@ -973,11 +975,24 @@ class SuperConverter { const exporter = new DocxExporter(this); const xml = exporter.schemaToXml(result); + const { + updatedXml: footnotesUpdatedXml, + relationships: footnotesRels, + media: footnotesMedia, + } = prepareFootnotesXmlForExport({ + footnotes: this.footnotes, + editor, + converter: this, + convertedXml: this.convertedXml, + }); + this.convertedXml = { ...this.convertedXml, ...footnotesUpdatedXml }; + // Update media await this.#exportProcessMediaFiles( { ...documentMedia, ...params.media, + ...footnotesMedia, ...this.media, }, editor, @@ -1001,7 +1016,7 @@ class SuperConverter { const headFootRels = this.#exportProcessHeadersFooters({ isFinalDoc }); // Update the rels table - this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...headFootRels]); + this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...footnotesRels, ...headFootRels]); // Store SuperDoc version SuperConverter.setStoredSuperdocVersion(this.convertedXml); diff --git a/packages/super-editor/src/core/super-converter/exporter-docx-defs.js b/packages/super-editor/src/core/super-converter/exporter-docx-defs.js index 539be538e..5f1b92761 100644 --- a/packages/super-editor/src/core/super-converter/exporter-docx-defs.js +++ b/packages/super-editor/src/core/super-converter/exporter-docx-defs.js @@ -797,6 +797,61 @@ export const COMMENTS_XML_DEF = { ], }; +export const FOOTNOTES_XML_DEF = { + declaration: { + attributes: { + version: '1.0', + encoding: 'UTF-8', + standalone: 'yes', + }, + }, + elements: [ + { + type: 'element', + name: 'w:footnotes', + attributes: { + 'xmlns:wpc': 'http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas', + 'xmlns:cx': 'http://schemas.microsoft.com/office/drawing/2014/chartex', + 'xmlns:cx1': 'http://schemas.microsoft.com/office/drawing/2015/9/8/chartex', + 'xmlns:cx2': 'http://schemas.microsoft.com/office/drawing/2015/10/21/chartex', + 'xmlns:cx3': 'http://schemas.microsoft.com/office/drawing/2016/5/9/chartex', + 'xmlns:cx4': 'http://schemas.microsoft.com/office/drawing/2016/5/10/chartex', + 'xmlns:cx5': 'http://schemas.microsoft.com/office/drawing/2016/5/11/chartex', + 'xmlns:cx6': 'http://schemas.microsoft.com/office/drawing/2016/5/12/chartex', + 'xmlns:cx7': 'http://schemas.microsoft.com/office/drawing/2016/5/13/chartex', + 'xmlns:cx8': 'http://schemas.microsoft.com/office/drawing/2016/5/14/chartex', + 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', + 'xmlns:aink': 'http://schemas.microsoft.com/office/drawing/2016/ink', + 'xmlns:am3d': 'http://schemas.microsoft.com/office/drawing/2017/model3d', + 'xmlns:o': 'urn:schemas-microsoft-com:office:office', + 'xmlns:oel': 'http://schemas.microsoft.com/office/2019/extlst', + 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + 'xmlns:m': 'http://schemas.openxmlformats.org/officeDocument/2006/math', + 'xmlns:v': 'urn:schemas-microsoft-com:vml', + 'xmlns:wp14': 'http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing', + 'xmlns:wp': 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing', + 'xmlns:w10': 'urn:schemas-microsoft-com:office:word', + 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + 'xmlns:w14': 'http://schemas.microsoft.com/office/word/2010/wordml', + 'xmlns:w15': 'http://schemas.microsoft.com/office/word/2012/wordml', + 'xmlns:w16cex': 'http://schemas.microsoft.com/office/word/2018/wordml/cex', + 'xmlns:w16cid': 'http://schemas.microsoft.com/office/word/2016/wordml/cid', + 'xmlns:w16': 'http://schemas.microsoft.com/office/word/2018/wordml', + 'xmlns:w16du': 'http://schemas.microsoft.com/office/word/2023/wordml/word16du', + 'xmlns:w16sdtdh': 'http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash', + 'xmlns:w16sdtfl': 'http://schemas.microsoft.com/office/word/2024/wordml/sdtformatlock', + 'xmlns:w16se': 'http://schemas.microsoft.com/office/word/2015/wordml/symex', + 'xmlns:wpg': 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup', + 'xmlns:wpi': 'http://schemas.microsoft.com/office/word/2010/wordprocessingInk', + 'xmlns:wne': 'http://schemas.microsoft.com/office/word/2006/wordml', + 'xmlns:wps': 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape', + 'mc:Ignorable': 'w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du wp14', + }, + elements: [], + }, + ], +}; + export const COMMENTS_EXTENDED_XML_DEF = { declaration: { attributes: { diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 28c4eb97c..85ecfa326 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -28,6 +28,7 @@ import { translator as sdTotalPageNumberTranslator } from '@converter/v3/handler 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'; +import { translator as wFootnoteReferenceTranslator } from './v3/handlers/w/footnoteReference/footnoteReference-translator.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; const DEFAULT_SECTION_PROPS_TWIPS = Object.freeze({ @@ -173,6 +174,7 @@ export function exportSchemaToJson(params) { permStart: wPermStartTranslator, permEnd: wPermEndTranslator, commentReference: () => null, + footnoteReference: wFootnoteReferenceTranslator, shapeContainer: pictTranslator, shapeTextbox: pictTranslator, contentBlock: pictTranslator, @@ -248,6 +250,13 @@ function translateBodyNode(params) { const defaultFooter = generateDefaultHeaderFooter('footer', params.converter.footerIds?.default); sectPr.elements.push(defaultFooter); } + + // Re-emit footnote properties if they were parsed during import + const hasFootnotePr = sectPr.elements?.some((n) => n.name === 'w:footnotePr'); + const footnoteProperties = params.converter.footnoteProperties; + if (!hasFootnotePr && footnoteProperties?.source === 'sectPr' && footnoteProperties.originalXml) { + sectPr.elements.push(carbonCopy(footnoteProperties.originalXml)); + } } const elements = translateChildNodes(params); diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js new file mode 100644 index 000000000..978bc96bb --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js @@ -0,0 +1,211 @@ +import { exportSchemaToJson } from '../../exporter.js'; +import { carbonCopy } from '../../../utilities/carbonCopy.js'; +import { FOOTNOTES_XML_DEF } from '../../exporter-docx-defs.js'; +import { mergeRelationshipElements } from '../../relationship-helpers.js'; + +const RELS_XMLNS = 'http://schemas.openxmlformats.org/package/2006/relationships'; +const FOOTNOTES_RELS_PATH = 'word/_rels/footnotes.xml.rels'; + +const paragraphHasFootnoteRef = (node) => { + if (!node) return false; + if (node.name === 'w:footnoteRef') return true; + const children = Array.isArray(node.elements) ? node.elements : []; + return children.some((child) => paragraphHasFootnoteRef(child)); +}; + +const insertFootnoteRefIntoParagraph = (paragraph) => { + if (!paragraph || paragraph.name !== 'w:p') return; + if (!Array.isArray(paragraph.elements)) paragraph.elements = []; + if (paragraphHasFootnoteRef(paragraph)) return; + + const footnoteRef = { type: 'element', name: 'w:footnoteRef', elements: [] }; + const footnoteRefRun = { + type: 'element', + name: 'w:r', + elements: [ + { + type: 'element', + name: 'w:rPr', + elements: [{ type: 'element', name: 'w:rStyle', attributes: { 'w:val': 'FootnoteReference' } }], + }, + footnoteRef, + ], + }; + + const pPrIndex = paragraph.elements.findIndex((el) => el?.name === 'w:pPr'); + const insertAt = pPrIndex >= 0 ? pPrIndex + 1 : 0; + paragraph.elements.splice(insertAt, 0, footnoteRefRun); +}; + +const ensureFootnoteRefMarker = (elements) => { + if (!Array.isArray(elements)) return; + const firstParagraphIndex = elements.findIndex((el) => el?.name === 'w:p'); + if (firstParagraphIndex >= 0) { + insertFootnoteRefIntoParagraph(elements[firstParagraphIndex]); + return; + } + + const paragraph = { + type: 'element', + name: 'w:p', + elements: [], + }; + insertFootnoteRefIntoParagraph(paragraph); + elements.unshift(paragraph); +}; + +const translateFootnoteContent = (content, exportContext) => { + if (!Array.isArray(content) || content.length === 0) return []; + + const translated = []; + content.forEach((node) => { + if (!node) return; + const result = exportSchemaToJson({ ...exportContext, node }); + if (Array.isArray(result)) { + result.filter(Boolean).forEach((entry) => translated.push(entry)); + return; + } + if (result) translated.push(result); + }); + + return translated; +}; + +export const createFootnoteElement = (footnote, exportContext) => { + if (!footnote) return null; + + const { id, content, type, originalXml } = footnote; + + if ((type === 'separator' || type === 'continuationSeparator') && originalXml) { + return carbonCopy(originalXml); + } + + const attributes = { 'w:id': String(id) }; + if (type) attributes['w:type'] = type; + + const translatedContent = translateFootnoteContent(content, exportContext); + + // Only add footnoteRef if the original had one. + // Custom mark footnotes (customMarkFollows=true on the reference) don't have w:footnoteRef + // in their footnote content - the custom symbol appears in the document body instead. + const originalHadFootnoteRef = originalXml ? paragraphHasFootnoteRef(originalXml) : true; + if (originalHadFootnoteRef) { + ensureFootnoteRefMarker(translatedContent); + } + + const base = originalXml + ? carbonCopy(originalXml) + : { + type: 'element', + name: 'w:footnote', + attributes: {}, + elements: [], + }; + + base.attributes = { ...(base.attributes || {}), ...attributes }; + base.elements = translatedContent; + + return base; +}; + +const applyFootnotePropertiesToSettings = (converter, convertedXml) => { + const props = converter?.footnoteProperties; + if (!props || props.source !== 'settings' || !props.originalXml) { + return convertedXml; + } + + const settingsXml = convertedXml['word/settings.xml']; + const settingsRoot = settingsXml?.elements?.[0]; + if (!settingsRoot) return convertedXml; + + const updatedSettings = carbonCopy(settingsXml); + const updatedRoot = updatedSettings.elements?.[0]; + if (!updatedRoot) return convertedXml; + + const elements = Array.isArray(updatedRoot.elements) ? updatedRoot.elements : []; + const nextElements = elements.filter((el) => el?.name !== 'w:footnotePr'); + nextElements.push(carbonCopy(props.originalXml)); + updatedRoot.elements = nextElements; + + return { ...convertedXml, 'word/settings.xml': updatedSettings }; +}; + +const buildFootnotesRelsXml = (converter, convertedXml, relationships) => { + if (!relationships.length) return null; + + const existingRels = convertedXml[FOOTNOTES_RELS_PATH]; + const existingRoot = existingRels?.elements?.find((el) => el.name === 'Relationships'); + const existingElements = Array.isArray(existingRoot?.elements) ? existingRoot.elements : []; + const merged = mergeRelationshipElements(existingElements, relationships); + + const declaration = existingRels?.declaration ?? converter?.initialJSON?.declaration; + const relsXml = { + ...(declaration ? { declaration } : {}), + elements: [ + { + name: 'Relationships', + attributes: { xmlns: RELS_XMLNS }, + elements: merged, + }, + ], + }; + + return relsXml; +}; + +export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => { + let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml); + + if (!footnotes || !Array.isArray(footnotes) || footnotes.length === 0) { + return { updatedXml, relationships: [], media: {} }; + } + + const footnoteRelationships = []; + const footnoteMedia = {}; + const exportContext = { + editor, + editorSchema: editor?.schema, + converter, + relationships: footnoteRelationships, + media: footnoteMedia, + }; + + const footnoteElements = footnotes.map((fn) => createFootnoteElement(fn, exportContext)).filter(Boolean); + + if (footnoteElements.length === 0) { + return { updatedXml, relationships: [], media: footnoteMedia }; + } + + let footnotesXml = updatedXml['word/footnotes.xml']; + if (!footnotesXml) { + footnotesXml = carbonCopy(FOOTNOTES_XML_DEF); + } else { + footnotesXml = carbonCopy(footnotesXml); + } + + if (footnotesXml.elements && footnotesXml.elements[0]) { + footnotesXml.elements[0].elements = footnoteElements; + } + + updatedXml = { ...updatedXml, 'word/footnotes.xml': footnotesXml }; + + if (footnoteRelationships.length > 0) { + const footnotesRelsXml = buildFootnotesRelsXml(converter, updatedXml, footnoteRelationships); + if (footnotesRelsXml) { + updatedXml = { ...updatedXml, [FOOTNOTES_RELS_PATH]: footnotesRelsXml }; + } + } + + const relationships = [ + { + type: 'element', + name: 'Relationship', + attributes: { + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes', + Target: 'footnotes.xml', + }, + }, + ]; + + return { updatedXml, relationships, media: footnoteMedia }; +}; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js index ff82a12f5..f31b81f60 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/documentFootnotesImporter.js @@ -1,4 +1,5 @@ import { defaultNodeListHandler } from './docxImporter'; +import { carbonCopy } from '../../../utilities/carbonCopy.js'; /** * Remove w:footnoteRef placeholders from converted footnote content. @@ -61,9 +62,23 @@ export function importFootnoteData({ docx, editor, converter, nodeListHandler, n if (idRaw === undefined || idRaw === null) return; const id = String(idRaw); const idNumber = Number(id); - // Skip special footnotes by explicit type (Word uses these for separators). - const type = el?.attributes?.['w:type']; - if (type === 'separator' || type === 'continuationSeparator') return; + const originalXml = carbonCopy(el); + + // Get the footnote type (separator, continuationSeparator, or undefined for regular) + const type = el?.attributes?.['w:type'] || null; + + // Preserve separator/continuationSeparator footnotes as-is for roundtrip fidelity. + // These are special Word constructs that shouldn't be converted to SuperDoc content. + if (type === 'separator' || type === 'continuationSeparator') { + results.push({ + id, + type, + originalXml, + content: [], + }); + return; + } + // Be permissive about ids: some producers emit footnotes starting at 0. // Only skip negative ids (Word uses -1 for separator). if (!Number.isFinite(idNumber) || idNumber < 0) return; @@ -78,12 +93,15 @@ export function importFootnoteData({ docx, editor, converter, nodeListHandler, n numbering, lists, inlineDocumentFonts, + filename: 'footnotes.xml', path: [el], }); const stripped = stripFootnoteMarkerNodes(converted); results.push({ id, + type, + originalXml, content: stripped, }); }); 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 394ef057c..796400a61 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 @@ -57,6 +57,10 @@ export const createDocumentJson = (docx, converter, editor) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; + if (converter) { + importFootnotePropertiesFromSettings(docx, converter); + } + // Track initial document structure if (converter?.telemetry) { const files = Object.keys(docx).map((filePath) => { @@ -369,6 +373,57 @@ const createNodeListHandler = (nodeHandlers) => { return nodeListHandlerFn; }; +/** + * Parse w:footnotePr element to extract footnote properties. + * These properties control footnote numbering format, starting number, restart behavior, and position. + * + * @param {Object} footnotePrElement The w:footnotePr XML element + * @returns {Object|null} Parsed footnote properties or null if none found + */ +function parseFootnoteProperties(footnotePrElement, source) { + if (!footnotePrElement) return null; + + const props = { source }; + const elements = Array.isArray(footnotePrElement.elements) ? footnotePrElement.elements : []; + + elements.forEach((el) => { + const val = el?.attributes?.['w:val']; + switch (el.name) { + case 'w:numFmt': + // Numbering format: decimal, lowerRoman, upperRoman, lowerLetter, upperLetter, etc. + if (val) props.numFmt = val; + break; + case 'w:numStart': + // Starting number for footnotes + if (val) props.numStart = val; + break; + case 'w:numRestart': + // Restart behavior: continuous, eachSect, eachPage + if (val) props.numRestart = val; + break; + case 'w:pos': + // Position: pageBottom, beneathText, sectEnd, docEnd + if (val) props.pos = val; + break; + } + }); + + // Also preserve the original XML for complete roundtrip fidelity + props.originalXml = carbonCopy(footnotePrElement); + + return props; +} + +function importFootnotePropertiesFromSettings(docx, converter) { + if (!docx || !converter || converter.footnoteProperties) return; + const settings = docx['word/settings.xml']; + const settingsRoot = settings?.elements?.[0]; + const elements = Array.isArray(settingsRoot?.elements) ? settingsRoot.elements : []; + const footnotePr = elements.find((el) => el?.name === 'w:footnotePr'); + if (!footnotePr) return; + converter.footnoteProperties = parseFootnoteProperties(footnotePr, 'settings'); +} + /** * * @param {XmlNode} node @@ -416,6 +471,12 @@ function getDocumentStyles(node, docx, converter, editor, numbering) { break; case 'w:titlePg': converter.headerIds.titlePg = true; + break; + case 'w:footnotePr': + if (!converter.footnoteProperties) { + converter.footnoteProperties = parseFootnoteProperties(el, 'sectPr'); + } + break; } }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js index ed9254362..64edf42d1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/index.js @@ -1,2 +1,2 @@ export { attrConfig as idAttrConfig } from './w-id.js'; - +export { attrConfig as customMarkFollowsAttrConfig } from './w-customMarkFollows.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-customMarkFollows.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-customMarkFollows.js new file mode 100644 index 000000000..132fb5dc5 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/attributes/w-customMarkFollows.js @@ -0,0 +1,35 @@ +// @ts-check + +/** + * Encoder for the 'w:customMarkFollows' attribute on the element. + * Maps to the 'customMarkFollows' attribute in SuperDoc. + * This attribute indicates that a custom mark (symbol) follows the footnote reference. + * + * @param {Record} attributes + * @returns {boolean|undefined} + */ +export const encode = (attributes) => { + const val = attributes?.['w:customMarkFollows']; + // Treat '1', 'true', or true as truthy + return val === '1' || val === 'true' || val === true ? true : undefined; +}; + +/** + * Decoder for the 'customMarkFollows' attribute in SuperDoc. + * Maps to the 'w:customMarkFollows' attribute in OOXML. + * + * @param {Record} attrs + * @returns {string|undefined} + */ +export const decode = (attrs) => { + // Only emit the attribute if it's truthy + return attrs?.customMarkFollows ? '1' : undefined; +}; + +/** @type {import('@translator').AttrConfig} */ +export const attrConfig = Object.freeze({ + xmlName: 'w:customMarkFollows', + sdName: 'customMarkFollows', + encode, + decode, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js index 43346d8b0..ae99f45d3 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/footnoteReference/footnoteReference-translator.js @@ -1,6 +1,6 @@ // @ts-check import { NodeTranslator } from '@translator'; -import { idAttrConfig } from './attributes/index.js'; +import { idAttrConfig, customMarkFollowsAttrConfig } from './attributes/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:footnoteReference'; @@ -45,9 +45,8 @@ export const config = { type: NodeTranslator.translatorTypes.NODE, encode, decode, - attributes: [idAttrConfig], + attributes: [idAttrConfig, customMarkFollowsAttrConfig], }; /** @type {import('@translator').NodeTranslator} */ export const translator = NodeTranslator.from(config); - diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js index 69be27c3f..96421ed7b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/hyperlink/hyperlink-translator.js @@ -53,7 +53,7 @@ const encode = (params, encodedAttrs) => { const { nodes, docx, nodeListHandler } = params; const node = nodes[0]; - let href = _resolveHref(docx, encodedAttrs); + let href = _resolveHref(docx, encodedAttrs, params.filename); // Add marks to the run nodes and process them const linkMark = { type: 'link', attrs: { ...encodedAttrs, href } }; @@ -80,17 +80,19 @@ const encode = (params, encodedAttrs) => { * @param {import('@translator').EncodedAttributes} encodedAttrs - The encoded attributes containing rId or anchor. * @returns {string|undefined} The resolved href or undefined if not found. */ -const _resolveHref = (docx, encodedAttrs) => { - const rels = docx['word/_rels/document.xml.rels']; - const relationships = rels.elements.find((el) => el.name === 'Relationships'); - const { elements } = relationships; +const _resolveHref = (docx, encodedAttrs, filename) => { + const currentFile = filename || 'document.xml'; + let rels = docx?.[`word/_rels/${currentFile}.rels`]; + if (!rels) rels = docx?.['word/_rels/document.xml.rels']; + const relationships = rels?.elements?.find((el) => el.name === 'Relationships'); + const elements = relationships?.elements || []; const { rId, anchor } = encodedAttrs; let href; if (!rId && anchor) { href = `#${anchor}`; } else if (rId) { - const rel = elements.find((el) => el.attributes['Id'] === rId) || {}; + const rel = elements.find((el) => el.attributes?.['Id'] === rId) || {}; const { attributes: relAttributes = {} } = rel; href = relAttributes['Target']; } diff --git a/packages/super-editor/src/extensions/footnote/footnote.js b/packages/super-editor/src/extensions/footnote/footnote.js index d05b64447..581953b76 100644 --- a/packages/super-editor/src/extensions/footnote/footnote.js +++ b/packages/super-editor/src/extensions/footnote/footnote.js @@ -2,16 +2,16 @@ import { Node, Attribute } from '@core/index.js'; const toSuperscriptDigits = (value) => { const map = { - '0': '⁰', - '1': '¹', - '2': '²', - '3': '³', - '4': '⁴', - '5': '⁵', - '6': '⁶', - '7': '⁷', - '8': '⁸', - '9': '⁹', + 0: '⁰', + 1: '¹', + 2: '²', + 3: '³', + 4: '⁴', + 5: '⁵', + 6: '⁶', + 7: '⁷', + 8: '⁸', + 9: '⁹', }; return String(value ?? '') .split('') @@ -106,6 +106,9 @@ export const FootnoteReference = Node.create({ id: { default: null, }, + customMarkFollows: { + default: null, + }, }; }, diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 9699f7d4e..858be1818 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1049,6 +1049,8 @@ export interface CommentReferenceAttrs extends InlineNodeAttributes { export interface FootnoteReferenceAttrs extends InlineNodeAttributes { /** Footnote id from OOXML (w:id) */ id?: string | null; + /** True when a custom mark symbol follows the reference */ + customMarkFollows?: boolean | null; } // ============================================ diff --git a/packages/super-editor/src/tests/data/basic-footnotes.docx b/packages/super-editor/src/tests/data/basic-footnotes.docx new file mode 100644 index 0000000000000000000000000000000000000000..52354feb401a30d3b395145532d238e3eb90f862 GIT binary patch literal 17152 zcmeHvWpJFy&hD6*nPO&Uj2U8vn3f8>V04Vd#QlQy{U=cFOIYFou&Otaz6M5DB9C3C24cBD@_UA zp(y3|=Xag@q0jDK5gucI1g?9l_pFUzCQvp`lIj|;FJ?;`s41_F@# zi|~Gk!*0BI=SZ1%*@Sr)ULAWQO9y(o-}?Uw?f+s){nOIF#dn+cGQjd*1iS=HwaYJe zW9G}y8%(ZXEdk9S(>q6P16299jf-xha zoR~Ersm;%}Q(CxB^=AH(#yB@`{z=0FqV%IkM=)N?IF>iGF<*5GGi_{4&w7nkpW<;s z6H}Bq^s?~Klfz9vInL0sBLas`4kqe!{B1TGPIMDV>&X5z(+w(EFw4T}$~(WfC( z3Q;a!PJJ1PIOmhkxkX)>48!TB+7QA;eK(=Y}I$!j)D* zM=6Zt5$jh<2)^$)1pX$t!-T{DHd=!*>P1pvH52~Mj`PYYD_VIr2BnJS&QxkFI4I+ z$=l}m=3vQD1>V8F4;gDMBPg6O0KdR_OPnljm7Wu9p&yt@*T6AYD4iaj3P%G9$H+^h zj1eh0J%+PILSr{TA`1QX#|wHDX^_ z4>q45y!4K)vmJ_+bP_`Oh zj}Vwp@~c{!$GZTI!wiCYkTx?fURUrrI!hgE2RCSYvr?VK0x!4MfFr#V_t~6fVmL|J zAn>=x7nR555EEjBEONliH}6F_O)OWW6P`mKn{PfVtejS-Y~d_gieo9_D7f_Q4E){+SAiFw+Jttw z?G(NcC>CJ^&Qha>m7Q363!g@v(#AFpQc0u>8rfai4PK8HO)Zz*rkuwkR8@!&t7aS1 z0OeO%O4BZ$+#MZflSY4~GztL+#p$YcvU96;50;7V+)ZzA33FkTsR}Ok*<39FuXCil z)BPzPq%3)zU+zXf*ZR$mzQ%I+Y&8IlJbSnhb<|oSrxVZpA%n!G!HA&4L7 z@D3EbF<6y%(Q=)Sbzub3F;q5Cj<%G^jAmVl;AYx`??CGvI}$~%LNV*mii?=ya9GGiMXM{65LBZuGg`)rDaO+E+e;Jbdm z@O8E6) zPW+oC)DDvLAXUqpQqWf*xO_)eey zG~KVBbtC@OE$BozX4nYxB;7yR!hl6NwZd0?$*{HP@_kLYzYC5{#%1#vMS=%9q}qnh z(C(Xu!Q8!=683U4B=bwc%%?)t*kB$ShL$&{JHRrH1N=h3xkS^*^TE>q@ zkDB3DAgEjZ$v&8D@s=8T^P!9qsa^yh%I4r(B1u(1M%V=On1HSP#P(;PNa8L6f{%RY zXuXtJ>XU|zyTTu5O1DwVh&bg@RA+0!8~SkpJ!9gKz26*%GFbR@ujB$xW^CuM&8E>| z+6LNW(*2uG)gYMhOyOeSeLy%*SK8Y zyeoXEhhCz-PO^QwDCXi_afTM1g2b%@OjLm%?b8$!e>XEpA#TQl$Qy%BB{W9 zut{1Z^N0$BW#Q9E!_JxD70ivEGcU5+isHxTF1;3)4Yg zB5f*~;q96RlfK&PavwOVb)wrGh!$2?No%3+QtxJ_Zk|8MyFFy*xLH0un!(-Xb}$&z z@@Zi_X7A{XOdLc!bJiI;{?1 zY7LtDM1U85tcA;mS`OO5)b{K=w2gO^+ORlFY1Sh~(V~6Z;N<%$%4db)_i6TS#<_?Y zg7sh+=DGQSg?_jEp{C(_IR`#?h?e>5v<+wW1<}j&Z5g7wqCyebwXuVmB0MQ=Tkg3A z)1(SiNlLH;aMhvcu{vXBIhxxzdC4KXvjfFP*R2GfzqND~x1&|N-o3zMm_J#%M%IS^ z)`WG*8a5dW@0yVOjGJKnv%_#u9@*To<>!q;r)NNg6);uD5F6p+U2U^5X_>FN8^bu` z!9iz?7ko!6-_$B6atYITLxs;yGrV5tP9$E#`Gw8~dfW*G99A%=iQ!3hnEi!I4&Pc5 zqh6|=bfN*GRDS`#2K$_PAAlke8F~(GEOs}X{0ZC++BM!V)Hhxd7I@;mG&>d-sbJJI zCKfN(9tW2zDqjyua>vq7(pQ0kmGc(XhshAkT7U%W2I*|(ruVj5ZZl*BU(G|EuP#89nCaSJiYYG` zNG@kBIU!1_Ecx?A_@1Gc?n@l&WqB$LfR2pNziLhofBwb4plaZVMYC0`>q!vqb8*Yp zlCa?+!Aay4jJ_w|JzS^TQKsR#FXddDY1*f`9G*1tos!z2*~wm!ce5#{)7sf&Rq279 zG=j$l`q`f;q)3Y>ytx?Ywj`}l({7xkz`5q&7w2&epNm^|6_?H)&A7y^xtnY)#T{K< zNGB-`ywJgmLw%9W>9h=Lj_NMU?gQ;K2b@obF?+`h9-P`4T_i17b5yO$Gxkli#)44a zR$Hxgmxg^iu&z&Ru?a6QA`?GHRJz_aV005x<>*RMdHL^0I|Gj7Y z?eITU8;ihUNA1LT;tOr&{t(ld;u^qLEA5;>dh&IbV&yt=K7EQ{xRsGmF8(upkf6Fd z<;}bhB^=A-q~u#_P$1h2F_oF+#L&FskLujqQ|x)-qx)=c_lu(=HHX{ffLLFYK8`~9 z5lSsPJl{OVtj!@CPM44X!CoNEE>mErD7kk&J=a`$xk)Iq6$7OZ=Kn6{3DJIxkv30nd6M@xNh3@*O4EHgM9Iq%&6d^cU^9E(^J9)wP~Rt;!i199KAr4;C5^ z5}jK2PvF`C!2bD5Y%R(*Eas9o*h~e66VNvE=GN_vfknmoWG%>#pLYAzA!8_UKKVM+ zp>hE0Bjr=#N@{YWCkA}jXY_-C=wry!bZ*ryApxn}c0Y+};8+!4AkK1(dQ?u?26;%` zQmxCxQ7B9BYxBI?KE3);JJ^}^(arwu0i!DBg7?8(IQCbWMmf=e9w^mMWmii5R#ZF`x_>OQ3aFK@eAd z$|E^rtr=-nm3inNLyfZI`)aFrkg}EzF6?cqIfG2+)zU`n{NQ6kL|X7(sDwbXm|28| zNUbr|dJ7;t#)&f}fP2P6eoNM2#F$7^iJ&`MikF%yq{LS?O9xv7M~do_i(C{G%6o7U zwxm!@<2LvM;9%te1`@ShUR1xR-6{VlxBv&jk7vY!0zUp6jl-+PFahV%6hD&Z&L9kt z(m#=1js~xWBpSzkvLp7|Db~OfuDQKv$ZA_53zX7X3RLtZYC$~KY(XYtXAF=5<4Nlgecc1bI z$8VI%W5_rIXEIW0E*deVKAy^&Cc1wWa*23%2A3BiBC4;}R~}L=dtZZ@+3<;&j6I)L zd)U?ZtHS7PVSPf=n2>>2vpNEZX<(AMgGU=(>Q*QFl7Yhh1D@fe#>i0L>p%mRpuSWr z_$Z=&m5pShLZ7VL0T5odQC{(ER6-WNuKuwdQmbr% zR!66y0+M2>kuWZ8ltjKEwz!gOf-qObnUE$O9UZQKHznv*-Cg8^44~BF%A8b>q*prl zM);93PRH%O=VO+Ml*3ZCk(+e?tv-)c>`-8aI1x&)9}f>(EB)fa*lYp#N272n?Aw8m z%X$eSoMt~^W0=hEA5|(OSPd{6GfRFhk>K~L9`8_n7T6Y}HK9ok_D&f^W~OtGg@BSm zAU#2-=7&YZ{jNIE;wMbm7_XvY@$=I#>sri&{S)?Ng0yF>C0e=yv1!(L>24-{B01n0@@4R$N?Iiv8uX1vhpM99vdU!+ zL`{WMKJ^TXTJ6bFOn&EDMkx@$qgd9Q2y5o8)3>E-{wu|IFiH0Pv@E4(_PJW+$&xST z!)H!yV$&2vkdkwT=YBt{Wzk1m?pu6LUc8#9>MJA7RKK>s)0ykNymsvFb#_9is_*NK z;joO1$&^phG(rSPRL)SawARbys+`Pw1ZX!do5FwK+!PL=KC^FBIAMpbS7_8Ip4AlT zoT8bf&R(dPU5WB8cri7zXuh7i|K66qaL)I)2cXF5GOEP8>wEj>$fkplqobL%$?u+T zoyvyIHV2|N-_a{X-;V-9l3YYuEdh=St)!Kip5yGSKA^H!q$L^3)|R)I0U0UN2xTJ` zFUmVl=8ecf=AE0~L2^-#7*#5whdO*sxTlY!4)TZ{S$Cb*$?HuUpRuoi<-4!ye7Z{C zuJ-RYcu!G+>cLs(K%gs-lVzW|%gg%il{$K1OQ_}b;B_fUgs>azPnb#YkH3$AmBo@_ z~g8p+ zaYZMPwFD`mrP0WSuWH$=R?V>#`H1j|&IqNoCi%=hTk&HtTHEB*?9Emfq^vNXUf?3@ zYy_*;c}uEBb1U^e1P65301nCID_EzyQj4KKn?o4NF!}@ThLwrH^T))$N&87Eer^|7 zQjdU6{D74C!+lX5Ov^dAe}Xh1R)Cs_8YORi>~hR z223_}z5dClk6DDZRi)*+E*Q*lcbU;4dZ6{)P3~<+~>nkj4E2pAsSX{-{ z^7?REc_b<6T=*x-T|D|P${S-?l#(>vKz_?Q0-Pw6cWe`lm}r-4monZKL2qD2m{?oj z%e#7Xb?B#ea%Th!oz4jr$*tExl{tdV{TPRd`Nz79IPeGZC?x{iB%gLfJkg=V0*n~= zBEEJLao8jb?6|qZrv-L1rDh4C@0jwK;_QIgM=tYaN0aWu^gCMDGSN;OM2Uo}l0hAp zkFrPFurNI`kgslG1q(#V#yMqiPX55=XB0j{rW*lbo1&o%Ek$ptvuF{tU%6_)zW#=~ ze^5l6k~XONN-CnodZ?XOO6gUEEG%y!-qb-pI;@mco`n`tv;$h|cJ(N~%;R*lM2;;n z93AUnC^>zsf9|E#NGiFvoQR{HTWVg)wgNuGOS+N;FO_Syh;~_G!M^0Ys4o-&hcjv| zXDtK1e3q90mvt&k?^sinAR>eARI@m|EyHGC6Bl=;DhzGLK*{3Hc0;dD z*y@jDl%}g&KclrWr#an3io-+b4;-{#dta+m5lMrFE=Ch@S+7mwwL@x~D6a?_5b_(O z5lUYCM>rLlOc_nE*$X$wN5nD5jfpR~J`JvOLEC2;bdM6aWRlz{$bU#!A)7lHSbG$m(~yn;?$_K=I|>GXKA{Z?V$w z{tSqsm4fboa5_~)Z~Z(CMnXHC?*Qp#tZ8^~Yn!=4C{3AtFZv(SOP1qg2$$W6%G+t`yCz-LoU)Kd+ z1at&UtvO8_mVDk$WGR?DS^T21V23PZ39*8OV{pM6lGU;!vz-vzfv+@(@G;lrsI4Yg zphX%WPHb5;+O>Ih>rY95-*og~7-gtSgL^XedG1kY5ahm_%MDH;e^IVldjhtD&PZdD z&BC_DM&_Y;h|>BRuU}1cq(=JQN@0J)H*dL*IlCLP$CIEhLf-tm(K(q6-s6M?0cnlj za)X+1LYYQAo!?np0kK-Rz2gY#n04afF5>isxMB*u=+&aR?D^~I<^zv6a0&IbSp9V` z^)deYF8$~JeV;%zZ$bnBOe+C^-(0=lL%0r(ZkGRu-=1n|Ijpdudu^n>AmDziM=0Sl z9EmQn9(3N8E)-8!mU)Jai|tP*swY7ke7TLFSNK9FaOKZladK`*mQ1lO1USf)1pMr} z=kr!0>o&>8uuIO4f$vzs2jhXOrI4|i5X-Ty(8-MU&oeaZi>KK;$cNC5^!~ZE@%Hrk z`i%k~SsN6XfOb52>(Nv`xvaLmok;VNl)7U?nFoz;yw-DA;)=C%O{3#L!m?lmvPlIMqzpGxQzDLk}FKefkE{89`0iL}wsmc8)no@ru~-O#V| z5Rw-BsfcZx_Mr(;OQZ~ZAry^5QAbD3+^3o>2buq$kA_AlYU%^7`%o*;4 zzeb^mab$|W_vq0&dNjVKQT`mbDtoa$hD||A!bWvn-AcG;!>YSde;P(5kQW4{<)TVy_}rDP#J)qbQF8 zb;}4nE%N9c+YSvf-vzzL`1be99t&SJP0Z9l?@hiz?mT!p#tlkcy7&yJ$!nuBwjwDZ z?x_yseX^mOCQ9a`+l8<=sN%q1>ywNl=e>O1T(BIT-pm-GafhrZ+=#y0^*eXX4*$r< zJhdL;{`tzJeXDrco^)h-F}dodVR>K%Bd5^$=$mvc2eOF%W5iJqG!F-tpQX}mhrV@e9u@hEWt??tQWvDb={3mo zA6yL!Pi3M`ozN5N4EIrZOQ-Fg1i;Fze%l%AR(kNEt^%3qvbus9-(4c)SPYiy7Zbq! z9@o*YN^d*0gYx2qf&-7Xqg3Ik2NEYwBAL{4=k6flG(Nx*XmLA}k z4mSj6iUYnj*A7=(aEIfY|9~9_P}B}07xrMT0ZX({A!H^Kj22a0AFp$9JC4geXAgqQ zJ!SWMht-AP;?i^9yOy=_V(U`mz_+@#wDSH{&A`rah4Q8HA`LA>t7W#4QF;1IZ3H^dwgi7Q}oR3 zCAifW`LwbD;u@7GF~cfso2LHt2E37xxc$Jf%9=WbeDFdU{XH_=mzu}uxX%qv!STeT_;w7VzF?02J z(I4I>`EnxA*6j91*EFnbpGt`n41UQ(Jz^;yY-SAH`It4)Q9!vFgb7-NAY9*0kp4kI zSB0@#T$I=d2`T>2c)(1`9$v|n?3*PFbwIGPKD~3Qd zr3`{XCuovY7hI(!NOWn93KVttq8rp4PE2VF+6O1E5QJA)?PSA8&#XySU9eB{{%8bh zD8|F?Y3PZp%C3QE%C57wq=}I|#HsP0UEhXxLL*_pMEe7Q^YaA!zFhMoIjoRr`OVhQrCgQ6jUL1ziO;vdM&|`42#)t zN*RsGBM8#&N#F*PKa@h$9pl9QwR{rI9J?I~|98&gz^dBvt>RCwSrbFyWYrWSA`Hae z<4HzB_vhKjanY9AT&z@5-7^hrk=}og-3Xd6*Yn~8Zso|7B5L$>Gk4qO%7wav)(2;K z@EMVhD>l7ScP^sJAa|mR=O7LBk9|C&)7W>}Vpr~JXsEnWj9B*w%Hh_aDhNZ4pf>Kv zQA0b~ahItHYjGLc_Qcm;O-Ok5%_ctW&4?PN;A>a^tWbg^*Lo@$bcMglax5u^xBh6E z5_>$v29^E(NiS)W2zGA{cu0nCqG{(jZ4fWUE)ECGfSg^zMyHvu1@?~m4R?f@o6M|q zXU;{|x2=+Es-5Y8{zPhGg`JM$)~EQS{E(vn3_;y);Duh3wI;F^&!Y&3UHGDHC-^YS zcNMXnDUKO8DSnlr-5~)d&n4bo=u0gc5AkLVTI8S+HReS^IYGE=1NJ10tq`{$KOz5` zlkMeU(v%`k2)$g42yCSY>Bm-jOb^fSe}f?c8z~~58!3kRvrz@|~6eXht*7an0DrH|N%Iy?WzqI*D?&@-BasZMKO|QHhFO`OO50uJ)A9)a%mr z+W^iB|F#;kiFx?4o!Z)fQlsz}I?q?nxtGA0DBjBg$$d3Qr!iDt{M#r{2P!eG_(tMx zf{S9wBWIFBrw>j>I^4*a;4v-n71u^ipTjxu{5C6C48ZU?dRlAh(cm+hsKqq=s)AaX zYSAEZnyB$L{09A`##g2WKdzW5Z0JBEw(Ob`W;P+U7(z9dPBXHI>P)XfdM!B+-xN$m z`EJiZ1(46f1sN~G?TNA0VI`aUA;G{%2?ZISPliUjC2n<7MD!;8oH8IQ~A9vt%GDnd{%(s^X5Qq}5+LnfJe>;E(Q} zh=j})Y}Rr3n(2#&&MJ;FjRf1zy}+F5OboT_G%+A6rHx$kW|s}sR|);Hdf@)o0#oKk zaK{t7f=``5^MF#k4bfXBB_`Em5{47cw6W5zefx6WJm^)sV(e8-YF@x)Mt}J%lCjk6 zvB?tl=4Oc}qyC69WvID_Q)(I5$ZaJ~iSjchv-PpPPcnv`;!9iS+wu10<+Nt8)=|OI zIfsrH;-JdPE=D&3{cNKkA3t@E$#s@~9@qfN?I~JD3J)KC7WRViQ@Qj7FZpd8tkXVb zd0Cf|WJfjMH`0xUg@6RU4z`uu3v=<)RUf|={=|BiWnI(2^@2iB%pCI zsYjn$we>_IU%E=x9%G>9WBuB8-^_Jm+OvJ)Y@s4oDd;?;2|a`>OUsgbi_~lCk&`b) zZKb1^qr0qZhAY?8E8yQPP7_m_*8)VfcT9*XONXC_%BpoqCA6c424fd;kIq<=I~Ct& zoeWXZ3!z7~MQ1m)$yhll6+su%88sK>R;a%D6j-n*T7W1yo+`#Q^F@k^3{D7%979eJ zi^0uCX=gV`vl|CW=VuI~EJSHgedm?s-NNlmd}|0IR*7C7+9yk;p#&d22D(iby|zcU7wICF zHW%3>zvV1{Yy8pd997Q-ExA^BM99-MLjq}iWT~g+>l8*|Zr_CpkN7%syGw)pMfzoP zUHrqXu1!Rws$X9PT2{IjBmTK-pzt_)*%?D!3!YHK}KCW($viQmStr!`MhMy zRx7kH?K@t>$U>^kI$gmdBL^f~vl;bL{Zm)cc4SZ1(O6d1D_?zNq2dH^E5%2WyxCEs zyN?DReS$+q&xx?+QL5>J2ZdoXns+KkFZm4p(T1zCVVoE}h5M?u4lc$n4P@5k((n%3}GE`0eAoW0AMs2pgZf1rF_^n@mt0VY3nN zC~UO6Luy6@o(8*TpuJi${a!k$aS-Ghi|nRxo^+g2y|&_Q$Bx@tQ-*+O{obM@;;CN9 zy6C|yoJctnrn$VNr1f3SK5F-l!@TvhyWNA8|1mzrH+4U!mbuaW+|~4olqaFguIHb} z_%sLmJ0zQ*BQx4d(|^xR8cMJm7OJ8 z!DMfpjvufNi$Avr$wI&-oXVXh{O~e~UTw>mpYzT57;HRz%tc2fIesHAZGlEqLZV_R zGUNP3Qf=+>BuTuo_M21?|Ezz;N;xMxANVy%BXh#?a;;t~lkuBIm~%t(T)uU4v!G3W zEmQHP^NJF8ae-8bdUH2&1?5|~Gf4R^n@;JBUvIo8ANL5a0 zSXsxS$jI~cB#;?!gIF>$J-sTxV@T|>vAR*QhlYW0 z)#%PI_MMb_pFr9Wd|F5-28_}VzTR|vr*LWm9a}}DX3(qQFH1wvYRyewI;+68okja$ zPn&cD!6h?;WrpPu;V(kS$wRzh#?UcbgszA<)uf<&{&lX=lvQ0`ZFf^sHAY!Xmf384 z)YKl#Y?zjd<|hM9VR><`;1h)vQTeN{8K15v*eJ*8GgeHwfROP*TwV?{H@AXg<{R1! zP+tWGqc_JKQby`}b>?N>>0s7)=z!+F+OVb@C3`>Mx0e){csL`j$q&=Mp3iTdy!6248?Z!TZac z5u(%5+eI!4WcBgWbJm@MOrjbgZ4ucXir8!rlCGGqkwS;(g?#sAc^ZBkdJMTfTN4;I z9i)ZKH?iv!+YYrbX3Ax+eHbx!V+|v=RWwn=9y77)C+x`R^m1s;*%##wWzgwWtX9+H zP?1RhRqaL#%BE}{=w4M7!RuI-&MWp>PgWT%j_0aIS7@RVhr$|!8|Vnszt&`U)m>D! zI}`NQZx+s*k_$zSu6vO*^)RTN#vTBwGA6ai&J)aGc-aE`1Z51Dx_VocYAgm6bmWCh zL^|Os&(7}-gvsoEIFQN`3Cg^J8|SnWDT^Cd&r!J|1ruw`;eSEV%?_~p^DF7w&;*C+?vM51w_5)QM`DwBZ?!rfGqAxMa-ir4|wLj z8{`ulLJ+ubU=g76@~s%lpl4r}WmSTF(gjNrpetePdgiFfSLI2sBQRhhS+96vk3`{% zLz@M@4|OLh*~_FNEX#-X0FpyU?5W-^zM!BBC7l%_VB@F?VomFh35>n5YmT%i;ETAB zZ_bWHkBWb)?{S);2uKiY>j5TjyR(3Y$(+H}WG*@ByvcP0_dW)BjNtw1U3RqlbShN) z(jA15d#v+pJ2MrMB!`28X*F;9`kk@w2>p#VRn*=^?>#@cf=F(sP~zv$t2$enJBjC-3`C>Eg6&cBTz zn{1eVw0&0(XYZp(@43?N3DUm@j_D0-?0;vz_gbF+CP=>vZe+ZcY!3r!@P+hG1j&oW z3Ig+FD-zQtWCJ469W5&axuxYoTG-e2wSnLWO;;D*>>qbGRx?MrH2xnksS`&L!}L*o z4Z~rY$0t|pKdk3>aZz#7CI-Udb?2FRdnGYtF&J(3Q@{;CRr#sJv*~dO;T$;nR~z9_ zltl3mdN~wGiD$`jegL+=PMYPQ7@uVw)psa+Zv;%(ojPkSE)!nKB8(PhyAFxMh5{6RY z%hP`_Ddv2C3pZN7$9u)d%%8>dSgX6D3obgHr`O_s_JAIp#I*W!B(Tp2pNy58bd`*pt-)x7|CN4!*M~;-A@6 z&(`)g75QH-eP_}4mZ>oETN-~G@fCvndHD>qk}sQRF{IrlDXY~)a=SPU?n(`u`CGO$ z2w^A=@7buk7_&w1ST6GtL~wBy?T`MpA?CViN~;W}4dwgV5uONfRno{>)qSzZM>syd zfQ3z-;IE9vBzgvEjg*fT19_I!D0VVdl3B|^G9>=FdKDG*eca?5+;w5J5&Hd?v6EDa z%zV)M0o%7?PnE^?HTM`Ub5jW)LZG>Agauz$Wz(DOOF-CaoEin|zH!NBw7kb-2(_&OE7(gdsSHg%|B=p#9Guyie4M)c&3YJBjP?qC$%;CmjE zM?)~WpCMzG;n{-yEy1Qhw^rzFi2b8b!50#<7J7t1?}WORowQZs4GcuwZR}2{T^1_0 zEOyyBhnp5k)?5jPd#=sXou_@_s*8tS4|Pd4{j!NuwX=<_0*;@@H$*!-*DmQF^;zi; zGtuwH4o`T$THdR3u61$Sncif-NR4WiX;@Znn{}Ur6nEND=n-EZ^E5B%;Mw zTV(pB#9R&&4~|y`-23^(93;GdpqmbLstXURVeLT7qpx2A>gp7wEKit?A>yk|`O+9B zjY=Xz^2jGnRZ4?w$C`6X@>w0H_W8$7ptXb&_y9joOl-NAk6YYqQTZ-S#4b>ndm=&3 z80gSNRX+`>qDmXx1Q|EMdN)c-!l7C$ZfAVf-s}7R!399TwD0Ei-xs$3Yn}gk|C=&* zImy2g_-k?Pzkt8Jx4%E~Po=iM0)MSK`~$l7p0fJ;4d*NuH>&2e)Vqtpa91DPYTBG|McPCzRs`szuKpN zpaFmYb^zc%tkqxPf3+L_45#Ay6a1f6#INYTs`Edir+EJS3;dmOmy-m07r5W76+{5a MyYIcg``f4g1ETH=0ssI2 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js b/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js new file mode 100644 index 000000000..9e541d0f0 --- /dev/null +++ b/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js @@ -0,0 +1,700 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'node:url'; +import { promises as fs } from 'fs'; +import { Editor } from '@core/Editor.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { initTestEditor } from '../helpers/helpers.js'; +import { createFootnoteElement, prepareFootnotesXmlForExport } from '@converter/v2/exporter/footnotesExporter.js'; +import { importFootnoteData } from '@converter/v2/importer/documentFootnotesImporter.js'; +import { carbonCopy } from '@core/utilities/carbonCopy.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DOCX_FIXTURE_NAME = 'basic-footnotes.docx'; + +// ============================================ +// Helper Functions +// ============================================ + +const findFootnotesRoot = (json) => { + if (!json?.elements?.length) return null; + if (json.elements[0]?.name === 'w:footnotes') return json.elements[0]; + return json.elements.find((el) => el?.name === 'w:footnotes') || null; +}; + +const findFootnoteById = (footnotesRoot, id) => + footnotesRoot?.elements?.find((el) => el?.name === 'w:footnote' && String(el.attributes?.['w:id']) === String(id)) || + null; + +const findFootnotesByType = (footnotesRoot, type) => + footnotesRoot?.elements?.filter((el) => el?.name === 'w:footnote' && el.attributes?.['w:type'] === type) || []; + +const collectFootnoteIds = (footnotesRoot) => + footnotesRoot?.elements + ?.filter((el) => el?.name === 'w:footnote') + ?.map((el) => el.attributes?.['w:id']) + ?.filter((id) => id != null) || []; + +const hasFootnoteRef = (node) => { + if (!node) return false; + if (node.name === 'w:footnoteRef') return true; + const children = Array.isArray(node.elements) ? node.elements : []; + return children.some((child) => hasFootnoteRef(child)); +}; + +const extractTextContent = (node) => { + if (!node) return ''; + if (node.name === 'w:t' && node.elements?.[0]?.text) { + return node.elements[0].text; + } + const parts = []; + const elements = Array.isArray(node.elements) ? node.elements : []; + elements.forEach((child) => { + parts.push(extractTextContent(child)); + }); + return parts.join(''); +}; + +const findContentTypes = (files) => { + const entry = files.find((f) => f.name === '[Content_Types].xml'); + return entry ? parseXmlToJson(entry.content) : null; +}; + +const hasContentTypeOverride = (contentTypesJson, partName) => { + const types = contentTypesJson?.elements?.find((el) => el.name === 'Types'); + return types?.elements?.some((el) => el.name === 'Override' && el.attributes?.PartName === partName) || false; +}; + +const findDocumentRels = (files) => { + const entry = files.find((f) => f.name === 'word/_rels/document.xml.rels'); + return entry ? parseXmlToJson(entry.content) : null; +}; + +const hasFootnotesRelationship = (relsJson) => { + const rels = relsJson?.elements?.find((el) => el.name === 'Relationships'); + return ( + rels?.elements?.some( + (el) => + el.name === 'Relationship' && + el.attributes?.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes', + ) || false + ); +}; + +const findSettingsXml = (files) => { + const entry = files.find((f) => f.name === 'word/settings.xml'); + return entry ? parseXmlToJson(entry.content) : null; +}; + +const findFootnotePrInSettings = (settingsJson) => { + const root = settingsJson?.elements?.[0]; + return root?.elements?.find((el) => el?.name === 'w:footnotePr') || null; +}; + +const findSectPr = (documentJson) => { + const body = documentJson?.elements?.[0]?.elements?.find((el) => el?.name === 'w:body'); + return body?.elements?.find((el) => el?.name === 'w:sectPr') || null; +}; + +const findFootnotePrInSectPr = (sectPr) => { + return sectPr?.elements?.find((el) => el?.name === 'w:footnotePr') || null; +}; + +// ============================================ +// Test Suite +// ============================================ + +describe('footnotes import/export roundtrip', () => { + let editor; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + // ------------------------------------------ + // Roundtrip Tests + // ------------------------------------------ + + describe('roundtrip preservation', () => { + it('preserves footnote content through import → export cycle', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + // Get original footnotes + const originalZipper = new DocxZipper(); + const originalFiles = await originalZipper.getDocxData(docxBuffer, true); + const originalFootnotesEntry = originalFiles.find((f) => f.name === 'word/footnotes.xml'); + expect(originalFootnotesEntry).toBeDefined(); + + const originalFootnotesJson = parseXmlToJson(originalFootnotesEntry.content); + const originalRoot = findFootnotesRoot(originalFootnotesJson); + expect(originalRoot).toBeDefined(); + + const originalIds = collectFootnoteIds(originalRoot); + const regularFootnoteIds = originalIds.filter((id) => { + const fn = findFootnoteById(originalRoot, id); + const type = fn?.attributes?.['w:type']; + return !type || (type !== 'separator' && type !== 'continuationSeparator'); + }); + expect(regularFootnoteIds.length).toBeGreaterThan(0); + + // Import and export + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + // Verify footnotes were imported + expect(editor.converter.footnotes).toBeDefined(); + expect(editor.converter.footnotes.length).toBeGreaterThan(0); + + // Export + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + expect(exportedBuffer?.byteLength || exportedBuffer?.length || 0).toBeGreaterThan(0); + + // Verify exported footnotes + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedFootnotesEntry = exportedFiles.find((f) => f.name === 'word/footnotes.xml'); + expect(exportedFootnotesEntry).toBeDefined(); + + const exportedFootnotesJson = parseXmlToJson(exportedFootnotesEntry.content); + const exportedRoot = findFootnotesRoot(exportedFootnotesJson); + expect(exportedRoot).toBeDefined(); + + // Verify all regular footnotes are present + regularFootnoteIds.forEach((id) => { + const exportedFn = findFootnoteById(exportedRoot, id); + expect(exportedFn).toBeDefined(); + expect(exportedFn.attributes?.['w:id']).toBe(id); + }); + }); + + it('preserves footnoteReference nodes in document body', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + // Count footnoteReference nodes in editor + let footnoteRefCount = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === 'footnoteReference') { + footnoteRefCount++; + } + }); + expect(footnoteRefCount).toBeGreaterThan(0); + + // Export and verify + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const documentEntry = exportedFiles.find((f) => f.name === 'word/document.xml'); + expect(documentEntry).toBeDefined(); + + const documentJson = parseXmlToJson(documentEntry.content); + const documentXml = documentEntry.content; + + // Verify footnoteReference elements exist in exported XML + expect(documentXml).toContain('w:footnoteReference'); + }); + }); + + // ------------------------------------------ + // Separator Footnotes Tests + // ------------------------------------------ + + describe('separator footnotes', () => { + it('preserves separator footnotes (w:type="separator") through roundtrip', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + // Get original separators + const originalZipper = new DocxZipper(); + const originalFiles = await originalZipper.getDocxData(docxBuffer, true); + const originalFootnotesEntry = originalFiles.find((f) => f.name === 'word/footnotes.xml'); + const originalFootnotesJson = parseXmlToJson(originalFootnotesEntry.content); + const originalRoot = findFootnotesRoot(originalFootnotesJson); + + const originalSeparators = findFootnotesByType(originalRoot, 'separator'); + const originalContinuationSeparators = findFootnotesByType(originalRoot, 'continuationSeparator'); + + // Import and export + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + // Verify separators were imported + const importedSeparators = editor.converter.footnotes.filter( + (fn) => fn.type === 'separator' || fn.type === 'continuationSeparator', + ); + expect(importedSeparators.length).toBe(originalSeparators.length + originalContinuationSeparators.length); + + // Export + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedFootnotesEntry = exportedFiles.find((f) => f.name === 'word/footnotes.xml'); + const exportedFootnotesJson = parseXmlToJson(exportedFootnotesEntry.content); + const exportedRoot = findFootnotesRoot(exportedFootnotesJson); + + // Verify separators preserved + const exportedSeparators = findFootnotesByType(exportedRoot, 'separator'); + const exportedContinuationSeparators = findFootnotesByType(exportedRoot, 'continuationSeparator'); + + expect(exportedSeparators.length).toBe(originalSeparators.length); + expect(exportedContinuationSeparators.length).toBe(originalContinuationSeparators.length); + }); + }); + + // ------------------------------------------ + // Content Types Tests + // ------------------------------------------ + + describe('content types', () => { + it('includes footnotes.xml override in [Content_Types].xml', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + + const contentTypes = findContentTypes(exportedFiles); + expect(contentTypes).toBeDefined(); + expect(hasContentTypeOverride(contentTypes, '/word/footnotes.xml')).toBe(true); + }); + + it('includes footnotes relationship in document.xml.rels', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + + const rels = findDocumentRels(exportedFiles); + expect(rels).toBeDefined(); + expect(hasFootnotesRelationship(rels)).toBe(true); + }); + }); + + // ------------------------------------------ + // w:footnoteRef Marker Tests + // ------------------------------------------ + + describe('w:footnoteRef marker', () => { + it('includes w:footnoteRef in regular footnote content', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedFootnotesEntry = exportedFiles.find((f) => f.name === 'word/footnotes.xml'); + const exportedFootnotesJson = parseXmlToJson(exportedFootnotesEntry.content); + const exportedRoot = findFootnotesRoot(exportedFootnotesJson); + + // Find regular footnotes (not separators) + const regularFootnotes = + exportedRoot?.elements?.filter((el) => { + if (el?.name !== 'w:footnote') return false; + const type = el.attributes?.['w:type']; + return !type || (type !== 'separator' && type !== 'continuationSeparator'); + }) || []; + + expect(regularFootnotes.length).toBeGreaterThan(0); + + // Each regular footnote should have w:footnoteRef + regularFootnotes.forEach((fn) => { + expect(hasFootnoteRef(fn)).toBe(true); + }); + }); + }); +}); + +// ============================================ +// Unit Tests for Export Functions +// ============================================ + +describe('footnotesExporter unit tests', () => { + describe('createFootnoteElement', () => { + it('returns original XML for separator footnotes', () => { + const separatorXml = { + type: 'element', + name: 'w:footnote', + attributes: { 'w:id': '-1', 'w:type': 'separator' }, + elements: [{ name: 'w:p', elements: [] }], + }; + + const footnote = { + id: '-1', + type: 'separator', + originalXml: separatorXml, + content: [], + }; + + const result = createFootnoteElement(footnote, {}); + expect(result).toBeDefined(); + expect(result.attributes['w:type']).toBe('separator'); + expect(result.attributes['w:id']).toBe('-1'); + }); + + it('creates footnote element with translated content', () => { + const footnote = { + id: '1', + type: null, + originalXml: { + type: 'element', + name: 'w:footnote', + attributes: { 'w:id': '1' }, + elements: [ + { + name: 'w:p', + elements: [ + { name: 'w:r', elements: [{ name: 'w:footnoteRef' }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Test' }] }] }, + ], + }, + ], + }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] }], + }; + + const exportContext = { + editor: { schema: {}, extensionService: { extensions: [] } }, + editorSchema: {}, + converter: {}, + }; + + const result = createFootnoteElement(footnote, exportContext); + expect(result).toBeDefined(); + expect(result.name).toBe('w:footnote'); + expect(result.attributes['w:id']).toBe('1'); + }); + + it('does not add w:footnoteRef if original did not have one (custom mark)', () => { + // Simulate a custom mark footnote - original has no w:footnoteRef + const originalXmlNoFootnoteRef = { + type: 'element', + name: 'w:footnote', + attributes: { 'w:id': '1' }, + elements: [ + { + name: 'w:p', + elements: [ + // No w:footnoteRef here - this is a custom mark footnote + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Custom' }] }] }, + ], + }, + ], + }; + + const footnote = { + id: '1', + type: null, + originalXml: originalXmlNoFootnoteRef, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Custom' }] }], + }; + + const exportContext = { + editor: { schema: {}, extensionService: { extensions: [] } }, + editorSchema: {}, + converter: {}, + }; + + const result = createFootnoteElement(footnote, exportContext); + expect(result).toBeDefined(); + + // Should NOT have w:footnoteRef since original didn't have one + expect(hasFootnoteRef(result)).toBe(false); + }); + }); + + describe('prepareFootnotesXmlForExport', () => { + it('returns unchanged xml when no footnotes', () => { + const convertedXml = { 'word/document.xml': {} }; + const result = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter: {}, + convertedXml, + }); + + expect(result.updatedXml).toEqual(convertedXml); + expect(result.relationships).toEqual([]); + }); + + it('creates footnotes.xml when footnotes exist', () => { + const footnote = { + id: '1', + type: null, + originalXml: { + type: 'element', + name: 'w:footnote', + attributes: { 'w:id': '1' }, + elements: [ + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:footnoteRef' }] }], + }, + ], + }, + content: [{ type: 'paragraph', content: [] }], + }; + + const result = prepareFootnotesXmlForExport({ + footnotes: [footnote], + editor: { schema: {} }, + converter: {}, + convertedXml: {}, + }); + + expect(result.updatedXml['word/footnotes.xml']).toBeDefined(); + expect(result.relationships.length).toBeGreaterThan(0); + expect(result.relationships[0].attributes.Target).toBe('footnotes.xml'); + }); + }); +}); + +// ============================================ +// Unit Tests for Import Functions +// ============================================ + +describe('documentFootnotesImporter unit tests', () => { + describe('importFootnoteData', () => { + it('imports regular footnotes with content', () => { + const footnotesXml = parseXmlToJson( + '' + + 'Test content' + + '', + ); + + const docx = { 'word/footnotes.xml': footnotesXml }; + const converter = {}; + const editor = { emit: () => {} }; + + const result = importFootnoteData({ docx, editor, converter }); + + expect(result.length).toBe(1); + expect(result[0].id).toBe('1'); + expect(result[0].content.length).toBeGreaterThan(0); + }); + + it('preserves separator footnotes with originalXml', () => { + const footnotesXml = parseXmlToJson( + '' + + '' + + '' + + '', + ); + + const docx = { 'word/footnotes.xml': footnotesXml }; + const converter = {}; + const editor = { emit: () => {} }; + + const result = importFootnoteData({ docx, editor, converter }); + + expect(result.length).toBe(2); + + const separator = result.find((fn) => fn.type === 'separator'); + expect(separator).toBeDefined(); + expect(separator.originalXml).toBeDefined(); + expect(separator.content).toEqual([]); + + const continuation = result.find((fn) => fn.type === 'continuationSeparator'); + expect(continuation).toBeDefined(); + expect(continuation.originalXml).toBeDefined(); + }); + + it('preserves originalXml for regular footnotes', () => { + const footnotesXml = parseXmlToJson( + '' + + 'Test' + + '', + ); + + const docx = { 'word/footnotes.xml': footnotesXml }; + const converter = {}; + const editor = { emit: () => {} }; + + const result = importFootnoteData({ docx, editor, converter }); + + expect(result.length).toBe(1); + expect(result[0].originalXml).toBeDefined(); + expect(result[0].originalXml.attributes?.['w:customAttr']).toBe('value'); + }); + }); +}); + +// ============================================ +// customMarkFollows Tests +// ============================================ + +describe('customMarkFollows attribute', () => { + it('imports footnoteReference with customMarkFollows attribute', async () => { + // Create minimal docx with customMarkFollows + const documentXml = parseXmlToJson( + '' + + '' + + '*' + + '' + + '', + ); + + const footnotesXml = parseXmlToJson( + '' + + 'Custom mark footnote' + + '', + ); + + const docx = { + 'word/document.xml': documentXml, + 'word/footnotes.xml': footnotesXml, + }; + + // Import using createDocumentJson + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + const result = createDocumentJson(docx, converter, editor); + expect(result).toBeTruthy(); + + // Find footnoteReference node + let foundCustomMark = false; + const walk = (node) => { + if (!node) return; + if (node.type === 'footnoteReference' && node.attrs?.customMarkFollows === true) { + foundCustomMark = true; + } + if (Array.isArray(node.content)) { + node.content.forEach(walk); + } + }; + walk(result.pmDoc); + + expect(foundCustomMark).toBe(true); + }); +}); + +// ============================================ +// w:footnotePr Properties Tests +// ============================================ + +describe('w:footnotePr properties', () => { + it('parses footnotePr from settings.xml', async () => { + const settingsXml = parseXmlToJson( + '' + + '' + + '' + + '' + + '' + + '', + ); + + const documentXml = parseXmlToJson( + '' + + 'Test' + + '', + ); + + const docx = { + 'word/document.xml': documentXml, + 'word/settings.xml': settingsXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.footnoteProperties).toBeDefined(); + expect(converter.footnoteProperties.numFmt).toBe('lowerRoman'); + expect(converter.footnoteProperties.numStart).toBe('1'); + expect(converter.footnoteProperties.source).toBe('settings'); + }); + + it('parses footnotePr from sectPr', async () => { + const documentXml = parseXmlToJson( + '' + + '' + + 'Test' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + ); + + const docx = { + 'word/document.xml': documentXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.footnoteProperties).toBeDefined(); + expect(converter.footnoteProperties.numRestart).toBe('eachPage'); + expect(converter.footnoteProperties.pos).toBe('beneathText'); + expect(converter.footnoteProperties.source).toBe('sectPr'); + }); + + it('preserves footnotePr originalXml for roundtrip', async () => { + const settingsXml = parseXmlToJson( + '' + + '' + + '' + + '' + + '' + + '', + ); + + const documentXml = parseXmlToJson( + '' + + 'Test' + + '', + ); + + const docx = { + 'word/document.xml': documentXml, + 'word/settings.xml': settingsXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.footnoteProperties).toBeDefined(); + expect(converter.footnoteProperties.originalXml).toBeDefined(); + expect(converter.footnoteProperties.originalXml.name).toBe('w:footnotePr'); + + // Verify unknown elements are preserved in originalXml + const unknownEl = converter.footnoteProperties.originalXml.elements?.find((el) => el?.name === 'w:unknownElement'); + expect(unknownEl).toBeDefined(); + }); +});