diff --git a/tests/e2e/export-soft-break-layout.spec.ts b/tests/e2e/export-soft-break-layout.spec.ts
new file mode 100644
index 0000000..208267b
--- /dev/null
+++ b/tests/e2e/export-soft-break-layout.spec.ts
@@ -0,0 +1,102 @@
+import { readFileSync } from 'node:fs'
+import { expect, test, type Page } from '@playwright/test'
+
+// Browser layout coverage for the #3676 export soft-break fix (PR review):
+// the export stylesheet renders tight list items `white-space: pre-wrap`, and
+// `white-space` inherits — so marked's formatting-only newlines around nested
+// block children would each render as a visible empty line box. The export
+// pipeline strips that serializer whitespace (markdownToHtml,
+// `_stripListFormattingWhitespace`; shape pinned by
+// third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts). This
+// spec closes the loop in a real Chromium: the stylesheet plus the pipeline's
+// output shape lay out without phantom blank rows, while authored soft
+// breaks still render as real line breaks.
+
+// Playwright runs from the repo root; the config's testDir keeps that cwd.
+const exportCss = readFileSync('third_party/muya/src/assets/styles/exportStyle.css', 'utf8')
+
+async function measureListItemHeight(page: Page, listInner: string, extraCss = '') {
+ await page.setContent(
+ `` +
+ ``,
+ )
+ return page.evaluate(
+ () => document.querySelector('.markdown-body > ul > li')!.getBoundingClientRect().height,
+ )
+}
+
+test('an authored soft break in a tight item renders as a real second line', async ({ page }) => {
+ const single = await measureListItemHeight(page, '
line A')
+ const soft = await measureListItemHeight(page, 'line A\nline B')
+
+ expect(soft).toBeGreaterThan(single * 1.8)
+ expect(soft).toBeLessThan(single * 2.6)
+})
+
+test('a soft break between inline-wrapped lines renders as a real second line', async ({ page }) => {
+ // The authored newline lives in a whitespace-only text node BETWEEN two
+ // inline elements — the strip must not eat it (review round 2).
+ const single = await measureListItemHeight(page, 'left')
+ const soft = await measureListItemHeight(
+ page,
+ 'left\nright',
+ )
+
+ expect(soft).toBeGreaterThan(single * 1.8)
+ expect(soft).toBeLessThan(single * 2.6)
+})
+
+test('the cleaned nested-list shape adds no phantom blank rows', async ({ page }) => {
+ // Exactly what the export pipeline emits for `- line A\n - child`.
+ const cleaned = 'line A'
+ const withPreWrap = await measureListItemHeight(page, cleaned)
+ const control = await measureListItemHeight(
+ page,
+ cleaned,
+ '.markdown-body li { white-space: normal !important; }',
+ )
+
+ expect(withPreWrap).toBe(control)
+})
+
+test("marked's uncleaned serializer newlines DO create blank rows (premise)", async ({ page }) => {
+ // marked's raw serialization for the same markdown — the shape the
+ // pipeline strip exists to prevent. If this stops failing tall, the strip
+ // (and this suite) can be reconsidered.
+ const raw = 'line A\n\n'
+ const withPreWrap = await measureListItemHeight(page, raw)
+ const control = await measureListItemHeight(
+ page,
+ raw,
+ '.markdown-body li { white-space: normal !important; }',
+ )
+
+ expect(withPreWrap).toBeGreaterThan(control)
+})
+
+test('a heading as the sole item child adds no phantom blank rows', async ({ page }) => {
+ // `- # heading` emits `…
\n`; H1 must count as a block
+ // child in the cleanup or the trailing newline renders as a phantom row
+ // (review round 3 measured 85px instead of 43px).
+ const cleaned = 'heading
'
+ const withPreWrap = await measureListItemHeight(page, cleaned)
+ const control = await measureListItemHeight(
+ page,
+ cleaned,
+ '.markdown-body li { white-space: normal !important; }',
+ )
+
+ expect(withPreWrap).toBe(control)
+})
+
+test('the cleaned nested-blockquote shape adds no phantom blank rows', async ({ page }) => {
+ const cleaned = 'line Aquoted
'
+ const withPreWrap = await measureListItemHeight(page, cleaned)
+ const control = await measureListItemHeight(
+ page,
+ cleaned,
+ '.markdown-body li { white-space: normal !important; }',
+ )
+
+ expect(withPreWrap).toBe(control)
+})
diff --git a/third_party/muya/src/assets/styles/exportStyle.css b/third_party/muya/src/assets/styles/exportStyle.css
index e362724..333faba 100644
--- a/third_party/muya/src/assets/styles/exportStyle.css
+++ b/third_party/muya/src/assets/styles/exportStyle.css
@@ -89,6 +89,22 @@
white-space: pre-wrap;
}
+/* Render soft line breaks (Shift+Enter → a bare `\n` inside a block) the way
+ the editor does (`.mu-content` is pre-wrap) instead of emitting a
+ non-standard `
`, so the exported HTML stays CommonMark-conformant (the
+ `\n` is a plain line ending) yet still shows the break (marktext#3676).
+
+ `li:not(:has(> p))` targets only tight list items, whose soft break is a
+ bare `\n` directly inside the ``. Loose items wrap their content in
+ `` (handled by the `p` rule); giving them `li` pre-wrap would expose
+ marked's pretty-printing newline between `
` and `` as a stray blank
+ line, so they are deliberately excluded. Specificity stays level with the
+ earlier `.toc-container ul li` rule (no-descending-specificity). */
+.markdown-body p,
+.markdown-body li:not(:has(> p)) {
+ white-space: pre-wrap;
+}
+
.markdown-body table {
display: table;
}
diff --git a/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts
new file mode 100644
index 0000000..110b5cc
--- /dev/null
+++ b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts
@@ -0,0 +1,132 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it } from 'vitest';
+import { MarkdownToHtml } from '../markdownToHtml';
+import { getHighlightHtml } from '../../utils/marked';
+
+// marktext#3676 — a soft line break (Shift+Enter, serialized as a bare `\n`
+// inside a block) shows as a line break in the editor (`.mu-content` is
+// pre-wrap) but was lost on export because marked renders a soft break as a
+// space. Rather than emit a non-standard `
` (which CommonMark reserves for
+// hard breaks), the export keeps the conformant `\n` and renders it with
+// `white-space: pre-wrap` on `.markdown-body p` and `li:not(:has(> p))`
+// (tight items only).
+//
+// Because `white-space` inherits, marked's pretty-printing newlines around
+// nested block children (`line A\n`, `\n`) would
+// each render as a visible empty line box under that rule, so the export
+// pipeline strips serializer-only whitespace from list/blockquote contexts
+// while leaving authored soft breaks (which always have text after them)
+// untouched. The pixel-level rendering of both halves is covered by the
+// Playwright layout spec (tests/e2e/export-soft-break-layout.spec.ts).
+
+const OPTS = { math: false, superSubScript: false, footnote: false, frontMatter: false };
+
+describe('#3676 — soft line breaks survive export as a conformant newline', () => {
+ it('keeps a paragraph soft break as a newline, never a
', () => {
+ const html = getHighlightHtml('line one\nline two', OPTS);
+ expect(html).toContain('line one\nline two
');
+ expect(html).not.toMatch(/
/);
+ });
+
+ it('keeps a soft break inside a tight list item, never a
', () => {
+ const html = getHighlightHtml('- line A\n line B', OPTS);
+ expect(html).toMatch(/- line A\nline B<\/li>/);
+ expect(html).not.toMatch(/
/);
+ });
+
+ it('leaves a real hard break (two trailing spaces) as
', () => {
+ // Sanity: the change only touches soft breaks; hard breaks are untouched.
+ const html = getHighlightHtml('line one \nline two', OPTS);
+ expect(html).toMatch(/
/);
+ });
+});
+
+describe('export pipeline strips serializer whitespace in pre-wrap list contexts', () => {
+ it('a tight item with a nested list carries no formatting newlines', async () => {
+ const html = await new MarkdownToHtml('- line A\n - child\n').renderHtml();
+
+ // marked emits `line A\n\n `; every one
+ // of those newlines is an empty line box under inherited pre-wrap.
+ expect(html).toContain('- line A
');
+ });
+
+ it('keeps the authored soft break while stripping the serializer newline', async () => {
+ const html = await new MarkdownToHtml('- line A\n line B\n - child\n').renderHtml();
+
+ // The `\n` between "line A" and "line B" is the user's; the `\n`
+ // between "line B" and the nested list is marked's.
+ expect(html).toContain('- line A\nline B
');
+ });
+
+ it('a nested blockquote in a tight item carries no formatting newlines', async () => {
+ const html = await new MarkdownToHtml('- line A\n > quoted\n').renderHtml();
+
+ expect(html).toMatch(/- line A
\s*quoted<\/p>\s*<\/blockquote><\/li>/);
+ // The blockquote's own children are not under the tight-item pre-wrap
+ // text path, but strip its formatting whitespace too — `white-space`
+ // inherits into it.
+ expect(html).not.toMatch(/\n<\/li>/);
+ });
+
+ it('preserves the authored space between inline siblings', async () => {
+ const html = await new MarkdownToHtml('- **left** **right**\n').renderHtml();
+
+ // The space between the two elements is authored text, not
+ // serializer formatting — removing it would export "leftright".
+ expect(html).toContain('left right');
+ });
+
+ it('preserves a soft break whose lines are both wrapped in inline markup', async () => {
+ const html = await new MarkdownToHtml('- **left**\n **right**\n').renderHtml();
+
+ // The newline lives in a whitespace-only text node between two
+ // inline siblings — exactly the soft break this feature exists to
+ // keep.
+ expect(html).toContain('left\nright');
+ });
+
+ it('preserves authored whitespace between inline siblings in a raw blockquote', () => {
+ // Raw HTML blockquotes may carry inline flow content directly —
+ // unlike marked-generated ones, which always wrap children in
+ // blocks. happy-dom's sanitize unwraps top-level blockquotes, so
+ // drive the cleanup itself; the real-browser pipeline case lives in
+ // the upstream muya e2e suite.
+ const container = document.createElement('div');
+ container.innerHTML
+ = 'left right
';
+ (new MarkdownToHtml('') as unknown as {
+ _stripListFormattingWhitespace: (c: HTMLElement) => void;
+ })._stripListFormattingWhitespace(container);
+
+ expect(container.innerHTML).toBe(
+ 'left right
',
+ );
+ });
+
+ it('a heading as the sole list-item child carries no formatting newline', async () => {
+ // marked emits `heading
\n ` for `- # heading`; H1
+ // must count as a block child or the newline renders as a phantom
+ // row under inherited pre-wrap.
+ const html = await new MarkdownToHtml('- # heading\n').renderHtml();
+
+ expect(html).toMatch(/]*>heading<\/h1><\/li>/);
+ expect(html).not.toMatch(/\n<\/li>/);
+ });
+
+ it('a thematic break as the sole list-item child carries no formatting newline', async () => {
+ const html = await new MarkdownToHtml('- * * *\n').renderHtml();
+
+ expect(html).toContain('
');
+ expect(html).not.toMatch(/\n<\/li>/);
+ });
+
+ it('leaves paragraph soft breaks intact through the full pipeline', async () => {
+ // happy-dom's DOMPurify pass unwraps the in this environment (a
+ // test-runtime artifact — the getHighlightHtml assertion above pins
+ // the real markup), so assert only what this suite owns: the strip
+ // never touches an authored newline.
+ const html = await new MarkdownToHtml('line one\nline two\n').renderHtml();
+ expect(html).toContain('line one\nline two');
+ });
+});
diff --git a/third_party/muya/src/state/markdownToHtml.ts b/third_party/muya/src/state/markdownToHtml.ts
index 615f480..75bdb37 100644
--- a/third_party/muya/src/state/markdownToHtml.ts
+++ b/third_party/muya/src/state/markdownToHtml.ts
@@ -28,6 +28,18 @@ const CDN_STYLESHEET_LINKS = `