diff --git a/README.md b/README.md
index 623114a..0f29b6d 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ termdown uses the Kitty graphics protocol to render Markdown with large-font hea
termdown rasterizes H1-H3 headings as PNG images and draws them directly in the terminal through the Kitty graphics protocol. It provides two modes:
- **Interactive TUI** (default) -- `termdown README.md` provides a vim/less-like experience with paging, search, a table of contents, and link navigation for longer documents.
-- **Direct output** -- `termdown --cat README.md` prints rendered Markdown like `cat`, making it suitable for short documents or piping to other programs.
+- **Direct output** -- `termdown --cat README.md` prints rendered Markdown like `cat`, making it suitable for short documents. Piped or redirected output uses text headings instead of Kitty images.
H4-H6 headings always use ANSI bold text instead of simulating more font sizes and weights that could reduce readability in a terminal.
diff --git a/README_CN.md b/README_CN.md
index c7ffd81..6f9a5aa 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -16,7 +16,7 @@ termdown 基于 Kitty 图形协议,在终端中以大字体标题渲染 Markdo
termdown 将 H1-H3 标题栅格化为 PNG 图片,通过 Kitty 图形协议直接绘制到终端。提供两种使用模式:
- **交互式 TUI**(默认)—— `termdown README.md`,提供类似 vim/less 的体验,支持翻页、搜索、查看目录和链接跳转,适合阅读较长文档。
-- **直接输出** —— `termdown --cat README.md`,像 `cat` 一样直接输出渲染后的 Markdown,适合查看短文档或通过管道交给其他程序处理。
+- **直接输出** —— `termdown --cat README.md`,像 `cat` 一样直接输出渲染后的 Markdown,适合查看短文档;通过管道或重定向输出时,标题使用文本而不是 Kitty 图片。
H4-H6 标题始终以 ANSI 粗体文本渲染,不再模拟更多字号和字重,以免损害终端中的可读性。
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 038c0e6..92d366c 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -20,7 +20,9 @@ termdown [OPTIONS] [FILE]
| `-V`, `--version` | Show version. |
By default, passing a `FILE` opens it in the interactive TUI. Piped/redirected
-stdout, stdin input, or `--cat` all fall back to cat mode.
+stdout, stdin input, or `--cat` all fall back to cat mode. Piped or redirected
+output uses text headings instead of Kitty images so downstream programs retain
+the heading content.
### Examples
diff --git a/docs/USAGE_CN.md b/docs/USAGE_CN.md
index 3ff26b7..81c6c27 100644
--- a/docs/USAGE_CN.md
+++ b/docs/USAGE_CN.md
@@ -20,7 +20,8 @@ termdown [选项] [文件]
| `-V`, `--version` | 显示版本。 |
默认情况下,传入文件会进入交互式 TUI。stdout 被管道/重定向、输入来自 stdin、或加
-`--cat` 时都会回退到 cat 模式。
+`--cat` 时都会回退到 cat 模式。通过管道或重定向输出时,标题使用文本而不是 Kitty
+图片,确保下游程序保留标题内容。
### 示例
diff --git a/fixtures/expected/specialized/metadata-none.ansi b/fixtures/expected/specialized/metadata-none.ansi
index 2af0710..d4a3964 100644
--- a/fixtures/expected/specialized/metadata-none.ansi
+++ b/fixtures/expected/specialized/metadata-none.ansi
@@ -1,4 +1,4 @@
-
+[1mPlain Document, No Frontmatter[0m
This file has no YAML or TOML metadata block. The [38;5;213m[48;5;236m --- [0m line further down
should be rendered as a normal horizontal rule, [1mnot[0m as the opening
diff --git a/fixtures/expected/supported-syntax.ansi b/fixtures/expected/supported-syntax.ansi
index e2a26cd..50619a2 100644
--- a/fixtures/expected/supported-syntax.ansi
+++ b/fixtures/expected/supported-syntax.ansi
@@ -1,6 +1,6 @@
[2m[metadata · title=Supported Syntax Showcase, author=termdown, tags=[markdown, …][0m
-
+[1mSupported Syntax Showcase[0m
This fixture is the [1msingle source of truth[0m for syntax termdown currently
supports
@@ -12,7 +12,7 @@ The YAML frontmatter above is parsed and rendered as a single dim summary line
in [38;5;213m[48;5;236m --cat [0m, or as a collapsible inline box in TUI (toggle with [38;5;213m[48;5;236m m [0m). It does
not leak into body content.
-
+[1m1. Headings[0m
This section exercises all six heading levels, depth nesting, repeated titles,
and
@@ -20,13 +20,13 @@ long-title wrapping — covering both the heading rasterizer and the TUI
table-of-contents
panel.
-
+[1m1.1 Levels[0m
-
+[1mH1 标题 / Heading One[0m
-
+[1mH2 标题 / Heading Two[0m
-
+[1mH3 标题 / Heading Three[0m
[1mH4 — 四级标题[0m
@@ -34,25 +34,25 @@ panel.
[1mH6 — 六级标题 (the deepest level Markdown supports)[0m
-
+[1m1.2 Headings with emoji & mixed scripts[0m
-
+[1m🚀 Launch builders, not just code[0m
-
+[1m✨ 标题里的 emoji 和中文混排[0m
-
+[1m🧪 Body Text Baseline · 正文基线[0m
-
+[1m1.3 Repeated titles (slug disambiguation)[0m
[1mDuplicate[0m
[1mDuplicate[0m
-
+[1m1.4 Very long heading that should wrap inside a narrow TOC panel across multiple lines[0m
Filler paragraph so the long heading above is followed by some body content.
-
+[1m2. Emphasis[0m
[3mItalic via underscores[23m, [3mitalic via asterisks[23m.
@@ -64,9 +64,9 @@ Filler paragraph so the long heading above is followed by some body content.
[3m斜体[23m · [1m粗体[0m · [1m[3m粗斜体组合[0m · [9m中文删除线[29m
-
+[1m3. Lists[0m
-
+[1m3.1 Unordered, nested[0m
• Item 1
• Item 2
@@ -76,7 +76,7 @@ Filler paragraph so the long heading above is followed by some body content.
• Item 2b.ii
• Item 3
-
+[1m3.2 Ordered, nested[0m
1. Step one
2. Step two
@@ -84,16 +84,16 @@ Filler paragraph so the long heading above is followed by some body content.
2. Sub-step 2.2
3. Step three
-
+[1m3.3 中英混排列表[0m
• 第一项 (item 1)
• 第二项 (item 2)
• 嵌套 a / nested a
• 嵌套 b / nested b
-
+[1m4. Task lists[0m
-
+[1m4.1 Project TODO[0m
[✓] Setup project structure
[✓] Add markdown parser
@@ -101,7 +101,7 @@ Filler paragraph so the long heading above is followed by some body content.
[ ] Add configuration options
[ ] Write documentation
-
+[1m4.2 Nested tasks[0m
[✓] Phase 1
[✓] Design architecture
@@ -111,7 +111,7 @@ Filler paragraph so the long heading above is followed by some body content.
[ ] Performance optimization
[ ] Integration testing
-
+[1m4.3 Mixed with regular list items & inline formatting[0m
1. Ordered item one
2. Ordered item two
@@ -124,7 +124,7 @@ Filler paragraph so the long heading above is followed by some body content.
[ ] Task with [1mbold[0m and [3mitalic[23m text
[ ] Task with [38;5;213m[48;5;236m inline code [0m
-
+[1m5. Links[0m
Inline link: [36m[4mMarkdown Live Preview[24m[0m [38;5;245m(https://markdownlivepreview.com/)[0m.
@@ -135,7 +135,7 @@ Reference-style link: [36m[4mreference example[24m[0m [38;5;245m(https://ex
Bare URL (GFM autolink): https://example.com/docs/readme.html — bare email:
support@example.com — these are part of the autolink roadmap.
-
+[1m6. Images[0m
[38;5;240m│ [3;38;5;250mRoadmap feature — [38;5;213m[48;5;236m TODO.md [0m 中"图片支持"。当前 termdown[0m
[38;5;240m│ [3;38;5;250m渲染图片的能力还不完整,本节[0m
@@ -158,7 +158,7 @@ Reference-style image:
[2m[🖼 ref image](https://example.com/banner.png)[0m
-
+[1m7. Blockquotes[0m
[38;5;240m│ [3;38;5;250mMarkdown is a lightweight markup language with plain-text-formatting syntax,[0m
[38;5;240m│ [3;38;5;250mcreated in 2004 by John Gruber with Aaron Swartz.[0m
@@ -169,9 +169,9 @@ Reference-style image:
[38;5;240m│ [3;38;5;250m中文引用块:Markdown 是一种轻量级标记语言,使用纯文本格式语法。[0m
[38;5;240m│ [38;5;240m│ [3;38;5;250m嵌套引用:常用于编写 readme 文件与在线论坛中的消息格式化。[0m
-
+[1m8. Tables[0m
-
+[1m8.1 Three alignment modes[0m
[1mLeft[0m [2m │ [0m[1mCenter[0m [2m │ [0m[1mRight[0m
[2m─────────────────[0m[2m ┼ [0m[2m────────────[0m[2m ┼ [0m[2m──────[0m
@@ -179,7 +179,7 @@ a [2m │ [0mb [2m │ [0mc
long left content[2m │ [0mcenter [2m │ [0m1
x [2m │ [0m[1mbold[0m in cell[2m │ [0m[38;5;213m[48;5;236m code [0m
-
+[1m8.2 Mixed scripts & emoji in cells[0m
[1mCase[0m [2m │ [0m[1mExample[0m
[2m──────────────────[0m[2m ┼ [0m[2m───────────────────[0m
@@ -188,7 +188,7 @@ Mixed text [2m │ [0m修正版 ✨ version 2
Symbol-like [2m │ [0m✅ ⚠ ❌
Variation selector[2m │ [0m☀️ ❤️ ⭐️
-
+[1m9. Code blocks[0m
Fenced block with language tag:
@@ -211,7 +211,7 @@ Indented code block:
Inline code: this web site is using [38;5;213m[48;5;236m markedjs/marked [0m, and shell command
[38;5;213m[48;5;236m cargo run -- fixtures/supported-syntax.md [0m should render this file.
-
+[1m10. HTML tags[0m
[38;5;240m│ [3;38;5;250mRoadmap feature — [38;5;213m[48;5;236m TODO.md [0m 中"测试 html 标签支持"。覆盖块级 HTML、行内[0m
[38;5;240m│ [3;38;5;250mHTML、HTML 注释。[0m
@@ -233,13 +233,13 @@ An HTML comment: end of line.
Self-closing void element: line one
line two on the same paragraph.
-
+[1m11. Long-text wrap & indent[0m
[38;5;240m│ [3;38;5;250mRoadmap feature — [38;5;213m[48;5;236m TODO.md [0m[0m
[38;5;240m│ [3;38;5;250m中"长文本换行时缩进的处理"。本节专门用于验证:长行换行后[0m
[38;5;240m│ [3;38;5;250m续行的缩进是否与首行对齐(特别是在列表、引用、标题里)。[0m
-
+[1m11.1 Long paragraph[0m
This is a deliberately long English paragraph that should wrap multiple times
in a narrow terminal so we can verify that continuation lines start flush with
@@ -247,7 +247,7 @@ the left margin and do not get an accidental indent.
这是一段足够长的中文段落,用来验证终端窗口较窄时,软换行产生的续行应该与首行左对齐而不应该出现额外缩进,特别是混排了
emoji 🚀 与英文 ASCII 之后宽度计算仍然正确。
-
+[1m11.2 Long line in a nested list[0m
• 顶级列表项 / Top-level list item
•
@@ -255,7 +255,7 @@ emoji 🚀 与英文 ASCII 之后宽度计算仍然正确。
— this nested item should wrap with its continuation lines aligned to the start
of the item text, not to the bullet marker.
-
+[1m11.3 Long line in a blockquote[0m
[38;5;240m│ [3;38;5;250m引用块里的长行也需要正确换行:这是一段被包在 [38;5;213m[48;5;236m > [0m[0m
[38;5;240m│ [3;38;5;250m引用标记里的中英混排长文,软换行[0m
@@ -264,27 +264,27 @@ of the item text, not to the bullet marker.
[38;5;240m│ [3;38;5;250mEnglish — wrapped continuation lines must remain inside the blockquote prefix[0m
[38;5;240m│ [3;38;5;250mrather than escape to the outer margin.[0m
-
+[1m11.4 Long heading (continuation indent test)[0m
[1m一个超长四级标题,包含中文、English、还有 emoji 🚀,用来验证标题在窄终端中换行后续行的缩进表现[0m
After the long heading above, this short paragraph anchors the test so the
heading is followed by body content.
-
+[1m12. Emoji & 中英混排[0m
-
+[1m12.1 Inline emoji[0m
• 单个 emoji: 😀 😎 ✨ 🚀
• 中英混排: Hello 世界 🌍
• 符号混排: ✅ Done · ⚠ Warning · ❌ Failed
• 常见 emoji 变体: ☀️ ❤️ ⭐️
-
+[1m12.2 Complex ZWJ sequences[0m
观察复杂 ZWJ emoji 的边界表现:👨👩👧👦 👩🏽💻 🧑🚀
-
+[1m12.3 Bidirectional & mixed-script paragraph[0m
正文仍然由终端自身字体渲染,所以这里主要用来对比标题图片化与正文文本化的表现差异。
Body text mixes 中文、English、ASCII punctuation, and emoji 🌍 within the same
@@ -292,7 +292,7 @@ line.
[38;5;240m│ [3;38;5;250m引用块里也放几个字符:💡 🛠 📦[0m
-
+[1mEnd[0m
如果以上每一节都能正确(或按当前能力部分正确)渲染,termdown
的核心语法覆盖就达标了。
diff --git a/fixtures/expected/unsupported-syntax.ansi b/fixtures/expected/unsupported-syntax.ansi
index 2e0f48d..5df7ae5 100644
--- a/fixtures/expected/unsupported-syntax.ansi
+++ b/fixtures/expected/unsupported-syntax.ansi
@@ -1,4 +1,4 @@
-
+[1mUnsupported Syntax Showcase[0m
This fixture collects Markdown features termdown does [1mnot[0m plan to support, or
treats as out-of-scope. It exists to lock the current "graceful degradation"
@@ -8,13 +8,13 @@ one of these, the snapshot will diff.
Features that are on the [1mroadmap[0m (HTML tags, images, markdown frontmatter,
long-text wrap & indent) live in [38;5;213m[48;5;236m supported-syntax.md [0m, not here.
-
+[1m1. GFM autolinks (bare URLs)[0m
Bare URL: https://example.com/docs/readme.html
Bare email: support@example.com
URL in text: visit https://github.com/rrbe/termdown for the source.
-
+[1m2. GFM alerts / admonitions[0m
[38;5;240m│ [3;38;5;250m[!NOTE][0m
[38;5;240m│ [3;38;5;250mUseful information that users should know, even when skimming content.[0m
@@ -31,7 +31,7 @@ URL in text: visit https://github.com/rrbe/termdown for the source.
[38;5;240m│ [3;38;5;250m[!CAUTION][0m
[38;5;240m│ [3;38;5;250mAdvises about risks or negative outcomes of certain actions.[0m
-
+[1m3. Footnotes[0m
Here is a sentence with a footnote.[^1] And another one.[^longnote]
@@ -44,7 +44,7 @@ body.]
[48;5;236m[38;5;213m paragraphs. It should render as a numbered reference in the main text, [0m
[48;5;236m[38;5;213m with the body collected at the bottom of the document. [0m
-
+[1m4. Math (LaTeX)[0m
Inline math: the Pythagorean theorem says $a^2 + b^2 = c^2$.
@@ -60,7 +60,7 @@ $$
A = \begin{pmatrix} 1 & 2 \ 3 & 4 \end{pmatrix}
$$
-
+[1m5. Definition list[0m
Term 1
: Definition of term 1.
@@ -76,21 +76,21 @@ Apple
Orange
: A citrus fruit with a tough rind.
-
+[1m6. Smart punctuation[0m
Straight quotes that should become curly: "Hello," she said. 'Yes,' he replied.
Ellipsis from three dots... and an em-dash -- like this, and an en-dash -- too.
-
+[1m7. Wikilinks[0m
Reference a page with [[WikiLink]] syntax, and with an alias like
[[Target Page|the display text]].
-
+[1m8. Subscript and superscript[0m
Water is H~2~O and Einstein said E=mc^2^. Also 10^th^ and x~n+1~.
-
+[1m9. Mermaid diagrams[0m
A flowchart:
@@ -119,7 +119,7 @@ A class diagram:
[48;5;236m[38;5;213m Animal <|-- Dog [0m
[48;5;236m[38;5;213m Animal <|-- Cat [0m
-
+[1m10. PlantUML / Graphviz[0m
[48;5;236m[38;5;213m @startuml [0m
[48;5;236m[38;5;213m Alice -> Bob: Authentication Request [0m
@@ -132,7 +132,7 @@ A class diagram:
[48;5;236m[38;5;213m A -> C; [0m
[48;5;236m[38;5;213m } [0m
-
+[1m11. Code block syntax highlighting[0m
Fenced blocks with a language tag are recognized but the contents are not
syntax-highlighted today — only the structure is preserved.
@@ -149,12 +149,12 @@ syntax-highlighted today — only the structure is preserved.
[48;5;236m[38;5;213m "version": "0.2.0" [0m
[48;5;236m[38;5;213m } [0m
-
+[1m12. Emoji shortcodes[0m
Shortcodes like :smile:, :rocket:, :tada: should ideally become 😄 🚀 🎉.
Unicode emoji themselves work fine: 😄 🚀 🎉.
-
+[1mEnd[0m
If every section above renders with at least [3msome[23m graceful fallback,
termdown is degrading correctly on out-of-scope syntax.
diff --git a/src/cat.rs b/src/cat.rs
index 97b48e2..b993ada 100644
--- a/src/cat.rs
+++ b/src/cat.rs
@@ -3,6 +3,8 @@
use std::io::{BufWriter, Write};
+use crossterm::tty::IsTty;
+
use crate::config::Config;
use crate::frontmatter::{self, MetadataInfo};
use crate::layout::{Color, Line, LineKind, RenderedDoc, Span, Style};
@@ -14,6 +16,7 @@ use crate::style::{
pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors, config: &Config) {
let stdout = std::io::stdout();
+ let display_images = stdout.is_tty();
let mut out = BufWriter::new(stdout.lock());
if config.metadata.unwrap_or(true) {
@@ -40,7 +43,20 @@ pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors, config: &Con
continue;
}
- write_line(&mut out, line, &doc.images, term_width, colors);
+ let heading_text = doc
+ .headings
+ .iter()
+ .find(|heading| heading.line_index == i)
+ .map(|heading| heading.text.as_str());
+ write_line(
+ &mut out,
+ line,
+ &doc.images,
+ heading_text,
+ display_images,
+ term_width,
+ colors,
+ );
i += 1;
}
let _ = out.flush();
@@ -50,6 +66,8 @@ fn write_line(
out: &mut W,
line: &Line,
images: &[crate::render::HeadingImage],
+ heading_text: Option<&str>,
+ display_images: bool,
term_width: usize,
colors: &Colors,
) {
@@ -62,13 +80,17 @@ fn write_line(
let _ = writeln!(out, "{DIM_ON}{}{RESET}", "\u{2500}".repeat(width));
}
LineKind::Heading { id, .. } => {
- if let Some(image_id) = id {
- if let Some(img) = images.iter().find(|i| i.id == *image_id) {
- let _ = writeln!(out, "{}", render::kitty_display(&img.png));
- return;
+ if display_images {
+ if let Some(image_id) = id {
+ if let Some(img) = images.iter().find(|i| i.id == *image_id) {
+ let _ = writeln!(out, "{}", render::kitty_display(&img.png));
+ return;
+ }
}
}
- let text = render_spans_plain(&line.spans);
+ let text = heading_text
+ .map(str::to_owned)
+ .unwrap_or_else(|| render_spans_plain(&line.spans));
let _ = writeln!(out, "{BOLD_ON}{text}{RESET}");
}
LineKind::BlockQuote { depth } => {
diff --git a/src/render.rs b/src/render.rs
index 2cf3dee..12a764e 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -499,7 +499,7 @@ pub fn delete_all_for_client(w: &mut W) -> std::io::Result<()> {
/// PNG data + vertical dimensions for a rendered heading image.
/// Stored by id in `RenderedDoc` and transmitted to the terminal
-/// once per TUI session (or emitted directly in cat mode).
+/// once per TUI session (or emitted directly when cat mode writes to a TTY).
///
/// `rows` is the number of terminal cell rows the image occupies. This is
/// a conservative estimate at layout time (based on heading level) and is
@@ -545,6 +545,13 @@ mod kitty_tests {
assert!(s.contains(";") && s.ends_with("\x1b\\"));
}
+ #[test]
+ fn display_transmits_and_displays() {
+ let s = kitty_display(b"\x89PNG\r\n");
+ assert!(s.starts_with("\x1b_Gf=100,a=T,q=2"));
+ assert!(s.ends_with("\x1b\\"));
+ }
+
#[test]
fn place_produces_cursor_move_then_a_eq_p() {
let mut buf = Vec::new();
@@ -602,6 +609,35 @@ mod heading_cache_tests {
assert!(map.contains_key(&(1u8, Theme::Light, fp, text.to_owned())));
}
+ #[test]
+ fn rendered_heading_pngs_are_valid_nonblank_and_scale_by_level() {
+ let cfg = Config::default();
+ let titles = [
+ "🚀 H1 标题 with emoji 中文",
+ "✨ H2 标题 with emoji 中文",
+ "🧪 H3 标题 with emoji 中文",
+ ];
+ let images: Vec<_> = titles
+ .iter()
+ .enumerate()
+ .map(|(index, title)| {
+ let (png, _, _) = render_heading(title, index as u8 + 1, &cfg, Theme::Dark)
+ .expect("heading should render");
+ image::load_from_memory_with_format(&png, ImageFormat::Png)
+ .expect("heading should be a valid PNG")
+ .to_rgba8()
+ })
+ .collect();
+
+ assert!(images[0].height() > images[1].height());
+ assert!(images[1].height() > images[2].height());
+ for image in images {
+ assert!(image.width() > 100);
+ let nonblank = image.pixels().filter(|pixel| pixel[3] > 0).count();
+ assert!(nonblank * 20 >= (image.width() * image.height()) as usize);
+ }
+ }
+
#[test]
fn font_fingerprint_distinguishes_configs() {
let mut a = Config::default();
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 2f210c6..9e7abb0 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -6,12 +6,10 @@ pub fn binary_path() -> &'static str {
}
/// Run the compiled termdown binary against `path` in a controlled test
-/// environment: ghostty-like terminal (so kitty graphics emission is enabled),
-/// dark theme, and `HOME`/`USERPROFILE`/`XDG_CONFIG_HOME` cleared so a
+/// environment: ghostty-like terminal, dark theme, and
+/// `HOME`/`USERPROFILE`/`XDG_CONFIG_HOME` cleared so a
/// developer's `~/.config/termdown/config.toml` can't leak into the test.
-/// Returns raw stdout
-/// bytes; callers decide whether to treat it as UTF-8 or scan for kitty APC
-/// payloads.
+/// Stdout is piped, so headings are emitted as text rather than Kitty images.
pub fn run_termdown(path: &Path) -> Vec {
let out = Command::new(binary_path())
.arg("--theme")
diff --git a/tests/headings.rs b/tests/headings.rs
index 7cfe1e7..f086f2e 100644
--- a/tests/headings.rs
+++ b/tests/headings.rs
@@ -1,211 +1,21 @@
mod common;
-use base64::Engine;
-use image::ImageReader;
-use std::collections::HashMap;
-use std::io::Cursor;
use std::path::Path;
use common::run_termdown;
-/// One emitted Kitty graphics frame: the parsed APC header fields and the
-/// decoded PNG bytes (with any `m=1` chunks already reassembled).
-struct Frame {
- header: HashMap,
- png: Vec,
-}
-
-/// Walk `out` looking for Kitty graphics protocol APC frames
-/// (`ESC _ G ; ESC \`). Frames where the header has `m=1`
-/// continue the current image; `m=0` (or absent) terminates it. Returns one
-/// `Frame` per emitted image with the *terminator* frame's parsed header.
-///
-/// Panics if an APC frame is not terminated by `ESC \` — a regression where
-/// termdown truncates output should surface as a clear parse error here,
-/// not as a confusing base64-decode failure downstream.
-fn extract_kitty_frames(out: &[u8]) -> Vec {
- let mut frames = Vec::new();
- let mut current_b64 = String::new();
- let mut start_header: Option> = None;
- let mut i = 0;
- while i + 2 < out.len() {
- if !(out[i] == 0x1b && out[i + 1] == b'_' && out[i + 2] == b'G') {
- i += 1;
- continue;
- }
- let mut sep = i + 3;
- while sep < out.len() && out[sep] != b';' && out[sep] != 0x1b {
- sep += 1;
- }
- let header_str = std::str::from_utf8(&out[i + 3..sep]).expect("APC header is ASCII");
- let header = parse_header(header_str);
- // The first chunk of an image carries the full header (f=, a=, i=, ...).
- // Continuation chunks only carry `m=1` or `m=0`. Capture the first one
- // and let later chunks just append payload.
- if start_header.is_none() {
- start_header = Some(header.clone());
- }
-
- let payload_start = if sep < out.len() && out[sep] == b';' {
- sep + 1
- } else {
- sep
- };
- let mut end = payload_start;
- let mut terminated = false;
- while end + 1 < out.len() {
- if out[end] == 0x1b && out[end + 1] == b'\\' {
- terminated = true;
- break;
- }
- end += 1;
- }
- assert!(
- terminated,
- "unterminated kitty APC frame starting at byte offset {i}"
- );
- let chunk = std::str::from_utf8(&out[payload_start..end]).expect("APC payload is ASCII");
- current_b64.push_str(chunk);
-
- let more = header.get("m").map(|s| s.as_str()) == Some("1");
- if !more {
- let png = base64::engine::general_purpose::STANDARD
- .decode(¤t_b64)
- .expect("base64 payload decodes");
- frames.push(Frame {
- header: start_header.take().expect("start header captured"),
- png,
- });
- current_b64.clear();
- }
-
- i = end + 2;
- }
- frames
-}
-
-fn parse_header(s: &str) -> HashMap {
- s.split(',')
- .filter_map(|kv| {
- let (k, v) = kv.split_once('=')?;
- Some((k.to_string(), v.to_string()))
- })
- .collect()
-}
-
-fn decode_png(png: &[u8], idx: usize) -> image::RgbaImage {
- ImageReader::new(Cursor::new(png))
- .with_guessed_format()
- .unwrap_or_else(|e| panic!("png {idx} guess_format: {e}"))
- .decode()
- .unwrap_or_else(|e| panic!("png {idx} decode: {e}"))
- .to_rgba8()
-}
-
const BASIC_FIXTURE: &str = "fixtures/specialized/headings-basic.md";
-const EMOJI_FIXTURE: &str = "fixtures/specialized/headings-emoji.md";
#[test]
-fn cat_path_emits_only_display_form_apc_headers() {
- // Cat mode must emit `a=T` (transmit + display) on every frame. `a=t`
- // (transmit-only) is the TUI's cached-placement form and would paint
- // nothing here — a single bad frame would silently break heading
- // rendering, so we check *every* frame, not just the first.
+fn piped_output_preserves_heading_text_without_images() {
let stdout = run_termdown(Path::new(BASIC_FIXTURE));
- let frames = extract_kitty_frames(&stdout);
- assert!(
- !frames.is_empty(),
- "expected at least one APC frame in cat output"
- );
- for (idx, frame) in frames.iter().enumerate() {
- let a = frame.header.get("a").map(|s| s.as_str());
- assert_eq!(
- a,
- Some("T"),
- "frame {idx}: cat mode must use display-form a=T, got a={a:?} (full header: {:?})",
- frame.header
- );
- }
-}
+ let text = String::from_utf8(stdout).expect("cat output should be UTF-8");
-#[test]
-fn every_emitted_png_decodes_and_is_nonblank() {
- let stdout = run_termdown(Path::new(BASIC_FIXTURE));
- let frames = extract_kitty_frames(&stdout);
- assert!(!frames.is_empty(), "expected emitted PNGs");
- for (idx, frame) in frames.iter().enumerate() {
- let img = decode_png(&frame.png, idx);
- assert!(
- img.width() > 0 && img.height() > 0,
- "png {idx} has empty dimensions ({}x{})",
- img.width(),
- img.height()
- );
- assert!(
- img.pixels().any(|p| p[3] > 0),
- "png {idx} is fully transparent"
- );
- }
-}
-
-#[test]
-fn heading_level_determines_descending_height() {
- let stdout = run_termdown(Path::new(BASIC_FIXTURE));
- let frames = extract_kitty_frames(&stdout);
- assert_eq!(
- frames.len(),
- 3,
- "BASIC_FIXTURE has exactly H1+H2+H3 — expected 3 PNGs, got {}",
- frames.len()
- );
- let heights: Vec = frames
- .iter()
- .enumerate()
- .map(|(idx, f)| decode_png(&f.png, idx).height())
- .collect();
assert!(
- heights[0] > heights[1],
- "H1 height ({}) should exceed H2 height ({})",
- heights[0],
- heights[1]
- );
- assert!(
- heights[1] > heights[2],
- "H2 height ({}) should exceed H3 height ({})",
- heights[1],
- heights[2]
- );
-}
-
-#[test]
-fn emoji_headings_emit_decodable_nonblank_pngs() {
- // Regression coverage for emoji font fallback: a broken fallback chain
- // would render emoji glyphs as transparent .notdef and the PNG, while
- // still decodable, would be missing pixels in the emoji region. We
- // assert non-transparency to catch that.
- let stdout = run_termdown(Path::new(EMOJI_FIXTURE));
- let frames = extract_kitty_frames(&stdout);
- assert_eq!(
- frames.len(),
- 3,
- "EMOJI_FIXTURE has exactly H1+H2+H3 — expected 3 PNGs, got {}",
- frames.len()
+ !text.contains("\x1b_G"),
+ "piped output must not contain images"
);
- for (idx, frame) in frames.iter().enumerate() {
- let img = decode_png(&frame.png, idx);
- assert!(
- img.width() > 100,
- "emoji heading {idx} png is suspiciously narrow ({}px) — emoji or text may have been cropped",
- img.width()
- );
- let nonblank = img.pixels().filter(|p| p[3] > 0).count();
- // Require at least ~5% non-transparent pixels. A "mostly empty"
- // image would indicate font fallback failure dropping both text
- // and emoji glyphs.
- let total = (img.width() * img.height()) as usize;
- assert!(
- nonblank * 20 >= total,
- "emoji heading {idx} png is mostly transparent ({nonblank}/{total} non-blank pixels)"
- );
- }
+ assert!(text.contains("Heading One"));
+ assert!(text.contains("Heading Two"));
+ assert!(text.contains("Heading Three"));
}
diff --git a/tests/snapshots.rs b/tests/snapshots.rs
index 84f6156..81677b9 100644
--- a/tests/snapshots.rs
+++ b/tests/snapshots.rs
@@ -5,10 +5,8 @@ use std::path::Path;
use common::run_termdown;
-/// Replace each run of kitty image APC sequences (`ESC _ G ... ESC \`) with a
-/// single `
` marker. Font rasterization produces OS-specific PNG bytes
-/// that can't be compared across platforms — we only validate that an image
-/// was emitted at a given position, not its pixel content.
+/// Replace any Kitty image APC sequences (`ESC _ G ... ESC \`) with a stable
+/// marker so an accidental image in piped output produces a readable diff.
fn strip_kitty_images(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec = Vec::with_capacity(bytes.len());