Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ export type TextFormatting = {
color?: string;
fontSize?: number;
fontFamily?: string;
letterSpacing?: number;
};

/** A single text part with optional formatting. */
Expand Down
12 changes: 6 additions & 6 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
});
});

Expand Down
31 changes: 11 additions & 20 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { anchorX: number; anchorY: number; pageNumber: number }>();
// 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<string, { anchorX: number; anchorY: number }>();

for (const entry of preRegisteredAnchors) {
// Ensure first page exists
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
48 changes: 48 additions & 0 deletions packages/super-editor/src/core/commands/insertContent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<table><tbody><tr><td>Query</td><td>Assessment</td></tr><tr><td>A</td><td>B</td></tr></tbody></table>',
{ 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(
'<table><thead><tr><th>Search Query</th><th>Findings / Assessment</th></tr></thead><tbody><tr><td>A</td><td>B</td></tr></tbody></table>',
{ 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);
});
});
63 changes: 62 additions & 1 deletion packages/super-editor/src/core/helpers/importHtml.js
Original file line number Diff line number Diff line change
@@ -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 (<th>).
* 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
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -434,6 +436,7 @@ export function handleImageNode(node, params, isAnchor) {
: {}),
wrapTopAndBottom: wrap.type === 'TopAndBottom',
shouldCover,
...(shouldFillClippedStretch ? { objectFit: 'fill' } : {}),
...(clipPath ? { clipPath } : {}),
rawSrcRect: srcRect,
originalPadding: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ describe('handleImageNode', () => {
* - srcRect has no negative values
*
* Real-world examples:
* - whalar_tables_issue_tbl_only/word/header1.xml: <a:srcRect r="84800"/> → clipPath + shouldCover=false
* - whalar_tables_issue_tbl_only/word/header1.xml: <a:srcRect r="84800"/> → clipPath + shouldCover=false + objectFit=fill
* - whalar_tables_issue_tbl_only/word/header2.xml: <a:srcRect/> (empty) → shouldCover=true
* - certn_logo_left/word/header2.xml: <a:srcRect b="-3978"/> → shouldCover=false
*/
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({}, {}, {});
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
};
17 changes: 2 additions & 15 deletions packages/super-editor/src/extensions/table-cell/table-cell.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading