From 1f6ac31816dc5218cd6db370c0d060682ab20c11 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 30 Jan 2026 21:22:51 +0200 Subject: [PATCH 1/8] fix: normalize bookmarks in tables --- .../v2/importer/docxImporter.js | 155 ++++++++++++++++++ .../v2/importer/docxImporter.test.js | 56 ++++++- 2 files changed, 210 insertions(+), 1 deletion(-) 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 ae34942c3c..8d59f92734 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 @@ -167,6 +167,7 @@ export const createDocumentJson = (docx, converter, editor) => { // Safety: drop any inline-only nodes that accidentally landed at the doc root parsedContent = filterOutRootInlineNodes(parsedContent); + parsedContent = normalizeTableBookmarksInContent(parsedContent, editor); collapseWhitespaceNextToInlinePassthrough(parsedContent); const result = { @@ -841,6 +842,160 @@ export function filterOutRootInlineNodes(content = []) { return result; } +/** + * Normalize bookmark nodes that appear as direct table children. + * Moves bookmarkStart/End into the first/last cell textblock of the table. + * + * @param {Array<{type: string, content?: any[], attrs?: any}>} content + * @param {Editor} [editor] + * @returns {Array} + */ +export function normalizeTableBookmarksInContent(content = [], editor) { + if (!Array.isArray(content) || content.length === 0) return content; + + return content.map((node) => normalizeTableBookmarksInNode(node, editor)); +} + +function normalizeTableBookmarksInNode(node, editor) { + if (!node || typeof node !== 'object') return node; + + if (node.type === 'table') { + node = normalizeTableBookmarksInTable(node, editor); + } + + if (Array.isArray(node.content)) { + node.content = normalizeTableBookmarksInContent(node.content, editor); + } + + return node; +} + +function normalizeTableBookmarksInTable(tableNode, editor) { + if (!tableNode || tableNode.type !== 'table' || !Array.isArray(tableNode.content)) return tableNode; + + const leading = []; + const trailing = []; + let seenRow = false; + + const rows = tableNode.content.filter((child) => child?.type === 'tableRow'); + if (!rows.length) return tableNode; + + const updatedRows = rows.slice(); + let rowCursor = 0; + + const normalizedContent = tableNode.content.reduce((acc, child) => { + if (child?.type === 'tableRow') { + acc.push(updatedRows[rowCursor] ?? child); + rowCursor += 1; + seenRow = true; + return acc; + } + + if (isBookmarkNode(child)) { + if (seenRow) { + trailing.push(child); + } else { + leading.push(child); + } + return acc; + } + + acc.push(child); + return acc; + }, []); + + if (leading.length) { + updatedRows[0] = insertInlineIntoRow(updatedRows[0], leading, editor, 'start'); + } + if (trailing.length) { + const lastIndex = updatedRows.length - 1; + updatedRows[lastIndex] = insertInlineIntoRow(updatedRows[lastIndex], trailing, editor, 'end'); + } + + // Rebuild content with updated rows (preserve any non-row nodes in place). + let updatedRowIndex = 0; + const rebuiltContent = normalizedContent.map((child) => { + if (child?.type === 'tableRow') { + const replacement = updatedRows[updatedRowIndex] ?? child; + updatedRowIndex += 1; + return replacement; + } + return child; + }); + + return { + ...tableNode, + content: rebuiltContent, + }; +} + +function insertInlineIntoRow(rowNode, inlineNodes, editor, position) { + if (!rowNode || !inlineNodes?.length || !Array.isArray(rowNode.content)) return rowNode; + + const targetIndex = position === 'end' ? rowNode.content.length - 1 : 0; + const targetCell = rowNode.content[targetIndex]; + const updatedCell = insertInlineIntoCell(targetCell, inlineNodes, editor, position); + + if (updatedCell === targetCell) return rowNode; + + const nextContent = rowNode.content.slice(); + nextContent[targetIndex] = updatedCell; + return { ...rowNode, content: nextContent }; +} + +function insertInlineIntoCell(cellNode, inlineNodes, editor, position) { + if (!cellNode || !inlineNodes?.length) return cellNode; + + const content = Array.isArray(cellNode.content) ? cellNode.content.slice() : []; + let targetIndex = -1; + + if (position === 'end') { + for (let i = content.length - 1; i >= 0; i -= 1) { + if (isTextblockNode(content[i], editor)) { + targetIndex = i; + break; + } + } + } else { + for (let i = 0; i < content.length; i += 1) { + if (isTextblockNode(content[i], editor)) { + targetIndex = i; + break; + } + } + } + + if (targetIndex === -1) { + const paragraph = { type: 'paragraph', content: inlineNodes }; + if (position === 'end') { + content.push(paragraph); + } else { + content.unshift(paragraph); + } + return { ...cellNode, content }; + } + + const targetBlock = content[targetIndex] || { type: 'paragraph', content: [] }; + const blockContent = Array.isArray(targetBlock.content) ? targetBlock.content.slice() : []; + const nextBlockContent = position === 'end' ? blockContent.concat(inlineNodes) : inlineNodes.concat(blockContent); + + content[targetIndex] = { ...targetBlock, content: nextBlockContent }; + return { ...cellNode, content }; +} + +function isBookmarkNode(node) { + const typeName = node?.type; + return typeName === 'bookmarkStart' || typeName === 'bookmarkEnd'; +} + +function isTextblockNode(node, editor) { + const typeName = node?.type; + if (!typeName) return false; + const nodeType = editor?.schema?.nodes?.[typeName]; + if (nodeType && typeof nodeType.isTextblock === 'boolean') return nodeType.isTextblock; + return typeName === 'paragraph'; +} + /** * Reconstruct original OOXML for preservable inline nodes using their attribute decoders. * diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index 0f7327b5a8..f0c4474acc 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { collapseWhitespaceNextToInlinePassthrough, filterOutRootInlineNodes } from './docxImporter.js'; +import { + collapseWhitespaceNextToInlinePassthrough, + filterOutRootInlineNodes, + normalizeTableBookmarksInContent, +} from './docxImporter.js'; const n = (type, attrs = {}) => ({ type, attrs, marks: [] }); @@ -178,3 +182,53 @@ describe('collapseWhitespaceNextToInlinePassthrough', () => { expect(tree[0].content[2].content[0].text).toBe('bar'); }); }); + +describe('normalizeTableBookmarksInContent', () => { + const table = (content) => ({ type: 'table', content, attrs: {}, marks: [] }); + const row = (cells) => ({ type: 'tableRow', content: cells, attrs: {}, marks: [] }); + const cell = (content) => ({ type: 'tableCell', content, attrs: {}, marks: [] }); + const paragraph = (content) => ({ type: 'paragraph', content, attrs: {}, marks: [] }); + const text = (value) => ({ type: 'text', text: value, marks: [] }); + const bookmarkStart = (id) => ({ type: 'bookmarkStart', attrs: { id } }); + const bookmarkEnd = (id) => ({ type: 'bookmarkEnd', attrs: { id } }); + + it('moves leading bookmarkStart into the first cell paragraph', () => { + const input = [table([bookmarkStart('b1'), row([cell([paragraph([text('Cell')])])])])]; + + const result = normalizeTableBookmarksInContent(input); + const normalizedTable = result[0]; + + expect(normalizedTable.content.some((node) => node.type === 'bookmarkStart')).toBe(false); + const paraContent = normalizedTable.content[0].content[0].content[0].content; + expect(paraContent[0]).toMatchObject({ type: 'bookmarkStart', attrs: { id: 'b1' } }); + expect(paraContent[1]).toMatchObject({ type: 'text', text: 'Cell' }); + }); + + it('moves trailing bookmarkEnd into the last cell paragraph', () => { + const input = [table([row([cell([paragraph([text('Cell')])])]), bookmarkEnd('b1')])]; + + const result = normalizeTableBookmarksInContent(input); + const normalizedTable = result[0]; + + expect(normalizedTable.content.some((node) => node.type === 'bookmarkEnd')).toBe(false); + const paraContent = normalizedTable.content[0].content[0].content[0].content; + expect(paraContent[0]).toMatchObject({ type: 'text', text: 'Cell' }); + expect(paraContent[1]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'b1' } }); + }); + + it('moves bookmarkStart and bookmarkEnd into the same cell when no textblocks exist', () => { + const input = [table([bookmarkStart('b1'), row([cell([])]), bookmarkEnd('b1')])]; + + const result = normalizeTableBookmarksInContent(input); + const normalizedTable = result[0]; + + expect(normalizedTable.content.some((node) => node.type === 'bookmarkStart')).toBe(false); + expect(normalizedTable.content.some((node) => node.type === 'bookmarkEnd')).toBe(false); + + const paraContent = normalizedTable.content[0].content[0].content[0].content; + expect(paraContent).toEqual([ + { type: 'bookmarkStart', attrs: { id: 'b1' } }, + { type: 'bookmarkEnd', attrs: { id: 'b1' } }, + ]); + }); +}); From 1f659c674607f6136ad83095fdf5490fb19244e7 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 30 Jan 2026 21:47:25 +0200 Subject: [PATCH 2/8] fix: review comment --- .../v2/importer/docxImporter.js | 39 ++++++++++++------- .../v2/importer/docxImporter.test.js | 24 ++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) 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 8d59f92734..18d244da0c 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 @@ -873,13 +873,11 @@ function normalizeTableBookmarksInNode(node, editor) { function normalizeTableBookmarksInTable(tableNode, editor) { if (!tableNode || tableNode.type !== 'table' || !Array.isArray(tableNode.content)) return tableNode; - const leading = []; - const trailing = []; - let seenRow = false; - const rows = tableNode.content.filter((child) => child?.type === 'tableRow'); if (!rows.length) return tableNode; + const rowStartInlines = rows.map(() => []); + const rowEndInlines = rows.map(() => []); const updatedRows = rows.slice(); let rowCursor = 0; @@ -887,15 +885,25 @@ function normalizeTableBookmarksInTable(tableNode, editor) { if (child?.type === 'tableRow') { acc.push(updatedRows[rowCursor] ?? child); rowCursor += 1; - seenRow = true; return acc; } if (isBookmarkNode(child)) { - if (seenRow) { - trailing.push(child); + const prevRowIndex = rowCursor > 0 ? rowCursor - 1 : null; + const nextRowIndex = rowCursor < rows.length ? rowCursor : null; + + if (child.type === 'bookmarkStart') { + if (nextRowIndex != null) { + rowStartInlines[nextRowIndex].push(child); + } else if (prevRowIndex != null) { + rowEndInlines[prevRowIndex].push(child); + } } else { - leading.push(child); + if (prevRowIndex != null) { + rowEndInlines[prevRowIndex].push(child); + } else if (nextRowIndex != null) { + rowStartInlines[nextRowIndex].push(child); + } } return acc; } @@ -904,13 +912,14 @@ function normalizeTableBookmarksInTable(tableNode, editor) { return acc; }, []); - if (leading.length) { - updatedRows[0] = insertInlineIntoRow(updatedRows[0], leading, editor, 'start'); - } - if (trailing.length) { - const lastIndex = updatedRows.length - 1; - updatedRows[lastIndex] = insertInlineIntoRow(updatedRows[lastIndex], trailing, editor, 'end'); - } + updatedRows.forEach((row, index) => { + if (rowStartInlines[index]?.length) { + updatedRows[index] = insertInlineIntoRow(row, rowStartInlines[index], editor, 'start'); + } + if (rowEndInlines[index]?.length) { + updatedRows[index] = insertInlineIntoRow(updatedRows[index], rowEndInlines[index], editor, 'end'); + } + }); // Rebuild content with updated rows (preserve any non-row nodes in place). let updatedRowIndex = 0; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index f0c4474acc..e10031b1c9 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -231,4 +231,28 @@ describe('normalizeTableBookmarksInContent', () => { { type: 'bookmarkEnd', attrs: { id: 'b1' } }, ]); }); + + it('anchors bookmark boundaries to adjacent rows when markers appear between rows', () => { + const input = [ + table([ + bookmarkStart('b1'), + row([cell([paragraph([text('R1')])])]), + bookmarkEnd('b1'), + row([cell([paragraph([text('R2')])])]), + ]), + ]; + + const result = normalizeTableBookmarksInContent(input); + const normalizedTable = result[0]; + + const row1Content = normalizedTable.content[0].content[0].content[0].content; + expect(row1Content).toEqual([ + { type: 'bookmarkStart', attrs: { id: 'b1' } }, + { type: 'text', text: 'R1', marks: [] }, + { type: 'bookmarkEnd', attrs: { id: 'b1' } }, + ]); + + const row2Content = normalizedTable.content[1].content[0].content[0].content; + expect(row2Content).toEqual([{ type: 'text', text: 'R2', marks: [] }]); + }); }); From 14292c10d5e381d8c86c6cd4014c3fd4e993297c Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 2 Feb 2026 14:46:02 +0200 Subject: [PATCH 3/8] fix: check for empty row --- .../super-converter/v2/importer/docxImporter.js | 10 +++++++++- .../v2/importer/docxImporter.test.js | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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 18d244da0c..40d78d17e0 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 @@ -939,7 +939,15 @@ function normalizeTableBookmarksInTable(tableNode, editor) { } function insertInlineIntoRow(rowNode, inlineNodes, editor, position) { - if (!rowNode || !inlineNodes?.length || !Array.isArray(rowNode.content)) return rowNode; + if (!rowNode || !inlineNodes?.length) return rowNode; + + if (!Array.isArray(rowNode.content) || rowNode.content.length === 0) { + const cellType = editor?.schema?.nodes?.tableCell ? 'tableCell' : 'tableCell'; + const paragraph = { type: 'paragraph', content: inlineNodes }; + const newCell = { type: cellType, content: [paragraph], attrs: {}, marks: [] }; + const nextContent = [newCell]; + return { ...rowNode, content: nextContent }; + } const targetIndex = position === 'end' ? rowNode.content.length - 1 : 0; const targetCell = rowNode.content[targetIndex]; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index e10031b1c9..3952e61e62 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -255,4 +255,21 @@ describe('normalizeTableBookmarksInContent', () => { const row2Content = normalizedTable.content[1].content[0].content[0].content; expect(row2Content).toEqual([{ type: 'text', text: 'R2', marks: [] }]); }); + + it('creates a cell when a row is empty', () => { + const input = [table([bookmarkStart('b1'), row([]), bookmarkEnd('b1')])]; + + const result = normalizeTableBookmarksInContent(input); + const normalizedTable = result[0]; + + const rowContent = normalizedTable.content[0].content; + expect(rowContent).toHaveLength(1); + expect(rowContent[0].type).toBe('tableCell'); + + const paraContent = rowContent[0].content[0].content; + expect(paraContent).toEqual([ + { type: 'bookmarkStart', attrs: { id: 'b1' } }, + { type: 'bookmarkEnd', attrs: { id: 'b1' } }, + ]); + }); }); From 6009879cb83b94899d5f345f2ce707c52bcb49f7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 3 Feb 2026 11:40:33 -0300 Subject: [PATCH 4/8] ci: update spec review --- .github/workflows/spec-review.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spec-review.yml b/.github/workflows/spec-review.yml index fc7b3cdd32..8e0b8fc7ac 100644 --- a/.github/workflows/spec-review.yml +++ b/.github/workflows/spec-review.yml @@ -3,6 +3,8 @@ name: OOXML Spec Compliance Review on: pull_request: paths: + - 'packages/super-editor/src/core/super-converter/v2/importer/**' + - 'packages/super-editor/src/core/super-converter/v2/exporter/**' - 'packages/super-editor/src/core/super-converter/v3/handlers/**' concurrency: @@ -31,7 +33,7 @@ jobs: - name: Get changed files id: changed run: | - FILES=$(gh pr diff ${{ github.event.pull_request.number }} --name-only | grep 'v3/handlers/' || true) + FILES=$(gh pr diff ${{ github.event.pull_request.number }} --name-only | grep -E 'v2/(importer|exporter)/|v3/handlers/' || true) echo "files<> $GITHUB_OUTPUT echo "$FILES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT From dcdaf2f3a7a351b44d29cec31a680108fc1b8002 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 3 Feb 2026 17:56:43 +0200 Subject: [PATCH 5/8] fix: consider colFirst/colLast attributes --- .../v2/importer/docxImporter.js | 77 +++++++++++++++---- .../v2/importer/docxImporter.test.js | 24 +++++- 2 files changed, 86 insertions(+), 15 deletions(-) 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 40d78d17e0..b77d77bb08 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 @@ -846,6 +846,12 @@ export function filterOutRootInlineNodes(content = []) { * Normalize bookmark nodes that appear as direct table children. * Moves bookmarkStart/End into the first/last cell textblock of the table. * + * Some non-conformant DOCX producers place bookmarks as direct table children. + * Per ECMA-376 ยง17.13.6.2, they should be inside cells (bookmarkStart) or + * as children of rows (bookmarkEnd). + * PM can't accept bookmarks as a direct child of table row and that is why + * we relocate them for compatibility. + * * @param {Array<{type: string, content?: any[], attrs?: any}>} content * @param {Editor} [editor] * @returns {Array} @@ -870,14 +876,31 @@ function normalizeTableBookmarksInNode(node, editor) { return node; } +function parseColIndex(val) { + if (val == null || val === '') return null; + const n = parseInt(String(val), 10); + return Number.isNaN(n) ? null : Math.max(0, n); +} + +function getCellIndexForBookmark(bookmarkNode, position, rowCellCount) { + if (!rowCellCount) return 0; + const attrs = bookmarkNode?.attrs ?? {}; + const col = position === 'start' ? parseColIndex(attrs.colFirst) : parseColIndex(attrs.colLast); + if (col == null) return position === 'start' ? 0 : rowCellCount - 1; + return Math.min(col, rowCellCount - 1); +} + function normalizeTableBookmarksInTable(tableNode, editor) { if (!tableNode || tableNode.type !== 'table' || !Array.isArray(tableNode.content)) return tableNode; const rows = tableNode.content.filter((child) => child?.type === 'tableRow'); if (!rows.length) return tableNode; - const rowStartInlines = rows.map(() => []); - const rowEndInlines = rows.map(() => []); + /** @type {{ start: Record, end: Record }[]} */ + const rowCellInlines = rows.map(() => ({ + start: /** @type {Record} */ ({}), + end: /** @type {Record} */ ({}), + })); const updatedRows = rows.slice(); let rowCursor = 0; @@ -891,18 +914,33 @@ function normalizeTableBookmarksInTable(tableNode, editor) { if (isBookmarkNode(child)) { const prevRowIndex = rowCursor > 0 ? rowCursor - 1 : null; const nextRowIndex = rowCursor < rows.length ? rowCursor : null; + const rowIndex = nextRowIndex ?? prevRowIndex; + const row = rowIndex != null ? rows[rowIndex] : null; + const rowCellCount = row && Array.isArray(row.content) ? row.content.length : 0; if (child.type === 'bookmarkStart') { if (nextRowIndex != null) { - rowStartInlines[nextRowIndex].push(child); + const cellIndex = getCellIndexForBookmark(child, 'start', rowCellCount); + const bucket = rowCellInlines[nextRowIndex].start; + if (!bucket[cellIndex]) bucket[cellIndex] = []; + bucket[cellIndex].push(child); } else if (prevRowIndex != null) { - rowEndInlines[prevRowIndex].push(child); + const cellIndex = getCellIndexForBookmark(child, 'end', rowCellCount); + const bucket = rowCellInlines[prevRowIndex].end; + if (!bucket[cellIndex]) bucket[cellIndex] = []; + bucket[cellIndex].push(child); } } else { if (prevRowIndex != null) { - rowEndInlines[prevRowIndex].push(child); + const cellIndex = getCellIndexForBookmark(child, 'end', rowCellCount); + const bucket = rowCellInlines[prevRowIndex].end; + if (!bucket[cellIndex]) bucket[cellIndex] = []; + bucket[cellIndex].push(child); } else if (nextRowIndex != null) { - rowStartInlines[nextRowIndex].push(child); + const cellIndex = getCellIndexForBookmark(child, 'start', rowCellCount); + const bucket = rowCellInlines[nextRowIndex].start; + if (!bucket[cellIndex]) bucket[cellIndex] = []; + bucket[cellIndex].push(child); } } return acc; @@ -913,11 +951,19 @@ function normalizeTableBookmarksInTable(tableNode, editor) { }, []); updatedRows.forEach((row, index) => { - if (rowStartInlines[index]?.length) { - updatedRows[index] = insertInlineIntoRow(row, rowStartInlines[index], editor, 'start'); - } - if (rowEndInlines[index]?.length) { - updatedRows[index] = insertInlineIntoRow(updatedRows[index], rowEndInlines[index], editor, 'end'); + const { start: startByCell, end: endByCell } = rowCellInlines[index] ?? { start: {}, end: {} }; + const allCellIndices = [ + ...new Set([...Object.keys(startByCell).map(Number), ...Object.keys(endByCell).map(Number)]), + ].sort((a, b) => a - b); + for (const cellIndex of allCellIndices) { + const startNodes = startByCell[cellIndex]; + const endNodes = endByCell[cellIndex]; + if (startNodes?.length) { + updatedRows[index] = insertInlineIntoRow(updatedRows[index], startNodes, editor, 'start', cellIndex); + } + if (endNodes?.length) { + updatedRows[index] = insertInlineIntoRow(updatedRows[index], endNodes, editor, 'end', cellIndex); + } } }); @@ -938,7 +984,10 @@ function normalizeTableBookmarksInTable(tableNode, editor) { }; } -function insertInlineIntoRow(rowNode, inlineNodes, editor, position) { +/** + * @param {number} [cellIndex] - If set, insert into this cell; otherwise first (start) or last (end) cell. + */ +function insertInlineIntoRow(rowNode, inlineNodes, editor, position, cellIndex) { if (!rowNode || !inlineNodes?.length) return rowNode; if (!Array.isArray(rowNode.content) || rowNode.content.length === 0) { @@ -949,7 +998,9 @@ function insertInlineIntoRow(rowNode, inlineNodes, editor, position) { return { ...rowNode, content: nextContent }; } - const targetIndex = position === 'end' ? rowNode.content.length - 1 : 0; + const lastCellIndex = rowNode.content.length - 1; + const targetIndex = + cellIndex != null ? Math.min(Math.max(0, cellIndex), lastCellIndex) : position === 'end' ? lastCellIndex : 0; const targetCell = rowNode.content[targetIndex]; const updatedCell = insertInlineIntoCell(targetCell, inlineNodes, editor, position); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index 3952e61e62..ac5ed86929 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -189,8 +189,8 @@ describe('normalizeTableBookmarksInContent', () => { const cell = (content) => ({ type: 'tableCell', content, attrs: {}, marks: [] }); const paragraph = (content) => ({ type: 'paragraph', content, attrs: {}, marks: [] }); const text = (value) => ({ type: 'text', text: value, marks: [] }); - const bookmarkStart = (id) => ({ type: 'bookmarkStart', attrs: { id } }); - const bookmarkEnd = (id) => ({ type: 'bookmarkEnd', attrs: { id } }); + const bookmarkStart = (id, attrs = {}) => ({ type: 'bookmarkStart', attrs: { id, ...attrs } }); + const bookmarkEnd = (id, attrs = {}) => ({ type: 'bookmarkEnd', attrs: { id, ...attrs } }); it('moves leading bookmarkStart into the first cell paragraph', () => { const input = [table([bookmarkStart('b1'), row([cell([paragraph([text('Cell')])])])])]; @@ -272,4 +272,24 @@ describe('normalizeTableBookmarksInContent', () => { { type: 'bookmarkEnd', attrs: { id: 'b1' } }, ]); }); + + it('places bookmarks in the cell indicated by colFirst/colLast when present', () => { + const twoCells = row([cell([paragraph([text('A')])]), cell([paragraph([text('B')])])]); + const input = [table([bookmarkStart('b1', { colFirst: '1' }), twoCells, bookmarkEnd('b1', { colLast: '1' })])]; + + const result = normalizeTableBookmarksInContent(input); + const normalizedTable = result[0]; + const rowContent = normalizedTable.content[0].content; + + expect(normalizedTable.content.some((node) => node.type === 'bookmarkStart')).toBe(false); + expect(normalizedTable.content.some((node) => node.type === 'bookmarkEnd')).toBe(false); + + const firstCellContent = rowContent[0].content[0].content; + expect(firstCellContent).toEqual([{ type: 'text', text: 'A', marks: [] }]); + + const secondCellContent = rowContent[1].content[0].content; + expect(secondCellContent[0]).toMatchObject({ type: 'bookmarkStart', attrs: { id: 'b1', colFirst: '1' } }); + expect(secondCellContent[1]).toMatchObject({ type: 'text', text: 'B', marks: [] }); + expect(secondCellContent[2]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'b1', colLast: '1' } }); + }); }); From 0becae2ca1e83531f5be1595b4dca583c8e2532e Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 3 Feb 2026 18:16:14 +0200 Subject: [PATCH 6/8] fix: add colFirst/colLast logic only to bookmarkStart --- .../src/core/super-converter/v2/importer/docxImporter.js | 7 +++++-- .../core/super-converter/v2/importer/docxImporter.test.js | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) 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 b77d77bb08..dcc1236a00 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 @@ -882,10 +882,13 @@ function parseColIndex(val) { return Number.isNaN(n) ? null : Math.max(0, n); } +/** colFirst/colLast apply only to bookmarkStart; bookmarkEnd always uses first/last cell by position. */ function getCellIndexForBookmark(bookmarkNode, position, rowCellCount) { if (!rowCellCount) return 0; - const attrs = bookmarkNode?.attrs ?? {}; - const col = position === 'start' ? parseColIndex(attrs.colFirst) : parseColIndex(attrs.colLast); + if (bookmarkNode?.type === 'bookmarkEnd') { + return position === 'start' ? 0 : rowCellCount - 1; + } + const col = parseColIndex(bookmarkNode?.attrs?.colFirst); if (col == null) return position === 'start' ? 0 : rowCellCount - 1; return Math.min(col, rowCellCount - 1); } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index ac5ed86929..61029204cc 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -273,9 +273,9 @@ describe('normalizeTableBookmarksInContent', () => { ]); }); - it('places bookmarks in the cell indicated by colFirst/colLast when present', () => { + it('places bookmarkStart in the cell indicated by colFirst when present; bookmarkEnd uses first/last cell only', () => { const twoCells = row([cell([paragraph([text('A')])]), cell([paragraph([text('B')])])]); - const input = [table([bookmarkStart('b1', { colFirst: '1' }), twoCells, bookmarkEnd('b1', { colLast: '1' })])]; + const input = [table([bookmarkStart('b1', { colFirst: '1' }), twoCells, bookmarkEnd('b1')])]; const result = normalizeTableBookmarksInContent(input); const normalizedTable = result[0]; @@ -290,6 +290,6 @@ describe('normalizeTableBookmarksInContent', () => { const secondCellContent = rowContent[1].content[0].content; expect(secondCellContent[0]).toMatchObject({ type: 'bookmarkStart', attrs: { id: 'b1', colFirst: '1' } }); expect(secondCellContent[1]).toMatchObject({ type: 'text', text: 'B', marks: [] }); - expect(secondCellContent[2]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'b1', colLast: '1' } }); + expect(secondCellContent[2]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'b1' } }); }); }); From a2779f9eb8577854b29781a0539ffbec6bf0323a Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 3 Feb 2026 18:33:26 +0200 Subject: [PATCH 7/8] fix: cleanup --- .../v2/importer/docxImporter.js | 94 +++++++------------ 1 file changed, 36 insertions(+), 58 deletions(-) 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 dcc1236a00..e6c65a8d11 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 @@ -893,6 +893,13 @@ function getCellIndexForBookmark(bookmarkNode, position, rowCellCount) { return Math.min(col, rowCellCount - 1); } +function addBookmarkToRowCellInlines(rowCellInlines, rowIndex, position, bookmarkNode, rowCellCount) { + const cellIndex = getCellIndexForBookmark(bookmarkNode, position, rowCellCount); + const bucket = rowCellInlines[rowIndex][position]; + if (!bucket[cellIndex]) bucket[cellIndex] = []; + bucket[cellIndex].push(bookmarkNode); +} + function normalizeTableBookmarksInTable(tableNode, editor) { if (!tableNode || tableNode.type !== 'table' || !Array.isArray(tableNode.content)) return tableNode; @@ -917,34 +924,18 @@ function normalizeTableBookmarksInTable(tableNode, editor) { if (isBookmarkNode(child)) { const prevRowIndex = rowCursor > 0 ? rowCursor - 1 : null; const nextRowIndex = rowCursor < rows.length ? rowCursor : null; - const rowIndex = nextRowIndex ?? prevRowIndex; - const row = rowIndex != null ? rows[rowIndex] : null; - const rowCellCount = row && Array.isArray(row.content) ? row.content.length : 0; + const row = (nextRowIndex ?? prevRowIndex) != null ? rows[nextRowIndex ?? prevRowIndex] : null; + const rowCellCount = row?.content?.length ?? 0; if (child.type === 'bookmarkStart') { - if (nextRowIndex != null) { - const cellIndex = getCellIndexForBookmark(child, 'start', rowCellCount); - const bucket = rowCellInlines[nextRowIndex].start; - if (!bucket[cellIndex]) bucket[cellIndex] = []; - bucket[cellIndex].push(child); - } else if (prevRowIndex != null) { - const cellIndex = getCellIndexForBookmark(child, 'end', rowCellCount); - const bucket = rowCellInlines[prevRowIndex].end; - if (!bucket[cellIndex]) bucket[cellIndex] = []; - bucket[cellIndex].push(child); - } + if (nextRowIndex != null) + addBookmarkToRowCellInlines(rowCellInlines, nextRowIndex, 'start', child, rowCellCount); + else if (prevRowIndex != null) + addBookmarkToRowCellInlines(rowCellInlines, prevRowIndex, 'end', child, rowCellCount); } else { - if (prevRowIndex != null) { - const cellIndex = getCellIndexForBookmark(child, 'end', rowCellCount); - const bucket = rowCellInlines[prevRowIndex].end; - if (!bucket[cellIndex]) bucket[cellIndex] = []; - bucket[cellIndex].push(child); - } else if (nextRowIndex != null) { - const cellIndex = getCellIndexForBookmark(child, 'start', rowCellCount); - const bucket = rowCellInlines[nextRowIndex].start; - if (!bucket[cellIndex]) bucket[cellIndex] = []; - bucket[cellIndex].push(child); - } + if (prevRowIndex != null) addBookmarkToRowCellInlines(rowCellInlines, prevRowIndex, 'end', child, rowCellCount); + else if (nextRowIndex != null) + addBookmarkToRowCellInlines(rowCellInlines, nextRowIndex, 'start', child, rowCellCount); } return acc; } @@ -954,19 +945,17 @@ function normalizeTableBookmarksInTable(tableNode, editor) { }, []); updatedRows.forEach((row, index) => { - const { start: startByCell, end: endByCell } = rowCellInlines[index] ?? { start: {}, end: {} }; - const allCellIndices = [ + const { start: startByCell, end: endByCell } = rowCellInlines[index]; + const cellIndices = [ ...new Set([...Object.keys(startByCell).map(Number), ...Object.keys(endByCell).map(Number)]), ].sort((a, b) => a - b); - for (const cellIndex of allCellIndices) { + for (const cellIndex of cellIndices) { const startNodes = startByCell[cellIndex]; const endNodes = endByCell[cellIndex]; - if (startNodes?.length) { + if (startNodes?.length) updatedRows[index] = insertInlineIntoRow(updatedRows[index], startNodes, editor, 'start', cellIndex); - } - if (endNodes?.length) { + if (endNodes?.length) updatedRows[index] = insertInlineIntoRow(updatedRows[index], endNodes, editor, 'end', cellIndex); - } } }); @@ -994,11 +983,9 @@ function insertInlineIntoRow(rowNode, inlineNodes, editor, position, cellIndex) if (!rowNode || !inlineNodes?.length) return rowNode; if (!Array.isArray(rowNode.content) || rowNode.content.length === 0) { - const cellType = editor?.schema?.nodes?.tableCell ? 'tableCell' : 'tableCell'; const paragraph = { type: 'paragraph', content: inlineNodes }; - const newCell = { type: cellType, content: [paragraph], attrs: {}, marks: [] }; - const nextContent = [newCell]; - return { ...rowNode, content: nextContent }; + const newCell = { type: 'tableCell', content: [paragraph], attrs: {}, marks: [] }; + return { ...rowNode, content: [newCell] }; } const lastCellIndex = rowNode.content.length - 1; @@ -1014,35 +1001,26 @@ function insertInlineIntoRow(rowNode, inlineNodes, editor, position, cellIndex) return { ...rowNode, content: nextContent }; } +function findTextblockIndex(content, editor, fromEnd) { + const start = fromEnd ? content.length - 1 : 0; + const end = fromEnd ? -1 : content.length; + const step = fromEnd ? -1 : 1; + for (let i = start; fromEnd ? i > end : i < end; i += step) { + if (isTextblockNode(content[i], editor)) return i; + } + return -1; +} + function insertInlineIntoCell(cellNode, inlineNodes, editor, position) { if (!cellNode || !inlineNodes?.length) return cellNode; const content = Array.isArray(cellNode.content) ? cellNode.content.slice() : []; - let targetIndex = -1; - - if (position === 'end') { - for (let i = content.length - 1; i >= 0; i -= 1) { - if (isTextblockNode(content[i], editor)) { - targetIndex = i; - break; - } - } - } else { - for (let i = 0; i < content.length; i += 1) { - if (isTextblockNode(content[i], editor)) { - targetIndex = i; - break; - } - } - } + const targetIndex = findTextblockIndex(content, editor, position === 'end'); if (targetIndex === -1) { const paragraph = { type: 'paragraph', content: inlineNodes }; - if (position === 'end') { - content.push(paragraph); - } else { - content.unshift(paragraph); - } + if (position === 'end') content.push(paragraph); + else content.unshift(paragraph); return { ...cellNode, content }; } From 67fa08b7942879b853ff9e459989e6f208a43781 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 3 Feb 2026 20:16:47 +0200 Subject: [PATCH 8/8] fix: minor fix for cell index calculation --- .../src/core/super-converter/v2/importer/docxImporter.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e6c65a8d11..05684e1747 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 @@ -888,7 +888,8 @@ function getCellIndexForBookmark(bookmarkNode, position, rowCellCount) { if (bookmarkNode?.type === 'bookmarkEnd') { return position === 'start' ? 0 : rowCellCount - 1; } - const col = parseColIndex(bookmarkNode?.attrs?.colFirst); + const attrs = bookmarkNode?.attrs ?? {}; + const col = parseColIndex(position === 'start' ? attrs.colFirst : attrs.colLast); if (col == null) return position === 'start' ? 0 : rowCellCount - 1; return Math.min(col, rowCellCount - 1); }