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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const CSS_LENGTH_TO_PT = { pt: 1, px: 72 / 96, in: 72, cm: 28.3465, mm: 2.83465 };
const CSS_ALIGN_TO_OOXML = { center: 'center', right: 'right', justify: 'justify', end: 'right' };

/**
* Parse a CSS length value and return { points, unit }.
Expand Down Expand Up @@ -104,6 +105,17 @@ export function parseAttrs(node) {
}
}

// CSS inline style fallback for text-align (e.g. Google Docs paste)
// Skip 'left' — Google Docs sets text-align: left on every paragraph,
// and storing it would bake in unnecessary direct formatting on export.
let justification;
if (!justification && node.style) {
const textAlign = node.style.textAlign;
if (textAlign && CSS_ALIGN_TO_OOXML[textAlign]) {
justification = CSS_ALIGN_TO_OOXML[textAlign];
}
}

let attrs = {
paragraphProperties: {
styleId: styleId || null,
Expand All @@ -119,6 +131,10 @@ export function parseAttrs(node) {
attrs.paragraphProperties.spacing = spacing;
}

if (justification) {
attrs.paragraphProperties.justification = justification;
}

if (Object.keys(numberingProperties).length > 0) {
attrs.paragraphProperties.numberingProperties = numberingProperties;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('parseAttrs', () => {
const node = createMockNode({}, { marginTop: '16px' });
const result = parseAttrs(node);
// 16px / 1.333 = ~12pt, * 20 = ~240 twips
const expectedPt = 16 * 72 / 96;
const expectedPt = (16 * 72) / 96;
expect(result.paragraphProperties.spacing.before).toBe(Math.round(expectedPt * 20));
});

Expand All @@ -108,7 +108,7 @@ describe('parseAttrs', () => {
it('extracts marginLeft in px and converts to twips', () => {
const node = createMockNode({}, { marginLeft: '48px' });
const result = parseAttrs(node);
const expectedPt = 48 * 72 / 96;
const expectedPt = (48 * 72) / 96;
expect(result.paragraphProperties.indent.left).toBe(Math.round(expectedPt * 20));
});

Expand All @@ -133,7 +133,7 @@ describe('parseAttrs', () => {
const node = createMockNode({}, { lineHeight: '24px' });
const result = parseAttrs(node);
// 24px / 1.333 ≈ 18pt, * 20 = 360 twips
expect(result.paragraphProperties.spacing.line).toBe(Math.round((24 * 72 / 96) * 20));
expect(result.paragraphProperties.spacing.line).toBe(Math.round(((24 * 72) / 96) * 20));
expect(result.paragraphProperties.spacing.lineRule).toBe('exact');
});

Expand Down Expand Up @@ -234,4 +234,56 @@ describe('parseAttrs', () => {
expect(result.paragraphProperties.indent).toBeUndefined();
});
});

describe('CSS text-align fallback (Google Docs paste)', () => {
it('extracts text-align: center as justification', () => {
const node = createMockNode({}, { textAlign: 'center' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBe('center');
});

it('extracts text-align: right as justification', () => {
const node = createMockNode({}, { textAlign: 'right' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBe('right');
});

it('extracts text-align: justify as justification', () => {
const node = createMockNode({}, { textAlign: 'justify' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBe('justify');
});

it('skips text-align: left (default, avoids unnecessary direct formatting)', () => {
const node = createMockNode({}, { textAlign: 'left' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBeUndefined();
});

it('skips text-align: start (maps to left, which is default)', () => {
const node = createMockNode({}, { textAlign: 'start' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBeUndefined();
});

it('maps text-align: end to justification right', () => {
const node = createMockNode({}, { textAlign: 'end' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBe('right');
});

it('ignores invalid text-align values', () => {
const node = createMockNode({}, { textAlign: 'middle' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBeUndefined();
});

it('combines text-align with spacing and indent CSS fallbacks', () => {
const node = createMockNode({}, { textAlign: 'center', lineHeight: '1.5', marginLeft: '36pt' });
const result = parseAttrs(node);
expect(result.paragraphProperties.justification).toBe('center');
expect(result.paragraphProperties.spacing.line).toBe(Math.round((1.5 * 240) / 1.15));
expect(result.paragraphProperties.indent.left).toBe(720);
});
});
});
2 changes: 1 addition & 1 deletion tests/behavior/fixtures/superdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ function createFixture(page: Page, editor: Locator, modKey: string) {
throw new Error(`assertTextMarkAttrs only supports "link" and "textStyle" via document-api; got "${markName}".`);
},

async assertTextAlignment(text: string, expectedAlignment: string, occurrence = 0) {
async assertTextAlignment(text: string, expectedAlignment: string | null, occurrence = 0) {
await expect
.poll(() =>
page.evaluate(
Expand Down
43 changes: 43 additions & 0 deletions tests/behavior/tests/formatting/paste-text-align.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test } from '../../fixtures/superdoc.js';

test.use({ config: { toolbar: 'full', showSelection: true } });

/**
* Insert HTML via editor.commands.insertContent to simulate the paste path
* (HTML → parseDOM → parseAttrs → document model).
*/
async function insertHTML(page: import('@playwright/test').Page, html: string) {
await page.evaluate((h) => {
const editor = (window as any).editor;
editor.commands.insertContent(h);
}, html);
}

test('pasted center-aligned paragraph preserves alignment', async ({ superdoc }) => {
await insertHTML(superdoc.page, '<p style="text-align: center">Centered text</p>');
await superdoc.waitForStable();

await superdoc.assertTextAlignment('Centered text', 'center');
});

test('pasted right-aligned paragraph preserves alignment', async ({ superdoc }) => {
await insertHTML(superdoc.page, '<p style="text-align: right">Right text</p>');
await superdoc.waitForStable();

await superdoc.assertTextAlignment('Right text', 'right');
});

test('pasted justified paragraph preserves alignment', async ({ superdoc }) => {
await insertHTML(superdoc.page, '<p style="text-align: justify">Justified text</p>');
await superdoc.waitForStable();

await superdoc.assertTextAlignment('Justified text', 'justify');
});

test('pasted left-aligned paragraph does not store alignment (default)', async ({ superdoc }) => {
await insertHTML(superdoc.page, '<p style="text-align: left">Left text</p>');
await superdoc.waitForStable();

// left is the default — parseAttrs skips it to avoid baking in direct formatting
await superdoc.assertTextAlignment('Left text', null);
});
Loading