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([ + '

Heading Bold

', + '', + ].join('')), + ).toBe('# Heading **Bold**\n\n- bullet **bold**\n- bullet *italic*'); + }); + + it('preserves CSS formatting inside links', () => { + 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 Date: Mon, 6 Jul 2026 00:07:25 +0800 Subject: [PATCH 2/2] fix(desktop): preserve RTL text direction in PDF and print export (#4874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): preserve RTL text direction in PDF and print export RTL documents (e.g. Persian) exported to PDF or printed came out left-aligned with reversed word order, even though the editor renders them right-to-left. #4553 added `dir` to the exporter's `` shell, which works for the styled-HTML export (written to disk verbatim). But the PDF/print path feeds that HTML through `printer.renderMarkdown(html)`, which assigns it to `printContainer.innerHTML` — dropping the outer `` wrapper so only body content survives. The print container is appended to `document.body` as a sibling of `.editor-wrapper` (which holds the live `dir`), so it inherits no direction and `printToPDF` prints it LTR. Propagate the direction onto the print container itself, mirroring how the editor applies `dir` to its wrapper. LTR stays implicit to keep existing exports unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * test(desktop): cover RTL direction on the print container Pin that the PDF/print container carries `dir=rtl`/`auto` and stays implicit for LTR and the unset default (#4833). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/editorWithTabs/editor.vue | 4 +- .../src/renderer/src/services/printService.ts | 9 +++- .../unit/specs/printService-direction.spec.ts | 52 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/test/unit/specs/printService-direction.spec.ts diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue index de80df4eae..6d2999ba17 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue @@ -1327,7 +1327,7 @@ const handleExport = async (options: unknown) => { headerFooterStyled: headerFooterStyled as boolean | undefined, dir: props.textDirection }) - printer!.renderMarkdown(html, true) + printer!.renderMarkdown(html, true, props.textDirection) editorStore.EXPORT({ type, pageOptions }) } catch (err) { log.error('Failed to export document:', err) @@ -1353,7 +1353,7 @@ const handleExport = async (options: unknown) => { headerFooterStyled: headerFooterStyled as boolean | undefined, dir: props.textDirection }) - printer!.renderMarkdown(html, true) + printer!.renderMarkdown(html, true, props.textDirection) editorStore.PRINT_RESPONSE() } catch (err) { log.error('Failed to export document:', err) diff --git a/packages/desktop/src/renderer/src/services/printService.ts b/packages/desktop/src/renderer/src/services/printService.ts index 747a00ec16..f42b14b756 100644 --- a/packages/desktop/src/renderer/src/services/printService.ts +++ b/packages/desktop/src/renderer/src/services/printService.ts @@ -9,11 +9,18 @@ class MarkdownPrint { * * @param html HTML string * @param renderStatic Render for static files like PDF documents + * @param dir Text direction to mirror onto the container. `innerHTML` drops + * the exporter's outer `` shell and the container is a sibling + * of `.editor-wrapper`, so RTL documents print LTR unless we set it here + * (#4833). LTR is the default and stays implicit. */ - renderMarkdown(html: string, renderStatic?: boolean): void { + renderMarkdown(html: string, renderStatic?: boolean, dir?: string): void { this.clearup() const printContainer = document.createElement('article') printContainer.classList.add('print-container') + if (dir === 'rtl' || dir === 'auto') { + printContainer.setAttribute('dir', dir) + } this.container = printContainer printContainer.innerHTML = html diff --git a/packages/desktop/test/unit/specs/printService-direction.spec.ts b/packages/desktop/test/unit/specs/printService-direction.spec.ts new file mode 100644 index 0000000000..55ab2b7e6c --- /dev/null +++ b/packages/desktop/test/unit/specs/printService-direction.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// `printService` imports `resolveLocalImageSrc`, which reads `window.path` / +// `window.DIRNAME` for its relative-resolve branch. Stub those preload +// surfaces before the hoisted import runs (mirrors printService-image.spec.ts). +vi.hoisted(() => { + const w = globalThis as unknown as { + window?: { path?: { sep: string }; DIRNAME?: string } + } + w.window ??= {} + w.window.path ??= { sep: '/' } + w.window.DIRNAME = '/docs' +}) + +import MarkdownPrint from '@/services/printService' + +// PDF / print render into an `