From 8a3fd7e1bd238e9114fa6322d3d4bc4d874959f7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 07:13:47 +0000 Subject: [PATCH] Scale box drawing and legacy computing unicode characters to eliminate visual gaps. This commit updates both the GIF and SVG renderers to identify unicode characters falling into the Box Drawing, Block Elements, and Symbols for Legacy Computing (and Supplement) blocks. When these characters are encountered, they are explicitly stretched via explicit X and Y scaling factors so that they fully occupy the cell bounding box. This prevents visual gaps from appearing between adjacent block characters when custom line heights or letter spacing are used. The glyph cache key in the GIF renderer was updated to account for varying scaling factors in X and Y independently. Tests were also added to verify the `is_box_drawing` function accurately categorizes the character points. Co-authored-by: HalFrgrd <4559349+HalFrgrd@users.noreply.github.com> --- src/render_common.rs | 26 +++++++++++++++++++++ src/render_gif.rs | 27 +++++++++++++++++++--- src/render_svg.rs | 55 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 96 insertions(+), 12 deletions(-) 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; } }