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
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion packages/desktop/src/renderer/src/services/printService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html dir=…>` 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

Expand Down
52 changes: 52 additions & 0 deletions packages/desktop/test/unit/specs/printService-direction.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<article class="print-container">` appended to
// `document.body` (a sibling of `.editor-wrapper`), and `innerHTML = html`
// discards the outer `<html dir="rtl">` shell produced by the exporter. Without
// propagating the direction onto the container itself, RTL documents print LTR
// (#4833). These specs pin that the container carries the direction.
describe('MarkdownPrint — text direction on the print container', () => {
afterEach(() => {
document.body.querySelectorAll('article.print-container').forEach((n) => n.remove())
})

const article = '<article class="markdown-body"><p>سلام</p></article>'

it('sets dir="rtl" on the print container for an RTL export', () => {
new MarkdownPrint().renderMarkdown(article, true, 'rtl')
const container = document.body.querySelector('article.print-container')
expect(container?.getAttribute('dir')).toBe('rtl')
})

it('sets dir="auto" on the print container for an auto export', () => {
new MarkdownPrint().renderMarkdown(article, true, 'auto')
const container = document.body.querySelector('article.print-container')
expect(container?.getAttribute('dir')).toBe('auto')
})

it('leaves the container without a dir attribute for LTR (unchanged default)', () => {
new MarkdownPrint().renderMarkdown(article, true, 'ltr')
const container = document.body.querySelector('article.print-container')
expect(container?.hasAttribute('dir')).toBe(false)
})

it('leaves the container without a dir attribute when no direction is passed', () => {
new MarkdownPrint().renderMarkdown(article, true)
const container = document.body.querySelector('article.print-container')
expect(container?.hasAttribute('dir')).toBe(false)
})
})
24 changes: 24 additions & 0 deletions packages/muya/e2e/tests/editing/clipboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
'<b style="font-weight:normal" id="docs-internal-guid-abc">',
'<p dir="ltr">',
'<span style="font-weight:700">Bold</span>',
'<span> normal </span>',
'<span style="font-style:italic">italic</span>',
'</p>',
'</b>',
].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 <a href> 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);
Expand Down
138 changes: 138 additions & 0 deletions packages/muya/src/state/__tests__/htmlToMarkdown.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = [
'<b style="font-weight:normal" id="docs-internal-guid-abc">',
'<p dir="ltr">',
'<span style="font-weight:700">Bold</span>',
'<span> normal </span>',
'<span style="font-style:italic">italic</span>',
'</p>',
'</b>',
].join('');

expect(convert(html)).toBe('**Bold** normal *italic*');
});

it('does not treat a non-bold Google Docs wrapper as strong across paragraphs', () => {
const html = [
'<b style="font-weight:normal" id="docs-internal-guid-abc">',
'<p>First para.</p>',
'<p><span style="font-weight:700">Second bold para.</span></p>',
'</b>',
].join('');

expect(convert(html)).toBe('First para.\n\n**Second bold para.**');
});

it('preserves CSS formatting inside headings and list items', () => {
expect(
convert([
'<h1><span>Heading </span><span style="font-weight:700">Bold</span></h1>',
'<ul>',
'<li><span>bullet </span><span style="font-weight:700">bold</span></li>',
'<li><span>bullet </span><span style="font-style:italic">italic</span></li>',
'</ul>',
].join('')),
).toBe('# Heading **Bold**\n\n- bullet **bold**\n- bullet *italic*');
});

it('preserves CSS formatting inside links', () => {
expect(
convert([
'<p>',
'Plain <a href="https://example.com/plain"><span>link</span></a>',
' and <a href="https://example.com/bold">',
'<span style="font-weight:700">boldlink</span>',
'</a>',
'</p>',
].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([
'<p>',
'<span style="font-weight:bold">Bold keyword</span>',
'<span> </span>',
'<span style="font-weight:600">Bold numeric</span>',
'<span> </span>',
'<span style="font-weight:500">Medium</span>',
'</p>',
].join('')),
).toBe('**Bold keyword** **Bold numeric** Medium');
});

it('combines CSS bold and italic when both styles are on the same span', () => {
expect(
convert('<p><span style="font-weight:700;font-style:italic">Bold italic</span></p>'),
).toBe('***Bold italic***');
});

it('lets explicit normal CSS override semantic bold and italic wrappers', () => {
expect(
convert([
'<p>',
'<b style="font-weight:normal">Not bold</b>',
'<span> </span>',
'<i style="font-style:normal">Not italic</i>',
'</p>',
].join('')),
).toBe('Not bold Not italic');
});

it('does not duplicate formatting when CSS spans are already inside semantic tags', () => {
expect(
convert([
'<p>',
'<strong><span style="font-weight:700">bold</span></strong>',
' ',
'<em><span style="font-style:italic">italic</span></em>',
' ',
'<strong><span style="font-style:italic">bold italic</span></strong>',
'</p>',
].join('')),
).toBe('**bold** *italic* ***bold italic***');
});

it('still applies CSS spans inside semantic tags disabled by normal CSS', () => {
expect(
convert([
'<p>',
'<b style="font-weight:normal">',
'<span style="font-weight:700">Bold</span>',
'</b>',
' ',
'<i style="font-style:normal">',
'<span style="font-style:italic">italic</span>',
'</i>',
'</p>',
].join('')),
).toBe('**Bold** *italic*');
});

it('does not duplicate formatting through disabled semantic wrappers inside active ancestors', () => {
expect(
convert('<p><strong><b style="font-weight:normal"><span style="font-weight:700">Bold</span></b></strong></p>'),
).toBe('**Bold**');

expect(
convert('<p><em><i style="font-style:normal"><span style="font-style:italic">italic</span></i></em></p>'),
).toBe('*italic*');
});

it('keeps normal semantic strong and emphasis HTML unchanged', () => {
expect(
convert('<p>Plain <strong>boldword</strong> and <em>italicword</em> end.</p>'),
).toBe('Plain **boldword** and *italicword* end.');
});
});
Loading
Loading