diff --git a/src/render_common.rs b/src/render_common.rs index 2833abb..81b2cce 100644 --- a/src/render_common.rs +++ b/src/render_common.rs @@ -33,6 +33,16 @@ pub struct ViewportConfig { pub content_y: u32, } +/// Returns `true` if the character is a box drawing character. +/// This includes the Box Drawing block (U+2500..=U+257F), Block Elements (U+2580..=U+259F), +/// Symbols for Legacy Computing (U+1FB00..=U+1FBFF), and Symbols for Legacy Computing Supplement (U+1CC00..=U+1CEBF). +pub fn is_box_drawing(c: char) -> bool { + let cp = c as u32; + (0x2500..=0x259F).contains(&cp) || + (0x1FB00..=0x1FBFF).contains(&cp) || + (0x1CC00..=0x1CEBF).contains(&cp) +} + impl ViewportConfig { pub fn new( cols: u16, @@ -119,3 +129,19 @@ impl Default for RenderOptions { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_box_drawing() { + assert!(is_box_drawing('╭')); // Box Drawing + assert!(is_box_drawing('█')); // Block Elements + assert!(is_box_drawing('\u{1FB00}')); // Legacy Computing + assert!(is_box_drawing('\u{1CC00}')); // Legacy Computing Supplement + + assert!(!is_box_drawing('A')); + assert!(!is_box_drawing(' ')); + } +} diff --git a/src/render_gif.rs b/src/render_gif.rs index 69ee557..f127e02 100644 --- a/src/render_gif.rs +++ b/src/render_gif.rs @@ -12,6 +12,8 @@ use std::{ use ab_glyph::{Font, FontArc, Glyph, GlyphId, PxScale, ScaleFont}; use anyhow::{Context, Result, anyhow}; + +use crate::render_common::is_box_drawing; use crossbeam_channel::{Receiver, Sender, bounded}; use gifski::{Settings, progress}; use tracing::{debug, warn}; @@ -114,7 +116,8 @@ struct GlyphCacheKey { /// ab_glyph glyph identifier within the face. glyph_id: u16, /// Uniform px-scale as `f32` bits (we only use uniform scales). - scale_bits: u32, + scale_bits_x: u32, + scale_bits_y: u32, } /// Colour-independent coverage mask for one rasterised glyph. @@ -491,13 +494,31 @@ fn rasterize_raw_frame( font_idx <= u16::MAX as usize, "font_idx {font_idx} exceeds u16 range" ); + let mut glyph_scale = scale; + + if is_box_drawing(ch) { + let scaled = font.as_scaled(scale); + let advance = scaled.h_advance(glyph_id); + let bbox_w = cell_w as f32; + let bbox_h = cell_h as f32; + + let glyph_w = advance.max(1.0); + let glyph_h = (scaled.ascent() - scaled.descent()).max(1.0); + + // We want the box drawing character to exactly fill the cell width and height, + // so we stretch it accordingly. + glyph_scale.x = scale.x * (bbox_w / glyph_w); + glyph_scale.y = scale.y * (bbox_h / glyph_h); + } + let cache_key = GlyphCacheKey { font_idx: font_idx as u16, glyph_id: glyph_id.0, - scale_bits: scale.x.to_bits(), + scale_bits_x: glyph_scale.x.to_bits(), + scale_bits_y: glyph_scale.y.to_bits(), }; let bitmap = glyph_cache.entry(cache_key).or_insert_with(|| { - let glyph: Glyph = glyph_id.with_scale(scale); + let glyph: Glyph = glyph_id.with_scale(glyph_scale); font.outline_glyph(glyph).map(|outline| { let bounds = outline.px_bounds(); let w = (bounds.max.x - bounds.min.x).ceil() as u32; diff --git a/src/render_svg.rs b/src/render_svg.rs index 16cd437..6db50fe 100644 --- a/src/render_svg.rs +++ b/src/render_svg.rs @@ -39,6 +39,7 @@ use std::{ thread::{self, JoinHandle}, }; +use crate::render_common::is_box_drawing; use anyhow::{Context, Result, anyhow}; use crossbeam_channel::{Receiver, Sender, bounded}; @@ -418,11 +419,14 @@ fn emit_frame_body(s: &mut String, frame: &RawFrame, cfg: ViewportConfig, font_s let style = cell.flags; let mut run = String::new(); run.push_str(&cell.text); + let is_box = cell.text.chars().any(is_box_drawing); let mut run_end = col + 1; while run_end < frame.cols { let next = &frame.cells[row as usize * frame.cols as usize + run_end as usize]; let (nfg, _) = effective_colors(next); - if next.text.is_empty() || nfg != fg || next.flags != style { + let next_is_box = next.text.chars().any(is_box_drawing); + if next.text.is_empty() || nfg != fg || next.flags != style || is_box != next_is_box + { break; } run.push_str(&next.text); @@ -450,14 +454,47 @@ fn emit_frame_body(s: &mut String, frame: &RawFrame, cfg: ViewportConfig, font_s } else { "" }; - s.push_str(&format!( - r#"{txt}"#, - fg = rgb_hex(fg), - w = weight, - i = italic, - d = decoration, - txt = escape_text(&run), - )); + if is_box { + let run_len = (run_end - col) as u32; + let text_length = run_len * cell_w; + let bbox_h = font_size * 0.8; // approximate bbox height + let scale_y = (cell_h as f32 / bbox_h).max(1.0); + + // We use lengthAdjust="spacingAndGlyphs" combined with a vertical scale + // to make the box drawing glyphs fill the cell boundary. + let transform = if scale_y > 1.0 { + // SVG text coordinates are roughly on the baseline. Scaling by Y will stretch the ascent and descent. + // To keep the character centered vertically in the cell, we scale it relative to its vertical center. + let cy = y as f32 - (font_size * 0.3); // Approximate vertical center of the character, closer to baseline + format!( + r#" transform="translate(0, {cy}) scale(1, {scale_y}) translate(0, -{cy})""#, + cy = cy, + scale_y = scale_y + ) + } else { + String::new() + }; + + s.push_str(&format!( + r#"{txt}"#, + fg = rgb_hex(fg), + w = weight, + i = italic, + d = decoration, + transform = transform, + text_length = text_length, + txt = escape_text(&run), + )); + } else { + s.push_str(&format!( + r#"{txt}"#, + fg = rgb_hex(fg), + w = weight, + i = italic, + d = decoration, + txt = escape_text(&run), + )); + } col = run_end; } }