سلام
From 5de5df43b3cda77b35cfba5f576cbf7a1e416c0e Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:25:42 +0800 Subject: [PATCH 1/2] fix: preserve Google Docs CSS formatting on paste (#4699) * fix: preserve Google Docs CSS formatting on paste * fix(muya): avoid duplicate paste formatting --- .../muya/e2e/tests/editing/clipboard.spec.ts | 24 +++ .../state/__tests__/htmlToMarkdown.spec.ts | 138 +++++++++++++++++ .../muya/src/utils/turndownService/index.ts | 141 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 packages/muya/src/state/__tests__/htmlToMarkdown.spec.ts diff --git a/packages/muya/e2e/tests/editing/clipboard.spec.ts b/packages/muya/e2e/tests/editing/clipboard.spec.ts index b9eb2ac7eb..6b8834f433 100644 --- a/packages/muya/e2e/tests/editing/clipboard.spec.ts +++ b/packages/muya/e2e/tests/editing/clipboard.spec.ts @@ -45,6 +45,30 @@ test.describe('clipboard paste', () => { }).toMatch(/\*\*foo\*\*/); }); + test('pasting Google Docs CSS formatting preserves the real bold and italic ranges', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + const html = [ + '', + '
', + 'Bold', + ' normal ', + 'italic', + '
', + '', + ].join(''); + + await pasteClipboard(page, html, 'Bold normal italic'); + + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe('**Bold** normal *italic*\n'); + + const md = await getMarkdown(page); + expect(md).not.toContain('**\n\n'); + }); + test('pasting converts to markdown link', async ({ browserName, context, page }) => { test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); await grantClipboardPermissions(context); diff --git a/packages/muya/src/state/__tests__/htmlToMarkdown.spec.ts b/packages/muya/src/state/__tests__/htmlToMarkdown.spec.ts new file mode 100644 index 0000000000..9d2fd61006 --- /dev/null +++ b/packages/muya/src/state/__tests__/htmlToMarkdown.spec.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; +import HtmlToMarkdown from '../htmlToMarkdown'; + +function convert(html: string): string { + return new HtmlToMarkdown().generate(html); +} + +describe('htmlToMarkdown — Google Docs style inline formatting', () => { + it('preserves CSS bold and italic ranges from Google Docs without bolding the wrapper', () => { + const html = [ + '', + '', + 'Bold', + ' normal ', + 'italic', + '
', + '', + ].join(''); + + expect(convert(html)).toBe('**Bold** normal *italic*'); + }); + + it('does not treat a non-bold Google Docs wrapper as strong across paragraphs', () => { + const html = [ + '', + 'First para.
', + 'Second bold para.
', + '', + ].join(''); + + expect(convert(html)).toBe('First para.\n\n**Second bold para.**'); + }); + + it('preserves CSS formatting inside headings and list items', () => { + expect( + convert([ + '', + 'Plain link', + ' and ', + 'boldlink', + '', + '
', + ].join('')), + ).toBe('Plain [link](https://example.com/plain) and [**boldlink**](https://example.com/bold)'); + }); + + it('recognizes common CSS strong weights without treating medium weights as bold', () => { + expect( + convert([ + '', + 'Bold keyword', + ' ', + 'Bold numeric', + ' ', + 'Medium', + '
', + ].join('')), + ).toBe('**Bold keyword** **Bold numeric** Medium'); + }); + + it('combines CSS bold and italic when both styles are on the same span', () => { + expect( + convert('Bold italic
'), + ).toBe('***Bold italic***'); + }); + + it('lets explicit normal CSS override semantic bold and italic wrappers', () => { + expect( + convert([ + '', + 'Not bold', + ' ', + 'Not italic', + '
', + ].join('')), + ).toBe('Not bold Not italic'); + }); + + it('does not duplicate formatting when CSS spans are already inside semantic tags', () => { + expect( + convert([ + '', + 'bold', + ' ', + 'italic', + ' ', + 'bold italic', + '
', + ].join('')), + ).toBe('**bold** *italic* ***bold italic***'); + }); + + it('still applies CSS spans inside semantic tags disabled by normal CSS', () => { + expect( + convert([ + '', + '', + 'Bold', + '', + ' ', + '', + 'italic', + '', + '
', + ].join('')), + ).toBe('**Bold** *italic*'); + }); + + it('does not duplicate formatting through disabled semantic wrappers inside active ancestors', () => { + expect( + convert('Bold
'), + ).toBe('**Bold**'); + + expect( + convert('italic
'), + ).toBe('*italic*'); + }); + + it('keeps normal semantic strong and emphasis HTML unchanged', () => { + expect( + convert('Plain boldword and italicword end.
'), + ).toBe('Plain **boldword** and *italicword* end.'); + }); +}); diff --git a/packages/muya/src/utils/turndownService/index.ts b/packages/muya/src/utils/turndownService/index.ts index 19590f9f49..fe8a264767 100644 --- a/packages/muya/src/utils/turndownService/index.ts +++ b/packages/muya/src/utils/turndownService/index.ts @@ -5,6 +5,110 @@ import { identity, isHTMLElement, isHTMLInputElement } from '../../utils'; const DEFAULT_KEEPS: Filter = ['u', 'mark', 'ruby', 'rt', 'sub', 'sup']; +function inlineStyleValue(node: Node, name: keyof CSSStyleDeclaration): string { + return isHTMLElement(node) ? String(node.style[name]).trim().toLowerCase() : ''; +} + +function hasStrongFontWeight(node: Node): boolean { + const fontWeight = inlineStyleValue(node, 'fontWeight'); + if (/^(?:bold|bolder)$/.test(fontWeight)) + return true; + + const numericWeight = Number.parseInt(fontWeight, 10); + return Number.isFinite(numericWeight) && numericWeight >= 600; +} + +function hasNonStrongFontWeight(node: Node): boolean { + const fontWeight = inlineStyleValue(node, 'fontWeight'); + if (!fontWeight) + return false; + if (/^(?:normal|lighter)$/.test(fontWeight)) + return true; + + const numericWeight = Number.parseInt(fontWeight, 10); + return Number.isFinite(numericWeight) && numericWeight < 600; +} + +function hasItalicFontStyle(node: Node): boolean { + return /^(?:italic|oblique)/.test(inlineStyleValue(node, 'fontStyle')); +} + +function hasNormalFontStyle(node: Node): boolean { + return inlineStyleValue(node, 'fontStyle') === 'normal'; +} + +function isStyledSpan(node: Node): boolean { + return isHTMLElement(node) && node.nodeName === 'SPAN'; +} + +function isSemanticStrong(node: Node): boolean { + return isHTMLElement(node) && /^(?:B|STRONG)$/.test(node.nodeName); +} + +function isSemanticEmphasis(node: Node): boolean { + return isHTMLElement(node) && /^(?:I|EM)$/.test(node.nodeName); +} + +function hasSemanticAncestor( + node: Node, + isSemantic: (node: Node) => boolean, + isDisabled: (node: Node) => boolean, +): boolean { + let current = node.parentElement; + while (current) { + if (isSemantic(current) && !isDisabled(current)) + return true; + current = current.parentElement; + } + + return false; +} + +function hasStrongSemanticAncestor(node: Node): boolean { + return hasSemanticAncestor(node, isSemanticStrong, hasNonStrongFontWeight); +} + +function hasEmphasisSemanticAncestor(node: Node): boolean { + return hasSemanticAncestor(node, isSemanticEmphasis, hasNormalFontStyle); +} + +function strongDelimiter(options: TurndownService.Options): string { + return options.strongDelimiter ?? '**'; +} + +function emDelimiter(options: TurndownService.Options): string { + return options.emDelimiter ?? '*'; +} + +function formatContent( + content: string, + options: TurndownService.Options, + strong: boolean, + emphasis: boolean, +): string { + if (!content) + return ''; + + let result = content; + if (emphasis) { + const delimiter = emDelimiter(options); + result = `${delimiter}${result}${delimiter}`; + } + if (strong) { + const delimiter = strongDelimiter(options); + result = `${delimiter}${result}${delimiter}`; + } + + return result; +} + +function getInlineStyleFormatting(node: Node) { + return { + strong: hasStrongFontWeight(node) && !hasStrongSemanticAncestor(node), + emphasis: hasItalicFontStyle(node) && !hasEmphasisSemanticAncestor(node), + }; +} + function isTaskListCheckbox(node: unknown) { return ( isHTMLInputElement(node) @@ -37,6 +141,43 @@ export function usePluginsAddRules(turndownService: TurndownService) { }, }); + turndownService.addRule('nonStrongSemantic', { + filter(node: Node) { + return isSemanticStrong(node) && hasNonStrongFontWeight(node); + }, + replacement(content: string, node: Node, options: TurndownService.Options) { + return formatContent(content, options, false, hasItalicFontStyle(node)); + }, + }); + + turndownService.addRule('nonEmphasisSemantic', { + filter(node: Node) { + return isSemanticEmphasis(node) && hasNormalFontStyle(node); + }, + replacement(content: string, node: Node, options: TurndownService.Options) { + return formatContent(content, options, hasStrongFontWeight(node), false); + }, + }); + + turndownService.addRule('cssInlineStyle', { + filter(node: Node) { + if (!isStyledSpan(node)) + return false; + + const formatting = getInlineStyleFormatting(node); + return formatting.strong || formatting.emphasis; + }, + replacement(content: string, node: Node, options: TurndownService.Options) { + const formatting = getInlineStyleFormatting(node); + return formatContent( + content, + options, + formatting.strong, + formatting.emphasis, + ); + }, + }); + turndownService.addRule('heading', { filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], From 43bd8b77795fb27b1a9512737c000f7362031ea0 Mon Sep 17 00:00:00 2001 From: Ran Luoسلام