Skip to content

Commit 1768159

Browse files
Branc0caio-pizzol
andauthored
fix: Preserve table cell styles on Google Docs paste (#2628)
* fix(table-cell): add background color parsing from inline styles * fix(table-cell): dd cell margin parsing from inline styles * fix(table-row): persists row height on pasted content from html * fix(table-cell): persists cell verticallAlignment on paste from googleDocs * fix(test): tests for color and margin were failing * feat(table-cell): add border parsing from inline styles and corresponding tests * fix(table-row): prevent double interior borders * refactor(table-cell): optimize border style parsing using a dynamic regex pattern * refactor(table-cell): returns margins with value only * test(table-cell): increase cooverage and refactor tests * docs(renderTableRow): clarify assumptions about shared interior cell borders * test(renderTableRow): add test for non-final row border behavior in collapsed mode * test(table-cell): add integration tests for HTML paste handling of table cell styles * feat(table): add parsing for table header and cell * feat(table): move cell border rendering logic to shared * fix(table): strip # prefix from pasted background colors cssColorToHex returns values with the # prefix (e.g. #ffff00) but the rest of the system stores background colors as bare hex. renderDOM prepends # and the OOXML exporter writes background.color directly into shading.fill, so the mismatch produced ##ffff00 in CSS and #ffff00 in OOXML — both invalid. Also adds parseDOM → renderDOM round-trip tests to catch this class of format mismatch in the future. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
1 parent 2e035ea commit 1768159

14 files changed

Lines changed: 1252 additions & 13 deletions

File tree

packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,89 @@ describe('renderTableRow', () => {
126126
expect(call.borders?.right).toBeDefined();
127127
});
128128

129+
it('does not paint interior right border for explicit cell borders in collapsed mode', () => {
130+
renderTableRow(
131+
createDeps({
132+
rowIndex: 0,
133+
totalRows: 1,
134+
cellSpacingPx: 0,
135+
columnWidths: [100, 100],
136+
rowMeasure: {
137+
height: 20,
138+
cells: [
139+
{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 },
140+
{ width: 100, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 },
141+
],
142+
},
143+
row: {
144+
id: 'row-1',
145+
cells: [
146+
{
147+
id: 'cell-1',
148+
attrs: {
149+
borders: {
150+
top: { style: 'single', width: 2, color: '#123456' },
151+
right: { style: 'single', width: 2, color: '#123456' },
152+
bottom: { style: 'single', width: 2, color: '#123456' },
153+
left: { style: 'single', width: 2, color: '#123456' },
154+
},
155+
},
156+
blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }],
157+
},
158+
{
159+
id: 'cell-2',
160+
blocks: [{ kind: 'paragraph', id: 'p2', runs: [] }],
161+
},
162+
],
163+
},
164+
}) as never,
165+
);
166+
167+
expect(renderTableCellMock).toHaveBeenCalledTimes(2);
168+
const firstCall = renderTableCellMock.mock.calls[0][0] as {
169+
borders?: { right?: unknown; left?: unknown };
170+
};
171+
172+
expect(firstCall.borders?.right).toBeUndefined();
173+
expect(firstCall.borders?.left).toBeDefined();
174+
});
175+
176+
it('does not paint interior bottom border for explicit cell borders in collapsed mode on non-final row', () => {
177+
const explicit = {
178+
top: { style: 'single' as const, width: 2, color: '#123456' },
179+
right: { style: 'single' as const, width: 2, color: '#123456' },
180+
bottom: { style: 'single' as const, width: 2, color: '#123456' },
181+
left: { style: 'single' as const, width: 2, color: '#123456' },
182+
};
183+
renderTableRow(
184+
createDeps({
185+
rowIndex: 2,
186+
totalRows: 5,
187+
cellSpacingPx: 0,
188+
columnWidths: [100],
189+
rowMeasure: {
190+
height: 20,
191+
cells: [{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }],
192+
},
193+
row: {
194+
id: 'row-1',
195+
cells: [
196+
{
197+
id: 'cell-1',
198+
attrs: { borders: explicit },
199+
blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }],
200+
},
201+
],
202+
},
203+
}) as never,
204+
);
205+
206+
expect(renderTableCellMock).toHaveBeenCalledTimes(1);
207+
const call = getRenderedCellCall();
208+
expect(call.borders?.bottom).toBeUndefined();
209+
expect(call.borders?.top).toBeDefined();
210+
});
211+
129212
it('applies the table bottom border to a rowspan cell that reaches the final row', () => {
130213
renderTableRow(
131214
createDeps({

packages/layout-engine/painters/dom/src/table/renderTableRow.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ const resolveRenderedCellBorders = ({
7474
const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext;
7575

7676
if (hasExplicitBorders) {
77+
if (cellSpacingPx === 0) {
78+
// Collapsed model: avoid double interior borders by using single-owner sides.
79+
// Keep explicit top/left (or table fallbacks), and only render right/bottom on table edges.
80+
// Assumes shared interior edges specify the same border on both adjacent cells (e.g. Google Docs
81+
// round-trips this way). Asymmetric (only one cell’s side set) may miss a line until we add conflict resolution.
82+
return {
83+
top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH),
84+
right: cellBounds.touchesRightEdge ? resolveTableBorderValue(cellBorders.right, tableBorders.right) : undefined,
85+
bottom: touchesBottomBoundary ? resolveTableBorderValue(cellBorders.bottom, tableBorders.bottom) : undefined,
86+
left: resolveTableBorderValue(
87+
cellBorders.left,
88+
cellBounds.touchesLeftEdge ? tableBorders.left : tableBorders.insideV,
89+
),
90+
};
91+
}
92+
7793
return {
7894
top: resolveTableBorderValue(cellBorders.top, touchesTopBoundary ? tableBorders.top : tableBorders.insideH),
7995
right: resolveTableBorderValue(cellBorders.right, cellBounds.touchesRightEdge ? tableBorders.right : undefined),
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// @ts-check
2+
import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js';
3+
import { cssColorToHex } from '@core/utilities/cssColorToHex.js';
4+
import { halfPointToPixels } from '@core/super-converter/helpers.js';
5+
6+
/**
7+
* Parsed cell border shape used by table cell / header parseDOM.
8+
* @typedef {Object} ParsedCellBorder
9+
* @property {'none' | 'single' | 'dashed' | 'dotted'} val
10+
* @property {number} size
11+
* @property {string} color
12+
* @property {string} style
13+
*/
14+
15+
/**
16+
* Parsed borders object for each side.
17+
* @typedef {Object} ParsedCellBorders
18+
* @property {ParsedCellBorder} [top]
19+
* @property {ParsedCellBorder} [right]
20+
* @property {ParsedCellBorder} [bottom]
21+
* @property {ParsedCellBorder} [left]
22+
*/
23+
24+
const STYLE_TOKEN_SET = new Set([
25+
'none',
26+
'hidden',
27+
'dotted',
28+
'dashed',
29+
'solid',
30+
'double',
31+
'groove',
32+
'ridge',
33+
'inset',
34+
'outset',
35+
]);
36+
37+
const STYLE_TOKEN_PATTERN = Array.from(STYLE_TOKEN_SET).join('|');
38+
39+
/**
40+
* Parse border width token into pixel number.
41+
*
42+
* @param {string} value
43+
* @returns {number | null}
44+
*/
45+
const parseBorderWidth = (value) => {
46+
const widthMatch = value.match(/(?:^|\s)(-?\d*\.?\d+(?:px|pt))(?=\s|$)/i);
47+
if (!widthMatch?.[1]) return null;
48+
49+
const [widthValue, widthUnit] = parseSizeUnit(widthMatch[1]);
50+
51+
const numericWidth = Number(widthValue);
52+
const size = widthUnit === 'pt' ? halfPointToPixels(numericWidth) : numericWidth;
53+
return size;
54+
};
55+
56+
/**
57+
* Parse border style token.
58+
*
59+
* @param {string} value
60+
* @returns {string | null}
61+
*/
62+
const parseBorderStyle = (value) => {
63+
const styleMatch = value.match(new RegExp(`(?:^|\\s)(${STYLE_TOKEN_PATTERN})(?=\\s|$)`, 'i'));
64+
return styleMatch?.[1] ? styleMatch[1].toLowerCase() : null;
65+
};
66+
67+
/**
68+
* Parse border color token.
69+
*
70+
* @param {string} value
71+
* @returns {string | null}
72+
*/
73+
const parseBorderColor = (value) => {
74+
const directColorMatch = value.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8}|var\([^)]+\))/i);
75+
if (directColorMatch?.[1]) return directColorMatch[1];
76+
77+
const tokenColorMatch = value
78+
.split(/\s+/)
79+
.find((part) => /^[a-z]+$/i.test(part) && !STYLE_TOKEN_SET.has(part.toLowerCase()));
80+
return tokenColorMatch || null;
81+
};
82+
83+
/**
84+
* Parse a single CSS border declaration.
85+
*
86+
* @param {string | undefined | null} rawValue
87+
* @returns {ParsedCellBorder | null}
88+
*/
89+
const parseBorderValue = (rawValue) => {
90+
if (!rawValue || typeof rawValue !== 'string') return null;
91+
const value = rawValue.trim();
92+
if (!value) return null;
93+
94+
if (value === 'none') {
95+
return { val: 'none', size: 0, color: 'auto', style: 'none' };
96+
}
97+
98+
const size = parseBorderWidth(value);
99+
const style = parseBorderStyle(value);
100+
const color = parseBorderColor(value);
101+
102+
const hexColor = cssColorToHex(color);
103+
if (style === 'none') {
104+
return { val: 'none', size: 0, color: 'auto', style: 'none' };
105+
}
106+
107+
if (size == null && !hexColor && !style) return null;
108+
109+
return {
110+
val: style === 'dashed' || style === 'dotted' ? style : 'single',
111+
size: size ?? 1,
112+
color: hexColor || 'auto',
113+
style: style || 'solid',
114+
};
115+
};
116+
117+
/**
118+
* Parse cell borders from inline TD/TH styles.
119+
*
120+
* @param {HTMLElement} element
121+
* @returns {ParsedCellBorders | null}
122+
*/
123+
export const parseCellBorders = (element) => {
124+
const { style } = element;
125+
126+
const top = parseBorderValue(style?.borderTop || style?.border);
127+
const right = parseBorderValue(style?.borderRight || style?.border);
128+
const bottom = parseBorderValue(style?.borderBottom || style?.border);
129+
const left = parseBorderValue(style?.borderLeft || style?.border);
130+
131+
if (!top && !right && !bottom && !left) return null;
132+
133+
return {
134+
...(top ? { top } : {}),
135+
...(right ? { right } : {}),
136+
...(bottom ? { bottom } : {}),
137+
...(left ? { left } : {}),
138+
};
139+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// @ts-check
2+
import { parseSizeUnit } from '@core/utilities/parseSizeUnit.js';
3+
import { halfPointToPixels } from '@core/super-converter/helpers.js';
4+
5+
/**
6+
* Cell margins configuration in pixels.
7+
* @typedef {Object} CellMargins
8+
* @property {number} [top] - Top margin in pixels
9+
* @property {number} [right] - Right margin in pixels
10+
* @property {number} [bottom] - Bottom margin in pixels
11+
* @property {number} [left] - Left margin in pixels
12+
*/
13+
14+
/**
15+
* Parse one CSS padding side into pixels.
16+
*
17+
* @param {string} sideValue
18+
* @returns {number | undefined}
19+
*/
20+
const parseSide = (sideValue) => {
21+
if (!sideValue) return undefined;
22+
const [rawValue, unit] = parseSizeUnit(sideValue);
23+
const numericValue = Number(rawValue);
24+
const calculatedValue = unit === 'pt' ? halfPointToPixels(numericValue) : numericValue;
25+
return calculatedValue;
26+
};
27+
28+
/**
29+
* Parse cell margins from inline TD/TH padding styles.
30+
*
31+
* @param {HTMLElement} element
32+
* @returns {CellMargins | null}
33+
*/
34+
export const parseCellMargins = (element) => {
35+
const { style } = element;
36+
37+
const top = parseSide(style?.paddingTop);
38+
const right = parseSide(style?.paddingRight);
39+
const bottom = parseSide(style?.paddingBottom);
40+
const left = parseSide(style?.paddingLeft);
41+
42+
if (top == null && right == null && bottom == null && left == null) {
43+
return null;
44+
}
45+
46+
return {
47+
...(top != null ? { top } : {}),
48+
...(right != null ? { right } : {}),
49+
...(bottom != null ? { bottom } : {}),
50+
...(left != null ? { left } : {}),
51+
};
52+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// @ts-check
2+
3+
/**
4+
* Parse `vertical-align` from inline styles on table cells (TD/TH), e.g. pasted HTML.
5+
* Maps CSS `middle` to the schema value `center`.
6+
*
7+
* @param {HTMLElement} element
8+
* @returns {string | null}
9+
*/
10+
export function parseCellVerticalAlignFromStyle(element) {
11+
const value = element.style?.verticalAlign;
12+
if (!value || typeof value !== 'string') return null;
13+
const normalized = value.trim().toLowerCase();
14+
if (normalized === 'middle') return 'center';
15+
return normalized;
16+
}

packages/super-editor/src/editors/v1/extensions/table-cell/helpers/renderCellBorderStyle.js renamed to packages/super-editor/src/editors/v1/extensions/shared/renderCellBorderStyle.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Shared by both `tableCell` and `tableHeader` node `renderDOM` methods
77
* so the border-rendering logic stays in one place.
88
*
9-
* @param {import('./createCellBorders.js').CellBorders | null | undefined} borders
9+
* @param {import('../table-cell/helpers/createCellBorders.js').CellBorders | null | undefined} borders
1010
* @returns {{ style: string } | {}}
1111
*/
1212
export const renderCellBorderStyle = (borders) => {

0 commit comments

Comments
 (0)