From 570b305923746ad8531b65c61f4f10b1ead1c0c6 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Feb 2026 10:03:58 -0800 Subject: [PATCH 1/2] fix(rendering): align anchored media pagination/cropping and preserve textbox letter-spacing --- packages/layout-engine/contracts/src/index.ts | 1 + .../layout-engine/src/index.test.ts | 12 +++---- .../layout-engine/layout-engine/src/index.ts | 31 +++++++------------ .../painters/dom/src/renderer.ts | 3 ++ .../wp/helpers/encode-image-node-helpers.js | 7 +++-- .../helpers/encode-image-node-helpers.test.js | 5 ++- .../wp/helpers/textbox-content-helpers.js | 8 +++++ .../helpers/textbox-content-helpers.test.js | 6 ++++ 8 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 1a2f24827f..168b53a746 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -626,6 +626,7 @@ export type TextFormatting = { color?: string; fontSize?: number; fontFamily?: string; + letterSpacing?: number; }; /** A single text part with optional formatting. */ diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 2ac733e793..7487de935b 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -3307,7 +3307,7 @@ describe('requirePageBoundary edge cases', () => { expect(fragment.height).toBe(60); }); - it('emits pre-registered page-relative drawings on their stored page after pagination advances', () => { + it('emits pre-registered page-relative drawings on the page where they are encountered after pagination advances', () => { const firstPageParagraph: FlowBlock = { kind: 'paragraph', id: 'para-page-1', @@ -3391,8 +3391,8 @@ describe('requirePageBoundary edge cases', () => { (fragment) => fragment.kind === 'drawing' && fragment.blockId === 'drawing-pre-reg-page', ); - expect(drawingOnPage1).toBeTruthy(); - expect(drawingOnPage2).toBeUndefined(); + expect(drawingOnPage1).toBeUndefined(); + expect(drawingOnPage2).toBeTruthy(); }); it('creates fragment for margin-relative anchored drawing with wrapNone', () => { @@ -3476,7 +3476,7 @@ describe('requirePageBoundary edge cases', () => { expect(img.zIndex).toBe(0); }); - it('emits pre-registered page-relative images on their stored page after pagination advances', () => { + it('emits pre-registered page-relative images on the page where they are encountered after pagination advances', () => { const firstPageParagraph: FlowBlock = { kind: 'paragraph', id: 'para-page-1', @@ -3543,8 +3543,8 @@ describe('requirePageBoundary edge cases', () => { (fragment) => fragment.kind === 'image' && fragment.blockId === 'img-pre-reg-page', ); - expect(imageOnPage1).toBeTruthy(); - expect(imageOnPage2).toBeUndefined(); + expect(imageOnPage1).toBeUndefined(); + expect(imageOnPage2).toBeTruthy(); }); }); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 614b71ff3c..448c3e487d 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1353,8 +1353,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // must be registered first so all paragraphs can wrap around them. const preRegisteredAnchors = collectPreRegisteredAnchors(blocks, measures); - // Map to store pre-computed positions for page-relative anchors (for fragment creation later) - const preRegisteredPositions = new Map(); + // Map to store pre-computed positions for page-relative anchors (for fragment creation later). + // Page placement is resolved at encounter time so anchors follow pagination (e.g., after page breaks). + const preRegisteredPositions = new Map(); for (const entry of preRegisteredAnchors) { // Ensure first page exists @@ -1420,8 +1421,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // This prevents the section break logic from seeing "content" on the page and creating a new page. floatManager.registerDrawing(entry.block, entry.measure, anchorY, state.columnIndex, state.page.number); - // Store pre-computed position for later use when creating the fragment - preRegisteredPositions.set(entry.block.id, { anchorX, anchorY, pageNumber: state.page.number }); + // Store pre-computed position for later use when creating the fragment. + preRegisteredPositions.set(entry.block.id, { anchorX, anchorY }); } // Pre-compute keepNext chains for correct pagination grouping. @@ -1932,14 +1933,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Check if this is a pre-registered page-relative anchor const preRegPos = preRegisteredPositions.get(block.id); - if ( - preRegPos && - Number.isFinite(preRegPos.anchorX) && - Number.isFinite(preRegPos.anchorY) && - Number.isFinite(preRegPos.pageNumber) - ) { - // Use pre-computed position for page-relative anchors - const state = paginator.getPageByNumber(preRegPos.pageNumber); + if (preRegPos && Number.isFinite(preRegPos.anchorX) && Number.isFinite(preRegPos.anchorY)) { + // Use pre-computed coordinates, but place on the current pagination page where this block is encountered. + const state = paginator.ensurePage(); const imgBlock = block as ImageBlock; const imgMeasure = measure as ImageMeasure; @@ -2008,14 +2004,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Check if this is a pre-registered page-relative anchor const preRegPos = preRegisteredPositions.get(block.id); - if ( - preRegPos && - Number.isFinite(preRegPos.anchorX) && - Number.isFinite(preRegPos.anchorY) && - Number.isFinite(preRegPos.pageNumber) - ) { - // Use pre-computed position for page-relative anchored drawings - const state = paginator.getPageByNumber(preRegPos.pageNumber); + if (preRegPos && Number.isFinite(preRegPos.anchorX) && Number.isFinite(preRegPos.anchorY)) { + // Use pre-computed coordinates, but place on the current pagination page where this block is encountered. + const state = paginator.ensurePage(); const drawBlock = block as DrawingBlock; const drawMeasure = measure as DrawingMeasure; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 189de0ef3d..50b0471601 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3397,6 +3397,9 @@ export class DomPainter { if (part.formatting.fontSize) { span.style.fontSize = `${part.formatting.fontSize}px`; } + if (part.formatting.letterSpacing != null) { + span.style.letterSpacing = `${part.formatting.letterSpacing}px`; + } } currentParagraph.appendChild(span); } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 3756175fc8..f41e8c6ccf 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -328,9 +328,11 @@ export function handleImageNode(node, params, isAnchor) { }); const shouldStretch = Boolean(stretch && fillRect); - // Use cover mode when stretching, unless srcRect already produced an explicit clipPath - // or srcRect has negative values (Word already adjusted mapping). + // Use cover mode for plain stretch/fillRect when there is no explicit srcRect clipping. + // When srcRect emits clipping, we set explicit objectFit='fill' so clip-path math applies + // to a fully filled extent box (avoids "thin strip" rendering for cropped anchors). const shouldCover = shouldStretch && !srcRectHasNegativeValues && !clipPath; + const shouldFillClippedStretch = shouldStretch && !srcRectHasNegativeValues && Boolean(clipPath); const spPr = picture.elements.find((el) => el.name === 'pic:spPr'); if (spPr) { @@ -434,6 +436,7 @@ export function handleImageNode(node, params, isAnchor) { : {}), wrapTopAndBottom: wrap.type === 'TopAndBottom', shouldCover, + ...(shouldFillClippedStretch ? { objectFit: 'fill' } : {}), ...(clipPath ? { clipPath } : {}), rawSrcRect: srcRect, originalPadding: { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index fcaabaf00a..48cbe2032a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -675,7 +675,7 @@ describe('handleImageNode', () => { * - srcRect has no negative values * * Real-world examples: - * - whalar_tables_issue_tbl_only/word/header1.xml: → clipPath + shouldCover=false + * - whalar_tables_issue_tbl_only/word/header1.xml: → clipPath + shouldCover=false + objectFit=fill * - whalar_tables_issue_tbl_only/word/header2.xml: (empty) → shouldCover=true * - certn_logo_left/word/header2.xml: → shouldCover=false */ @@ -766,6 +766,7 @@ describe('handleImageNode', () => { expect(result).not.toBeNull(); expect(result.attrs.shouldCover).toBe(false); + expect(result.attrs.objectFit).toBe('fill'); }); it('sets clipPath when srcRect has positive values', () => { @@ -803,6 +804,7 @@ describe('handleImageNode', () => { expect(result).not.toBeNull(); expect(result.attrs.clipPath).toBe('inset(0% 50% 0% 0%)'); expect(result.attrs.shouldCover).toBe(false); + expect(result.attrs.objectFit).toBe('fill'); }); it('does not set clipPath when srcRect has negative values', () => { @@ -839,6 +841,7 @@ describe('handleImageNode', () => { expect(result).not.toBeNull(); expect(result.attrs.shouldCover).toBe(false); + expect(result.attrs.objectFit).toBe('fill'); }); it('sets shouldCover=false when stretch+fillRect with NEGATIVE srcRect value', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js index fd25a38ed9..5f87ad3709 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.js @@ -2,6 +2,7 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { preProcessNodesForFldChar } from '@converter/field-references/preProcessNodesForFldChar.js'; import { preProcessPageFieldsOnly } from '@converter/field-references/preProcessPageFieldsOnly.js'; import { resolveParagraphProperties, resolveRunProperties } from '@converter/styles'; +import { twipsToPixels } from '@converter/helpers.js'; import { translator as w_pPrTranslator } from '@converter/v3/handlers/w/pPr'; import { translator as w_rPrTranslator } from '@converter/v3/handlers/w/rpr'; import { resolveDocxFontFamily } from '@superdoc/style-engine/ooxml'; @@ -151,6 +152,13 @@ export function extractRunFormatting(rPr, paragraphProperties, params) { const fontFamily = resolveFontFamilyForTextBox(resolvedRunProperties.fontFamily, params.docx); if (fontFamily) formatting.fontFamily = fontFamily; + if (resolvedRunProperties.letterSpacing != null) { + const letterSpacingPx = Number(twipsToPixels(resolvedRunProperties.letterSpacing)); + if (Number.isFinite(letterSpacingPx) && letterSpacingPx !== 0) { + formatting.letterSpacing = letterSpacingPx; + } + } + return formatting; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js index d432f8481b..bee7acc5b3 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/textbox-content-helpers.test.js @@ -471,6 +471,12 @@ describe('textbox-content-helpers', () => { expect(result.fontFamily).toBe('Arial'); }); + it('should extract letterSpacing from twips to pixels', () => { + resolveRunProperties.mockReturnValue({ letterSpacing: -6 }); + const result = extractRunFormatting({}, {}, {}); + expect(result.letterSpacing).toBeCloseTo(-0.4, 3); + }); + it('should handle color with w:val attribute', () => { resolveRunProperties.mockReturnValue({ color: { 'w:val': '00FF00' } }); const result = extractRunFormatting({}, {}, {}); From 0d5e17dd4af1f3b99e708cb49e0414c06cc3d545 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Feb 2026 12:56:41 -0800 Subject: [PATCH 2/2] fix(html-import): normalize table header cell borders for correct DOCX export --- .../src/core/commands/insertContent.test.js | 48 ++++++++++++++ .../src/core/helpers/importHtml.js | 63 ++++++++++++++++++- .../helpers/renderCellBorderStyle.js | 28 +++++++++ .../src/extensions/table-cell/table-cell.js | 17 +---- .../extensions/table-header/table-header.js | 8 +++ .../table/table.import-width.test.js | 28 +++++++++ .../src/extensions/table/table.js | 24 +++++++ .../table/tableHelpers/appendRows.js | 20 +++++- .../table/tableHelpers/tableHelpers.test.js | 24 +++++++ 9 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/extensions/table-cell/helpers/renderCellBorderStyle.js create mode 100644 packages/super-editor/src/extensions/table/table.import-width.test.js diff --git a/packages/super-editor/src/core/commands/insertContent.test.js b/packages/super-editor/src/core/commands/insertContent.test.js index 39c1a220d8..22c57b7242 100644 --- a/packages/super-editor/src/core/commands/insertContent.test.js +++ b/packages/super-editor/src/core/commands/insertContent.test.js @@ -278,4 +278,52 @@ describe('insertContent (integration) list export', () => { expect(first.numId).toBeDefined(); expect(first.ilvl).toBe('0'); }); + + it('defaults imported HTML tables to 100% width', async () => { + const editor = await setupEditor(); + editor.commands.insertContent( + '
QueryAssessment
AB
', + { contentType: 'html' }, + ); + await Promise.resolve(); + + const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table'); + expect(tableNode).toBeTruthy(); + expect(tableNode.attrs?.tableProperties?.tableWidth).toEqual({ + value: 5000, + type: 'pct', + }); + }); + + it('normalizes imported HTML table header borders for render and export parity', async () => { + const editor = await setupEditor(); + editor.commands.insertContent( + '
Search QueryFindings / Assessment
AB
', + { contentType: 'html' }, + ); + await Promise.resolve(); + + const tableNode = (editor.getJSON().content || []).find((node) => node.type === 'table'); + expect(tableNode).toBeTruthy(); + const headerCell = tableNode?.content?.[0]?.content?.[0]; + expect(headerCell?.type).toBe('tableHeader'); + const borders = headerCell?.attrs?.borders; + expect(borders?.top).toBeDefined(); + expect(borders?.right).toBeDefined(); + expect(borders?.bottom).toBeDefined(); + expect(borders?.left).toBeDefined(); + + const result = await exportFromEditorContent(editor); + const body = result.elements?.find((el) => el.name === 'w:body'); + const table = body?.elements?.find((el) => el.name === 'w:tbl'); + const firstRow = table?.elements?.find((el) => el.name === 'w:tr'); + const firstCell = firstRow?.elements?.find((el) => el.name === 'w:tc'); + const firstCellProperties = firstCell?.elements?.find((el) => el.name === 'w:tcPr'); + const firstCellBorders = firstCellProperties?.elements?.find((el) => el.name === 'w:tcBorders'); + const topBorder = firstCellBorders?.elements?.find((el) => el.name === 'w:top'); + + expect(firstCellBorders).toBeDefined(); + expect(topBorder?.attributes?.['w:val']).toBe('single'); + expect(Number(topBorder?.attributes?.['w:sz'])).toBeGreaterThan(0); + }); }); diff --git a/packages/super-editor/src/core/helpers/importHtml.js b/packages/super-editor/src/core/helpers/importHtml.js index 6c615a4269..bf11888e2f 100644 --- a/packages/super-editor/src/core/helpers/importHtml.js +++ b/packages/super-editor/src/core/helpers/importHtml.js @@ -1,8 +1,66 @@ //@ts-check -import { DOMParser } from 'prosemirror-model'; +import { DOMParser, Fragment } from 'prosemirror-model'; import { stripHtmlStyles } from './htmlSanitizer.js'; import { htmlHandler } from '../InputRule.js'; import { wrapTextsInRuns } from '../inputRules/docx-paste/docx-paste.js'; +import { createCellBorders } from '../../extensions/table-cell/helpers/createCellBorders.js'; + +const TABLE_HEADER_NODE_NAME = 'tableHeader'; + +/** + * @param {unknown} borderValue + * @returns {boolean} + */ +const hasMeaningfulCellBorders = (borderValue) => { + if (!borderValue || typeof borderValue !== 'object') return false; + + return Object.values(borderValue).some((side) => side && typeof side === 'object' && Object.keys(side).length > 0); +}; + +/** + * Fill missing border metadata for imported HTML header cells (). + * This keeps editor rendering and DOCX export aligned without overriding explicit borders. + * + * @param {import('prosemirror-model').Node} doc + * @returns {import('prosemirror-model').Node} + */ +const normalizeImportedHtmlTableHeaders = (doc) => { + const normalizeNode = (node) => { + let nextNode = node; + + if (node.childCount > 0) { + const nextChildren = []; + let childrenChanged = false; + + node.forEach((child) => { + const normalizedChild = normalizeNode(child); + if (normalizedChild !== child) childrenChanged = true; + nextChildren.push(normalizedChild); + }); + + if (childrenChanged) { + nextNode = node.copy(Fragment.fromArray(nextChildren)); + } + } + + if (nextNode.type.name !== TABLE_HEADER_NODE_NAME) { + return nextNode; + } + + if (hasMeaningfulCellBorders(nextNode.attrs?.borders)) { + return nextNode; + } + + const nextAttrs = { + ...nextNode.attrs, + borders: createCellBorders(), + }; + + return nextNode.type.create(nextAttrs, nextNode.content, nextNode.marks); + }; + + return normalizeNode(doc); +}; /** * Create a document from HTML content @@ -39,6 +97,9 @@ export function createDocFromHTML(content, editor, options = {}) { } let doc = DOMParser.fromSchema(editor.schema).parse(parsedContent); + if (isImport) { + doc = normalizeImportedHtmlTableHeaders(doc); + } doc = wrapTextsInRuns(doc); return doc; } diff --git a/packages/super-editor/src/extensions/table-cell/helpers/renderCellBorderStyle.js b/packages/super-editor/src/extensions/table-cell/helpers/renderCellBorderStyle.js new file mode 100644 index 0000000000..f55bf32b7f --- /dev/null +++ b/packages/super-editor/src/extensions/table-cell/helpers/renderCellBorderStyle.js @@ -0,0 +1,28 @@ +// @ts-check + +/** + * Build an inline CSS style string for cell borders. + * + * Shared by both `tableCell` and `tableHeader` node `renderDOM` methods + * so the border-rendering logic stays in one place. + * + * @param {import('./createCellBorders.js').CellBorders | null | undefined} borders + * @returns {{ style: string } | {}} + */ +export const renderCellBorderStyle = (borders) => { + if (!borders) return {}; + + const sides = ['top', 'right', 'bottom', 'left']; + const style = sides + .map((side) => { + const border = borders?.[side]; + if (border && border.val === 'none') return `border-${side}: ${border.val};`; + let color = border?.color || 'black'; + if (color === 'auto') color = 'black'; + if (border) return `border-${side}: ${Math.ceil(border.size)}px solid ${color};`; + return ''; + }) + .join(' '); + + return { style }; +}; diff --git a/packages/super-editor/src/extensions/table-cell/table-cell.js b/packages/super-editor/src/extensions/table-cell/table-cell.js index 313e9ef253..6a0326f540 100644 --- a/packages/super-editor/src/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/extensions/table-cell/table-cell.js @@ -39,6 +39,7 @@ import { Node, Attribute } from '@core/index.js'; import { createCellBorders } from './helpers/createCellBorders.js'; +import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; /** * Cell margins configuration @@ -164,21 +165,7 @@ export const TableCell = Node.create({ borders: { default: () => createCellBorders(), - renderDOM({ borders }) { - if (!borders) return {}; - const sides = ['top', 'right', 'bottom', 'left']; - const style = sides - .map((side) => { - const border = borders?.[side]; - if (border && border.val === 'none') return `border-${side}: ${border.val};`; - let color = border?.color || 'black'; - if (color === 'auto') color = 'black'; - if (border) return `border-${side}: ${Math.ceil(border.size)}px solid ${color};`; - return ''; - }) - .join(' '); - return { style }; - }, + renderDOM: ({ borders }) => renderCellBorderStyle(borders), }, widthType: { diff --git a/packages/super-editor/src/extensions/table-header/table-header.js b/packages/super-editor/src/extensions/table-header/table-header.js index d153da8943..21730c3da0 100644 --- a/packages/super-editor/src/extensions/table-header/table-header.js +++ b/packages/super-editor/src/extensions/table-header/table-header.js @@ -1,6 +1,8 @@ // @ts-nocheck import { Node, Attribute } from '@core/index.js'; +import { createCellBorders } from '../table-cell/helpers/createCellBorders.js'; +import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderStyle.js'; /** * Configuration options for TableHeader @@ -16,6 +18,7 @@ import { Node, Attribute } from '@core/index.js'; * @property {number} [colspan=1] - Number of columns this header spans * @property {number} [rowspan=1] - Number of rows this header spans * @property {number[]} [colwidth] - Column widths array in pixels + * @property {import('../table-cell/helpers/createCellBorders.js').CellBorders} [borders] - Cell border configuration */ /** @@ -66,6 +69,11 @@ export const TableHeader = Node.create({ }, }, + borders: { + default: () => createCellBorders(), + renderDOM: ({ borders }) => renderCellBorderStyle(borders), + }, + __placeholder: { default: null, parseDOM: (element) => { diff --git a/packages/super-editor/src/extensions/table/table.import-width.test.js b/packages/super-editor/src/extensions/table/table.import-width.test.js new file mode 100644 index 0000000000..8b5a0ab4d4 --- /dev/null +++ b/packages/super-editor/src/extensions/table/table.import-width.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; + +import { Table } from './table.js'; + +describe('Table import width defaults', () => { + const attributes = Table.config.addAttributes.call(Table); + + it('defaults imported HTML tables to 100% width', () => { + const tableElement = { + closest: (selector) => (selector === '[data-superdoc-import="true"]' ? {} : null), + }; + + expect(attributes.tableProperties.parseDOM(tableElement)).toEqual({ + tableWidth: { + value: 5000, + type: 'pct', + }, + }); + }); + + it('leaves non-imported tables unchanged', () => { + const tableElement = { + closest: () => null, + }; + + expect(attributes.tableProperties.parseDOM(tableElement)).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index cc47ea8f1f..a3242a8865 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -215,6 +215,18 @@ import { insertRowAtIndex, } from './tableHelpers/appendRows.js'; +const IMPORT_CONTEXT_SELECTOR = '[data-superdoc-import="true"]'; +const IMPORT_DEFAULT_TABLE_WIDTH_PCT = 5000; // OOXML percent units where 5000 == 100% + +/** + * Detects whether a table element is being parsed from imported content + * (e.g. insertContent with contentType "html"/"markdown"). + * + * @param {Element} element + * @returns {boolean} + */ +const isImportedTableElement = (element) => Boolean(element?.closest?.(IMPORT_CONTEXT_SELECTOR)); + /** * Table configuration options * @typedef {Object} TableConfig @@ -428,6 +440,18 @@ export const Table = Node.create({ type: 'auto', }, }, + parseDOM: (element) => { + if (!isImportedTableElement(element)) return undefined; + + // Imported HTML tables usually have no structural width metadata. + // Default them to 100% so visual rendering matches DOCX export behavior. + return { + tableWidth: { + value: IMPORT_DEFAULT_TABLE_WIDTH_PCT, + type: 'pct', + }, + }; + }, rendered: false, }, diff --git a/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js b/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js index e87742a4bb..76cec36734 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js @@ -22,6 +22,20 @@ const ROW_START_TO_TEXT_OFFSET = 3; */ const CELL_TO_TEXT_OFFSET = 2; +/** + * When converting tableHeader nodes into tableCell nodes, avoid passing + * `borders: null` so tableCell defaults can apply. + * + * @param {Record} attrs + * @returns {Record} + */ +const normalizeHeaderAttrsForBodyCell = (attrs) => { + if (attrs?.borders !== null) return attrs; + const nextAttrs = { ...attrs }; + delete nextAttrs.borders; + return nextAttrs; +}; + /** * Row template formatting * @typedef {Object} RowTemplateFormatting @@ -174,7 +188,7 @@ export function buildRowFromTemplateRow({ schema, tableNode, templateRow, values templateRow.content.content.forEach((cellNode, cellIndex) => { const isHeaderCell = cellNode.type === HeaderType; const targetCellType = isHeaderCell ? CellType : cellNode.type; - const attrs = { ...cellNode.attrs }; + const attrs = isHeaderCell ? normalizeHeaderAttrsForBodyCell({ ...cellNode.attrs }) : { ...cellNode.attrs }; const formatting = extractRowTemplateFormatting(cellNode, schema); let cellValue = ''; @@ -301,7 +315,9 @@ export function insertRowAtIndex({ tr, tablePos, tableNode, sourceRowIndex, inse const content = buildFormattedCellBlock(schema, '', formatting, true); const targetCellType = sourceCell.type.name === 'tableHeader' ? CellType : sourceCell.type; - const newCell = targetCellType.createAndFill(cellAttrs, content); + const normalizedCellAttrs = + sourceCell.type.name === 'tableHeader' ? normalizeHeaderAttrsForBodyCell(cellAttrs) : cellAttrs; + const newCell = targetCellType.createAndFill(normalizedCellAttrs, content); if (newCell) newCells.push(newCell); col += colspan; diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableHelpers.test.js b/packages/super-editor/src/extensions/table/tableHelpers/tableHelpers.test.js index 3fe3cde7c3..04c9f5f9a6 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableHelpers.test.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableHelpers.test.js @@ -7,6 +7,7 @@ import { createCell } from './createCell.js'; import { createColGroup } from './createColGroup.js'; import { createTableBorders } from './createTableBorders.js'; import { getColStyleDeclaration } from './getColStyleDeclaration.js'; +import { createCellBorders } from '../../table-cell/helpers/createCellBorders.js'; import { deleteTableWhenSelected } from './deleteTableWhenSelected.js'; import { isCellSelection } from './isCellSelection.js'; import { cellAround } from './cellAround.js'; @@ -18,6 +19,7 @@ import { buildFormattedCellBlock, buildRowFromTemplateRow, insertRowsAtTableEnd, + insertRowAtIndex, } from './appendRows.js'; const cellMinWidth = 80; @@ -397,6 +399,7 @@ describe('tableHelpers', () => { }); expect(newRow?.content.content[0].type.name).toBe('tableCell'); + expect(newRow?.content.content[0].attrs.borders).toEqual(createCellBorders()); }); it('buildRowFromTemplateRow copies style when copyRowStyle is true', () => { @@ -462,5 +465,26 @@ describe('tableHelpers', () => { const updatedTable = tr.doc.nodeAt(tablePos); expect(updatedTable?.childCount).toBe(initialChildCount); }); + + it('insertRowAtIndex keeps default body borders when source row has headers', () => { + const { table, state } = buildTableDoc(2, 1, true); + const tr = state.tr; + const tablePos = 0; + + const didInsert = insertRowAtIndex({ + tr, + tablePos, + tableNode: table, + sourceRowIndex: 0, + insertIndex: 1, + schema, + }); + + expect(didInsert).toBe(true); + const updatedTable = tr.doc.nodeAt(tablePos); + const insertedCell = updatedTable?.child(1)?.child(0); + expect(insertedCell?.type.name).toBe('tableCell'); + expect(insertedCell?.attrs.borders).toEqual(createCellBorders()); + }); }); });