feat: enabled dynamic font rendering with CJK and unicode support
- Added Silver.ttf TrueType font support to `pi-natives` with automated fallback logic for bitmap font rendering. - Implemented wide code point detection and cell-width calculation to improve CJK character handling and layout. - Introduced dynamic font-aware preflight probing via `resolveShapeForText` and `renderabilityProbeText` for better font selection. - Enabled semantic emoji folding and improved text normalization to handle non-Latin characters and emoji filtering.
This commit is contained in:
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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<Font> =
|
||||
LazyLock::new(|| parse_bdf(include_str!("fonts/6x12.bdf"), 6, 12));
|
||||
static FONT_8X13: LazyLock<Font> =
|
||||
LazyLock::new(|| parse_bdf(include_str!("fonts/8x13.bdf"), 8, 13));
|
||||
static FONT_SILVER: LazyLock<TtfFont> =
|
||||
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<char>,
|
||||
px: f32,
|
||||
ascent: f32,
|
||||
cell_w: usize,
|
||||
cell_h: usize,
|
||||
}
|
||||
|
||||
struct RasterizedGlyph {
|
||||
metrics: Metrics,
|
||||
bitmap: Vec<u8>,
|
||||
}
|
||||
|
||||
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<RenderFont<'static>> {
|
||||
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<char, RasterizedGlyph>,
|
||||
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<u32> = 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<u8> {
|
||||
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<char> = 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<u32> = 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<u8> {
|
||||
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<char> = 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<String>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+20
-9
@@ -1315,13 +1315,15 @@ export declare function readImageFromClipboard(): Promise<ClipboardImage | undef
|
||||
* 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.
|
||||
@@ -1468,8 +1470,8 @@ export interface SnapcompactRenderOptions {
|
||||
*/
|
||||
size: number
|
||||
/**
|
||||
* 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"`.
|
||||
*/
|
||||
font?: string
|
||||
/**
|
||||
@@ -1504,6 +1506,15 @@ export interface SnapcompactRenderOptions {
|
||||
columns?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export declare function snapcompactSupportedChars(font: string, chars: string): string
|
||||
|
||||
export declare function summarizeCode(options: SummaryOptions): SummaryResult
|
||||
|
||||
export interface SummaryOptions {
|
||||
|
||||
@@ -64,6 +64,7 @@ export const renderSnapcompactPng = nativeBindings.renderSnapcompactPng;
|
||||
export const search = nativeBindings.search;
|
||||
export const setHangulCompatJamoWidthOverride = nativeBindings.setHangulCompatJamoWidthOverride;
|
||||
export const sliceWithWidth = nativeBindings.sliceWithWidth;
|
||||
export const snapcompactSupportedChars = nativeBindings.snapcompactSupportedChars;
|
||||
export const summarizeCode = nativeBindings.summarizeCode;
|
||||
export const supportsLanguage = nativeBindings.supportsLanguage;
|
||||
export const truncateToWidth = nativeBindings.truncateToWidth;
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `silver16-bw` shape with Silver TrueType font support for CJK and non-Latin text
|
||||
- Added `resolveShapeForText` to support font-aware shape resolution
|
||||
- Added semantic emoji folding to ASCII labels (e.g., [OK], [WARN], [FAIL])
|
||||
- Added support for East Asian wide characters across two grid cells in bitmap shapes
|
||||
|
||||
- Added the `silver16-bw` snapcompact shape backed by the embedded Silver TrueType font for CJK and other non-Latin text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved normalization to preserve Unicode glyphs supported by the system font or Silver fallback
|
||||
- Updated `wrap` and pagination logic to account for wide Character footprint in bitmap shapes
|
||||
- Dropped decorative emoji from rendered output instead of printing fallbacks
|
||||
- Folded box-drawing and compatibility symbols to their ASCII skeletons
|
||||
- Updated provider shape geometries to use updated X.org 8x13 font metrics (11px/22px pitches)
|
||||
|
||||
- Improved snapcompact normalization for non-ASCII text: semantic emoji fold to ASCII labels, decorative emoji drop, box drawing and compatibility symbols keep ASCII folds, and Unicode text is preserved when either the selected font or the embedded Silver fallback can render it.
|
||||
- Bitmap snapcompact shapes now draw missing glyphs through the embedded Silver TrueType fallback per character instead of switching entire snippets or rendering blanks; East Asian wide characters render full-width across two grid cells (TypeScript capacity/pagination and the native renderer share one wide-character cell model).
|
||||
|
||||
## [16.1.23] - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,7 +9,7 @@ Built for [oh-my-pi](https://github.com/can1357/oh-my-pi)'s compaction pipeline,
|
||||
## How it works
|
||||
|
||||
1. Discarded history is serialized to compact text (`serializeConversation`), with per-tool-result and per-argument character caps.
|
||||
2. Text is normalized for the bundled bitmap fonts (`normalize`): ANSI sequences stripped, whitespace collapsed, newline runs folded into a single full-block glyph so line structure survives.
|
||||
2. Text is normalized for the selected native font (`normalize`): ANSI sequences stripped, whitespace collapsed, newline runs folded into a single full-block glyph, box drawing and compatibility symbols folded to ASCII, semantic emoji folded to ASCII labels, decorative emoji dropped, and non-Latin glyphs preserved when either the selected font or the embedded Silver fallback can render them.
|
||||
3. Pages of text are rasterized into PNG frames (`render` / `renderMany`). Frame width is fixed per shape; height hugs the rows actually printed, so a partially filled frame never bills blank pixel rows.
|
||||
4. Frames persist in the compaction entry's `preserveData` and are re-attached to the summary message on every context rebuild.
|
||||
|
||||
@@ -17,13 +17,15 @@ Frame shapes are provider-aware, chosen by SQuAD recall evals (see `research/`)
|
||||
|
||||
| Reader | Default shape | Notes |
|
||||
| --- | --- | --- |
|
||||
| Anthropic | `6x12-dim` | X.org 6x12 glyphs, stopwords dimmed gray; high-res Claude lines get 1932px frames |
|
||||
| Google | `doc-8on16-sent-dim` @2048 | Two newspaper columns, sentence-hue ink; Gemini bills a fixed per-image budget, so larger frames are free chars |
|
||||
| OpenAI | `8on16-bw` | 8x13 glyphs on a patch-aligned 16px pitch, sent at `detail: "original"` |
|
||||
| Anthropic | `11on16-bw` | X.org 8x13 glyphs on an 11px advance; high-res Claude lines get 1932px frames |
|
||||
| Google | `8on22-bw` @2048 | X.org 8x13 glyphs on a 22px pitch; Gemini bills a fixed per-image budget, so larger frames are free chars |
|
||||
| OpenAI | `8on22-bw` | X.org 8x13 glyphs on a 22px pitch, sent at `detail: "original"` |
|
||||
| Unknown | Anthropic shape | Per-provider image-count budgets guard against gateways that silently drop frames |
|
||||
|
||||
`resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape, priced for the gateway actually carrying the request.
|
||||
|
||||
Bitmap shapes keep their provider-tuned geometry and draw missing glyphs through the embedded Silver TrueType fallback one character at a time; East Asian (CJK/Kana/Hangul) glyphs render full-width across two cells so they stay legible in the narrow ASCII grid. Selecting `silver16-bw` uses Silver for the whole frame.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
@@ -58,8 +60,8 @@ const result = await compact(preparation, { model });
|
||||
|
||||
- **Compaction**: `compact`, `CompactionPreparation`, `CompactionResult`, `getPreservedArchive`, `images`, `historyBlocks`
|
||||
- **Rendering**: `render`, `renderMany`, `frames`, `geometry`
|
||||
- **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
|
||||
- **Text**: `serializeConversation`, `normalize`, `dimStopwords`, `wrap`
|
||||
- **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `resolveShapeForText`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
|
||||
- **Text**: `serializeConversation`, `normalize`, `scanRenderability`, `renderabilityProbeText`, `dimStopwords`, `wrap`
|
||||
- **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES`
|
||||
- **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
* re-attached to the compaction summary message on every context rebuild.
|
||||
*/
|
||||
|
||||
import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import { renderSnapcompactPng } from "@oh-my-pi/pi-natives";
|
||||
import type { Api, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import { renderSnapcompactPng, snapcompactSupportedChars } from "@oh-my-pi/pi-natives";
|
||||
import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
||||
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
||||
@@ -58,7 +58,7 @@ import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { t
|
||||
/** One eval-validated frame shape: font, cell, ink, repetition, and size. */
|
||||
export interface Shape {
|
||||
/** Bundled font in the native renderer. */
|
||||
font: "5x8" | "8x8" | "6x12" | "8x13";
|
||||
font: "5x8" | "8x8" | "6x12" | "8x13" | "silver";
|
||||
/** Target cell advance in pixels; differing from the font's natural cell
|
||||
* renders via Lanczos stretch (anti-aliased RGB frame). */
|
||||
cellWidth: number;
|
||||
@@ -97,9 +97,10 @@ export type ShapeGeometry = Omit<Shape, "frameTokenEstimate" | "imageDetail">;
|
||||
* 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<TMessage = Message> extends SerializeOptions {
|
||||
/** App-level message transformer (same contract as agent-core's `SummaryOptions.convertToLlm`). */
|
||||
convertToLlm?: ConvertToLlm<TMessage>;
|
||||
/** Model whose provider API selects the frame shape. */
|
||||
model?: Pick<Model, "api">;
|
||||
/** 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<string, string> = {
|
||||
"✅": "[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<Shape, "font">;
|
||||
/** 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<string> {
|
||||
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<string>();
|
||||
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<Shape, "font">): 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<RenderedFrame> {
|
||||
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, "api">;
|
||||
/** 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<ImageContent[]> {
|
||||
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<RenderManyOptions, "shape" | "model" | "frameSize">): 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<string, unknown>,
|
||||
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<TMessage = Message>(
|
||||
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<TMessage = Message>(
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <q> ' " <= ^`);
|
||||
// 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({
|
||||
|
||||
Reference in New Issue
Block a user