Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -5,6 +5,17 @@ import { createSingleItemList } from '../html/html-helpers.js';
import { getLvlTextForGoogleList, googleNumDefMap } from '../../helpers/pasteListHelpers.js';
import { wrapTextsInRuns } from '../docx-paste/docx-paste.js';

// Ordered largest → smallest; first match wins.
const headingSizeMap = [
{ minPt: 20, tag: 'h1' },
{ minPt: 16, tag: 'h2' },
{ minPt: 14, tag: 'h3' },
{ minPt: 12, tag: 'h4' },
{ minPt: 10, tag: 'h5' },
];

const boldWeightRegex = /^(bold|700|800|900)$/i;

/**
* Main handler for pasted Google Docs content.
*
Expand All @@ -21,7 +32,9 @@ export const handleGoogleDocsHtml = (html, editor, view) => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = cleanedHtml;

const htmlWithMergedLists = mergeSeparateLists(tempDiv);
const tempDivWithHeadings = convertStyledHeadings(tempDiv);

const htmlWithMergedLists = mergeSeparateLists(tempDivWithHeadings);
const flattenHtml = flattenListsInHtml(htmlWithMergedLists, editor);

let doc = DOMParser.fromSchema(editor.schema).parse(flattenHtml);
Expand Down Expand Up @@ -253,3 +266,60 @@ function buildListPath(level, map) {
}
return path;
}

/**
* Converts Google Docs styled <p> elements that represent headings into proper
* <h1>–<h5> tags before ProseMirror parsing.
*
* Google Docs converts heading levels to <p> tags with inline font-size /
* font-weight styling instead of semantic heading tags. This function detects
* that pattern and replaces the elements in-place.
*
* @param {HTMLElement} container
*/
function convertStyledHeadings(container) {
const paragraphs = Array.from(container.querySelectorAll('p'));

paragraphs.forEach((p) => {
const { fontSize, isBold } = getHeadingStyleProps(p);
if (!isBold || fontSize === null) return;

const match = headingSizeMap.find(({ minPt }) => fontSize >= minPt);
if (!match) return;

const heading = document.createElement(match.tag);
heading.innerHTML = p.innerHTML;
Array.from(p.attributes).forEach((attr) => heading.setAttribute(attr.name, attr.value));
p.replaceWith(heading);
});

return container;
}

/**
* Reads font-size (in pt) and bold status from an element's inline style.
* Checks both the element itself and its first child <span> to cover both
* Google Docs style placements (style on <p> vs. style on inner <span>).
*
* @param {HTMLElement} el
* @returns {{ fontSize: number|null, isBold: boolean }}
*/
function getHeadingStyleProps(el) {
const span = el.querySelector('span');
const fontSize = parsePtValue(el.style.fontSize) ?? parsePtValue(span?.style.fontSize);
const isBold = boldWeightRegex.test(el.style.fontWeight || '') || boldWeightRegex.test(span?.style.fontWeight || '');
return { fontSize, isBold };
}

/**
* Parses a CSS font-size value in pt units, e.g. "20pt" → 20. Returns null
* for any other format.
*
* @param {string|undefined} cssValue
* @returns {number|null}
*/
function parsePtValue(cssValue) {
if (!cssValue) return null;
const m = cssValue.match(/^([\d.]+)pt$/i);
return m ? parseFloat(m[1]) : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,79 @@ describe('handleGoogleDocsHtml', () => {
expect(replaceSelectionWith).toHaveBeenCalledWith(parseResult, true);
expect(dispatch).toHaveBeenCalledWith('next');
});

describe('convertStyledHeadings', () => {
function makeEditor(dispatch, replaceSelectionWith) {
return {
editor: { schema: {}, view: { dispatch }, options: {} },
view: { state: { tr: { replaceSelectionWith } } },
};
}

function parseHeadings(html) {
const dispatch = vi.fn();
const replaceSelectionWith = vi.fn(() => 'next');
const { editor, view } = makeEditor(dispatch, replaceSelectionWith);
handleGoogleDocsHtml(html, editor, view);
return parseSpy.mock.calls[0][0];
}

it('converts bold <p> with large font-size to heading tags', () => {
const html = `
<p style="font-size:20pt;font-weight:700">Heading 1</p>
<p style="font-size:16pt;font-weight:bold">Heading 2</p>
<p style="font-size:14pt;font-weight:700">Heading 3</p>
<p style="font-size:12pt;font-weight:700">Heading 4</p>
<p style="font-size:11pt;font-weight:700">Heading 5</p>
`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1')?.textContent?.trim()).toBe('Heading 1');
expect(dom.querySelector('h2')?.textContent?.trim()).toBe('Heading 2');
expect(dom.querySelector('h3')?.textContent?.trim()).toBe('Heading 3');
expect(dom.querySelector('h4')?.textContent?.trim()).toBe('Heading 4');
expect(dom.querySelector('h5')?.textContent?.trim()).toBe('Heading 5');
});

it('converts when style is on a child <span> instead of the <p>', () => {
const html = `
<p><span style="font-size:20pt;font-weight:700">Heading from span</span></p>
`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1')?.textContent?.trim()).toBe('Heading from span');
expect(dom.querySelector('p')).toBeNull();
});

it('does not convert non-bold paragraphs', () => {
const html = `<p style="font-size:20pt">Not a heading</p>`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1')).toBeNull();
expect(dom.querySelector('p')?.textContent?.trim()).toBe('Not a heading');
});

it('does not convert bold paragraphs with small font-size', () => {
const html = `<p style="font-size:9pt;font-weight:700">Small bold</p>`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1,h2,h3,h4,h5')).toBeNull();
});

it('handles large font-sizes from alternate Google Docs themes (e.g. 24pt → h1)', () => {
const html = `<p style="font-size:24pt;font-weight:700">Big Heading</p>`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1')?.textContent?.trim()).toBe('Big Heading');
});

it('converts when font-size is on <p> but font-weight is only on the child <span>', () => {
const html = `
<p style="font-size:20pt"><span style="font-weight:700">Split style heading</span></p>
`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1')?.textContent?.trim()).toBe('Split style heading');
});

it('preserves attributes from the original <p> on the new heading element', () => {
const html = `<p style="font-size:20pt;font-weight:700" data-custom="yes">With attr</p>`;
const dom = parseHeadings(html);
expect(dom.querySelector('h1')?.getAttribute('data-custom')).toBe('yes');
});
});
});