(symmetric to video) so the round-trip md -> html ->
+// json restores the caption via the image extension's parseHTML.
+test("image without a caption emits plain ", () => {
+ const input = doc({
+ type: "image",
+ attrs: { src: "/files/a.png", alt: "cat" },
+ });
+ assert.equal(convertProseMirrorToMarkdown(input), "");
+});
+
+test("image with a caption emits a raw
![]()
in a block div", () => {
+ const input = doc({
+ type: "image",
+ attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
+ });
+ assert.equal(
+ convertProseMirrorToMarkdown(input),
+ '
',
+ );
+});
+
+test("image caption escapes & and \" in the data-caption attribute", () => {
+ const input = doc({
+ type: "image",
+ attrs: { src: "/files/a.png", caption: 'Tom & "Jerry"' },
+ });
+ assert.equal(
+ convertProseMirrorToMarkdown(input),
+ '
',
+ );
+});
diff --git a/packages/mcp/test/unit/media-roundtrip.test.mjs b/packages/mcp/test/unit/media-roundtrip.test.mjs
index 01c6d25f..9ef99602 100644
--- a/packages/mcp/test/unit/media-roundtrip.test.mjs
+++ b/packages/mcp/test/unit/media-roundtrip.test.mjs
@@ -142,3 +142,31 @@ test("round-trip: pdf node survives markdown export with src + name + attachment
assert.equal(found[0].attrs?.name, "x.pdf");
assert.equal(found[0].attrs?.attachmentId, "a4");
});
+
+// The converter emits captioned images as a raw
![]()
; for
+// the caption to survive the PM -> markdown -> PM round-trip the docmost-schema
+// Image node must parse data-caption back into the `caption` attr. Without that
+// (stock @tiptap/extension-image), the caption is silently lost — these guard
+// the "lossless" claim.
+test("round-trip: image caption survives markdown export (data-caption restored)", async () => {
+ const found = await roundtrip(
+ { type: "image", attrs: { src: "/api/files/cat.png", alt: "cat", caption: "A grey cat" } },
+ "image",
+ );
+ assert.equal(found.length, 1, "image node should survive");
+ assert.equal(found[0].attrs?.src, "/api/files/cat.png");
+ assert.equal(found[0].attrs?.caption, "A grey cat", "caption must round-trip");
+});
+
+test("round-trip: image caption with special chars survives markdown export", async () => {
+ const found = await roundtrip(
+ { type: "image", attrs: { src: "/api/files/cat.png", caption: 'Tom & "Jerry"' } },
+ "image",
+ );
+ assert.equal(found.length, 1, "image node should survive");
+ assert.equal(
+ found[0].attrs?.caption,
+ 'Tom & "Jerry"',
+ "special-char caption must round-trip unescaped",
+ );
+});
diff --git a/packages/mcp/test/unit/roundtrip.test.mjs b/packages/mcp/test/unit/roundtrip.test.mjs
index 56852cee..1b80e554 100644
--- a/packages/mcp/test/unit/roundtrip.test.mjs
+++ b/packages/mcp/test/unit/roundtrip.test.mjs
@@ -82,6 +82,24 @@ test("round-trip: image inside a column survives as an image node (not literal m
assert.ok(!JSON.stringify(out).includes("![pic]"), "image must not become literal markdown text");
});
+test("round-trip: captioned image inside a column preserves its caption (imageToHtml branch)", async () => {
+ // A captioned image in a column is emitted via the imageToHtml helper (raw
+ // HTML container), a different path from the top-level image case. Special
+ // chars in the caption exercise attribute escaping on the way out and in.
+ const caption = 'Tom & "Jerry"';
+ const input = doc({
+ type: "columns",
+ content: [
+ { type: "column", content: [{ type: "image", attrs: { src: "/api/files/a/p.png", alt: "pic", caption } }] },
+ { type: "column", content: [para(text("right"))] },
+ ],
+ });
+ const out = await roundtrip(input);
+ const imgs = findNodes(out, "image");
+ assert.equal(imgs.length, 1, "captioned image inside a column must survive");
+ assert.equal(imgs[0].attrs?.caption, caption, "caption (incl. special chars) must be preserved");
+});
+
test("round-trip: blockquote inside a column survives as a blockquote node", async () => {
const input = doc({
type: "columns",