diff --git a/Cargo.lock b/Cargo.lock index 8fddc76a3..bdf91c7ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,6 +1231,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2992,6 +3001,7 @@ dependencies = [ "base64", "clap", "dashmap", + "fontdue", "globset", "grep-matcher", "grep-regex", @@ -4725,6 +4735,12 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "type-map" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d8bd0a57a..e4033c8f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -210,6 +210,7 @@ regex = "1" similar = "3.1.0" unicode-segmentation = "1.13" unicode-width = "0.2" +fontdue = { version = "0.9", default-features = false } # ────────────────────────────────────────────────────────────────────────────── # Data Structures - Collections diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 7cd6e3305..843741f24 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -20,6 +20,7 @@ base64.workspace = true clap.workspace = true dashmap.workspace = true globset.workspace = true +fontdue.workspace = true grep-matcher.workspace = true grep-regex.workspace = true grep-searcher.workspace = true diff --git a/crates/pi-natives/src/fonts/Silver.LICENSE b/crates/pi-natives/src/fonts/Silver.LICENSE new file mode 100644 index 000000000..2d57d97db --- /dev/null +++ b/crates/pi-natives/src/fonts/Silver.LICENSE @@ -0,0 +1,15 @@ +Silver — a pixel font for games + +Source: https://poppyworks.itch.io/silver +Author: Poppy Works, Wolfgang Wozniak +Major contributors: + - Itou Hiro (PixelMPlus) + - leedheo (DOSGothic) + - ぶち (Buchidot) + +License: Creative Commons Attribution 4.0 International (CC BY 4.0) + https://creativecommons.org/licenses/by/4.0/ + +This license allows the work and adaptations of it to be shared and used +commercially, as long as it is attributed to Poppy Works. The font is bundled +here (crates/pi-natives/src/fonts/Silver.ttf) as a CJK/Unicode bitmap fallback. diff --git a/crates/pi-natives/src/fonts/Silver.ttf b/crates/pi-natives/src/fonts/Silver.ttf new file mode 100644 index 000000000..7c06c406d Binary files /dev/null and b/crates/pi-natives/src/fonts/Silver.ttf differ diff --git a/crates/pi-natives/src/snapcompact.rs b/crates/pi-natives/src/snapcompact.rs index 8bf0e6cb6..fa47c573e 100644 --- a/crates/pi-natives/src/snapcompact.rs +++ b/crates/pi-natives/src/snapcompact.rs @@ -8,6 +8,7 @@ //! - `8x8` — unscii-8 hex font (Latin-1 subset), the square cell that won the //! snapcompact `SQuAD` evals. //! - `6x12` / `8x13` — X.org misc BDF fonts (higher-density eval winners). +//! - `silver` — bundled TrueType font for CJK and other non-Latin text. //! //! Shape controls, all eval-validated in `packages/snapcompact`: //! @@ -38,9 +39,15 @@ //! management live in `packages/snapcompact/src/snapcompact.ts`; this module //! is only the hot `text -> PNG bytes` path. -use std::{borrow::Cow, collections::HashMap, f32::consts::PI, sync::LazyLock}; +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, + f32::consts::PI, + sync::LazyLock, +}; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use fontdue::{Font as TtfFace, FontSettings, Metrics}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -84,6 +91,8 @@ static FONT_6X12: LazyLock = LazyLock::new(|| parse_bdf(include_str!("fonts/6x12.bdf"), 6, 12)); static FONT_8X13: LazyLock = LazyLock::new(|| parse_bdf(include_str!("fonts/8x13.bdf"), 8, 13)); +static FONT_SILVER: LazyLock = + LazyLock::new(|| parse_ttf(include_bytes!("fonts/Silver.ttf"), 16.0, 16, 16)); struct Glyph { /// Glyph width in pixels (≤ 8 for the bundled fonts). @@ -106,6 +115,20 @@ struct Font { cell_h: usize, } +struct TtfFont { + face: TtfFace, + supported: HashSet, + px: f32, + ascent: f32, + cell_w: usize, + cell_h: usize, +} + +struct RasterizedGlyph { + metrics: Metrics, + bitmap: Vec, +} + fn parse_bdf(text: &str, cell_w: usize, cell_h: usize) -> Font { let mut glyphs = HashMap::new(); let mut ascent = 0i32; @@ -168,12 +191,54 @@ fn parse_hex(text: &str) -> Font { Font { glyphs, ascent: 7, cell_w: 8, cell_h: 8 } } -fn resolve_font(name: &str) -> Option<&'static Font> { +fn parse_ttf(data: &'static [u8], px: f32, cell_w: usize, cell_h: usize) -> TtfFont { + let face = + TtfFace::from_bytes(data, FontSettings::default()).expect("bundled Silver.ttf must parse"); + let supported = face.chars().keys().copied().collect(); + let ascent = face + .horizontal_line_metrics(px) + .map_or(px * 0.8, |metrics| metrics.ascent); + TtfFont { face, supported, px, ascent, cell_w, cell_h } +} + +enum RenderFont<'a> { + Bitmap(&'a Font), + Ttf(&'a TtfFont), +} + +impl RenderFont<'_> { + const fn cell_w(&self) -> usize { + match self { + Self::Bitmap(font) => font.cell_w, + Self::Ttf(font) => font.cell_w, + } + } + + const fn cell_h(&self) -> usize { + match self { + Self::Bitmap(font) => font.cell_h, + Self::Ttf(font) => font.cell_h, + } + } + + fn supports(&self, code: u32) -> bool { + if matches!(code, DIM_ON | DIM_OFF | FULL_BLOCK | 0x0a) { + return true; + } + match self { + Self::Bitmap(font) => font.glyphs.contains_key(&code), + Self::Ttf(font) => char::from_u32(code).is_some_and(|ch| font.supported.contains(&ch)), + } + } +} + +fn resolve_font(name: &str) -> Option> { match name { - "5x8" => Some(&FONT_5X8), - "8x8" => Some(&FONT_8X8), - "6x12" => Some(&FONT_6X12), - "8x13" => Some(&FONT_8X13), + "5x8" => Some(RenderFont::Bitmap(&FONT_5X8)), + "8x8" => Some(RenderFont::Bitmap(&FONT_8X8)), + "6x12" => Some(RenderFont::Bitmap(&FONT_6X12)), + "8x13" => Some(RenderFont::Bitmap(&FONT_8X13)), + "silver" => Some(RenderFont::Ttf(&FONT_SILVER)), _ => None, } } @@ -191,21 +256,79 @@ struct Grid { cell_h: usize, } +/// East Asian Wide / Fullwidth code points that occupy two grid cells when +/// drawn through the Silver fallback in a narrow bitmap shape. The same ranges +/// are mirrored in `packages/snapcompact/src/snapcompact.ts` so the TypeScript +/// capacity/pagination math and this layout never disagree on cell counts. +const fn is_wide(cp: u32) -> bool { + matches!(cp, + 0x1100..=0x115F + | 0x2E80..=0x2EFF + | 0x2F00..=0x2FDF + | 0x3000..=0x303E + | 0x3041..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xA000..=0xA4CF + | 0xAC00..=0xD7A3 + | 0xF900..=0xFAFF + | 0xFE30..=0xFE4F + | 0xFF00..=0xFF60 + | 0xFFE0..=0xFFE6 + | 0x20000..=0x2FFFD + | 0x30000..=0x3FFFD + ) +} + +/// Cells one code point consumes in a grid of `wide_cells`-capable shape: zero +/// for the zero-width dim toggles, two for wide code points when the shape uses +/// a narrow bitmap cell (so CJK draws full-width through Silver), one +/// otherwise. +const fn cell_units(code: u32, wide_cells: bool) -> usize { + match code { + DIM_ON | DIM_OFF => 0, + _ if wide_cells && is_wide(code) => 2, + _ => 1, + } +} + +/// Advance the running cell cursor for one code point, inserting a one-cell pad +/// when a wide glyph would straddle the right edge so it starts the next row. +/// Returns `(cell_at_which_to_draw, units, next_cursor)`, or `None` for a +/// zero-width toggle. +const fn place_cell( + cursor: usize, + cols: usize, + code: u32, + wide_cells: bool, +) -> Option<(usize, usize, usize)> { + let units = cell_units(code, wide_cells); + if units == 0 { + return None; + } + let mut cell = cursor; + if units == 2 && cols >= 2 && cell % cols == cols - 1 { + cell += 1; // pad: never split a wide glyph across two rows + } + Some((cell, units, cell + units)) +} + /// Grid rows the text actually occupies, so the canvas height hugs the /// content instead of padding the frame to a full square. Mirrors the -/// renderers' cell accounting: dim toggles are zero-width, every other code -/// point (including `U+2588` and glyphs missing from the font) consumes one -/// cell; doc layout fills one row per `\n`-separated line down the first -/// column before spilling into the second. -fn used_rows(text: &str, grid: &Grid, doc: bool) -> usize { +/// renderers' cell accounting: dim toggles are zero-width, wide code points +/// take two cells in bitmap shapes (with a straddle pad), every other code +/// point one; doc layout fills one row per `\n`-separated line. +fn used_rows(text: &str, grid: &Grid, doc: bool, wide_cells: bool) -> usize { let rows = if doc { text.split('\n').count() } else { - let cells = text - .chars() - .filter(|&ch| !matches!(ch as u32, DIM_ON | DIM_OFF)) - .count(); - cells.div_ceil(grid.cols) + let mut cursor = 0usize; + for ch in text.chars() { + if let Some((_, _, next)) = place_cell(cursor, grid.cols, ch as u32, wide_cells) { + cursor = next; + } + } + cursor.div_ceil(grid.cols) }; rows.clamp(1, grid.rows) } @@ -278,6 +401,175 @@ fn fill_cell( } } +fn fill_repeat_bands_rgb(pixels: &mut [u8], width: usize, height: usize, grid: &Grid) { + if grid.repeat <= 1 { + return; + } + let band = PALETTE[BG_REPEAT as usize]; + for row in 0..grid.rows { + for copy in 1..grid.repeat { + let band_top = (row * grid.repeat + copy) * grid.cell_h; + for y in band_top..(band_top + grid.cell_h).min(height) { + for px in pixels[y * width * 3..(y + 1) * width * 3].chunks_exact_mut(3) { + px.copy_from_slice(&band); + } + } + } + } +} + +fn fill_cell_rgb( + pixels: &mut [u8], + width: usize, + height: usize, + grid: &Grid, + x_origin: usize, + row: usize, + ink: u8, +) { + let x0 = x_origin.min(width); + let x1 = (x_origin + grid.cell_w).min(width); + if x0 >= x1 { + return; + } + let color = PALETTE[ink as usize]; + for copy in 0..grid.repeat { + let top = (row * grid.repeat + copy) * grid.cell_h; + for y in top..(top + grid.cell_h).min(height) { + let row = &mut pixels[y * width * 3..(y + 1) * width * 3]; + for x in x0..x1 { + row[x * 3..x * 3 + 3].copy_from_slice(&color); + } + } + } +} + +fn ttf_pixel_size(font: &TtfFont, grid: &Grid) -> f32 { + let sx = grid.cell_w as f32 / font.cell_w as f32; + let sy = grid.cell_h as f32 / font.cell_h as f32; + font.px * sx.min(sy) +} + +/// Pixel size for a full-width fallback glyph spanning two grid cells: scaled +/// to the two-cell box so CJK fills the doubled width instead of a single +/// narrow ASCII cell. +fn ttf_wide_pixel_size(font: &TtfFont, grid: &Grid) -> f32 { + let sx = (2 * grid.cell_w) as f32 / font.cell_w as f32; + let sy = grid.cell_h as f32 / font.cell_h as f32; + font.px * sx.min(sy) +} + +fn ttf_ascent(font: &TtfFont, px: f32) -> f32 { + font + .face + .horizontal_line_metrics(px) + .map_or(font.ascent * px / font.px, |metrics| metrics.ascent) +} + +fn cached_ttf_glyph<'a>( + cache: &'a mut HashMap, + font: &TtfFont, + ch: char, + px: f32, +) -> Option<&'a RasterizedGlyph> { + if !font.supported.contains(&ch) { + return None; + } + Some(cache.entry(ch).or_insert_with(|| { + let (metrics, bitmap) = font.face.rasterize(ch, px); + RasterizedGlyph { metrics, bitmap } + })) +} + +fn blit_ttf_glyph( + pixels: &mut [u8], + width: usize, + height: usize, + glyph: &RasterizedGlyph, + left: i32, + top: i32, + ink: u8, +) { + if glyph.metrics.width == 0 || glyph.metrics.height == 0 { + return; + } + let color = PALETTE[ink as usize]; + for y in 0..glyph.metrics.height { + let dst_y = top + y as i32; + if dst_y < 0 || dst_y >= height as i32 { + continue; + } + for x in 0..glyph.metrics.width { + let alpha = u16::from(glyph.bitmap[y * glyph.metrics.width + x]); + if alpha == 0 { + continue; + } + let dst_x = left + x as i32; + if dst_x < 0 || dst_x >= width as i32 { + continue; + } + let offset = (dst_y as usize * width + dst_x as usize) * 3; + let inv = 255 - alpha; + for c in 0..3 { + let bg = u16::from(pixels[offset + c]); + let fg = u16::from(color[c]); + pixels[offset + c] = ((bg * inv + fg * alpha + 127) / 255) as u8; + } + } + } +} + +fn blit_ttf_glyph_indexed( + pixels: &mut [u8], + width: usize, + height: usize, + glyph: &RasterizedGlyph, + left: i32, + top: i32, + ink: u8, +) { + if glyph.metrics.width == 0 || glyph.metrics.height == 0 { + return; + } + for y in 0..glyph.metrics.height { + let dst_y = top + y as i32; + if dst_y < 0 || dst_y >= height as i32 { + continue; + } + let row_base = dst_y as usize * width; + for x in 0..glyph.metrics.width { + // Two-level anti-alias for the on/off indexed palette: a solid core + // only where coverage is high, and a single dim-gray fringe on the + // partially covered edges, so scaled CJK reads lighter than a + // flat-thresholded (and visibly bold) glyph. Dim spans stay dim. + let coverage = glyph.bitmap[y * glyph.metrics.width + x]; + let cell = if coverage >= 170 { + ink + } else if ink == INK_BLACK && coverage >= 56 { + INK_DIM // soft gray fringe only on black ink (one neutral palette slot) + } else if coverage >= 110 { + ink + } else { + continue; + }; + let dst_x = left + x as i32; + if dst_x >= 0 && dst_x < width as i32 { + pixels[row_base + dst_x as usize] = cell; + } + } + } +} + +fn ttf_glyph_origin(x_origin: usize, cell_w: usize, metrics: &Metrics) -> i32 { + let advance = metrics.advance_width.ceil() as i32; + let pad = (cell_w as i32 - advance).max(0) / 2; + x_origin as i32 + pad + metrics.xmin +} + +fn ttf_glyph_top(cell_top: usize, ascent: f32, metrics: &Metrics) -> i32 { + (cell_top as f32 + ascent - metrics.height as f32 - metrics.ymin as f32).round() as i32 +} + /// Rasterize `text` onto a `width` x `height` palette-indexed bitmap on the /// grid's cell box, row-major with no word wrap. Glyphs keep their natural /// size with the baseline at the font's ascent from the cell top, so a cell @@ -288,8 +580,8 @@ fn fill_cell( /// pins it to black; `U+000E`/`U+000F` toggle dim gray ink without occupying a /// cell, and dim wins over both variants. `U+2588` fills its whole cell with /// pitch-black ink, ignoring hue and dim state. Characters beyond -/// `cols * rows` are ignored; code points missing from the font leave their -/// cell blank. +/// `cols * rows` are ignored; code points missing from the bitmap font draw +/// through the embedded Silver TrueType fallback when it has a glyph. fn render_bitmap( text: &str, width: usize, @@ -305,11 +597,14 @@ fn render_bitmap( } fill_repeat_bands(&mut pixels, width, height, grid); let codes: Vec = text.chars().map(|ch| ch as u32).collect(); + let narrow_px = ttf_pixel_size(&FONT_SILVER, grid); + let wide_px = ttf_wide_pixel_size(&FONT_SILVER, grid); + let mut fallback_cache = HashMap::new(); let mut sentence = 0usize; let mut dim = false; - let mut cell = 0usize; + let mut cursor = 0usize; for i in 0..codes.len() { - if cell >= capacity { + if cursor >= capacity { break; } let code = codes[i]; @@ -336,24 +631,111 @@ fn render_bitmap( { sentence += 1; } - let row = cell / grid.cols; - let col = cell - row * grid.cols; - cell += 1; + let Some((at, units, next)) = place_cell(cursor, grid.cols, code, true) else { + continue; + }; + cursor = next; + if at >= capacity { + break; + } + let row = at / grid.cols; + let col = at - row * grid.cols; if code == FULL_BLOCK { fill_cell(&mut pixels, width, height, grid, col * grid.cell_w, row, INK_BLACK); continue; } - let Some(glyph) = font.glyphs.get(&code) else { - continue; + if let Some(glyph) = font.glyphs.get(&code) { + if glyph.rows.is_empty() { + continue; + } + let left = (col * grid.cell_w) as i32 + glyph.xoff; + for copy in 0..grid.repeat { + let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32; + let top = cell_top + font.ascent - glyph.h - glyph.yoff; + blit_glyph(&mut pixels, width, height, glyph, left, top, ink); + } + } else if let Some(ch) = char::from_u32(code) { + let px = if units == 2 { wide_px } else { narrow_px }; + let Some(glyph) = cached_ttf_glyph(&mut fallback_cache, &FONT_SILVER, ch, px) else { + continue; + }; + let span = units * grid.cell_w; + let left = ttf_glyph_origin(col * grid.cell_w, span, &glyph.metrics); + for copy in 0..grid.repeat { + let cell_top = (row * grid.repeat + copy) * grid.cell_h; + let top = ttf_glyph_top(cell_top, font.ascent as f32, &glyph.metrics); + blit_ttf_glyph_indexed(&mut pixels, width, height, glyph, left, top, ink); + } + } + } + pixels +} + +fn render_ttf_rgb( + text: &str, + width: usize, + height: usize, + font: &TtfFont, + grid: &Grid, + black_ink: bool, +) -> Vec { + let mut pixels = vec![255u8; width * height * 3]; + let capacity = grid.cols * grid.rows; + if capacity == 0 { + return pixels; + } + fill_repeat_bands_rgb(&mut pixels, width, height, grid); + let px = ttf_pixel_size(font, grid); + let ascent = ttf_ascent(font, px); + let codes: Vec = text.chars().collect(); + let mut cache = HashMap::new(); + let mut sentence = 0usize; + let mut dim = false; + let mut cell = 0usize; + for i in 0..codes.len() { + if cell >= capacity { + break; + } + let ch = codes[i]; + let code = ch as u32; + match code { + DIM_ON => { + dim = true; + continue; + }, + DIM_OFF => { + dim = false; + continue; + }, + _ => {}, + } + let ink = if dim { + INK_DIM + } else if black_ink { + INK_BLACK + } else { + (1 + sentence % INK_COLORS) as u8 }; - if glyph.rows.is_empty() { + if matches!(code, 0x2e | 0x21 | 0x3f) + && matches!(codes.get(i + 1).map(|next| *next as u32), Some(0x20 | FULL_BLOCK)) + { + sentence += 1; + } + let row = cell / grid.cols; + let col = cell - row * grid.cols; + cell += 1; + if code == FULL_BLOCK { + fill_cell_rgb(&mut pixels, width, height, grid, col * grid.cell_w, row, INK_BLACK); continue; } - let left = (col * grid.cell_w) as i32 + glyph.xoff; + let Some(glyph) = cached_ttf_glyph(&mut cache, font, ch, px) else { + continue; + }; + let left = ttf_glyph_origin(col * grid.cell_w, grid.cell_w, &glyph.metrics); for copy in 0..grid.repeat { - let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32; - let top = cell_top + font.ascent - glyph.h - glyph.yoff; - blit_glyph(&mut pixels, width, height, glyph, left, top, ink); + let cell_top = (row * grid.repeat + copy) * grid.cell_h; + let top = ttf_glyph_top(cell_top, ascent, &glyph.metrics); + blit_ttf_glyph(&mut pixels, width, height, glyph, left, top, ink); } } pixels @@ -387,6 +769,9 @@ fn render_doc_bitmap( } fill_repeat_bands(&mut pixels, width, height, grid); let codes: Vec = text.chars().map(|ch| ch as u32).collect(); + let narrow_px = ttf_pixel_size(&FONT_SILVER, grid); + let wide_px = ttf_wide_pixel_size(&FONT_SILVER, grid); + let mut fallback_cache = HashMap::new(); let mut sentence = 0usize; let mut dim = false; let mut line = 0usize; @@ -424,32 +809,125 @@ fn render_doc_bitmap( { sentence += 1; } - let cell = col; - col += 1; - if cell >= col_w { + let units = cell_units(code, true); + let mut cell = col; + if units == 2 && col_w >= 2 && cell == col_w - 1 { + cell += 1; // pad: never split a wide glyph across the column edge + } + col = cell + units; + if cell + units > col_w { continue; // clip past the column width } - if code == FULL_BLOCK { - let column = line / grid.rows; - let row = line - column * grid.rows; - let x_origin = (column * (col_w + GUTTER) + cell) * grid.cell_w; - fill_cell(&mut pixels, width, height, grid, x_origin, row, INK_BLACK); - continue; - } - let Some(glyph) = font.glyphs.get(&code) else { - continue; - }; - if glyph.rows.is_empty() { - continue; - } let column = line / grid.rows; let row = line - column * grid.rows; let x_origin = column * (col_w + GUTTER) * grid.cell_w; - let left = (x_origin + cell * grid.cell_w) as i32 + glyph.xoff; + if code == FULL_BLOCK { + fill_cell(&mut pixels, width, height, grid, x_origin + cell * grid.cell_w, row, INK_BLACK); + continue; + } + if let Some(glyph) = font.glyphs.get(&code) { + if glyph.rows.is_empty() { + continue; + } + let left = (x_origin + cell * grid.cell_w) as i32 + glyph.xoff; + for copy in 0..grid.repeat { + let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32; + let top = cell_top + font.ascent - glyph.h - glyph.yoff; + blit_glyph(&mut pixels, width, height, glyph, left, top, ink); + } + } else if let Some(ch) = char::from_u32(code) { + let px = if units == 2 { wide_px } else { narrow_px }; + let Some(glyph) = cached_ttf_glyph(&mut fallback_cache, &FONT_SILVER, ch, px) else { + continue; + }; + let span = units * grid.cell_w; + let left = ttf_glyph_origin(x_origin + cell * grid.cell_w, span, &glyph.metrics); + for copy in 0..grid.repeat { + let cell_top = (row * grid.repeat + copy) * grid.cell_h; + let top = ttf_glyph_top(cell_top, font.ascent as f32, &glyph.metrics); + blit_ttf_glyph_indexed(&mut pixels, width, height, glyph, left, top, ink); + } + } + } + pixels +} + +fn render_ttf_doc_rgb( + text: &str, + width: usize, + height: usize, + font: &TtfFont, + grid: &Grid, + black_ink: bool, +) -> Vec { + let mut pixels = vec![255u8; width * height * 3]; + let col_w = grid.cols.saturating_sub(GUTTER) / 2; + if col_w == 0 || grid.rows == 0 { + return pixels; + } + fill_repeat_bands_rgb(&mut pixels, width, height, grid); + let px = ttf_pixel_size(font, grid); + let ascent = ttf_ascent(font, px); + let codes: Vec = text.chars().collect(); + let mut cache = HashMap::new(); + let mut sentence = 0usize; + let mut dim = false; + let mut line = 0usize; + let mut col = 0usize; + for i in 0..codes.len() { + let ch = codes[i]; + let code = ch as u32; + match code { + DIM_ON => { + dim = true; + continue; + }, + DIM_OFF => { + dim = false; + continue; + }, + 0x0a => { + line += 1; + col = 0; + if line >= grid.rows * 2 { + break; + } + continue; + }, + _ => {}, + } + let ink = if dim { + INK_DIM + } else if black_ink { + INK_BLACK + } else { + (1 + sentence % INK_COLORS) as u8 + }; + if matches!(code, 0x2e | 0x21 | 0x3f) + && matches!(codes.get(i + 1).map(|next| *next as u32), Some(0x20 | 0x0a | FULL_BLOCK)) + { + sentence += 1; + } + let cell = col; + col += 1; + if cell >= col_w { + continue; + } + let column = line / grid.rows; + let row = line - column * grid.rows; + let x_origin = (column * (col_w + GUTTER) + cell) * grid.cell_w; + if code == FULL_BLOCK { + fill_cell_rgb(&mut pixels, width, height, grid, x_origin, row, INK_BLACK); + continue; + } + let Some(glyph) = cached_ttf_glyph(&mut cache, font, ch, px) else { + continue; + }; + let left = ttf_glyph_origin(x_origin, grid.cell_w, &glyph.metrics); for copy in 0..grid.repeat { - let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32; - let top = cell_top + font.ascent - glyph.h - glyph.yoff; - blit_glyph(&mut pixels, width, height, glyph, left, top, ink); + let cell_top = (row * grid.repeat + copy) * grid.cell_h; + let top = ttf_glyph_top(cell_top, ascent, &glyph.metrics); + blit_ttf_glyph(&mut pixels, width, height, glyph, left, top, ink); } } pixels @@ -650,8 +1128,8 @@ pub struct SnapcompactRenderOptions { /// (`floor(size/cellHeight/lineRepeat)`). Output height hugs the rows the /// text actually uses instead of padding to a square. pub size: u32, - /// Bundled font: `"5x8"`, `"6x12"`, `"8x13"` (X.org BDF) or `"8x8"` - /// (unscii-8). Default `"5x8"`. + /// Bundled font: `"5x8"`, `"6x12"`, `"8x13"` (X.org BDF), `"8x8"` + /// (unscii-8), or `"silver"` (embedded TrueType). Default `"5x8"`. pub font: Option, /// Target cell advance in pixels. Differing from the font's natural cell /// triggers the Lanczos stretch path. Default: font natural width. @@ -675,19 +1153,43 @@ pub struct SnapcompactRenderOptions { pub columns: Option, } +/// Return the subset of `chars` that the named snapcompact font can render. +/// +/// The TypeScript normalizer uses this to keep Unicode text intact only when +/// the selected native font has a glyph for it; renderer control codes are +/// considered renderable because they are interpreted outside font lookup. +#[napi] +pub fn snapcompact_supported_chars(font: String, chars: String) -> Result { + let font = resolve_font(&font).ok_or_else(|| { + Error::from_reason(format!( + "Unknown snapcompact font {font:?}: expected \"5x8\", \"8x8\", \"6x12\", \"8x13\", or \ + \"silver\"" + )) + })?; + let mut supported = String::new(); + for ch in chars.chars() { + if matches!(ch as u32, DIM_ON | DIM_OFF | FULL_BLOCK | 0x0a) || font.supports(ch as u32) { + supported.push(ch); + } + } + Ok(supported) +} + /// Render one snapcompact frame on a libuv worker: print pre-normalized text /// onto a `size`-wide bitmap and encode it as PNG. /// /// The bitmap height hugs the rows the text actually occupies /// (`usedRows * lineRepeat * cellHeight`), so a partially filled frame never /// pays for blank padding rows. The glyph grid holds `floor(size/cellWidth) * -/// floor(size/cellHeight/lineRepeat)` characters; input beyond that is ignored -/// (the caller chunks text to capacity). Native-cell shapes encode as 4-bit -/// indexed PNG; stretched shapes (target cell != font cell) encode as RGB. -/// `stretch: false` pins the indexed path, printing natural-size glyphs on the -/// requested cell box; `columns: 2` flows pre-wrapped newline-separated lines -/// down two newspaper columns. `U+000E`/`U+000F` in `text` toggle dim-gray ink -/// spans without occupying a cell. +/// floor(size/cellHeight/lineRepeat)` characters; input beyond that is ignored. +/// Native-cell bitmap-font shapes encode as indexed PNG; stretched bitmap-font +/// shapes (target cell != font cell) encode as RGB. TrueType shapes encode RGB +/// directly from grayscale coverage. +/// `stretch: false` pins bitmap fonts to the indexed path, printing +/// natural-size glyphs on the requested cell box; `columns: 2` flows +/// pre-wrapped newline-separated lines down two newspaper columns. +/// `U+000E`/`U+000F` in `text` toggle dim-gray ink spans without occupying a +/// cell. /// Returns a promise for the PNG encoded as base64, created as a one-byte /// (Latin-1) JS string straight from native code — no `Uint8Array` hop or /// JS-side re-encode. @@ -712,7 +1214,8 @@ fn render_snapcompact_png_sync( let font_name = options.font.as_deref().unwrap_or("5x8"); let font = resolve_font(font_name).ok_or_else(|| { Error::from_reason(format!( - "Unknown snapcompact font {font_name:?}: expected \"5x8\", \"8x8\", \"6x12\", or \"8x13\"" + "Unknown snapcompact font {font_name:?}: expected \"5x8\", \"8x8\", \"6x12\", \"8x13\", \ + or \"silver\"" )) })?; let black_ink = match options.variant.as_deref().unwrap_or("sent") { @@ -724,8 +1227,10 @@ fn render_snapcompact_png_sync( ))); }, }; - let target_w = options.cell_width.unwrap_or(font.cell_w as u32).max(1) as usize; - let target_h = options.cell_height.unwrap_or(font.cell_h as u32).max(1) as usize; + let natural_w = font.cell_w(); + let natural_h = font.cell_h(); + let target_w = options.cell_width.unwrap_or(natural_w as u32).max(1) as usize; + let target_h = options.cell_height.unwrap_or(natural_h as u32).max(1) as usize; let repeat = options.line_repeat.unwrap_or(1).max(1) as usize; let columns = options.columns.unwrap_or(1); if !matches!(columns, 1 | 2) { @@ -748,58 +1253,75 @@ fn render_snapcompact_png_sync( ))); } // Tight canvas: width stays the frame edge (the reading geometry the - // caller derives cols from), height hugs the rows the text needs. - let used = used_rows(&text, &grid, doc); + // caller derives cols from), height hugs the rows the text needs. Bitmap + // shapes draw wide code points through Silver across two cells, so they + // count double here; the square-celled Silver shape keeps one cell each. + let wide_cells = matches!(font, RenderFont::Bitmap(_)); + let used = used_rows(&text, &grid, doc, wide_cells); let height = used * grid.repeat * grid.cell_h; - let stretch = - options.stretch != Some(false) && (target_w, target_h) != (font.cell_w, font.cell_h); - if !stretch { - // Indexed path: rasterize straight onto the frame at the requested - // cell box (the natural cell, or natural glyphs on a padded pitch - // when `stretch: false`). - let pixels = if doc { - render_doc_bitmap(&text, size, height, font, &grid, black_ink) - } else { - render_bitmap(&text, size, height, font, &grid, black_ink) - }; - return Ok(STANDARD - .encode(encode_indexed_png(&pixels, size, height, png::Compression::High)?) - .into()); - } + match font { + RenderFont::Ttf(font) => { + let pixels = if doc { + render_ttf_doc_rgb(&text, size, height, font, &grid, black_ink) + } else { + render_ttf_rgb(&text, size, height, font, &grid, black_ink) + }; + Ok(STANDARD + .encode(encode_rgb_png(&pixels, size, height, png::Compression::High)?) + .into()) + }, + RenderFont::Bitmap(font) => { + let stretch = + options.stretch != Some(false) && (target_w, target_h) != (natural_w, natural_h); + if !stretch { + // Indexed path: rasterize straight onto the frame at the requested + // cell box (the natural cell, or natural glyphs on a padded pitch + // when `stretch: false`). + let pixels = if doc { + render_doc_bitmap(&text, size, height, font, &grid, black_ink) + } else { + render_bitmap(&text, size, height, font, &grid, black_ink) + }; + return Ok(STANDARD + .encode(encode_indexed_png(&pixels, size, height, png::Compression::High)?) + .into()); + } - // Stretch shape: rasterize at the font's natural cell on a tight canvas - // (layout stays in character cells from the target grid), Lanczos3- - // resample to the target cell, paste onto the white frame. - let native = Grid { cell_w: font.cell_w, cell_h: font.cell_h, ..grid }; - let src_w = grid.cols * font.cell_w; - let src_h = used * grid.repeat * font.cell_h; - let dst_w = grid.cols * target_w; - let dst_h = used * grid.repeat * target_h; - let indexed = if doc { - render_doc_bitmap(&text, src_w, src_h, font, &native, black_ink) - } else { - render_bitmap(&text, src_w, src_h, font, &native, black_ink) - }; - let mut rgb = vec![0f32; src_w * src_h * 3]; - for (dst, &idx) in rgb.chunks_exact_mut(3).zip(&indexed) { - let [r, g, b] = PALETTE[idx as usize]; - dst[0] = f32::from(r); - dst[1] = f32::from(g); - dst[2] = f32::from(b); + // Stretch shape: rasterize at the font's natural cell on a tight canvas + // (layout stays in character cells from the target grid), Lanczos3- + // resample to the target cell, paste onto the white frame. + let native = Grid { cell_w: natural_w, cell_h: natural_h, ..grid }; + let src_w = grid.cols * natural_w; + let src_h = used * grid.repeat * natural_h; + let dst_w = grid.cols * target_w; + let dst_h = used * grid.repeat * target_h; + let indexed = if doc { + render_doc_bitmap(&text, src_w, src_h, font, &native, black_ink) + } else { + render_bitmap(&text, src_w, src_h, font, &native, black_ink) + }; + let mut rgb = vec![0f32; src_w * src_h * 3]; + for (dst, &idx) in rgb.chunks_exact_mut(3).zip(&indexed) { + let [r, g, b] = PALETTE[idx as usize]; + dst[0] = f32::from(r); + dst[1] = f32::from(g); + dst[2] = f32::from(b); + } + let resized = resize_rgb(&rgb, src_w, src_h, dst_w, dst_h); + let mut frame = vec![255u8; size * dst_h * 3]; + for y in 0..dst_h { + let src_row = &resized[y * dst_w * 3..(y + 1) * dst_w * 3]; + let dst_row = &mut frame[y * size * 3..]; + for (d, &s) in dst_row[..dst_w.min(size) * 3].iter_mut().zip(src_row) { + *d = s.round().clamp(0.0, 255.0) as u8; + } + } + Ok(STANDARD + .encode(encode_rgb_png(&frame, size, dst_h, png::Compression::High)?) + .into()) + }, } - let resized = resize_rgb(&rgb, src_w, src_h, dst_w, dst_h); - let mut frame = vec![255u8; size * dst_h * 3]; - for y in 0..dst_h { - let src_row = &resized[y * dst_w * 3..(y + 1) * dst_w * 3]; - let dst_row = &mut frame[y * size * 3..]; - for (d, &s) in dst_row[..dst_w.min(size) * 3].iter_mut().zip(src_row) { - *d = s.round().clamp(0.0, 255.0) as u8; - } - } - Ok(STANDARD - .encode(encode_rgb_png(&frame, size, dst_h, png::Compression::High)?) - .into()) } #[cfg(test)] @@ -821,6 +1343,13 @@ mod tests { } } + #[test] + fn silver_font_covers_cjk_scripts() { + assert!(FONT_SILVER.supported.contains(&'こ'), "Silver must cover Japanese kana"); + assert!(FONT_SILVER.supported.contains(&'你'), "Silver must cover Han text"); + assert!(FONT_SILVER.supported.contains(&'안'), "Silver must cover Hangul syllables"); + } + #[test] fn bitmap_inks_sentences_and_caps_capacity() { // 40px -> 8 cols x 5 rows = 40 cells (5x8 font). @@ -942,6 +1471,22 @@ mod tests { assert_eq!(stretched[25], 2); let legacy = png_bytes(render_snapcompact_png_sync("Hi. Ok.".into(), opts(40)).unwrap()); assert_eq!(legacy[25], 3, "default shape stays the legacy 5x8 indexed path"); + + let silver = png_bytes( + render_snapcompact_png_sync( + "こんにちは 你好 안녕".into(), + SnapcompactRenderOptions { + size: 128, + font: Some("silver".into()), + cell_width: Some(16), + cell_height: Some(16), + variant: Some("bw".into()), + ..Default::default() + }, + ) + .unwrap(), + ); + assert_eq!(silver[25], 2, "TrueType frames render as RGB"); } #[test] diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c68409f79..122258be0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changed + +- Enabled contextual snapcompact shape resolution based on rendered text content + +### Fixed + +- Fixed snapcompact preflight to use the same font-aware renderability probe as compaction, including prior preserved archive text, so CJK history remains renderable through per-glyph Silver fallback across repeated compactions. + ## [16.2.6] - 2026-06-29 ### Changed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 049d8ac03..fc0535afd 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -8871,6 +8871,8 @@ export class AgentSession { const wantsSnapcompact = compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions; const snapcompactReady = wantsSnapcompact; + const snapcompactShapeSetting = this.settings.get("snapcompact.shape"); + let snapcompactShape: snapcompact.Shape | undefined; if (wantsSnapcompact && !this.model.input.includes("image")) { this.emitNotice( "warning", @@ -8879,8 +8881,16 @@ export class AgentSession { ); throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`); } else if (snapcompactReady) { - const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize)); - const renderScan = snapcompact.scanRenderability(text); + const text = snapcompact.serializeConversation( + convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)), + ); + const probeText = snapcompact.renderabilityProbeText( + text, + preparation.previousPreserveData, + preparation.previousSummary, + ); + snapcompactShape = snapcompact.resolveShapeForText(probeText, this.model, snapcompactShapeSetting); + const renderScan = snapcompact.scanRenderability(probeText, { shape: snapcompactShape }); if (!renderScan.isSafe) { const percent = (renderScan.unrenderableRatio * 100).toFixed(1); this.emitNotice( @@ -8918,10 +8928,14 @@ export class AgentSession { ); throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget."); } else { + const shape = snapcompactShape; + if (!shape) { + throw new Error("snapcompact shape was not resolved before rendering."); + } snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.model, - shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")), + ...(snapcompactShapeSetting === "auto" ? {} : { shape }), maxFrames, }); const ctxWindow = this.model?.contextWindow ?? 0; @@ -11273,7 +11287,14 @@ export class AgentSession { const text = snapcompact.serializeConversation( convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)), ); - const renderScan = snapcompact.scanRenderability(text); + const probeText = snapcompact.renderabilityProbeText( + text, + preparation.previousPreserveData, + preparation.previousSummary, + ); + const shapeSetting = this.settings.get("snapcompact.shape"); + const shape = snapcompact.resolveShapeForText(probeText, this.model, shapeSetting); + const renderScan = snapcompact.scanRenderability(probeText, { shape }); if (!renderScan.isSafe) { const percent = (renderScan.unrenderableRatio * 100).toFixed(1); logger.warn("Snapcompact disabled: high non-ASCII rate detected", { @@ -11293,7 +11314,7 @@ export class AgentSession { snapcompactResult = await snapcompact.compact(preparation, { convertToLlm, model: this.model, - shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")), + ...(shapeSetting === "auto" ? {} : { shape }), maxFrames, }); if (snapcompactResult) { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index ffa546ab8..fa3d88637 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Added + +- Added support for the silver TrueType font to renderSnapcompactPng +- Exposed snapcompactSupportedChars to check font capability for characters + +- Added embedded Silver TrueType rendering to `renderSnapcompactPng` plus `snapcompactSupportedChars(font, chars)`, including per-glyph Silver fallback when bitmap snapcompact fonts lack a renderable glyph; East Asian wide code points render full-width across two grid cells, with a soft gray fringe on black-ink frames so scaled glyphs read anti-aliased rather than bold. + ## [16.2.5] - 2026-06-28 ### Fixed diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 6cec7e310..a67fbf4ba 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1315,13 +1315,15 @@ export declare function readImageFromClipboard(): Promise; * and `8x13` the X.org misc fonts, `8on16` 8x13 glyphs on an 8x16 cell pitch * (no stretch, extra leading), `8on22` the same glyphs on a 22px pitch (more * leading), `11on16` the same glyphs on an 11px advance (more tracking), - * `doc-` prefixed shapes a two-column word-wrapped newspaper layout. Ink: - * `sent` cycles six hues at sentence boundaries, `bw` is plain black, `-dim` - * suffix prints stopwords in gray. + * `silver16` the embedded Silver TrueType font on a 16px grid for CJK and + * other non-Latin text, and `doc-` prefixed shapes a two-column word-wrapped + * newspaper layout. Ink: `sent` cycles six hues at sentence boundaries, `bw` + * is plain black, `-dim` suffix prints stopwords in gray. */ export const SHAPE_VARIANTS = { "8x8r-bw": { font: "8x8", cellWidth: 8, cellHeight: 8, variant: "bw", lineRepeat: 2, frameSize: 1568 }, @@ -147,6 +148,14 @@ export const SHAPE_VARIANTS = { lineRepeat: 1, frameSize: 1568, }, + "silver16-bw": { + font: "silver", + cellWidth: 16, + cellHeight: 16, + variant: "bw", + lineRepeat: 1, + frameSize: 1568, + }, "doc-8on16-bw": { font: "8x13", cellWidth: 8, @@ -271,7 +280,7 @@ export function isShape(value: unknown): value is Shape { const variant = shape.variant; const detail = shape.imageDetail; return ( - (font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13") && + (font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13" || font === "silver") && typeof shape.cellWidth === "number" && shape.cellWidth > 0 && typeof shape.cellHeight === "number" && @@ -376,6 +385,17 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | " return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family); } +/** + * Pick the frame shape for `text` without changing the selected shape. + * + * Glyph-level Silver fallback happens during normalization/rendering, so this + * helper exists for callers that need a text-aware API name while preserving + * explicit and provider-selected shapes. + */ +export function resolveShapeForText(_text: string, model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape { + return resolveShape(model, variant); +} + // ============================================================================ // Constants // ============================================================================ @@ -492,8 +512,8 @@ export interface Geometry { export interface Options extends SerializeOptions { /** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */ convertToLlm?: ConvertToLlm; - /** Model whose provider API selects the frame shape. */ - model?: Pick; + /** Model whose provider API and id select the frame shape. */ + model?: ShapeTarget; /** Explicit shape override; wins over `model`. */ shape?: Shape; /** Frame edge in pixels. Defaults to the shape's `frameSize`. */ @@ -918,6 +938,48 @@ const UNRENDERABLE = /[\p{Cc}\p{Mn}\p{Me}\p{Cs}]/u; * letter prints without the diacritic the bundled fonts cannot compose. */ const COMBINING_MARKS = /\p{M}+/gu; +/** Status-like pictographs that carry meaning in tool output; all other emoji + * pictographs drop instead of burning cells as `?`. */ +const EMOJI_FOLD: Record = { + "✅": "[OK]", + "☑": "[OK]", + "✔": "[OK]", + "❌": "[FAIL]", + "❎": "[FAIL]", + "✖": "[FAIL]", + "⚠": "[WARN]", + "🚨": "[ALERT]", + ℹ: "[INFO]", + "🐛": "[BUG]", + "💥": "[CRASH]", + "🔥": "[HOT]", + "🔒": "[LOCK]", + "🔓": "[UNLOCK]", + "📁": "[DIR]", + "📂": "[DIR]", + "📄": "[FILE]", + "📝": "[NOTE]", + "🧪": "[TEST]", + "⏳": "[WAIT]", + "⌛": "[WAIT]", + "🚀": "[RUN]", +}; + +const EMOJI_PICTOGRAPH = /\p{Extended_Pictographic}/u; + +export interface NormalizeOptions { + /** Shape whose font is tried before the embedded Silver fallback. */ + shape?: Pick; + /** Native font name when a full shape is not available. */ + font?: Shape["font"]; +} + +interface NormalizedText { + text: string; + totalGraphics: number; + fallbackCount: number; +} + /** * Aggressive single-code-point ASCII fold via Unicode NFKD: decompose the * compatibility form (fullwidth, super/subscripts, ligatures, circled and @@ -927,13 +989,17 @@ const COMBINING_MARKS = /\p{M}+/gu; * point has no decomposition or still leaves an undrawable glyph, so the * caller falls back to `?`. */ +function isAsciiOrLatin1(cp: number): boolean { + return (cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff); +} + function foldToAscii(ch: string): string | undefined { const decomposed = ch.normalize("NFKD").replace(COMBINING_MARKS, ""); if (decomposed === ch) return undefined; let out = ""; for (const part of decomposed) { - const cp = part.codePointAt(0) as number; - if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { + const cp = part.codePointAt(0); + if (cp !== undefined && isAsciiOrLatin1(cp)) { out += part; continue; } @@ -944,90 +1010,126 @@ function foldToAscii(ch: string): string | undefined { return out; } -/** - * Prepare text for printing: strip ANSI escape sequences, collapse horizontal - * whitespace runs to single spaces and newline-bearing runs to one - * {@link NEWLINE_GLYPH} (drawn as a pitch-black cell), then fold everything - * outside the fonts' ASCII + Latin-1 coverage to ASCII approximations — first - * through the {@link CHAR_FOLD} punctuation table, then via an NFKD - * decomposition that recovers the ASCII skeleton of compatibility characters - * (fullwidth, super/subscripts, ligatures, circled/math-styled alphanumerics, - * Roman numerals, vulgar fractions). Unrenderable control/format/combining - * characters are dropped without occupying a cell; `?` remains the fallback - * for unsupported graphic characters. The zero-width ink toggles - * {@link DIM_ON}/{@link DIM_OFF} pass through untouched. - */ -export function normalize(text: string): string { +function renderableUnicodeChars(chars: readonly string[], font: Shape["font"] | undefined): ReadonlySet { + if (chars.length === 0) return new Set(); + const text = chars.join(""); + const primaryFont = font ?? "5x8"; + const supported = new Set(snapcompactSupportedChars(primaryFont, text)); + if (primaryFont !== "silver") { + for (const ch of snapcompactSupportedChars("silver", text)) supported.add(ch); + } + return supported; +} + +function normalizedInputChars(text: string): string[] { const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; const collapsed = stripped // A run of pure format chars (BOM is both \s and Cf) vanishes; only a // run containing genuine whitespace separates words. .replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : "")) .replace(EDGE_RUNS, ""); - let out = ""; - for (const ch of collapsed) { - const cp = ch.codePointAt(0) as number; - if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { - out += ch; + return [...collapsed]; +} + +function candidateUnicodeChars(chars: readonly string[]): string[] { + const unique = new Set(); + for (const ch of chars) { + const cp = ch.codePointAt(0); + if (cp === undefined || isAsciiOrLatin1(cp) || ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { + continue; + } + if ( + CHAR_FOLD[ch] !== undefined || + (cp >= 0x2500 && cp <= 0x257f) || + EMOJI_FOLD[ch] !== undefined || + EMOJI_PICTOGRAPH.test(ch) || + foldToAscii(ch) !== undefined || + UNRENDERABLE.test(ch) + ) { + continue; + } + unique.add(ch); + } + return [...unique]; +} + +function normalizeWithStats(text: string, options?: NormalizeOptions): NormalizedText { + const chars = normalizedInputChars(text); + const font = options?.font ?? options?.shape?.font; + const supported = renderableUnicodeChars(candidateUnicodeChars(chars), font); + const out: string[] = []; + let totalGraphics = 0; + let fallbackCount = 0; + + for (const ch of chars) { + const cp = ch.codePointAt(0); + if (cp === undefined) continue; + if (isAsciiOrLatin1(cp)) { + out.push(ch); + totalGraphics++; continue; } if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { - out += ch; + out.push(ch); + continue; + } + const emoji = EMOJI_FOLD[ch]; + if (emoji !== undefined) { + out.push(emoji); + totalGraphics++; continue; } const fold = CHAR_FOLD[ch]; if (fold !== undefined) { - out += fold; - } else if (cp >= 0x2500 && cp <= 0x257f) { - // Box drawing: keep table skeletons legible. - out += cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+"; - } else { - const folded = foldToAscii(ch); - if (folded !== undefined) out += folded; - else if (!UNRENDERABLE.test(ch)) out += "?"; + out.push(fold); + totalGraphics++; + continue; + } + if (cp >= 0x2500 && cp <= 0x257f) { + out.push(cp === 0x2502 || cp === 0x2503 ? "|" : cp === 0x2500 || cp === 0x2501 ? "-" : "+"); + totalGraphics++; + continue; + } + if (!EMOJI_PICTOGRAPH.test(ch) && supported.has(ch)) { + out.push(ch); + totalGraphics++; + continue; + } + const folded = foldToAscii(ch); + if (folded !== undefined) { + out.push(folded); + totalGraphics++; + } else if (EMOJI_PICTOGRAPH.test(ch)) { + } else if (!UNRENDERABLE.test(ch)) { + out.push("?"); + totalGraphics++; + fallbackCount++; } } - return out; + + return { text: out.join("").replace(/ +/g, " ").replace(EDGE_RUNS, ""), totalGraphics, fallbackCount }; } /** - * Scan text to determine the proportion of graphic characters that will hit the - * `?` fallback during {@link normalize}. Used as a preflight check to abort - * snapcompact and fall back to the text summarizer when the input is heavily - * non-renderable (e.g., CJK). + * Prepare text for printing: strip ANSI escape sequences, collapse horizontal + * whitespace runs, fold unsupported symbols (including box drawing to ASCII), + * preserve Unicode glyphs that either the selected font or embedded Silver + * fallback can render, and drop decorative emoji instead of printing `?`. */ -export function scanRenderability(text: string): { isSafe: boolean; unrenderableRatio: number } { - const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text; - const collapsed = stripped - .replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : "")) - .replace(EDGE_RUNS, ""); - let totalGraphics = 0; - let fallbackCount = 0; - for (const ch of collapsed) { - const cp = ch.codePointAt(0) as number; - if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) { - totalGraphics++; - continue; - } - if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) { - continue; - } - const fold = CHAR_FOLD[ch]; - if (fold !== undefined) { - totalGraphics++; - } else if (cp >= 0x2500 && cp <= 0x257f) { - totalGraphics++; - } else { - const folded = foldToAscii(ch); - if (folded !== undefined) { - totalGraphics++; - } else if (!UNRENDERABLE.test(ch)) { - totalGraphics++; - fallbackCount++; - } - } - } - const unrenderableRatio = totalGraphics > 0 ? fallbackCount / totalGraphics : 0; +export function normalize(text: string, options?: NormalizeOptions): string { + return normalizeWithStats(text, options).text; +} + +/** + * Scan text with the same font-aware path as {@link normalize}; unsafe means + * more than 5% of graphic characters would hit the `?` fallback. + */ +export function scanRenderability( + text: string, + options?: NormalizeOptions, +): { isSafe: boolean; unrenderableRatio: number } { + const normalized = normalizeWithStats(text, options); + const unrenderableRatio = normalized.totalGraphics > 0 ? normalized.fallbackCount / normalized.totalGraphics : 0; return { isSafe: unrenderableRatio <= 0.05, unrenderableRatio }; } @@ -1086,34 +1188,131 @@ export function dimStopwords(text: string): string { /** Char cells between the two doc columns (research exp14 `GUTTER`). */ const DOC_GUTTER = 3; +/** East Asian Wide / Fullwidth code points that occupy two grid cells when a + * narrow bitmap shape draws them through the Silver fallback. Mirrors + * `is_wide` in `crates/pi-natives/src/snapcompact.rs`; the two MUST stay in + * sync or native layout and this capacity math disagree on cell counts. */ +function isWideCodePoint(cp: number): boolean { + return ( + (cp >= 0x1100 && cp <= 0x115f) || + (cp >= 0x2e80 && cp <= 0x2eff) || + (cp >= 0x2f00 && cp <= 0x2fdf) || + (cp >= 0x3000 && cp <= 0x303e) || + (cp >= 0x3041 && cp <= 0x33ff) || + (cp >= 0x3400 && cp <= 0x4dbf) || + (cp >= 0x4e00 && cp <= 0x9fff) || + (cp >= 0xa000 && cp <= 0xa4cf) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe4f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x20000 && cp <= 0x2fffd) || + (cp >= 0x30000 && cp <= 0x3fffd) + ); +} + +/** Cells one character occupies: 0 for the zero-width dim toggles, 2 for wide + * code points in narrow bitmap shapes, 1 otherwise. Mirrors native + * `cell_units`. */ +function charCells(ch: string, wideCells: boolean): number { + if (ch === DIM_ON || ch === DIM_OFF) return 0; + const cp = ch.codePointAt(0); + return wideCells && cp !== undefined && isWideCodePoint(cp) ? 2 : 1; +} + +/** Wide code points span two cells in every shape except the square-celled + * Silver shape, which sizes each cell for a full-width glyph already. */ +function usesWideCells(shape: Pick): boolean { + return shape.font !== "silver"; +} + +/** Total grid cells a string occupies (ignoring row wrapping/pads). */ +function cellLength(text: string, wideCells: boolean): number { + let cells = 0; + for (const ch of text) cells += charCells(ch, wideCells); + return cells; +} + +/** Longest prefix of `text` that fits `width` cells (at least one char). */ +function sliceCells(text: string, width: number, wideCells: boolean): string { + let cells = 0; + let out = ""; + let placed = false; + for (const ch of text) { + const w = charCells(ch, wideCells); + if (placed && cells + w > width) break; + out += ch; + cells += w; + if (w > 0) placed = true; + } + return out; +} + +/** Split `text` into pages that each fill at most `capacity` grid cells, + * inserting a one-cell pad before a wide glyph that would straddle the right + * edge (mirrors native `place_cell`). Pages are contiguous substrings, so each + * renders independently starting at cell 0. A single char wider than the whole + * budget still rides its page; the native renderer clips it. */ +function paginateCells(text: string, capacity: number, cols: number, wideCells: boolean): string[] { + const chars = [...text]; + const pages: string[] = []; + let start = 0; + let cell = 0; + let hasCell = false; + for (let i = 0; i < chars.length; i++) { + const w = charCells(chars[i] ?? "", wideCells); + if (w === 0) continue; + let at = cell; + if (w === 2 && cols >= 2 && at % cols === cols - 1) at += 1; + if (hasCell && at + w > capacity) { + pages.push(chars.slice(start, i).join("")); + start = i; + at = 0; + } + cell = at + w; + hasCell = true; + } + if (hasCell) pages.push(chars.slice(start).join("")); + return pages; +} + /** * Greedy word-wrap, no mid-word breaks (hard split only for width+ words) — * ported verbatim from `research/exp14_bestgpt.py` `wrap()`. Zero-width dim * markers count toward word length here; serialized history places them at * word boundaries, so the drift is at most one cell per affected line. */ -export function wrap(text: string, width: number): string[] { +export function wrap(text: string, width: number, wideCells = false): string[] { const lines: string[] = []; let cur = ""; + let curCells = 0; for (const token of text.split(/\s+/)) { if (token.length === 0) continue; let word = token; - while (word.length > width) { + let wordCells = cellLength(word, wideCells); + while (wordCells > width) { // Pathological; never hit on prose. if (cur) { lines.push(cur); cur = ""; + curCells = 0; } - lines.push(word.slice(0, width)); - word = word.slice(width); + const head = sliceCells(word, width, wideCells); + lines.push(head); + word = word.slice(head.length); + wordCells = cellLength(word, wideCells); } if (!cur) { cur = word; - } else if (cur.length + 1 + word.length <= width) { + curCells = wordCells; + } else if (curCells + 1 + wordCells <= width) { cur += ` ${word}`; + curCells += 1 + wordCells; } else { lines.push(cur); cur = word; + curCells = wordCells; } } if (cur) lines.push(cur); @@ -1126,8 +1325,8 @@ export function wrap(text: string, width: number): string[] { * Every input character lands on exactly one page (whitespace becomes the * wrap points). */ -function docPages(normalized: string, geo: Geometry): string[] { - const lines = wrap(normalized, geo.cols); +function docPages(normalized: string, geo: Geometry, wideCells: boolean): string[] { + const lines = wrap(normalized, geo.cols, wideCells); const perPage = 2 * geo.rows; const pages: string[] = []; for (let offset = 0; offset < lines.length; offset += perPage) { @@ -1165,18 +1364,35 @@ function nativeRenderOptions(shape: Shape, size: number) { }; } -function renderedChars(text: string, shape: Shape, capacity: number): number { - let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0); - // Doc line separators consume no cell; in the grid they print as a blank. - if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0; - return Math.min(visible, capacity); +function renderedChars(text: string, shape: Shape, geo: Geometry): number { + if (shape.columns === 2) { + let visible = [...text].length - (text.match(DIM_MARKERS)?.length ?? 0); + visible -= text.match(NEWLINES)?.length ?? 0; + return Math.min(visible, geo.capacity); + } + // Grid: count visible chars that fit within the frame's cell budget, with + // wide glyphs taking two cells (and a straddle pad) exactly as the renderer. + const wideCells = usesWideCells(shape); + let cell = 0; + let count = 0; + for (const ch of text) { + const w = charCells(ch, wideCells); + if (w === 0) continue; + let at = cell; + if (w === 2 && geo.cols >= 2 && at % geo.cols === geo.cols - 1) at += 1; + if (at + w > geo.capacity) break; + cell = at + w; + count++; + } + return count; } /** Render one snapcompact frame from already-normalized text. Doc shapes * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */ export async function render(text: string, shape: Shape, size: number = shape.frameSize): Promise { - const { cols, rows, capacity } = geometry(shape, size); - const chars = renderedChars(text, shape, capacity); + const geo = geometry(shape, size); + const { cols, rows } = geo; + const chars = renderedChars(text, shape, geo); const data = await renderSnapcompactPng(text, nativeRenderOptions(shape, size)); return { data, cols, rows, chars }; } @@ -1197,8 +1413,8 @@ function pageFinisher(shape: Shape): (page: string) => string { export interface RenderManyOptions { /** Explicit shape; wins over `model`. */ shape?: Shape; - /** Model whose `api` selects the eval-optimal shape. */ - model?: Pick; + /** Model whose provider API and id select the frame shape. */ + model?: ShapeTarget; /** Frame edge in px; defaults to the shape's `frameSize`. */ frameSize?: number; /** Hard cap on frames produced; omit for unbounded (caller decides usage). */ @@ -1210,27 +1426,26 @@ export interface RenderManyOptions { * (first page first). Empty/whitespace-only input yields no frames. */ export async function renderMany(text: string, options?: RenderManyOptions): Promise { - const shape = options?.shape ?? resolveShape(options?.model); + const shape = options?.shape ?? resolveShapeForText(text, options?.model); const frameSize = options?.frameSize ?? shape.frameSize; const geo = geometry(shape, frameSize); - const normalized = normalize(text); + const normalized = normalize(text, { shape }); const cap = options?.maxFrames; // Build the per-frame texts in order first (cheap, synchronous), then fan // the native PNG renders out concurrently — render() is async/off-thread, // so awaiting each before starting the next leaves throughput on the table. const pageTexts: string[] = []; + const wideCells = usesWideCells(shape); if (shape.columns === 2) { const finish = pageFinisher(shape); - for (const page of docPages(normalized, geo)) { + for (const page of docPages(normalized, geo, wideCells)) { if (cap !== undefined && pageTexts.length >= cap) break; pageTexts.push(finish(page)); } } else { - for (let offset = 0; offset < normalized.length; offset += geo.capacity) { + for (const page of paginateCells(normalized, geo.capacity, geo.cols, wideCells)) { if (cap !== undefined && pageTexts.length >= cap) break; - let chunk = normalized.slice(offset, offset + geo.capacity); - if (shape.stopwordDim) chunk = dimStopwords(chunk); - pageTexts.push(chunk); + pageTexts.push(shape.stopwordDim ? dimStopwords(page) : page); } } const rendered = await Promise.all(pageTexts.map(page => render(page, shape, frameSize))); @@ -1246,11 +1461,12 @@ export async function renderMany(text: string, options?: RenderManyOptions): Pro * For doc shapes this wraps the text once and counts pages of `2 * rows` * lines; for grid shapes it divides by the frame capacity. */ export function frames(text: string, options?: Pick): number { - const shape = options?.shape ?? resolveShape(options?.model); + const shape = options?.shape ?? resolveShapeForText(text, options?.model); const geo = geometry(shape, options?.frameSize ?? shape.frameSize); - const normalized = normalize(text); - if (shape.columns === 2) return Math.ceil(wrap(normalized, geo.cols).length / (2 * geo.rows)); - return Math.ceil(normalized.length / geo.capacity); + const normalized = normalize(text, { shape }); + const wideCells = usesWideCells(shape); + if (shape.columns === 2) return Math.ceil(wrap(normalized, geo.cols, wideCells).length / (2 * geo.rows)); + return paginateCells(normalized, geo.capacity, geo.cols, wideCells).length; } // ============================================================================ @@ -1313,6 +1529,19 @@ export function archiveSourceText(archive: Archive): string | undefined { return text.length > 0 ? toPlainText(text) : undefined; } +/** Build the text used to choose and preflight a font-aware snapcompact shape. */ +export function renderabilityProbeText( + serialized: string, + previousPreserveData?: Record, + previousSummary?: string, +): string { + const previousArchive = getPreservedArchive(previousPreserveData); + const previousText = previousArchive ? (archiveSourceText(previousArchive) ?? "") : ""; + if (previousText.length > 0) return `${previousText}${NEWLINE_GLYPH}${serialized}`; + if (previousSummary) return `${previousSummary}${NEWLINE_GLYPH}${serialized}`; + return serialized; +} + /** Convert archive frames into LLM image blocks (oldest first). */ export function images(archive: Archive): ImageContent[] { return archive.frames.map(frame => ({ @@ -1356,9 +1585,10 @@ export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] /** Denser companion of `high` for the foveated archive middle: same family and * frame size (identical per-frame bill) but a tighter cell. Returns `high` - * unchanged for doc layouts or when no denser variant exists (foveation off). */ + * unchanged for doc layouts, TrueType Unicode shapes, or when no denser + * variant exists (foveation off). */ function denseCompanion(high: Shape, api: Api | undefined): Shape { - if (high.columns === 2) return high; + if (high.columns === 2 || high.font === "silver") return high; const family = billingFamily(api); const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family); return geometry(low).capacity > geometry(high).capacity ? low : high; @@ -1381,13 +1611,9 @@ interface ArchiveLayout { truncatedChars: number; } -/** Slice `text` into `capacity`-char frames at one shape (tier). */ -function sliceFrames(text: string, capacity: number, shape: Shape): PlanFrame[] { - const out: PlanFrame[] = []; - for (let offset = 0; offset < text.length; offset += capacity) { - out.push({ text: text.slice(offset, offset + capacity), shape }); - } - return out; +/** Wrap each page string as a planned frame at one shape (tier). */ +function planFrames(pages: readonly string[], shape: Shape): PlanFrame[] { + return pages.map(text => ({ text, shape })); } /** @@ -1425,7 +1651,7 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): // Doc layouts wrap (no char-slicing) and don't foveate: one tier, keep the // newest pages with the session head pinned, drop the oldest middle. if (high.columns === 2) { - const pages = docPages(imageText, geometry(high)); + const pages = docPages(imageText, geometry(high), usesWideCells(high)); let kept = pages; let truncatedChars = 0; if (pages.length > maxFrames) { @@ -1435,7 +1661,7 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): } const flat = kept.map(page => page.replaceAll("\n", " ")).join(" "); return { - frames: kept.map(page => ({ text: page, shape: high })), + frames: planFrames(kept, high), textHead, textTail, keptText: textHead + flat + textTail, @@ -1443,10 +1669,12 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): }; } - // Grid: render all-HQ when the image region fits the budget outright. - if (Math.ceil(imageText.length / capHi) <= maxFrames) { + // Grid: paginate the imaged region into HQ frames (cell-aware, so wide CJK + // glyphs spanning two cells never overflow a frame's capacity). + const hiPages = paginateCells(imageText, capHi, geometry(high).cols, usesWideCells(high)); + if (hiPages.length <= maxFrames) { return { - frames: sliceFrames(imageText, capHi, high), + frames: planFrames(hiPages, high), textHead, textTail, keptText: textHead + imageText + textTail, @@ -1457,22 +1685,23 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number): // Foveate the imaged middle: HQ edges, dense center, drop the oldest dense slice. const capLo = geometry(low).capacity; const imageEdgeFrames = Math.min(HQ_EDGE_FRAMES, Math.floor((maxFrames - 1) / 2)); - const imageEdgeCap = imageEdgeFrames * capHi; - const imageHead = imageText.slice(0, imageEdgeCap); - const imageTail = imageEdgeCap > 0 ? imageText.slice(imageText.length - imageEdgeCap) : ""; - let middleText = imageText.slice(imageEdgeCap, imageText.length - imageEdgeCap); + const headPages = hiPages.slice(0, imageEdgeFrames); + const tailPages = imageEdgeFrames > 0 ? hiPages.slice(hiPages.length - imageEdgeFrames) : []; + const imageHead = headPages.join(""); + const imageTail = tailPages.join(""); + const middleSource = imageText.slice(imageHead.length, imageText.length - imageTail.length); + let middlePages = paginateCells(middleSource, capLo, geometry(low).cols, usesWideCells(low)); + const middleBudget = maxFrames - 2 * imageEdgeFrames; let truncatedChars = 0; - const middleCap = (maxFrames - 2 * imageEdgeFrames) * capLo; - if (middleText.length > middleCap) { - truncatedChars = middleText.length - middleCap; - middleText = middleText.slice(truncatedChars); + let middleText = middleSource; + if (middlePages.length > middleBudget) { + const dropped = middlePages.slice(0, middlePages.length - middleBudget).join(""); + truncatedChars = dropped.length; + middleText = middleSource.slice(dropped.length); + middlePages = middlePages.slice(middlePages.length - middleBudget); } return { - frames: [ - ...sliceFrames(imageHead, capHi, high), - ...sliceFrames(middleText, capLo, low), - ...sliceFrames(imageTail, capHi, high), - ], + frames: [...planFrames(headPages, high), ...planFrames(middlePages, low), ...planFrames(tailPages, high)], textHead, textTail, keptText: textHead + imageHead + middleText + imageTail + textTail, @@ -1501,19 +1730,9 @@ export async function compact( if (!firstKeptEntryId) { throw new Error("First kept entry has no ID - session may need migration"); } - const baseShape = options?.shape ?? resolveShape(options?.model); - const frameSize = options?.frameSize ?? baseShape.frameSize; - const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize }; - const low = denseCompanion(high, options?.model?.api); - const geo = geometry(high); - // The engine default caps archive growth; a caller-supplied maxFrames only - // lowers it further (an upper limit), never raising it past the default. - const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT)); - const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages); - let archiveText = normalize(serializeConversation(llmMessages, options)); - + const serialized = serializeConversation(llmMessages, options); const previousArchive = getPreservedArchive(previousPreserveData); const previousText = previousArchive?.text ?? @@ -1522,8 +1741,20 @@ export async function compact( .join(NEWLINE_GLYPH); const hasPreviousText = previousText.length > 0; const includedPreviousSummary = !hasPreviousText && !!previousSummary; + const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary); + const baseShape = options?.shape ?? resolveShapeForText(shapeProbeText, options?.model); + const frameSize = options?.frameSize ?? baseShape.frameSize; + const high = frameSize === baseShape.frameSize ? baseShape : { ...baseShape, frameSize }; + const low = denseCompanion(high, options?.model?.api); + const geo = geometry(high); + // The engine default caps archive growth; a caller-supplied maxFrames only + // lowers it further (an upper limit), never raising it past the default. + const maxFrames = Math.max(1, Math.min(options?.maxFrames ?? MAX_FRAMES_DEFAULT, MAX_FRAMES_DEFAULT)); + + let archiveText = normalize(serialized, { shape: high }); + if (includedPreviousSummary && previousSummary) { - const head = `[Summary of earlier history] ${normalize(previousSummary)}`; + const head = `[Summary of earlier history] ${normalize(previousSummary, { shape: high })}`; archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head; } diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 7f20a41cf..34625148c 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -81,12 +81,16 @@ describe("scanRenderability", () => { expect(res.unrenderableRatio).toBe(0); }); - it("detects high unrenderable rates in CJK text and marks it unsafe", () => { - // Mix of ASCII and CJK: "const a = '你好世界';" - // Total graphics: ~15. Unrenderable: 4. Ratio > 5% (0.05). + it("uses the embedded Silver fallback for CJK text", () => { const res = snapcompact.scanRenderability("const a = '你好世界';"); + expect(res.isSafe).toBe(true); + expect(res.unrenderableRatio).toBe(0); + }); + + it("detects high unrenderable rates when neither bitmap fonts nor Silver cover the text", () => { + const res = snapcompact.scanRenderability("\u{e000}".repeat(10)); expect(res.isSafe).toBe(false); - expect(res.unrenderableRatio).toBeGreaterThan(0.05); + expect(res.unrenderableRatio).toBe(1); }); it("ignores whitespace, ANSI, and zero-width markers in ratio calculations", () => { @@ -210,7 +214,7 @@ describe("normalize", () => { expect(snapcompact.normalize("a \t b c")).toBe("a b c"); expect(snapcompact.normalize("x → y ✓ “quoted” — em…")).toBe(`x -> y v "quoted" - em...`); expect(snapcompact.normalize("café größe")).toBe("café größe"); // Latin-1 has glyphs - expect(snapcompact.normalize("box │─┌ emoji 🎞")).toBe("box |-+ emoji ?"); + expect(snapcompact.normalize("box │─┌ emoji 🎞")).toBe("box |-+ emoji"); }); it("folds newline runs to one full-block glyph, trimming the edges", () => { @@ -232,6 +236,17 @@ describe("normalize", () => { ); }); + it("preserves Silver-supported kana and Hangul for bitmap font fallback", () => { + expect(snapcompact.normalize("こんにちは")).toBe("こんにちは"); + expect(snapcompact.normalize("カタカナ")).toBe("カタカナ"); + expect(snapcompact.normalize("안녕하세요")).toBe("안녕하세요"); + }); + + it("folds semantic emoji and drops decorative emoji", () => { + expect(snapcompact.normalize("✅ pass ⚠️ warn ❌ fail 😄")).toBe("[OK] pass [WARN] warn [FAIL] fail"); + expect(snapcompact.normalize("✗ ✘")).toBe("x x"); + }); + it("folds compatibility characters to their ASCII skeleton via NFKD", () => { expect(snapcompact.normalize("x⁵")).toBe("x5"); // superscript outside Latin-1 expect(snapcompact.normalize("HELLO")).toBe("HELLO"); // fullwidth forms @@ -241,8 +256,8 @@ describe("normalize", () => { expect(snapcompact.normalize("⅓ cup")).toBe("1/3 cup"); // vulgar fraction expect(snapcompact.normalize("𝐇𝐞𝐥𝐥𝐨")).toBe("Hello"); // math-styled alphanumerics expect(snapcompact.normalize("™ ‹q› ′ ″ ⇐ ↑")).toBe(`TM ' " <= ^`); - // No decomposition and no glyph still falls back to ? (unchanged contract). - expect(snapcompact.normalize("emoji 🎞")).toBe("emoji ?"); + // Emoji drops, while characters missing from both selected font and Silver fall back to ?. + expect(snapcompact.normalize("emoji 🎞 \u{e000}")).toBe("emoji ?"); }); }); @@ -357,6 +372,17 @@ describe("shape resolution", () => { expect(snapcompact.isShape({ ...snapcompact.SHAPES.openai, imageDetail: "original" })).toBe(true); }); + it("keeps selected shapes while Silver fallback covers non-Latin glyphs", () => { + const text = "こんにちは 你好 안녕"; + const silver = snapcompact.resolveShape(undefined, "silver16-bw"); + expect(snapcompact.scanRenderability(text).isSafe).toBe(true); + expect(snapcompact.scanRenderability(text, { shape: silver }).isSafe).toBe(true); + expect(snapcompact.normalize(text)).toBe(text); + expect(snapcompact.normalize(text, { shape: silver })).toBe(text); + expect(snapcompact.resolveShapeForText(text).font).not.toBe("silver"); + expect(snapcompact.resolveShapeForText("plain ascii").font).not.toBe("silver"); + }); + it("images forwards the per-frame detail hint", () => { const archive: snapcompact.Archive = { frames: [ @@ -450,6 +476,33 @@ describe("render", () => { expect(frame.cols).toBe(Math.floor(TEST_FRAME_SIZE / 6)); }); + it("renders Silver TrueType Unicode text as truecolor RGB", async () => { + const silver = snapcompact.resolveShape(undefined, "silver16-bw"); + const frame = await snapcompact.render("你好안녕", silver, 64); + const png = Buffer.from(frame.data, "base64"); + expect(png[25]).toBe(2); + expect(png.readUInt32BE(16)).toBe(64); + expect(png.readUInt32BE(20)).toBe(16); + expect(frame.cols).toBe(4); + expect(frame.chars).toBe(4); + }); + + it("renders a Silver fallback glyph across two cells in a bitmap frame", async () => { + const bitmap = snapcompact.resolveShape(undefined, "8on16-bw"); // 8px-wide cells at 64px → cols 8 + const frame = await snapcompact.render("你", bitmap, 64); + const decoded = decodePng(Buffer.from(frame.data, "base64")); + expect(decoded.colorType).toBe(3); + expect(frame.chars).toBe(1); + // The wide glyph fills the full two-cell (16px) span, not a single 8px cell. + let inkBeyondFirstCell = false; + for (let y = 0; y < decoded.height; y++) { + for (let x = 8; x < 16; x++) { + if (decoded.pixels[y * decoded.width + x] === 7) inkBeyondFirstCell = true; + } + } + expect(inkBeyondFirstCell).toBe(true); + }); + it("caps printed characters at frame capacity", async () => { const { capacity } = snapcompact.geometry(snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); const frame = await snapcompact.render("x".repeat(capacity + 500), snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); @@ -501,6 +554,20 @@ describe("renderMany", () => { expect(snapcompact.frames(text, { shape, frameSize: TEST_FRAME_SIZE })).toBe(3); }); + it("counts wide CJK as two grid cells in bitmap shapes and one in Silver", () => { + const bitmap = snapcompact.resolveShape(undefined, "8on16-bw"); // cols 8 (even) at 64px + const cap = snapcompact.geometry(bitmap, 64).capacity; + // Wide glyphs take two cells, so half a frame's worth of cells fits as chars. + expect(snapcompact.frames("你".repeat(cap / 2), { shape: bitmap, frameSize: 64 })).toBe(1); + expect(snapcompact.frames("你".repeat(cap / 2 + 1), { shape: bitmap, frameSize: 64 })).toBe(2); + // ASCII stays one cell per char on the same shape. + expect(snapcompact.frames("a".repeat(cap), { shape: bitmap, frameSize: 64 })).toBe(1); + // The square-celled Silver shape draws CJK one cell each (no doubling). + const silver = snapcompact.resolveShape(undefined, "silver16-bw"); + const silverCap = snapcompact.geometry(silver, 64).capacity; + expect(snapcompact.frames("你".repeat(silverCap), { shape: silver, frameSize: 64 })).toBe(1); + }); + it("honors maxFrames and propagates the shape's detail hint", async () => { const shape = snapcompact.SHAPES.openai; const { capacity } = snapcompact.geometry(shape, TEST_FRAME_SIZE); @@ -793,6 +860,18 @@ describe("compact", () => { expect(cols[3]).toBeGreaterThan(hiCols); }); + it("keeps foveated Silver archives on the Silver font", async () => { + const silver = snapcompact.resolveShape(undefined, "silver16-bw"); + const result = await snapcompact.compact( + makePreparation({ messagesToSummarize: [createUserMessage("你好世界".repeat(200))] }), + { shape: silver, frameSize: 64, maxFrames: 1 }, + ); + const archive = snapcompact.getPreservedArchive(result.preserveData); + expect(archive).toBeDefined(); + expect(archive?.frames.length).toBeGreaterThan(0); + expect(archive?.frames.every(frame => frame.font === "silver")).toBe(true); + }); + it("re-renders later compactions from the kept source text", async () => { const first = await snapcompact.compact( makePreparation({