From c8b32f73b278b9afd257b77aa12db6a58ec70a86 Mon Sep 17 00:00:00 2001 From: andrii <25188+unorsk@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:35:13 +0100 Subject: [PATCH 1/5] Smart terminal colors --- src/main.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 454fef1..fdc74ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,6 +116,8 @@ async fn inner_main() -> Result<(), WrappedErr> { optional -w,--white-color white: String /// Custom black color, specified in css format (e.g "000000" or "rgb(0, 0, 0)") optional -b,--black-color black: String + /// Use terminal foreground/background colors for the PDF + optional -t,--terminal-colors /// Print the version and exit optional --version /// PDF file to read @@ -137,6 +139,12 @@ async fn inner_main() -> Result<(), WrappedErr> { .canonicalize() .map_err(|e| WrappedErr(format!("Cannot canonicalize provided file: {e}").into()))?; + let (default_black, default_white) = if flags.terminal_colors { + query_terminal_colors() + } else { + (MUPDF_BLACK, MUPDF_WHITE) + }; + let black = flags .black_color .as_deref() @@ -151,7 +159,7 @@ async fn inner_main() -> Result<(), WrappedErr> { }) }) .transpose()? - .unwrap_or(MUPDF_BLACK); + .unwrap_or(default_black); let white = flags .white_color @@ -167,7 +175,7 @@ async fn inner_main() -> Result<(), WrappedErr> { }) }) .transpose()? - .unwrap_or(MUPDF_WHITE); + .unwrap_or(default_white); // need to keep it around throughout the lifetime of the program, but don't rly need to use it. // Just need to make sure it doesn't get dropped yet. @@ -565,6 +573,52 @@ fn parse_color_to_i32(cs: &str) -> Result Ok(i32::from_be_bytes([0, r, g, b])) } +fn query_terminal_colors() -> (i32, i32) { + let Ok(()) = enable_raw_mode() else { + return (MUPDF_BLACK, MUPDF_WHITE); + }; + + let fg = query_osc_color(10); + let bg = query_osc_color(11); + + let _ = disable_raw_mode(); + + (fg.unwrap_or(MUPDF_BLACK), bg.unwrap_or(MUPDF_WHITE)) +} + +fn query_osc_color(osc: u8) -> Option { + print!("\x1b]{osc};?\x1b\\"); + std::io::stdout().flush().unwrap(); + + let stdin = std::io::stdin(); + let mut handle = stdin.lock(); + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + handle.read_exact(&mut byte).ok()?; + if byte[0] == b'\\' || byte[0] == 0x07 { + break; + } + buf.push(byte[0]); + } + drop(handle); + + let input = String::from_utf8(buf).ok()?; + let rgb_str = input.split("rgb:").nth(1)?.trim_end_matches('\x1b'); + + let mut parts = rgb_str.split('/'); + let parse = |hex: &str| -> Option { + let val = u16::from_str_radix(hex, 16).ok()?; + Some(if hex.len() <= 2 { val as u8 } else { (val >> 8) as u8 }) + }; + + let r = parse(parts.next()?)?; + let g = parse(parts.next()?)?; + let b = parse(parts.next()?)?; + + Some(i32::from_be_bytes([0, r, g, b])) +} + fn get_font_size_through_stdio() -> Result<(u16, u16), WrappedErr> { // send the command code to get the terminal window size print!("\x1b[14t"); From 991a24bfca66e6afa652f1c12a856664aedbe182 Mon Sep 17 00:00:00 2001 From: andrii <25188+unorsk@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:43:05 +0100 Subject: [PATCH 2/5] Cleaning up + safeguards --- src/main.rs | 122 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 76 insertions(+), 46 deletions(-) diff --git a/src/main.rs b/src/main.rs index fdc74ad..dc14b45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use core::{ use std::{ borrow::Cow, ffi::OsString, - io::{BufReader, Read as _, Stdout, Write as _, stdout}, + io::{BufReader, IsTerminal as _, Read as _, Stdout, Write as _, stdout}, mem, path::PathBuf, sync::{Arc, Mutex}, @@ -139,43 +139,49 @@ async fn inner_main() -> Result<(), WrappedErr> { .canonicalize() .map_err(|e| WrappedErr(format!("Cannot canonicalize provided file: {e}").into()))?; - let (default_black, default_white) = if flags.terminal_colors { + if flags.terminal_colors && (flags.black_color.is_some() || flags.white_color.is_some()) { + return Err(WrappedErr( + "--terminal-colors cannot be combined with --black-color or --white-color".into() + )); + } + + let (black, white) = if flags.terminal_colors { query_terminal_colors() } else { - (MUPDF_BLACK, MUPDF_WHITE) - }; - - let black = flags - .black_color - .as_deref() - .map(|color| { - parse_color_to_i32(color).map_err(|e| { - WrappedErr( - format!( - "Couldn't parse black color {color:?}: {e} - is it formatted like a CSS color?" + let black = flags + .black_color + .as_deref() + .map(|color| { + parse_color_to_i32(color).map_err(|e| { + WrappedErr( + format!( + "Couldn't parse black color {color:?}: {e} - is it formatted like a CSS color?" + ) + .into() ) - .into() - ) + }) }) - }) - .transpose()? - .unwrap_or(default_black); - - let white = flags - .white_color - .as_deref() - .map(|color| { - parse_color_to_i32(color).map_err(|e| { - WrappedErr( - format!( - "Couldn't parse white color {color:?}: {e} - is it formatted like a CSS color?" + .transpose()? + .unwrap_or(MUPDF_BLACK); + + let white = flags + .white_color + .as_deref() + .map(|color| { + parse_color_to_i32(color).map_err(|e| { + WrappedErr( + format!( + "Couldn't parse white color {color:?}: {e} - is it formatted like a CSS color?" + ) + .into() ) - .into() - ) + }) }) - }) - .transpose()? - .unwrap_or(default_white); + .transpose()? + .unwrap_or(MUPDF_WHITE); + + (black, white) + }; // need to keep it around throughout the lifetime of the program, but don't rly need to use it. // Just need to make sure it doesn't get dropped yet. @@ -574,42 +580,66 @@ fn parse_color_to_i32(cs: &str) -> Result } fn query_terminal_colors() -> (i32, i32) { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return (MUPDF_BLACK, MUPDF_WHITE); + } + let Ok(()) = enable_raw_mode() else { return (MUPDF_BLACK, MUPDF_WHITE); }; - let fg = query_osc_color(10); - let bg = query_osc_color(11); + struct RawModeGuard; + impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + } + } + let _guard = RawModeGuard; + + let stdin = std::io::stdin(); + let mut handle = stdin.lock(); - let _ = disable_raw_mode(); + let fg = query_osc_color(10, &mut handle); + let bg = query_osc_color(11, &mut handle); + drop(handle); (fg.unwrap_or(MUPDF_BLACK), bg.unwrap_or(MUPDF_WHITE)) } -fn query_osc_color(osc: u8) -> Option { +fn query_osc_color(osc: u8, handle: &mut std::io::StdinLock<'_>) -> Option { print!("\x1b]{osc};?\x1b\\"); - std::io::stdout().flush().unwrap(); + std::io::stdout().flush().ok()?; - let stdin = std::io::stdin(); - let mut handle = stdin.lock(); - let mut buf = Vec::new(); + let mut buf = Vec::with_capacity(64); + let mut prev = None::; let mut byte = [0u8; 1]; loop { handle.read_exact(&mut byte).ok()?; - if byte[0] == b'\\' || byte[0] == 0x07 { + let b = byte[0]; + + if b == 0x07 || b == 0x9c { + break; + } + if prev == Some(0x1b) && b == b'\\' { + buf.pop(); break; } - buf.push(byte[0]); + + buf.push(b); + prev = Some(b); } - drop(handle); - let input = String::from_utf8(buf).ok()?; - let rgb_str = input.split("rgb:").nth(1)?.trim_end_matches('\x1b'); + let input = core::str::from_utf8(&buf).ok()?; + let rgb_str = input.split("rgb:").nth(1)?; let mut parts = rgb_str.split('/'); let parse = |hex: &str| -> Option { let val = u16::from_str_radix(hex, 16).ok()?; - Some(if hex.len() <= 2 { val as u8 } else { (val >> 8) as u8 }) + Some(if hex.len() <= 2 { + val as u8 + } else { + (val >> 8) as u8 + }) }; let r = parse(parts.next()?)?; From de9bb5584783767b8ba3fab91b0d7f9eb33daa73 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sat, 15 Aug 2026 18:42:59 -0500 Subject: [PATCH 3/5] refactor terminal color parsing to avoid allocations and add basic tests --- src/main.rs | 86 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/src/main.rs b/src/main.rs index dc14b45..d0b4fca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -610,41 +610,53 @@ fn query_osc_color(osc: u8, handle: &mut std::io::StdinLock<'_>) -> Option print!("\x1b]{osc};?\x1b\\"); std::io::stdout().flush().ok()?; - let mut buf = Vec::with_capacity(64); - let mut prev = None::; - let mut byte = [0u8; 1]; - loop { - handle.read_exact(&mut byte).ok()?; - let b = byte[0]; + // response looks like "\u{1b}]10;rgb:rrrr/bbbb/gggg\u{1b}\\" or "\u{1b}]10;rgb:rr/bb/gg\u{1b}\\" + // or if osc is 11, "\u{1b}]11;rgb:rrrr/bbbb/gggg\u{1b}\\" or "\u{1b}]11;rgb:rr/bb/gg\u{1b}\\" - if b == 0x07 || b == 0x9c { - break; - } - if prev == Some(0x1b) && b == b'\\' { - buf.pop(); - break; + // We expect the response to be either 19 or 25 bytes. + let mut response_buf = [0u8; 25]; + + handle.read_exact(&mut response_buf[..19]) + .inspect_err(|e| eprintln!("Couldn't get a response from your terminal for querying OSC {osc}; please file a bug with tdf with your terminal emulator (underlying err: {e})")) + .ok()?; + + let two_digit_colors = response_buf[11] == b'/'; + + if !two_digit_colors { + handle.read_exact(&mut response_buf[19..]) + .inspect_err(|e| eprintln!("Couldn't get a response from your terminal for querying OSC {osc}; please file a bug with tdf with your terminal emulator (underlying err: {e})")) + .ok()?; + } + + osc_response_buf_to_color(response_buf) +} + +fn osc_response_buf_to_color(response_buf: [u8; 25]) -> Option { + fn parse(ascii_hex_bytes: &[u8]) -> Option { + let mut color = 0; + + for byte in ascii_hex_bytes { + color = (color << 4) + (match byte { + b'0'..=b'9' => byte - b'0', + b'A'..=b'F' => byte - (b'A' - 10), + b'a'..=b'f' => byte - (b'a' - 10), + _ => return None + }); } - buf.push(b); - prev = Some(b); + Some(color) } - let input = core::str::from_utf8(&buf).ok()?; - let rgb_str = input.split("rgb:").nth(1)?; - - let mut parts = rgb_str.split('/'); - let parse = |hex: &str| -> Option { - let val = u16::from_str_radix(hex, 16).ok()?; - Some(if hex.len() <= 2 { - val as u8 - } else { - (val >> 8) as u8 - }) - }; + let two_digit_colors = response_buf[11] == b'/'; - let r = parse(parts.next()?)?; - let g = parse(parts.next()?)?; - let b = parse(parts.next()?)?; + // this is minimimal validation. + // the response either has `rrrr/gggg/bbbb` or `rr/gg/bb` starting at byte 9. + // So we grab the first two digits out of the r, g, and b sections (since we can only support + // 8-bit colors with mupdf; we can't do the 16-bits that the terminal might respond with), we + // parse them, and we return it as an i32. + let r = parse(&response_buf[9..11])?; + let g = parse(&response_buf[if two_digit_colors { 12..14 } else { 14..16 }])?; + let b = parse(&response_buf[if two_digit_colors { 15..17 } else { 19..21 }])?; Some(i32::from_be_bytes([0, r, g, b])) } @@ -715,3 +727,19 @@ fn get_font_size_through_stdio() -> Result<(u16, u16), WrappedErr> { Ok((w, h)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc_response_parsing() { + fn eq(bytes: [u8; 25], r: u8, g: u8, b: u8) { + let resp = osc_response_buf_to_color(bytes); + assert_eq!(resp, Some(i32::from_be_bytes([0, r, g, b]))); + } + + eq(*b"\x1b]10;rgb:11/22/33\x1b\\000000", 0x11, 0x22, 0x33); + eq(*b"\x1b]11;rgb:aa11/23bf/fff0\x1b1", 0xaa, 0x23, 0xff); + } +} From 5e005a5d0343dbd3a1eebbddfcc5143570a9f249 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sat, 15 Aug 2026 18:46:51 -0500 Subject: [PATCH 4/5] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d578315..3bb9d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Added windows support! (thank you to [@jarjk](https://github.com/jarjk) for helping out!) - Added keybindings (`0`/`$`) to scroll to left or right side of zoomed-in image ([#131](https://github.com/itsjunetime/tdf/pull/131), thank you [@IshDeshpa](https://github.com/IshDeshpa)!) +- Added `-t` flag to use terminal foreground/background colors in pdf rendering ([#138](https://github.com/itsjunetime/tdf/pull/138), thank you [@unorsk](https://github.com/unorsk)! - Fixed issue with images clearing/flashing after displaying a certain number on kitty - (Internal) decreased runtime footprint of tokio runtime From db1264852fd28eaca2d944861cdf4011c9eeca50 Mon Sep 17 00:00:00 2001 From: itsjunetime Date: Sat, 15 Aug 2026 18:47:37 -0500 Subject: [PATCH 5/5] fmt --- src/main.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index d0b4fca..ccbd00b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -636,12 +636,13 @@ fn osc_response_buf_to_color(response_buf: [u8; 25]) -> Option { let mut color = 0; for byte in ascii_hex_bytes { - color = (color << 4) + (match byte { - b'0'..=b'9' => byte - b'0', - b'A'..=b'F' => byte - (b'A' - 10), - b'a'..=b'f' => byte - (b'a' - 10), - _ => return None - }); + color = (color << 4) + + (match byte { + b'0'..=b'9' => byte - b'0', + b'A'..=b'F' => byte - (b'A' - 10), + b'a'..=b'f' => byte - (b'a' - 10), + _ => return None + }); } Some(color)