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 @@ - +Plain Document, No Frontmatter This file has no YAML or TOML metadata block. The  ---  line further down should be rendered as a normal horizontal rule, not 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 @@ [metadata · title=Supported Syntax Showcase, author=termdown, tags=[markdown, …] - +Supported Syntax Showcase This fixture is the single source of truth for syntax termdown currently supports @@ -12,7 +12,7 @@ The YAML frontmatter above is parsed and rendered as a single dim summary line in  --cat , or as a collapsible inline box in TUI (toggle with  m ). It does not leak into body content. - +1. Headings 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. - +1.1 Levels - +H1 标题 / Heading One - +H2 标题 / Heading Two - +H3 标题 / Heading Three H4 — 四级标题 @@ -34,25 +34,25 @@ panel. H6 — 六级标题 (the deepest level Markdown supports) - +1.2 Headings with emoji & mixed scripts - +🚀 Launch builders, not just code - +✨ 标题里的 emoji 和中文混排 - +🧪 Body Text Baseline · 正文基线 - +1.3 Repeated titles (slug disambiguation) Duplicate Duplicate - +1.4 Very long heading that should wrap inside a narrow TOC panel across multiple lines Filler paragraph so the long heading above is followed by some body content. - +2. Emphasis Italic via underscores, italic via asterisks. @@ -64,9 +64,9 @@ Filler paragraph so the long heading above is followed by some body content. 斜体 · 粗体 · 粗斜体组合 · 中文删除线 - +3. Lists - +3.1 Unordered, nested • 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 - +3.2 Ordered, nested 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 - +3.3 中英混排列表 • 第一项 (item 1) • 第二项 (item 2) • 嵌套 a / nested a • 嵌套 b / nested b - +4. Task lists - +4.1 Project TODO [✓] 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 - +4.2 Nested tasks [✓] 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 - +4.3 Mixed with regular list items & inline formatting 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 bold and italic text [ ] Task with  inline code  - +5. Links Inline link: Markdown Live Preview (https://markdownlivepreview.com/). @@ -135,7 +135,7 @@ Reference-style link: reference example (https://ex Bare URL (GFM autolink): https://example.com/docs/readme.html — bare email: support@example.com — these are part of the autolink roadmap. - +6. Images │ Roadmap feature —  TODO.md  中"图片支持"。当前 termdown │ 渲染图片的能力还不完整,本节 @@ -158,7 +158,7 @@ Reference-style image: [🖼 ref image](https://example.com/banner.png) - +7. Blockquotes │ Markdown is a lightweight markup language with plain-text-formatting syntax, │ created in 2004 by John Gruber with Aaron Swartz. @@ -169,9 +169,9 @@ Reference-style image: │ 中文引用块:Markdown 是一种轻量级标记语言,使用纯文本格式语法。 │ │ 嵌套引用:常用于编写 readme 文件与在线论坛中的消息格式化。 - +8. Tables - +8.1 Three alignment modes Left  │ Center  │ Right ───────────────── ┼ ──────────── ┼ ────── @@ -179,7 +179,7 @@ a  │ b  │ c long left content │ center  │ 1 x  │ bold in cell │  code  - +8.2 Mixed scripts & emoji in cells Case  │ Example ────────────────── ┼ ─────────────────── @@ -188,7 +188,7 @@ Mixed text  │ 修正版 ✨ version 2 Symbol-like  │ ✅ ⚠ ❌ Variation selector │ ☀️ ❤️ ⭐️ - +9. Code blocks Fenced block with language tag: @@ -211,7 +211,7 @@ Indented code block: Inline code: this web site is using  markedjs/marked , and shell command  cargo run -- fixtures/supported-syntax.md  should render this file. - +10. HTML tags │ Roadmap feature —  TODO.md  中"测试 html 标签支持"。覆盖块级 HTML、行内 │ HTML、HTML 注释。 @@ -233,13 +233,13 @@ An HTML comment: end of line. Self-closing void element: line one line two on the same paragraph. - +11. Long-text wrap & indent │ Roadmap feature —  TODO.md  │ 中"长文本换行时缩进的处理"。本节专门用于验证:长行换行后 │ 续行的缩进是否与首行对齐(特别是在列表、引用、标题里)。 - +11.1 Long paragraph 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 之后宽度计算仍然正确。 - +11.2 Long line in a nested list • 顶级列表项 / 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. - +11.3 Long line in a blockquote │ 引用块里的长行也需要正确换行:这是一段被包在  >  │ 引用标记里的中英混排长文,软换行 @@ -264,27 +264,27 @@ of the item text, not to the bullet marker. │ English — wrapped continuation lines must remain inside the blockquote prefix │ rather than escape to the outer margin. - +11.4 Long heading (continuation indent test) 一个超长四级标题,包含中文、English、还有 emoji 🚀,用来验证标题在窄终端中换行后续行的缩进表现 After the long heading above, this short paragraph anchors the test so the heading is followed by body content. - +12. Emoji & 中英混排 - +12.1 Inline emoji • 单个 emoji: 😀 😎 ✨ 🚀 • 中英混排: Hello 世界 🌍 • 符号混排: ✅ Done · ⚠ Warning · ❌ Failed • 常见 emoji 变体: ☀️ ❤️ ⭐️ - +12.2 Complex ZWJ sequences 观察复杂 ZWJ emoji 的边界表现:👨‍👩‍👧‍👦 👩🏽‍💻 🧑‍🚀 - +12.3 Bidirectional & mixed-script paragraph 正文仍然由终端自身字体渲染,所以这里主要用来对比标题图片化与正文文本化的表现差异。 Body text mixes 中文、English、ASCII punctuation, and emoji 🌍 within the same @@ -292,7 +292,7 @@ line. │ 引用块里也放几个字符:💡 🛠 📦 - +End 如果以上每一节都能正确(或按当前能力部分正确)渲染,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 @@ - +Unsupported Syntax Showcase This fixture collects Markdown features termdown does not 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 roadmap (HTML tags, images, markdown frontmatter, long-text wrap & indent) live in  supported-syntax.md , not here. - +1. GFM autolinks (bare URLs) 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. - +2. GFM alerts / admonitions │ [!NOTE] │ Useful information that users should know, even when skimming content. @@ -31,7 +31,7 @@ URL in text: visit https://github.com/rrbe/termdown for the source. │ [!CAUTION] │ Advises about risks or negative outcomes of certain actions. - +3. Footnotes Here is a sentence with a footnote.[^1] And another one.[^longnote] @@ -44,7 +44,7 @@ body.]  paragraphs. It should render as a numbered reference in the main text,   with the body collected at the bottom of the document.  - +4. Math (LaTeX) 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} $$ - +5. Definition list Term 1 : Definition of term 1. @@ -76,21 +76,21 @@ Apple Orange : A citrus fruit with a tough rind. - +6. Smart punctuation 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. - +7. Wikilinks Reference a page with [[WikiLink]] syntax, and with an alias like [[Target Page|the display text]]. - +8. Subscript and superscript Water is H~2~O and Einstein said E=mc^2^. Also 10^th^ and x~n+1~. - +9. Mermaid diagrams A flowchart: @@ -119,7 +119,7 @@ A class diagram:  Animal <|-- Dog   Animal <|-- Cat  - +10. PlantUML / Graphviz  @startuml   Alice -> Bob: Authentication Request  @@ -132,7 +132,7 @@ A class diagram:  A -> C;   }  - +11. Code block syntax highlighting 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.  "version": "0.2.0"   }  - +12. Emoji shortcodes Shortcodes like :smile:, :rocket:, :tada: should ideally become 😄 🚀 🎉. Unicode emoji themselves work fine: 😄 🚀 🎉. - +End If every section above renders with at least some 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());