diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts
index 3f647a75..fdf54afd 100644
--- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts
+++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts
@@ -987,9 +987,14 @@ export function convertProseMirrorToMarkdown(
// like a paragraph followed by a list don't collide into "line1- a".
// Then collapse newlines and escape pipes so a cell containing "|" or a
// line break cannot corrupt the surrounding GFM row.
+ // #549: a GFM pipe cell is single-line — the generic ` \n`/newline
+ // collapse below flattens a hardBreak into a space, dropping it. Convert
+ // the two-space hardBreak marker to `
` FIRST (GFM cells allow inline
+ // `
`, which re-imports to a hardBreak) so intra-cell breaks survive.
return nodeContent
.map(processNode)
.join(" ")
+ .replace(/ {2}\n/g, "
")
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|");
}
@@ -1338,6 +1343,27 @@ export function convertProseMirrorToMarkdown(
parts[i] = mathInlineHtml(nodes[i].attrs?.text || "");
}
}
+ // #549: position-aware hardBreak. The two-space ` \n` form (emitted by the
+ // processNode hardBreak case) only re-imports to a hardBreak when it is
+ // FOLLOWED by same-paragraph text. It is silently lost when the break is
+ // (a) the last inline child — the top-level `.trim()` strips the trailing
+ // ` \n`, and even a non-last paragraph's trailing break sits before the
+ // `\n\n` block gap — or (b) immediately followed by another hardBreak — the
+ // intervening whitespace-only line reads as a paragraph separator on
+ // re-parse, splitting the run. Emit `
` there instead: marked passes the
+ // inline HTML break through and generateJSON rebuilds a hardBreak in every
+ // position. A safe mid-paragraph break keeps the ` \n` form so the existing
+ // golden output stays byte-stable. Skipped inside footnote bodies, whose
+ // `^[…]` inline form collapses breaks to spaces (unchanged; already lossy).
+ if (!inFootnoteBody) {
+ for (let i = 0; i < nodes.length; i++) {
+ if (nodes[i]?.type !== "hardBreak") continue;
+ const next = nodes[i + 1];
+ if (!next || next.type === "hardBreak" || (parts[i + 1] ?? "") === "") {
+ parts[i] = "
";
+ }
+ }
+ }
return parts.join("");
};
diff --git a/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts b/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts
index 6bc634a9..dc971532 100644
--- a/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts
+++ b/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts
@@ -295,3 +295,44 @@ describe('git-sync converter: lose-prone atoms keep their VALUES across a round
expect(allText(src)).toContain('shared body');
});
});
+
+// ---------------------------------------------------------------------------
+// #549: hardBreak COUNT/position preservation across a pm -> md -> pm round
+// trip. The FIXTURES block above only proves a mid-paragraph hardBreak survives
+// as a node; these pin the three sub-cases whose ` \n` two-space form was
+// silently dropped on re-import (trailing / consecutive / table cell), plus a
+// mid-paragraph regression guard so the byte-stable ` \n` form is not lost.
+// ---------------------------------------------------------------------------
+describe('#549: hardBreak survives every round-trip position', () => {
+ const HB = { type: 'hardBreak' };
+ const countHardBreaks = (n: any): number => {
+ let c = n?.type === 'hardBreak' ? 1 : 0;
+ if (Array.isArray(n?.content)) for (const ch of n.content) c += countHardBreaks(ch);
+ return c;
+ };
+ const roundTripCount = async (d: any): Promise =>
+ countHardBreaks(await markdownToProseMirror(convertProseMirrorToMarkdown(d)));
+
+ it('mid-paragraph break is still preserved (regression guard)', async () => {
+ expect(await roundTripCount(doc(P(T('a'), HB, T('b'))))).toBe(1);
+ });
+
+ it('(1) trailing break at the end of the last paragraph survives', async () => {
+ expect(await roundTripCount(doc(P(T('a'), HB)))).toBe(1);
+ });
+
+ it('(2) two consecutive breaks in one paragraph survive', async () => {
+ expect(await roundTripCount(doc(P(T('a'), HB, HB, T('b'))))).toBe(2);
+ });
+
+ it('(3) a break inside a table cell survives', async () => {
+ const table = doc({
+ type: 'table',
+ content: [
+ { type: 'tableRow', content: [{ type: 'tableHeader', content: [P(T('h'))] }] },
+ { type: 'tableRow', content: [{ type: 'tableCell', content: [P(T('a'), HB, T('b'))] }] },
+ ],
+ });
+ expect(await roundTripCount(table)).toBe(1);
+ });
+});