From d9438d91e77240076de5a3d5a68c2fec2aee4c39 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:53:40 +0000 Subject: [PATCH 1/2] markdown: in ansi(), write a line's quote bars and list indent with its first content A line break in inline content wrote the indent of the next line at once, also when no line followed. An HTML block in a list item or a block quote ended with that indent and started without one, so the next list marker moved right. Text behind a code block in a tight list item was at column 0. A line break now writes the indent of the next line only when more of the same text follows. Each inline write that can be the first of a line writes the indent when nothing is on the line yet. A line that has only its indent is ended like a line with content. --- src/md/ansi_renderer.rs | 102 ++++++---- test/cli/run/markdown-entrypoint.test.ts | 18 ++ test/js/bun/md/md-edge-cases.test.ts | 225 +++++++++++++++++++++++ 3 files changed, 305 insertions(+), 40 deletions(-) diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 84c9bd722ea4..05e5b67af5f9 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -555,6 +555,8 @@ impl<'a> AnsiRenderer<'a> { // ======================================== pub(crate) fn enter_span(&mut self, span_type: SpanType, detail: SpanDetail) { + // Before the span opens, so that its style and link do not cover the indent. + self.begin_line(); match span_type { SpanType::Em | SpanType::Strong | SpanType::U | SpanType::Del => { let s = InlineStyle::of(span_type).unwrap(); @@ -696,6 +698,7 @@ impl<'a> AnsiRenderer<'a> { // Render raw HTML dimmed. Close with the targeted dim-off // (\x1b[22m) rather than a full reset, then reapply any // outer span/link styles. + self.begin_line(); self.write_styled(ansi_b::DIM, b""); self.write_content(content); self.write_styled(b"\x1b[22m", b""); @@ -750,25 +753,23 @@ impl<'a> AnsiRenderer<'a> { /// Emit a chunk to output, wrapping at word boundaries when the column /// exceeds `theme.columns`. fn write_wrapped(&mut self, data: &[u8]) { + // Nothing to draw does not begin a line. + if self.last_was_newline && !data.is_empty() { + self.write_pending_indent(); + } if self.theme.columns == 0 { // No-wrap path: still emit the indent after each embedded // newline so continuation lines inside blockquotes / list // items keep their `│ ` / hanging prefix. - let mut start: usize = 0; - let mut i: usize = 0; - while i < data.len() { - if data[i] == b'\n' { - self.write_raw(&data[start..i + 1]); - self.col = 0; - self.last_was_newline = true; - self.write_indent(); - start = i + 1; - } - i += 1; + let mut rest = data; + while let Some(newline) = strings::index_of_char_usize(rest, b'\n') { + self.write_raw(&rest[..newline]); + rest = &rest[newline + 1..]; + self.break_line(rest); } - if start < data.len() { - self.write_raw(&data[start..]); - self.update_col_from_text(&data[start..]); + if !rest.is_empty() { + self.write_raw(rest); + self.update_col_from_text(rest); } return; } @@ -778,26 +779,16 @@ impl<'a> AnsiRenderer<'a> { while i < data.len() { let c = data[i]; if c == b'\n' { - self.write_raw(b"\n"); - self.last_was_newline = true; - self.col = 0; i += 1; - // Always re-emit the indent after a newline, even when - // this is the final byte of `data` — a hard break - // (`text(.br)`) arrives as a lone "\n" and the next - // text() call starts at col=0 with no indent pushed. - self.write_indent(); + self.break_line(&data[i..]); continue; } if c == b' ' && self.col >= max { - self.write_raw(b"\n"); - self.last_was_newline = true; - self.col = 0; - self.write_indent(); i += 1; while i < data.len() && data[i] == b' ' { i += 1; } + self.break_line(&data[i..]); continue; } let mut j = i; @@ -866,10 +857,7 @@ impl<'a> AnsiRenderer<'a> { && self.col > indent && next_word_width <= next_avail as usize { - self.write_raw(b"\n"); - self.last_was_newline = true; - self.col = 0; - self.write_indent(); + self.break_line(&data[k..]); } else { self.soft_break = Some(SoftBreak { at: self.out.list.len(), @@ -947,10 +935,12 @@ impl<'a> AnsiRenderer<'a> { /// both the escape prefix and the text through the active buffer so /// spans inside cells/headings flush correctly. fn write_styled(&mut self, prefix: &[u8], text_: &[u8]) { - let in_main_flow = !self.in_cell - && self.heading_level == 0 - && !self.in_code_block - && self.image_depth == 0; + let in_main_flow = self.in_main_flow(); + + // An escape alone does not begin a line: nothing may follow it. + if self.last_was_newline && !text_.is_empty() { + self.write_pending_indent(); + } // Pre-wrap before opening the style: an atomic span (`.code`, // `.latexmath`, link href fallback) is emitted in one piece via @@ -1115,9 +1105,9 @@ impl<'a> AnsiRenderer<'a> { if text_.is_empty() { return; } + self.begin_line(); self.emit_inline(text_); - if !self.in_cell && self.heading_level == 0 && !self.in_code_block && self.image_depth == 0 - { + if self.in_main_flow() { self.col += u32::try_from(visible_width(text_)).expect("int cast"); self.last_was_newline = false; } @@ -1215,12 +1205,48 @@ impl<'a> AnsiRenderer<'a> { (quote_bars, other_indent) } + /// Inline writes go to `out`, not to the buffer of a cell, heading, code block or image alt. + fn in_main_flow(&self) -> bool { + !self.in_cell && self.heading_level == 0 && !self.in_code_block && self.image_depth == 0 + } + + /// Writes the indent of a line before its first inline content. A line break writes none. + #[inline] + fn begin_line(&mut self) { + if self.last_was_newline { + self.write_pending_indent(); + } + } + + /// Out of line, so that `begin_line` costs each inline write one test. + #[cold] + fn write_pending_indent(&mut self) { + if self.in_main_flow() { + self.write_indent(); + } + } + + /// Ends a line of inline content. The next line gets its indent now only if `rest` follows. + #[inline(never)] // Inlined, its test is on the path of every word in `write_wrapped`. + fn break_line(&mut self, rest: &[u8]) { + self.write_raw(b"\n"); + self.col = 0; + if !rest.is_empty() { + self.write_indent(); + } + } + fn write_indent(&mut self) { // writeIndent is called at the start of every content line, so // this is the right place to clear the "blank line just emitted" // flag ensureBlankLine uses for dedup. self.blank_emitted = false; let (quote_bars, other_indent) = self.indent_counts(); + if quote_bars + other_indent == 0 { + return; + } + // A line with an indent is not empty: `ensure_newline` ends it and `begin_line` adds none. + self.last_was_newline = false; let bar: &[u8] = if self.theme.colors { "│ ".as_bytes() } else { @@ -2030,9 +2056,6 @@ impl<'a> AnsiRenderer<'a> { self.write_raw(b"\n"); self.col = 0; self.last_was_newline = true; - // Re-emit the active block indent so text that follows the image - // inside a blockquote / list item keeps its `│ ` / hanging prefix. - self.write_indent(); } /// Emit a Kitty Graphics Protocol transmit-and-display sequence with @@ -2046,7 +2069,6 @@ impl<'a> AnsiRenderer<'a> { self.write_raw(b"\n"); self.col = 0; self.last_was_newline = true; - self.write_indent(); } } diff --git a/test/cli/run/markdown-entrypoint.test.ts b/test/cli/run/markdown-entrypoint.test.ts index b27b69446d60..58600f4fc7ce 100644 --- a/test/cli/run/markdown-entrypoint.test.ts +++ b/test/cli/run/markdown-entrypoint.test.ts @@ -235,6 +235,24 @@ describe("bun ", () => { expect(exitCode).toBe(0); }); + test("draws an HTML block in a list item and in a block quote with the indent and the bar of its container", async () => { + using dir = tempDir("md-html-block-", { + "doc.md": "- para\n
\n \n x\n \n- next\n\n>
\n", + }); + const env: NodeJS.Dict = { ...bunEnv, NO_COLOR: "1", COLUMNS: "80" }; + delete env.FORCE_COLOR; + await using proc = Bun.spawn({ + cmd: [bunExe(), "./doc.md"], + env, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(" * para\n
\n \n x\n \n * next\n\n|
\n"); + expect(exitCode).toBe(0); + }); + test("renders via `bun run ./file.md`", async () => { using dir = tempDir("md-run-", { "doc.md": "# Title\n\nbody\n" }); await using proc = Bun.spawn({ diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index e0c247cf5b11..0c3eff8db32e 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1471,6 +1471,231 @@ describe("ansi renderer line wrapping", () => { }); }); +// The quote bars and the list indent of a line are written with the first content of that line, not behind the line +// break before it. Behind the last line of an HTML block, and behind a line break that ends a paragraph, no line follows. +describe("ansi renderer quote bars and list indent", () => { + describe.each([0, 80])("at a width of %d", columns => { + const plain = (markdown: string) => Markdown.ansi(markdown, { colors: false, columns }); + const rule = (width: number) => Buffer.alloc(width, "-").toString(); + const table = "| +-----+-----+\n| | a | b |\n| +-----+-----+\n| | 1 | 2 |\n| +-----+-----+\n"; + + test.each([ + [ + "behind the text of a tight list item", + "- para\n
\n \n x\n \n- next\n", + " * para\n
\n \n x\n \n * next\n", + ], + [ + "first in a list item", + "-
\n \n x\n \n- next\n", + " * \n
\n \n x\n \n * next\n", + ], + [ + "in each item of an ordered list", + "1. \n2. \n\nafter\n", + " 1. \n \n 2. \n \n\nafter\n", + ], + [ + "in a nested list", + "- a\n -
\n x\n
\n - b\n- c\n", + " * a\n * \n
\n x\n
\n * b\n * c\n", + ], + ["alone in a block quote", ">
\n", "|
\n"], + ["behind a paragraph in a block quote", "> para\n>
\n> x\n>
\n", "| para\n|
\n| x\n|
\n"], + [ + "between two paragraphs in a block quote", + "> para\n>\n>
\n>

x

\n>
\n>\n> para2\n", + "| para\n|
\n|

x

\n|
\n|\n| para2\n", + ], + ["behind a heading with an underline", "> # H\n>
\n", "| H\n| ===\n|
\n"], + ["behind a heading without an underline", "> ### H\n>
\n", "| H\n|
\n"], + ["behind a code block", "> ```\n> x\n> ```\n>
\n", "| +-\n| | x\n| +-\n|
\n"], + ["behind a table", "> | a | b |\n> |---|---|\n> | 1 | 2 |\n>
\n", table + "|
\n"], + ["behind a rule", "> ---\n>
\n", `| ${rule(58)}\n|
\n`], + ["behind a paragraph that draws nothing", "> []()\n>\n>
\n", "| \n|
\n"], + ["in a block quote behind a paragraph that draws nothing", "[]()\n\n>
\n", "|
\n"], + ])("an HTML block %s is drawn in its container", (_, markdown, expected) => { + expect(plain(markdown)).toBe(expected); + }); + + test("an HTML block in a block quote in a list item has the prefix that a paragraph has there", () => { + const paragraph = plain("- > a\\\n > x\\\n > b\n- next\n"); + expect(paragraph).toEndWith("\n * next\n"); + expect(plain("- >
\n > x\n >
\n- next\n")).toBe( + paragraph.replace("a\n", "
\n").replace("b\n", "
\n"), + ); + }); + + test.each([ + ["text in a tight list item", "- a\n \n b\n- next\n", " * a\n \n b\n * next\n"], + [ + "a paragraph in a loose list item", + "- \n\n para\n- next\n", + " * \n \n\n para\n * next\n", + ], + ["a second HTML block", ">
\n>\n>

\n", "|

\n|

\n"], + ["a code block", ">

\n>\n> ```\n> x\n> ```\n", "|
\n|\n| +-\n| | x\n| +-\n"], + ["a heading", ">
\n>\n> # H\n", "|
\n|\n| H\n| ===\n"], + ])("%s behind an HTML block is where it is without the block", (_, markdown, expected) => { + expect(plain(markdown)).toBe(expected); + }); + + test.each([ + ["a line feed reference that ends a list item", "- a \n- next\n", " * a\n * next\n"], + ["a line feed reference that ends a block quote", "> a \n\nafter\n", "| a\n\nafter\n"], + ["a line feed reference before an HTML block", "> a \n>
\n", "| a\n|
\n"], + ["a hard break that ends the alt text of an image", "> ![a\\\n> ]()\n", "| [img] a\n"], + ])("%s leaves no quote bar or indent behind", (_, markdown, expected) => { + expect(plain(markdown)).toBe(expected); + }); + + test.each([ + ["a code block", "- a\n ```\n x\n ```\n b\n- next\n", " * a\n\n +-\n | x\n +-\n b\n * next\n"], + ["a heading", "- a\n # H\n b\n- next\n", " * a\n\n H\n ===\n b\n * next\n"], + ["a rule", "- a\n ***\n b\n- next\n", ` * a\n\n ${rule(56)}\n b\n * next\n`], + [ + "a nested block quote", + "- a\n > q\n > ***\n b\n- next\n", + ` * a\n\n| q\n|\n| ${rule(54)}\n b\n * next\n`, + ], + [ + "a nested list", + "- a\n - i\n ***\n b\n- next\n", + ` * a\n * i\n\n ${rule(52)}\n b\n * next\n`, + ], + [ + "a list in a nested block quote", + "- a\n > - # b\n c\n", + " * a\n\n| * \n|\n| b\n| ===\n c\n", + ], + ])("text behind %s in a tight list item has the indent of the item", (_, markdown, expected) => { + expect(plain(markdown)).toBe(expected); + }); + + // A line that has only its quote bars and indent is ended. The next marker does not go behind them. + test.each([ + [ + "behind a code block in a tight list item", + "- a\n ```\n x\n ```\n []()\n- next\n", + " * a\n\n +-\n | x\n +-\n \n * next\n", + ], + [ + "behind a code block in a loose list item", + "- a\n\n ```\n x\n ```\n\n []()\n- next\n", + " * a\n\n +-\n | x\n +-\n\n \n * next\n", + ], + ["behind a hard break in a list item", "- a\\\n []()\n- next\n", " * a\n \n * next\n"], + ["in a block quote in a list item", "- > []()\n\n
\n- a\n", " * \n\n| \n
\n * a\n"], + [ + "in a nested list item, before a paragraph of the outer item", + "- outer\n\n - inner\n ```\n x\n ```\n []()\n\n after\n- next\n", + " * outer\n * inner\n\n +-\n | x\n +-\n \n\n after\n * next\n", + ], + [ + "in strong text in a nested list item, before a paragraph of the outer item", + "- outer\n\n - inner\n ```\n x\n ```\n **[]()**\n\n after\n- next\n", + " * outer\n * inner\n\n +-\n | x\n +-\n \n\n after\n * next\n", + ], + [ + "in a nested list item in a block quote, before a paragraph of the outer item", + "> - outer\n>\n> - inner\n> ```\n> x\n> ```\n> []()\n>\n> after\n> - next\n", + "| * outer\n| * inner\n|\n| +-\n| | x\n| +-\n| \n|\n| after\n| * next\n", + ], + ])("a link that draws nothing %s does not move what follows it", (_, markdown, expected) => { + expect(plain(markdown)).toBe(expected); + }); + + test.each([ + ["a hard break in a block quote", "> a \n> b\n", "| a\n| b\n"], + ["a hard break that ends the text of a link", "> [a\\\n> ](u)\n", "| a\n| (u)\n"], + ["a line feed reference in a paragraph", "> a b\n", "| a\n| \n| b\n"], + ["inline HTML over two lines in a block quote", '> a c="d">e f\n', '| a e f\n'], + ["an HTML block outside of a container", "
\n

\n

\n\nafter\n", "
\n

\n

\n\nafter\n"], + ["an HTML block behind a paragraph", "a\n\n
\n", "a\n
\n"], + [ + "text that is not drawn, behind a code block in a tight list item", + "- a\n ```\n x\n ```\n \x07\n- next\n", + " * a\n\n +-\n | x\n +-\n * next\n", + ], + ["emphasis in a heading in a block quote", "> # *H*\n", "| H\n| ===\n"], + ["inline HTML in a heading in a block quote", "> # H\n", "| H\n| ========\n"], + ["emphasis in a table cell in a block quote", "> | *a* | b |\n> |---|---|\n> | 1 | `2` |\n", table], + ["emphasis in the alt text of an image in a block quote", "> ![*a*]()\n", "| [img] a\n"], + ])("%s is drawn as before", (_, markdown, expected) => { + expect(plain(markdown)).toBe(expected); + }); + }); + + const bar = "\x1b[38;5;242m│ \x1b[39m"; + const bullet = "\x1b[36m• \x1b[0m"; + const dim = (text: string) => `\x1b[2m${text}\x1b[22m`; + // A PNG of 1 by 1 pixel. + const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg=="; + + test("with colors, the quote bar of a line of an HTML block is not dim", () => { + expect(Markdown.ansi(">
\n> x\n", { columns: 80 })).toBe( + bar + dim("
") + dim("\n") + bar + dim("x") + dim("\n"), + ); + }); + + test("with colors, an HTML block in a list item has the indent of the item and leaves none behind", () => { + expect(Markdown.ansi("- para\n
\n- next\n", { columns: 80 })).toBe( + " " + bullet + "para\n " + dim("
") + dim("\n") + " " + bullet + "next\n", + ); + }); + + test("with colors, a span that starts a line opens behind the quote bar, as before", () => { + expect(Markdown.ansi("> a \n> **b**\n", { columns: 80 })).toBe(`${bar}a\n${bar}\x1b[1mb\x1b[22m\x1b[0m\n`); + expect(Markdown.ansi("> a \n> [b](u)\n", { columns: 80, hyperlinks: true })).toBe( + `${bar}a\n${bar}\x1b]8;;u\x1b\\\x1b[34m\x1b[4mb\x1b[24m\x1b[39m\x1b]8;;\x1b\\\x1b[0m\n`, + ); + }); + + test("a link that a terminal draws nothing of does not move what follows it", () => { + const markdown = "- outer\n\n - inner\n ```\n x\n ```\n [](u)\n\n after\n- next\n"; + expect(Bun.stripANSI(Markdown.ansi(markdown, { columns: 80, hyperlinks: true }))).toBe( + " • outer\n • inner\n\n ┌─\n │ x\n └─\n \n\n after\n • next\n", + ); + }); + + test("an HTML block behind a paragraph that draws nothing wraps in its block quote", () => { + expect(Markdown.ansi("> []()\n>\n> \n", { colors: false, columns: 10 })).toBe("| \n| \n"); + }); + + test("text that a wrap moves to the next line is behind the quote bar, as before", () => { + const plain = (markdown: string) => Markdown.ansi(markdown, { colors: false, columns: 6 }); + expect(plain("> aaaa bbbb\n")).toBe("| aaaa\n| bbbb\n"); + expect(plain("> aaaa\n> bbbb\n")).toBe("| aaaa\n| bbbb\n"); + expect(plain("> **aaaa** b\n")).toBe("| aaaa\n| b\n"); + }); + + test("a closing delimiter that a wrap moves to the next line is behind the quote bar, as before", () => { + expect(Markdown.ansi("> [[t|aaaa ]]\n", { colors: false, columns: 8 })).toBe("| [[aaaa\n| ]]\n"); + expect(Markdown.ansi("> $aaa $\n", { colors: false, columns: 7 })).toBe("| $aaa\n| $\n"); + }); + + test("a space at the wrap column with nothing behind it leaves no quote bar behind", () => { + const visible = (markdown: string) => Bun.stripANSI(Markdown.ansi(markdown, { columns: 6, hyperlinks: true })); + expect(visible("> [aaaa ](u)\n")).toBe("│ aaaa\n"); + expect(visible("> [aaaa\n> ](u)\n")).toBe("│ aaaa\n"); + }); + + test("an image that Kitty draws leaves no quote bar behind, and text behind it gets one", () => { + const image = `\x1b_Ga=T,t=d,f=100,q=2;${png}\x1b\\\n`; + const kitty = (markdown: string) => Markdown.ansi(markdown, { columns: 80, kittyGraphics: true }); + expect(kitty(`> ![a](data:image/png;base64,${png})\n`)).toBe(bar + image + "\x1b[0m"); + expect(kitty(`> ![a](data:image/png;base64,${png}) b\n`)).toBe(bar + image + bar + " b\x1b[0m\n"); + }); + + test("an image file that Kitty draws leaves no quote bar behind", () => { + using dir = tempDir("md-kitty", { "x.png": Buffer.from(png, "base64") }); + const path = join(String(dir), "x.png"); + expect(Markdown.ansi(`> ![a](${path.replaceAll("\\", "/")})\n`, { columns: 80, kittyGraphics: true })).toBe( + `${bar}\x1b_Ga=T,t=f,f=100,q=2;${Buffer.from(path).toString("base64")}\x1b\\\n\x1b[0m`, + ); + }); +}); + // ============================================================================ // Pathological inputs: emphasis delimiter floods. Each closer used to scan // backward over every already-consumed delimiter in the paragraph, so From 1c7a8763f85211ad3d9c46bb9b6fa4f1e0587f4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:03:59 +0000 Subject: [PATCH 2/2] markdown: compare the Kitty image tests without escapes, name begin_line() where it is spelled out The two tests for an image that Kitty draws compare the text without the escapes where the image ends the paragraph. The reset that ends a paragraph comes behind the line break of the image, on main too, and the tests no longer hold that order. write_wrapped and write_styled spell the test of begin_line() out with their emptiness test. Their comments now say so. The call behind a test costs more instructions per render. --- src/md/ansi_renderer.rs | 4 ++-- test/js/bun/md/md-edge-cases.test.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index d8d88110b01a..fdb1b310d256 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -753,7 +753,7 @@ impl<'a> AnsiRenderer<'a> { /// Emit a chunk to output, wrapping at word boundaries when the column /// exceeds `theme.columns`. fn write_wrapped(&mut self, data: &[u8]) { - // Nothing to draw does not begin a line. + // `begin_line()` spelled out (cheaper than a call behind a test). Nothing to draw does not begin a line. if self.last_was_newline && !data.is_empty() { self.write_pending_indent(); } @@ -937,7 +937,7 @@ impl<'a> AnsiRenderer<'a> { fn write_styled(&mut self, prefix: &[u8], text_: &[u8]) { let in_main_flow = self.in_main_flow(); - // An escape alone does not begin a line: nothing may follow it. + // `begin_line()` spelled out as in `write_wrapped`. An escape alone begins no line: nothing may follow it. if self.last_was_newline && !text_.is_empty() { self.write_pending_indent(); } diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index 0c3eff8db32e..e5072d94ae36 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1683,16 +1683,19 @@ describe("ansi renderer quote bars and list indent", () => { test("an image that Kitty draws leaves no quote bar behind, and text behind it gets one", () => { const image = `\x1b_Ga=T,t=d,f=100,q=2;${png}\x1b\\\n`; const kitty = (markdown: string) => Markdown.ansi(markdown, { columns: 80, kittyGraphics: true }); - expect(kitty(`> ![a](data:image/png;base64,${png})\n`)).toBe(bar + image + "\x1b[0m"); expect(kitty(`> ![a](data:image/png;base64,${png}) b\n`)).toBe(bar + image + bar + " b\x1b[0m\n"); + // Without the escapes, because the reset that ends a paragraph comes behind the line break of the image. + const visible = (markdown: string) => Bun.stripANSI(kitty(markdown)); + expect(visible(`> ![a](data:image/png;base64,${png})\n`)).toBe("│ \n"); + expect(visible(`> ![a](data:image/png;base64,${png})\n>\n> b\n`)).toBe("│ \n│\n│ b\n"); }); test("an image file that Kitty draws leaves no quote bar behind", () => { using dir = tempDir("md-kitty", { "x.png": Buffer.from(png, "base64") }); const path = join(String(dir), "x.png"); - expect(Markdown.ansi(`> ![a](${path.replaceAll("\\", "/")})\n`, { columns: 80, kittyGraphics: true })).toBe( - `${bar}\x1b_Ga=T,t=f,f=100,q=2;${Buffer.from(path).toString("base64")}\x1b\\\n\x1b[0m`, - ); + const drawn = Markdown.ansi(`> ![a](${path.replaceAll("\\", "/")})\n`, { columns: 80, kittyGraphics: true }); + expect(drawn).toStartWith(`${bar}\x1b_Ga=T,t=f,f=100,q=2;${Buffer.from(path).toString("base64")}\x1b\\\n`); + expect(Bun.stripANSI(drawn)).toBe("│ \n"); }); });