Skip to main content

oxicode_snapcompact/renderer/
snapcompact_render.rs

1//! Snapcompact frame rendering.
2//!
3//! Rasterizes pre-normalized conversation text onto a `size`-wide bitmap
4//! (height hugs the rows the text actually needs) using one of the bundled
5//! public-domain pixel fonts, then encodes it as PNG:
6//!
7//! - `5x8`  — X.org BDF font (legacy shape).
8//! - `8x8`  — unscii-8 hex font (Latin-1 subset), the square cell that won the
9//!   snapcompact `SQuAD` evals.
10//! - `6x12` / `8x13` — X.org misc BDF fonts (higher-density eval winners).
11//! - `silver` — bundled TrueType font for CJK and other non-Latin text.
12//!
13//! Shape controls, all eval-validated in `packages/snapcompact`:
14//!
15//! - **variant** — `sent` cycles glyph ink through six hues at sentence
16//!   boundaries; `bw` prints plain black ink (best for Anthropic readers).
17//! - **lineRepeat** — prints every text line N times; copies after the first
18//!   sit on a pale highlight band. Redundancy coding: two looks per glyph at
19//!   half the density ("8x8r" shapes).
20//! - **cellWidth/cellHeight** — target cell size. When it differs from the
21//!   font's natural cell, glyphs are rasterized at native size and the canvas
22//!   is Lanczos3-resampled to the target (anisotropic stretch, e.g. the
23//!   OpenAI-optimal "6x6u" shape), producing an anti-aliased RGB frame.
24//! - **stretch** — `false` disables resampling: glyphs print at natural size on
25//!   the requested cell box while staying indexed (e.g. 8x13 glyphs on an 8x16
26//!   pitch, the "8on16" shapes). `true`/unset keeps the auto rule above.
27//! - **columns** — `2` flows pre-wrapped `\n`-separated lines down two
28//!   newspaper columns (the "doc" shapes); word wrap and pagination happen in
29//!   the TypeScript caller.
30//! - **dim spans** — `U+000E`/`U+000F` in the text toggle dim gray ink on/off
31//!   without occupying a cell; the TypeScript serializer wraps tool output in
32//!   them so archived conversation reads louder than archived tool noise.
33//! - **line breaks** — `U+2588` (FULL BLOCK) fills its entire cell with pitch
34//!   black ink regardless of variant or dim state; the TypeScript normalizer
35//!   folds newline runs to it so line structure survives whitespace collapse at
36//!   a one-cell cost.
37//!
38//! Text normalization, frame chunking, provider shape selection, and archive
39//! management live in `packages/snapcompact/src/snapcompact.ts`; this module
40//! is only the hot `text -> PNG bytes` path.
41
42use std::{
43    borrow::Cow,
44    collections::{HashMap, HashSet},
45    f32::consts::PI,
46    sync::LazyLock,
47};
48
49use fontdue::{Font as TtfFace, FontSettings, Metrics};
50
51/// Renderer error type.
52#[derive(Debug, thiserror::Error)]
53pub enum SnapcompactError {
54    /// Invalid options or unsupported font/variant.
55    #[error("{0}")]
56    Render(String),
57    /// PNG encoding failed.
58    #[error("png encode: {0}")]
59    PngEncode(String),
60}
61
62impl From<std::io::Error> for SnapcompactError {
63    fn from(e: std::io::Error) -> Self {
64        SnapcompactError::PngEncode(e.to_string())
65    }
66}
67
68impl From<std::fmt::Error> for SnapcompactError {
69    fn from(e: std::fmt::Error) -> Self {
70        SnapcompactError::PngEncode(e.to_string())
71    }
72}
73
74/// Upper bound on the frame edge: a hard stop against absurd allocations
75/// (`size * size` pixel buffer), far above the 2576px production frame.
76const MAX_FRAME_SIZE: u32 = 16384;
77
78/// Indexed palette: 0 is the white background, 1-6 are the six dark sentence
79/// hues from the eval renderer (HLS l=0.22 s=0.95, h ∈ {0, .08, .3, .5, .62,
80/// .78}), 7 is plain black ink (`bw` variant), 8 is the pale highlight band
81/// behind repeated line copies, 9 is the dim gray ink for tool-output spans.
82const PALETTE: [[u8; 3]; 10] = [
83    [255, 255, 255],
84    [109, 2, 2],     // red
85    [109, 53, 2],    // amber
86    [24, 109, 2],    // green
87    [2, 109, 109],   // teal
88    [2, 32, 109],    // blue
89    [75, 2, 109],    // violet
90    [0, 0, 0],       // bw ink
91    [255, 247, 194], // repeat highlight band
92    [128, 128, 128], // dim ink (tool-output spans)
93];
94const INK_COLORS: usize = 6;
95const INK_BLACK: u8 = 7;
96const BG_REPEAT: u8 = 8;
97const INK_DIM: u8 = 9;
98/// Zero-width ink toggles embedded in the text stream (shift-out/shift-in).
99const DIM_ON: u32 = 0x0e;
100const DIM_OFF: u32 = 0x0f;
101/// FULL BLOCK: fills its entire cell box with pitch-black ink (`INK_BLACK`,
102/// ignoring sentence hue and dim state). The TypeScript normalizer folds
103/// newline runs to it.
104const FULL_BLOCK: u32 = 0x2588;
105
106static FONT_5X8: LazyLock<Font> = LazyLock::new(|| parse_bdf(include_str!("fonts/5x8.bdf"), 5, 8));
107static FONT_8X8: LazyLock<Font> = LazyLock::new(|| parse_hex(include_str!("fonts/unscii-8.hex")));
108static FONT_6X12: LazyLock<Font> =
109    LazyLock::new(|| parse_bdf(include_str!("fonts/6x12.bdf"), 6, 12));
110static FONT_8X13: LazyLock<Font> =
111    LazyLock::new(|| parse_bdf(include_str!("fonts/8x13.bdf"), 8, 13));
112static FONT_SILVER: LazyLock<TtfFont> =
113    LazyLock::new(|| parse_ttf(include_bytes!("fonts/Silver.ttf"), 16.0, 16, 16));
114
115struct Glyph {
116    /// Glyph width in pixels (≤ 8 for the bundled fonts).
117    w: u8,
118    /// Glyph height in pixels.
119    h: i32,
120    xoff: i32,
121    yoff: i32,
122    /// One bitmask per bitmap row, MSB-leftmost.
123    rows: Vec<u8>,
124}
125
126struct Font {
127    /// Glyphs keyed by Unicode code point (ASCII + Latin-1 coverage).
128    glyphs: HashMap<u32, Glyph>,
129    ascent: i32,
130    /// Natural cell advance (x) in pixels.
131    cell_w: usize,
132    /// Natural cell pitch (y) in pixels.
133    cell_h: usize,
134}
135
136struct TtfFont {
137    face: TtfFace,
138    supported: HashSet<char>,
139    px: f32,
140    ascent: f32,
141    cell_w: usize,
142    cell_h: usize,
143}
144
145struct RasterizedGlyph {
146    metrics: Metrics,
147    bitmap: Vec<u8>,
148}
149
150fn parse_bdf(text: &str, cell_w: usize, cell_h: usize) -> Font {
151    let mut glyphs = HashMap::new();
152    let mut ascent = 0i32;
153    let mut enc = -1i64;
154    let mut bbx = [0i32; 4];
155    let mut lines = text.lines();
156    while let Some(line) = lines.next() {
157        if let Some(rest) = line.strip_prefix("FONT_ASCENT") {
158            ascent = rest.trim().parse().unwrap_or(0);
159        } else if let Some(rest) = line.strip_prefix("ENCODING") {
160            enc = rest.trim().parse().unwrap_or(-1);
161        } else if let Some(rest) = line.strip_prefix("BBX") {
162            let mut parts = rest.split_ascii_whitespace();
163            for slot in &mut bbx {
164                *slot = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0);
165            }
166        } else if line.starts_with("BITMAP") {
167            let mut rows = Vec::new();
168            for row in lines.by_ref() {
169                if row.starts_with("ENDCHAR") {
170                    break;
171                }
172                rows.push(u8::from_str_radix(row.trim(), 16).unwrap_or(0));
173            }
174            if enc >= 0 {
175                glyphs.insert(
176                    enc as u32,
177                    Glyph {
178                        w: bbx[0].clamp(0, 8) as u8,
179                        h: bbx[1],
180                        xoff: bbx[2],
181                        yoff: bbx[3],
182                        rows,
183                    },
184                );
185            }
186        }
187    }
188    Font {
189        glyphs,
190        ascent,
191        cell_w,
192        cell_h,
193    }
194}
195
196/// Parse a unifont-style `.hex` font (`CODEPOINT:16-hex-digit bitmap`, one
197/// byte per row of an 8x8 glyph). Baseline sits at row 7 (`ascent` 7 with a
198/// one-pixel descender row), matching the eval renderer.
199fn parse_hex(text: &str) -> Font {
200    let mut glyphs = HashMap::new();
201    for line in text.lines() {
202        let Some((cp, bits)) = line.split_once(':') else {
203            continue;
204        };
205        let Ok(enc) = u32::from_str_radix(cp.trim(), 16) else {
206            continue;
207        };
208        let bits = bits.trim();
209        if bits.len() != 16 {
210            continue;
211        }
212        let rows: Vec<u8> = (0..8)
213            .map(|i| u8::from_str_radix(&bits[i * 2..i * 2 + 2], 16).unwrap_or(0))
214            .collect();
215        glyphs.insert(
216            enc,
217            Glyph {
218                w: 8,
219                h: 8,
220                xoff: 0,
221                yoff: -1,
222                rows,
223            },
224        );
225    }
226    Font {
227        glyphs,
228        ascent: 7,
229        cell_w: 8,
230        cell_h: 8,
231    }
232}
233
234fn parse_ttf(data: &'static [u8], px: f32, cell_w: usize, cell_h: usize) -> TtfFont {
235    let face =
236        TtfFace::from_bytes(data, FontSettings::default()).expect("bundled Silver.ttf must parse");
237    let supported = face.chars().keys().copied().collect();
238    let ascent = face
239        .horizontal_line_metrics(px)
240        .map_or(px * 0.8, |metrics| metrics.ascent);
241    TtfFont {
242        face,
243        supported,
244        px,
245        ascent,
246        cell_w,
247        cell_h,
248    }
249}
250
251enum RenderFont<'a> {
252    Bitmap(&'a Font),
253    Ttf(&'a TtfFont),
254}
255
256impl RenderFont<'_> {
257    const fn cell_w(&self) -> usize {
258        match self {
259            Self::Bitmap(font) => font.cell_w,
260            Self::Ttf(font) => font.cell_w,
261        }
262    }
263
264    const fn cell_h(&self) -> usize {
265        match self {
266            Self::Bitmap(font) => font.cell_h,
267            Self::Ttf(font) => font.cell_h,
268        }
269    }
270
271    fn supports(&self, code: u32) -> bool {
272        if matches!(code, DIM_ON | DIM_OFF | FULL_BLOCK | 0x0a) {
273            return true;
274        }
275        match self {
276            Self::Bitmap(font) => font.glyphs.contains_key(&code),
277            Self::Ttf(font) => char::from_u32(code).is_some_and(|ch| font.supported.contains(&ch)),
278        }
279    }
280}
281
282fn resolve_font(name: &str) -> Option<RenderFont<'static>> {
283    match name {
284        "5x8" => Some(RenderFont::Bitmap(&FONT_5X8)),
285        "8x8" => Some(RenderFont::Bitmap(&FONT_8X8)),
286        "6x12" => Some(RenderFont::Bitmap(&FONT_6X12)),
287        "8x13" => Some(RenderFont::Bitmap(&FONT_8X13)),
288        "silver" => Some(RenderFont::Ttf(&FONT_SILVER)),
289        _ => None,
290    }
291}
292
293/// Frame grid geometry shared with the TypeScript caller. The cell box
294/// (`cell_w` x `cell_h`) is the advance/pitch glyphs are laid out on; it may
295/// differ from the font's natural cell (e.g. 8x13 glyphs on an 8x16 pitch).
296struct Grid {
297    cols: usize,
298    rows: usize,
299    repeat: usize,
300    /// Cell advance (x) in pixels.
301    cell_w: usize,
302    /// Cell pitch (y) in pixels.
303    cell_h: usize,
304}
305
306/// East Asian Wide / Fullwidth code points that occupy two grid cells when
307/// drawn through the Silver fallback in a narrow bitmap shape. The same ranges
308/// are mirrored in `packages/snapcompact/src/snapcompact.ts` so the TypeScript
309/// capacity/pagination math and this layout never disagree on cell counts.
310const fn is_wide(cp: u32) -> bool {
311    matches!(cp,
312        0x1100..=0x115F
313        | 0x2E80..=0x2EFF
314        | 0x2F00..=0x2FDF
315        | 0x3000..=0x303E
316        | 0x3041..=0x33FF
317        | 0x3400..=0x4DBF
318        | 0x4E00..=0x9FFF
319        | 0xA000..=0xA4CF
320        | 0xAC00..=0xD7A3
321        | 0xF900..=0xFAFF
322        | 0xFE30..=0xFE4F
323        | 0xFF00..=0xFF60
324        | 0xFFE0..=0xFFE6
325        | 0x20000..=0x2FFFD
326        | 0x30000..=0x3FFFD
327    )
328}
329
330/// Cells one code point consumes in a grid of `wide_cells`-capable shape: zero
331/// for the zero-width dim toggles, two for wide code points when the shape uses
332/// a narrow bitmap cell (so CJK draws full-width through Silver), one
333/// otherwise.
334const fn cell_units(code: u32, wide_cells: bool) -> usize {
335    match code {
336        DIM_ON | DIM_OFF => 0,
337        _ if wide_cells && is_wide(code) => 2,
338        _ => 1,
339    }
340}
341
342/// Advance the running cell cursor for one code point, inserting a one-cell pad
343/// when a wide glyph would straddle the right edge so it starts the next row.
344/// Returns `(cell_at_which_to_draw, units, next_cursor)`, or `None` for a
345/// zero-width toggle.
346const fn place_cell(
347    cursor: usize,
348    cols: usize,
349    code: u32,
350    wide_cells: bool,
351) -> Option<(usize, usize, usize)> {
352    let units = cell_units(code, wide_cells);
353    if units == 0 {
354        return None;
355    }
356    let mut cell = cursor;
357    if units == 2 && cols >= 2 && cell % cols == cols - 1 {
358        cell += 1; // pad: never split a wide glyph across two rows
359    }
360    Some((cell, units, cell + units))
361}
362
363/// Grid rows the text actually occupies, so the canvas height hugs the
364/// content instead of padding the frame to a full square. Mirrors the
365/// renderers' cell accounting: dim toggles are zero-width, wide code points
366/// take two cells in bitmap shapes (with a straddle pad), every other code
367/// point one; doc layout fills one row per `\n`-separated line.
368fn used_rows(text: &str, grid: &Grid, doc: bool, wide_cells: bool) -> usize {
369    let rows = if doc {
370        text.split('\n').count()
371    } else {
372        let mut cursor = 0usize;
373        for ch in text.chars() {
374            if let Some((_, _, next)) = place_cell(cursor, grid.cols, ch as u32, wide_cells) {
375                cursor = next;
376            }
377        }
378        cursor.div_ceil(grid.cols)
379    };
380    rows.clamp(1, grid.rows)
381}
382
383/// Paint the pale highlight bands behind line copies after the first.
384fn fill_repeat_bands(pixels: &mut [u8], width: usize, height: usize, grid: &Grid) {
385    if grid.repeat <= 1 {
386        return;
387    }
388    for row in 0..grid.rows {
389        for copy in 1..grid.repeat {
390            let band_top = (row * grid.repeat + copy) * grid.cell_h;
391            for y in band_top..(band_top + grid.cell_h).min(height) {
392                pixels[y * width..y * width + width].fill(BG_REPEAT);
393            }
394        }
395    }
396}
397
398/// Blit one glyph's bitmask rows at (`left`, `top`), clipped to the canvas.
399fn blit_glyph(
400    pixels: &mut [u8],
401    width: usize,
402    height: usize,
403    glyph: &Glyph,
404    left: i32,
405    top: i32,
406    ink: u8,
407) {
408    for (r, &bits) in glyph.rows.iter().enumerate() {
409        if bits == 0 {
410            continue;
411        }
412        let y = top + r as i32;
413        if y < 0 || y >= height as i32 {
414            continue;
415        }
416        let row_base = y as usize * width;
417        for b in 0..glyph.w {
418            if bits & (0x80u8 >> b) != 0 {
419                let x = left + i32::from(b);
420                if x >= 0 && (x as usize) < width {
421                    pixels[row_base + x as usize] = ink;
422                }
423            }
424        }
425    }
426}
427
428/// Fill one cell box (every repeat copy) with solid ink, clipped to canvas.
429fn fill_cell(
430    pixels: &mut [u8],
431    width: usize,
432    height: usize,
433    grid: &Grid,
434    x_origin: usize,
435    row: usize,
436    ink: u8,
437) {
438    let x0 = x_origin.min(width);
439    let x1 = (x_origin + grid.cell_w).min(width);
440    if x0 >= x1 {
441        return;
442    }
443    for copy in 0..grid.repeat {
444        let top = (row * grid.repeat + copy) * grid.cell_h;
445        for y in top..(top + grid.cell_h).min(height) {
446            pixels[y * width + x0..y * width + x1].fill(ink);
447        }
448    }
449}
450
451fn fill_repeat_bands_rgb(pixels: &mut [u8], width: usize, height: usize, grid: &Grid) {
452    if grid.repeat <= 1 {
453        return;
454    }
455    let band = PALETTE[BG_REPEAT as usize];
456    for row in 0..grid.rows {
457        for copy in 1..grid.repeat {
458            let band_top = (row * grid.repeat + copy) * grid.cell_h;
459            for y in band_top..(band_top + grid.cell_h).min(height) {
460                for px in pixels[y * width * 3..(y + 1) * width * 3].chunks_exact_mut(3) {
461                    px.copy_from_slice(&band);
462                }
463            }
464        }
465    }
466}
467
468fn fill_cell_rgb(
469    pixels: &mut [u8],
470    width: usize,
471    height: usize,
472    grid: &Grid,
473    x_origin: usize,
474    row: usize,
475    ink: u8,
476) {
477    let x0 = x_origin.min(width);
478    let x1 = (x_origin + grid.cell_w).min(width);
479    if x0 >= x1 {
480        return;
481    }
482    let color = PALETTE[ink as usize];
483    for copy in 0..grid.repeat {
484        let top = (row * grid.repeat + copy) * grid.cell_h;
485        for y in top..(top + grid.cell_h).min(height) {
486            let row = &mut pixels[y * width * 3..(y + 1) * width * 3];
487            for x in x0..x1 {
488                row[x * 3..x * 3 + 3].copy_from_slice(&color);
489            }
490        }
491    }
492}
493
494fn ttf_pixel_size(font: &TtfFont, grid: &Grid) -> f32 {
495    let sx = grid.cell_w as f32 / font.cell_w as f32;
496    let sy = grid.cell_h as f32 / font.cell_h as f32;
497    font.px * sx.min(sy)
498}
499
500/// Pixel size for a full-width fallback glyph spanning two grid cells: scaled
501/// to the two-cell box so CJK fills the doubled width instead of a single
502/// narrow ASCII cell.
503fn ttf_wide_pixel_size(font: &TtfFont, grid: &Grid) -> f32 {
504    let sx = (2 * grid.cell_w) as f32 / font.cell_w as f32;
505    let sy = grid.cell_h as f32 / font.cell_h as f32;
506    font.px * sx.min(sy)
507}
508
509fn ttf_ascent(font: &TtfFont, px: f32) -> f32 {
510    font.face
511        .horizontal_line_metrics(px)
512        .map_or(font.ascent * px / font.px, |metrics| metrics.ascent)
513}
514
515fn cached_ttf_glyph<'a>(
516    cache: &'a mut HashMap<char, RasterizedGlyph>,
517    font: &TtfFont,
518    ch: char,
519    px: f32,
520) -> Option<&'a RasterizedGlyph> {
521    if !font.supported.contains(&ch) {
522        return None;
523    }
524    Some(cache.entry(ch).or_insert_with(|| {
525        let (metrics, bitmap) = font.face.rasterize(ch, px);
526        RasterizedGlyph { metrics, bitmap }
527    }))
528}
529
530fn blit_ttf_glyph(
531    pixels: &mut [u8],
532    width: usize,
533    height: usize,
534    glyph: &RasterizedGlyph,
535    left: i32,
536    top: i32,
537    ink: u8,
538) {
539    if glyph.metrics.width == 0 || glyph.metrics.height == 0 {
540        return;
541    }
542    let color = PALETTE[ink as usize];
543    for y in 0..glyph.metrics.height {
544        let dst_y = top + y as i32;
545        if dst_y < 0 || dst_y >= height as i32 {
546            continue;
547        }
548        for x in 0..glyph.metrics.width {
549            let alpha = u16::from(glyph.bitmap[y * glyph.metrics.width + x]);
550            if alpha == 0 {
551                continue;
552            }
553            let dst_x = left + x as i32;
554            if dst_x < 0 || dst_x >= width as i32 {
555                continue;
556            }
557            let offset = (dst_y as usize * width + dst_x as usize) * 3;
558            let inv = 255 - alpha;
559            for c in 0..3 {
560                let bg = u16::from(pixels[offset + c]);
561                let fg = u16::from(color[c]);
562                pixels[offset + c] = ((bg * inv + fg * alpha + 127) / 255) as u8;
563            }
564        }
565    }
566}
567
568fn blit_ttf_glyph_indexed(
569    pixels: &mut [u8],
570    width: usize,
571    height: usize,
572    glyph: &RasterizedGlyph,
573    left: i32,
574    top: i32,
575    ink: u8,
576) {
577    if glyph.metrics.width == 0 || glyph.metrics.height == 0 {
578        return;
579    }
580    for y in 0..glyph.metrics.height {
581        let dst_y = top + y as i32;
582        if dst_y < 0 || dst_y >= height as i32 {
583            continue;
584        }
585        let row_base = dst_y as usize * width;
586        for x in 0..glyph.metrics.width {
587            // Two-level anti-alias for the on/off indexed palette: a solid core
588            // only where coverage is high, and a single dim-gray fringe on the
589            // partially covered edges, so scaled CJK reads lighter than a
590            // flat-thresholded (and visibly bold) glyph. Dim spans stay dim.
591            let coverage = glyph.bitmap[y * glyph.metrics.width + x];
592            let cell = if coverage >= 170 {
593                ink
594            } else if ink == INK_BLACK && coverage >= 56 {
595                INK_DIM // soft gray fringe only on black ink (one neutral palette slot)
596            } else if coverage >= 110 {
597                ink
598            } else {
599                continue;
600            };
601            let dst_x = left + x as i32;
602            if dst_x >= 0 && dst_x < width as i32 {
603                pixels[row_base + dst_x as usize] = cell;
604            }
605        }
606    }
607}
608
609fn ttf_glyph_origin(x_origin: usize, cell_w: usize, metrics: &Metrics) -> i32 {
610    let advance = metrics.advance_width.ceil() as i32;
611    let pad = (cell_w as i32 - advance).max(0) / 2;
612    x_origin as i32 + pad + metrics.xmin
613}
614
615fn ttf_glyph_top(cell_top: usize, ascent: f32, metrics: &Metrics) -> i32 {
616    (cell_top as f32 + ascent - metrics.height as f32 - metrics.ymin as f32).round() as i32
617}
618
619/// Rasterize `text` onto a `width` x `height` palette-indexed bitmap on the
620/// grid's cell box, row-major with no word wrap. Glyphs keep their natural
621/// size with the baseline at the font's ascent from the cell top, so a cell
622/// taller than the font pads below the baseline (the "8on16" shapes). Each
623/// text line is printed `grid.repeat` times; copies after the first sit on
624/// the highlight band. Ink cycles through six hues at sentence boundaries
625/// (terminator in `.!?` followed by a space or full block) unless `black_ink`
626/// pins it to black; `U+000E`/`U+000F` toggle dim gray ink without occupying a
627/// cell, and dim wins over both variants. `U+2588` fills its whole cell with
628/// pitch-black ink, ignoring hue and dim state. Characters beyond
629/// `cols * rows` are ignored; code points missing from the bitmap font draw
630/// through the embedded Silver TrueType fallback when it has a glyph.
631fn render_bitmap(
632    text: &str,
633    width: usize,
634    height: usize,
635    font: &Font,
636    grid: &Grid,
637    black_ink: bool,
638) -> Vec<u8> {
639    let mut pixels = vec![0u8; width * height]; // 0 = white background
640    let capacity = grid.cols * grid.rows;
641    if capacity == 0 {
642        return pixels;
643    }
644    fill_repeat_bands(&mut pixels, width, height, grid);
645    let codes: Vec<u32> = text.chars().map(|ch| ch as u32).collect();
646    let narrow_px = ttf_pixel_size(&FONT_SILVER, grid);
647    let wide_px = ttf_wide_pixel_size(&FONT_SILVER, grid);
648    let mut fallback_cache = HashMap::new();
649    let mut sentence = 0usize;
650    let mut dim = false;
651    let mut cursor = 0usize;
652    for i in 0..codes.len() {
653        if cursor >= capacity {
654            break;
655        }
656        let code = codes[i];
657        match code {
658            DIM_ON => {
659                dim = true;
660                continue;
661            }
662            DIM_OFF => {
663                dim = false;
664                continue;
665            }
666            _ => {}
667        }
668        let ink = if dim {
669            INK_DIM
670        } else if black_ink {
671            INK_BLACK
672        } else {
673            (1 + sentence % INK_COLORS) as u8
674        };
675        if matches!(code, 0x2e | 0x21 | 0x3f)
676            && matches!(codes.get(i + 1), Some(&(0x20 | FULL_BLOCK)))
677        {
678            sentence += 1;
679        }
680        let Some((at, units, next)) = place_cell(cursor, grid.cols, code, true) else {
681            continue;
682        };
683        cursor = next;
684        if at >= capacity {
685            break;
686        }
687        let row = at / grid.cols;
688        let col = at - row * grid.cols;
689        if code == FULL_BLOCK {
690            fill_cell(
691                &mut pixels,
692                width,
693                height,
694                grid,
695                col * grid.cell_w,
696                row,
697                INK_BLACK,
698            );
699            continue;
700        }
701        if let Some(glyph) = font.glyphs.get(&code) {
702            if glyph.rows.is_empty() {
703                continue;
704            }
705            let left = (col * grid.cell_w) as i32 + glyph.xoff;
706            for copy in 0..grid.repeat {
707                let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32;
708                let top = cell_top + font.ascent - glyph.h - glyph.yoff;
709                blit_glyph(&mut pixels, width, height, glyph, left, top, ink);
710            }
711        } else if let Some(ch) = char::from_u32(code) {
712            let px = if units == 2 { wide_px } else { narrow_px };
713            let Some(glyph) = cached_ttf_glyph(&mut fallback_cache, &FONT_SILVER, ch, px) else {
714                continue;
715            };
716            let span = units * grid.cell_w;
717            let left = ttf_glyph_origin(col * grid.cell_w, span, &glyph.metrics);
718            for copy in 0..grid.repeat {
719                let cell_top = (row * grid.repeat + copy) * grid.cell_h;
720                let top = ttf_glyph_top(cell_top, font.ascent as f32, &glyph.metrics);
721                blit_ttf_glyph_indexed(&mut pixels, width, height, glyph, left, top, ink);
722            }
723        }
724    }
725    pixels
726}
727
728fn render_ttf_rgb(
729    text: &str,
730    width: usize,
731    height: usize,
732    font: &TtfFont,
733    grid: &Grid,
734    black_ink: bool,
735) -> Vec<u8> {
736    let mut pixels = vec![255u8; width * height * 3];
737    let capacity = grid.cols * grid.rows;
738    if capacity == 0 {
739        return pixels;
740    }
741    fill_repeat_bands_rgb(&mut pixels, width, height, grid);
742    let px = ttf_pixel_size(font, grid);
743    let ascent = ttf_ascent(font, px);
744    let codes: Vec<char> = text.chars().collect();
745    let mut cache = HashMap::new();
746    let mut sentence = 0usize;
747    let mut dim = false;
748    let mut cell = 0usize;
749    for i in 0..codes.len() {
750        if cell >= capacity {
751            break;
752        }
753        let ch = codes[i];
754        let code = ch as u32;
755        match code {
756            DIM_ON => {
757                dim = true;
758                continue;
759            }
760            DIM_OFF => {
761                dim = false;
762                continue;
763            }
764            _ => {}
765        }
766        let ink = if dim {
767            INK_DIM
768        } else if black_ink {
769            INK_BLACK
770        } else {
771            (1 + sentence % INK_COLORS) as u8
772        };
773        if matches!(code, 0x2e | 0x21 | 0x3f)
774            && matches!(
775                codes.get(i + 1).map(|next| *next as u32),
776                Some(0x20 | FULL_BLOCK)
777            )
778        {
779            sentence += 1;
780        }
781        let row = cell / grid.cols;
782        let col = cell - row * grid.cols;
783        cell += 1;
784        if code == FULL_BLOCK {
785            fill_cell_rgb(
786                &mut pixels,
787                width,
788                height,
789                grid,
790                col * grid.cell_w,
791                row,
792                INK_BLACK,
793            );
794            continue;
795        }
796        let Some(glyph) = cached_ttf_glyph(&mut cache, font, ch, px) else {
797            continue;
798        };
799        let left = ttf_glyph_origin(col * grid.cell_w, grid.cell_w, &glyph.metrics);
800        for copy in 0..grid.repeat {
801            let cell_top = (row * grid.repeat + copy) * grid.cell_h;
802            let top = ttf_glyph_top(cell_top, ascent, &glyph.metrics);
803            blit_ttf_glyph(&mut pixels, width, height, glyph, left, top, ink);
804        }
805    }
806    pixels
807}
808
809/// Character cells between the two doc columns (eval `exp14` layout).
810const GUTTER: usize = 3;
811
812/// Rasterize pre-wrapped text as a two-column "doc" page onto a `width` x
813/// `height` palette-indexed bitmap. Input splits on `'\n'` (zero-width): line
814/// `li` lands at column `li / rows`, row `li % rows`; each column is
815/// `(cols - GUTTER) / 2` cells wide, the second starts `col_w + GUTTER` cells
816/// in, and no rule is drawn between them. Lines longer than the column width
817/// are clipped (the TypeScript caller pre-wraps; clipping is the overflow
818/// guard) and lines past the second column are ignored. Sentence hues advance
819/// on a terminator in `.!?` followed by a space, newline, *or* full block;
820/// dim toggles, repeat bands, and the `U+2588` black cell fill behave exactly
821/// as in the grid renderer.
822fn render_doc_bitmap(
823    text: &str,
824    width: usize,
825    height: usize,
826    font: &Font,
827    grid: &Grid,
828    black_ink: bool,
829) -> Vec<u8> {
830    let mut pixels = vec![0u8; width * height]; // 0 = white background
831    let col_w = grid.cols.saturating_sub(GUTTER) / 2;
832    if col_w == 0 || grid.rows == 0 {
833        return pixels;
834    }
835    fill_repeat_bands(&mut pixels, width, height, grid);
836    let codes: Vec<u32> = text.chars().map(|ch| ch as u32).collect();
837    let narrow_px = ttf_pixel_size(&FONT_SILVER, grid);
838    let wide_px = ttf_wide_pixel_size(&FONT_SILVER, grid);
839    let mut fallback_cache = HashMap::new();
840    let mut sentence = 0usize;
841    let mut dim = false;
842    let mut line = 0usize;
843    let mut col = 0usize;
844    for i in 0..codes.len() {
845        let code = codes[i];
846        match code {
847            DIM_ON => {
848                dim = true;
849                continue;
850            }
851            DIM_OFF => {
852                dim = false;
853                continue;
854            }
855            0x0a => {
856                line += 1;
857                col = 0;
858                if line >= grid.rows * 2 {
859                    break; // past the second column; the caller paginates
860                }
861                continue;
862            }
863            _ => {}
864        }
865        let ink = if dim {
866            INK_DIM
867        } else if black_ink {
868            INK_BLACK
869        } else {
870            (1 + sentence % INK_COLORS) as u8
871        };
872        if matches!(code, 0x2e | 0x21 | 0x3f)
873            && matches!(codes.get(i + 1), Some(&(0x20 | 0x0a | FULL_BLOCK)))
874        {
875            sentence += 1;
876        }
877        let units = cell_units(code, true);
878        let mut cell = col;
879        if units == 2 && col_w >= 2 && cell == col_w - 1 {
880            cell += 1; // pad: never split a wide glyph across the column edge
881        }
882        col = cell + units;
883        if cell + units > col_w {
884            continue; // clip past the column width
885        }
886        let column = line / grid.rows;
887        let row = line - column * grid.rows;
888        let x_origin = column * (col_w + GUTTER) * grid.cell_w;
889        if code == FULL_BLOCK {
890            fill_cell(
891                &mut pixels,
892                width,
893                height,
894                grid,
895                x_origin + cell * grid.cell_w,
896                row,
897                INK_BLACK,
898            );
899            continue;
900        }
901        if let Some(glyph) = font.glyphs.get(&code) {
902            if glyph.rows.is_empty() {
903                continue;
904            }
905            let left = (x_origin + cell * grid.cell_w) as i32 + glyph.xoff;
906            for copy in 0..grid.repeat {
907                let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32;
908                let top = cell_top + font.ascent - glyph.h - glyph.yoff;
909                blit_glyph(&mut pixels, width, height, glyph, left, top, ink);
910            }
911        } else if let Some(ch) = char::from_u32(code) {
912            let px = if units == 2 { wide_px } else { narrow_px };
913            let Some(glyph) = cached_ttf_glyph(&mut fallback_cache, &FONT_SILVER, ch, px) else {
914                continue;
915            };
916            let span = units * grid.cell_w;
917            let left = ttf_glyph_origin(x_origin + cell * grid.cell_w, span, &glyph.metrics);
918            for copy in 0..grid.repeat {
919                let cell_top = (row * grid.repeat + copy) * grid.cell_h;
920                let top = ttf_glyph_top(cell_top, font.ascent as f32, &glyph.metrics);
921                blit_ttf_glyph_indexed(&mut pixels, width, height, glyph, left, top, ink);
922            }
923        }
924    }
925    pixels
926}
927
928fn render_ttf_doc_rgb(
929    text: &str,
930    width: usize,
931    height: usize,
932    font: &TtfFont,
933    grid: &Grid,
934    black_ink: bool,
935) -> Vec<u8> {
936    let mut pixels = vec![255u8; width * height * 3];
937    let col_w = grid.cols.saturating_sub(GUTTER) / 2;
938    if col_w == 0 || grid.rows == 0 {
939        return pixels;
940    }
941    fill_repeat_bands_rgb(&mut pixels, width, height, grid);
942    let px = ttf_pixel_size(font, grid);
943    let ascent = ttf_ascent(font, px);
944    let codes: Vec<char> = text.chars().collect();
945    let mut cache = HashMap::new();
946    let mut sentence = 0usize;
947    let mut dim = false;
948    let mut line = 0usize;
949    let mut col = 0usize;
950    for i in 0..codes.len() {
951        let ch = codes[i];
952        let code = ch as u32;
953        match code {
954            DIM_ON => {
955                dim = true;
956                continue;
957            }
958            DIM_OFF => {
959                dim = false;
960                continue;
961            }
962            0x0a => {
963                line += 1;
964                col = 0;
965                if line >= grid.rows * 2 {
966                    break;
967                }
968                continue;
969            }
970            _ => {}
971        }
972        let ink = if dim {
973            INK_DIM
974        } else if black_ink {
975            INK_BLACK
976        } else {
977            (1 + sentence % INK_COLORS) as u8
978        };
979        if matches!(code, 0x2e | 0x21 | 0x3f)
980            && matches!(
981                codes.get(i + 1).map(|next| *next as u32),
982                Some(0x20 | 0x0a | FULL_BLOCK)
983            )
984        {
985            sentence += 1;
986        }
987        let cell = col;
988        col += 1;
989        if cell >= col_w {
990            continue;
991        }
992        let column = line / grid.rows;
993        let row = line - column * grid.rows;
994        let x_origin = (column * (col_w + GUTTER) + cell) * grid.cell_w;
995        if code == FULL_BLOCK {
996            fill_cell_rgb(&mut pixels, width, height, grid, x_origin, row, INK_BLACK);
997            continue;
998        }
999        let Some(glyph) = cached_ttf_glyph(&mut cache, font, ch, px) else {
1000            continue;
1001        };
1002        let left = ttf_glyph_origin(x_origin, grid.cell_w, &glyph.metrics);
1003        for copy in 0..grid.repeat {
1004            let cell_top = (row * grid.repeat + copy) * grid.cell_h;
1005            let top = ttf_glyph_top(cell_top, ascent, &glyph.metrics);
1006            blit_ttf_glyph(&mut pixels, width, height, glyph, left, top, ink);
1007        }
1008    }
1009    pixels
1010}
1011
1012// ============================================================================
1013// Lanczos3 resampling (stretch shapes)
1014// ============================================================================
1015
1016fn lanczos3(x: f32) -> f32 {
1017    let x = x.abs();
1018    if x < 1e-6 {
1019        return 1.0;
1020    }
1021    if x >= 3.0 {
1022        return 0.0;
1023    }
1024    let pix = PI * x;
1025    (pix.sin() / pix) * ((pix / 3.0).sin() / (pix / 3.0))
1026}
1027
1028/// Per-output-pixel kernel contributions for one axis, PIL-convention
1029/// (`center = (i + 0.5) * scale`, kernel stretched by `max(scale, 1)`,
1030/// weights normalized).
1031fn contributions(src_len: usize, dst_len: usize) -> Vec<(usize, Vec<f32>)> {
1032    let scale = src_len as f32 / dst_len as f32;
1033    let filt_scale = scale.max(1.0);
1034    let support = 3.0 * filt_scale;
1035    let mut out = Vec::with_capacity(dst_len);
1036    for i in 0..dst_len {
1037        let center = (i as f32 + 0.5) * scale;
1038        let begin = ((center - support) as isize).max(0) as usize;
1039        let end = ((center + support).ceil() as usize).min(src_len);
1040        let mut weights = Vec::with_capacity(end - begin);
1041        let mut total = 0.0f32;
1042        for x in begin..end {
1043            let w = lanczos3((x as f32 + 0.5 - center) / filt_scale);
1044            weights.push(w);
1045            total += w;
1046        }
1047        if total != 0.0 {
1048            for w in &mut weights {
1049                *w /= total;
1050            }
1051        }
1052        out.push((begin, weights));
1053    }
1054    out
1055}
1056
1057/// Separable Lanczos3 resize of an interleaved RGB f32 buffer.
1058fn resize_rgb(src: &[f32], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f32> {
1059    let horiz = contributions(sw, dw);
1060    let mut tmp = vec![0f32; dw * sh * 3];
1061    for y in 0..sh {
1062        let src_row = &src[y * sw * 3..(y + 1) * sw * 3];
1063        let dst_row = &mut tmp[y * dw * 3..(y + 1) * dw * 3];
1064        for (x, (begin, weights)) in horiz.iter().enumerate() {
1065            let mut acc = [0f32; 3];
1066            for (k, &w) in weights.iter().enumerate() {
1067                let s = (begin + k) * 3;
1068                acc[0] = src_row[s].mul_add(w, acc[0]);
1069                acc[1] = src_row[s + 1].mul_add(w, acc[1]);
1070                acc[2] = src_row[s + 2].mul_add(w, acc[2]);
1071            }
1072            dst_row[x * 3..x * 3 + 3].copy_from_slice(&acc);
1073        }
1074    }
1075    let vert = contributions(sh, dh);
1076    let mut out = vec![0f32; dw * dh * 3];
1077    for (y, (begin, weights)) in vert.iter().enumerate() {
1078        let dst_row = &mut out[y * dw * 3..(y + 1) * dw * 3];
1079        for (k, &w) in weights.iter().enumerate() {
1080            let src_row = &tmp[(begin + k) * dw * 3..(begin + k + 1) * dw * 3];
1081            for (d, &s) in dst_row.iter_mut().zip(src_row) {
1082                *d = s.mul_add(w, *d);
1083            }
1084        }
1085    }
1086    out
1087}
1088
1089// ============================================================================
1090// PNG encoding
1091// ============================================================================
1092
1093/// Pack one-byte-per-pixel palette indices into `bits`-per-pixel PNG
1094/// scanline data (big-endian within each byte), remapping each global
1095/// palette index through `remap` to its per-frame slot on the way.
1096fn pack_bits(
1097    pixels: &[u8],
1098    width: usize,
1099    height: usize,
1100    bits: usize,
1101    remap: &[u8; PALETTE.len()],
1102) -> Vec<u8> {
1103    let per = 8 / bits;
1104    let row_bytes = width.div_ceil(per);
1105    let mut packed = vec![0u8; row_bytes * height];
1106    for y in 0..height {
1107        let src = &pixels[y * width..(y + 1) * width];
1108        let dst = &mut packed[y * row_bytes..(y + 1) * row_bytes];
1109        for (x, &px) in src.iter().enumerate() {
1110            dst[x / per] |= remap[px as usize] << (bits * (per - 1 - x % per));
1111        }
1112    }
1113    packed
1114}
1115
1116/// Encode a palette-indexed bitmap as an indexed PNG with `None` row
1117/// filtering (the glyph bitmap is already minimal-entropy; filtering costs
1118/// encode time without helping deflate).
1119///
1120/// The palette is narrowed to the colors the frame actually uses and the bit
1121/// depth follows: a plain `bw` frame (background + ink) packs 1-bit rows, a
1122/// dim/banded frame 2-bit, sentence-hue frames 4-bit — bw frames shed
1123/// another ~half of the pre-deflate stream vs the fixed 4-bit layout.
1124fn encode_indexed_png(
1125    pixels: &[u8],
1126    width: usize,
1127    height: usize,
1128    compression: png::Compression,
1129) -> Result<Vec<u8>, SnapcompactError> {
1130    let mut used = [false; PALETTE.len()];
1131    for &px in pixels {
1132        used[px as usize] = true;
1133    }
1134    let mut remap = [0u8; PALETTE.len()];
1135    let mut palette = Vec::with_capacity(PALETTE.len() * 3);
1136    let mut count = 0u8;
1137    for (global, &is_used) in used.iter().enumerate() {
1138        if is_used {
1139            remap[global] = count;
1140            count += 1;
1141            palette.extend_from_slice(&PALETTE[global]);
1142        }
1143    }
1144    let (depth, bits) = match count {
1145        0..=2 => (png::BitDepth::One, 1),
1146        3..=4 => (png::BitDepth::Two, 2),
1147        _ => (png::BitDepth::Four, 4),
1148    };
1149    let mut out = Vec::new();
1150    let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
1151    encoder.set_color(png::ColorType::Indexed);
1152    encoder.set_depth(depth);
1153    encoder.set_palette(Cow::Owned(palette));
1154    encoder.set_compression(compression);
1155    // MUST come after `set_compression`, which resets the filter to the
1156    // compression level's default (`Adaptive` for `Balanced`/`High`).
1157    encoder.set_filter(png::FilterType::NoFilter);
1158    let mut writer = encoder
1159        .write_header()
1160        .map_err(|err| SnapcompactError::Render(format!("Failed to write PNG header: {err}")))?;
1161    writer
1162        .write_image_data(&pack_bits(pixels, width, height, bits, &remap))
1163        .map_err(|err| SnapcompactError::Render(format!("Failed to write PNG data: {err}")))?;
1164    writer
1165        .finish()
1166        .map_err(|err| SnapcompactError::Render(format!("Failed to finish PNG stream: {err}")))?;
1167    Ok(out)
1168}
1169
1170/// Encode an interleaved RGB8 buffer as PNG. Stretched frames are
1171/// continuous-tone, so adaptive filtering (the `Balanced` default) helps.
1172fn encode_rgb_png(
1173    pixels: &[u8],
1174    width: usize,
1175    height: usize,
1176    compression: png::Compression,
1177) -> Result<Vec<u8>, SnapcompactError> {
1178    let mut out = Vec::new();
1179    let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
1180    encoder.set_color(png::ColorType::Rgb);
1181    encoder.set_depth(png::BitDepth::Eight);
1182    encoder.set_compression(compression);
1183    let mut writer = encoder
1184        .write_header()
1185        .map_err(|err| SnapcompactError::Render(format!("Failed to write PNG header: {err}")))?;
1186    writer
1187        .write_image_data(pixels)
1188        .map_err(|err| SnapcompactError::Render(format!("Failed to write PNG data: {err}")))?;
1189    writer
1190        .finish()
1191        .map_err(|err| SnapcompactError::Render(format!("Failed to finish PNG stream: {err}")))?;
1192    Ok(out)
1193}
1194
1195// ============================================================================
1196// Entry point
1197// ============================================================================
1198
1199/// Shape options for one snapcompact frame.
1200
1201#[derive(Default, Clone)]
1202pub struct SnapcompactRenderOptions {
1203    /// Frame width in pixels; also bounds the grid rows
1204    /// (`floor(size/cellHeight/lineRepeat)`). Output height hugs the rows the
1205    /// text actually uses instead of padding to a square.
1206    pub size: u32,
1207    /// Bundled font: `"5x8"`, `"6x12"`, `"8x13"` (X.org BDF), `"8x8"`
1208    /// (unscii-8), or `"silver"` (embedded TrueType). Default `"5x8"`.
1209    pub font: Option<String>,
1210    /// Target cell advance in pixels. Differing from the font's natural cell
1211    /// triggers the Lanczos stretch path. Default: font natural width.
1212    pub cell_width: Option<u32>,
1213    /// Target cell pitch in pixels. Default: font natural height.
1214    pub cell_height: Option<u32>,
1215    /// Ink variant: `"sent"` (six-hue sentence cycling) or `"bw"` (black).
1216    /// Default `"sent"`.
1217    pub variant: Option<String>,
1218    /// Print each text line this many times; copies after the first sit on a
1219    /// pale highlight band. Default 1.
1220    pub line_repeat: Option<u32>,
1221    /// Stretch behavior. Unset: auto — Lanczos-stretch whenever the target
1222    /// cell differs from the font's natural cell. `false`: never stretch —
1223    /// render indexed with glyphs at natural size on the requested cell box
1224    /// (e.g. 8x13 glyphs on an 8x16 pitch, the "8on16" shapes). `true`: force
1225    /// the stretch path (identical to auto; natural cells render indexed).
1226    pub stretch: Option<bool>,
1227    /// Layout columns: `1` (default) row-major grid; `2` two newspaper "doc"
1228    /// columns of pre-wrapped newline-separated lines.
1229    pub columns: Option<u32>,
1230}
1231
1232/// Return the subset of `chars` that the named snapcompact font can render.
1233///
1234/// The TypeScript normalizer uses this to keep Unicode text intact only when
1235/// considered renderable because they are interpreted outside font lookup.
1236pub fn snapcompact_supported_chars(
1237    font: String,
1238    chars: String,
1239) -> Result<String, SnapcompactError> {
1240    let font = resolve_font(&font).ok_or_else(|| {
1241        SnapcompactError::Render(format!(
1242            "Unknown snapcompact font {font:?}: expected \"5x8\", \"8x8\", \"6x12\", \"8x13\", or \
1243			 \"silver\""
1244        ))
1245    })?;
1246    let mut supported = String::new();
1247    for ch in chars.chars() {
1248        if matches!(ch as u32, DIM_ON | DIM_OFF | FULL_BLOCK | 0x0a) || font.supports(ch as u32) {
1249            supported.push(ch);
1250        }
1251    }
1252    Ok(supported)
1253}
1254
1255/// Render one snapcompact frame on a libuv worker: print pre-normalized text
1256/// onto a `size`-wide bitmap and encode it as PNG.
1257///
1258/// The bitmap height hugs the rows the text actually occupies
1259/// (`usedRows * lineRepeat * cellHeight`), so a partially filled frame never
1260/// pays for blank padding rows. The glyph grid holds `floor(size/cellWidth) *
1261/// floor(size/cellHeight/lineRepeat)` characters; input beyond that is ignored.
1262/// Native-cell bitmap-font shapes encode as indexed PNG; stretched bitmap-font
1263/// shapes (target cell != font cell) encode as RGB. TrueType shapes encode RGB
1264/// directly from grayscale coverage.
1265/// `stretch: false` pins bitmap fonts to the indexed path, printing
1266/// natural-size glyphs on the requested cell box; `columns: 2` flows
1267/// pre-wrapped newline-separated lines down two newspaper columns.
1268/// `U+000E`/`U+000F` in `text` toggle dim-gray ink spans without occupying a
1269/// cell.
1270/// Returns a promise for the PNG encoded as base64, created as a one-byte
1271/// (Latin-1) JS string straight from native code — no `Uint8Array` hop or
1272pub fn render_snapcompact_png(
1273    text: String,
1274    options: SnapcompactRenderOptions,
1275) -> Result<Vec<u8>, SnapcompactError> {
1276    render_snapcompact_png_sync(text, options)
1277}
1278
1279fn render_snapcompact_png_sync(
1280    text: String,
1281    options: SnapcompactRenderOptions,
1282) -> Result<Vec<u8>, SnapcompactError> {
1283    let size = options.size;
1284    if size == 0 || size > MAX_FRAME_SIZE {
1285        return Err(SnapcompactError::Render(format!(
1286            "Invalid frame size {size}: expected 1..={MAX_FRAME_SIZE}"
1287        )));
1288    }
1289    let font_name = options.font.as_deref().unwrap_or("5x8");
1290    let font = resolve_font(font_name).ok_or_else(|| {
1291		SnapcompactError::Render(format!(
1292			"Unknown snapcompact font {font_name:?}: expected \"5x8\", \"8x8\", \"6x12\", \"8x13\", \
1293			 or \"silver\""
1294		))
1295	})?;
1296    let black_ink = match options.variant.as_deref().unwrap_or("sent") {
1297        "sent" => false,
1298        "bw" => true,
1299        other => {
1300            return Err(SnapcompactError::Render(format!(
1301                "Unknown snapcompact variant {other:?}: expected \"sent\" or \"bw\""
1302            )));
1303        }
1304    };
1305    let natural_w = font.cell_w();
1306    let natural_h = font.cell_h();
1307    let target_w = options.cell_width.unwrap_or(natural_w as u32).max(1) as usize;
1308    let target_h = options.cell_height.unwrap_or(natural_h as u32).max(1) as usize;
1309    let repeat = options.line_repeat.unwrap_or(1).max(1) as usize;
1310    let columns = options.columns.unwrap_or(1);
1311    if !matches!(columns, 1 | 2) {
1312        return Err(SnapcompactError::Render(format!(
1313            "Invalid snapcompact columns {columns}: expected 1 or 2"
1314        )));
1315    }
1316    let doc = columns == 2;
1317    let size = size as usize;
1318    let grid = Grid {
1319        cols: size / target_w,
1320        rows: size / target_h / repeat,
1321        repeat,
1322        cell_w: target_w,
1323        cell_h: target_h,
1324    };
1325    if grid.cols == 0 || grid.rows == 0 {
1326        return Err(SnapcompactError::Render(format!(
1327            "Frame size {size} cannot fit a {target_w}x{target_h} cell grid (repeat {repeat})"
1328        )));
1329    }
1330    // Tight canvas: width stays the frame edge (the reading geometry the
1331    // caller derives cols from), height hugs the rows the text needs. Bitmap
1332    // shapes draw wide code points through Silver across two cells, so they
1333    // count double here; the square-celled Silver shape keeps one cell each.
1334    let wide_cells = matches!(font, RenderFont::Bitmap(_));
1335    let used = used_rows(&text, &grid, doc, wide_cells);
1336    let height = used * grid.repeat * grid.cell_h;
1337
1338    match font {
1339        RenderFont::Ttf(font) => {
1340            let pixels = if doc {
1341                render_ttf_doc_rgb(&text, size, height, font, &grid, black_ink)
1342            } else {
1343                render_ttf_rgb(&text, size, height, font, &grid, black_ink)
1344            };
1345            Ok(encode_rgb_png(
1346                &pixels,
1347                size,
1348                height,
1349                png::Compression::Best,
1350            )?)
1351        }
1352        RenderFont::Bitmap(font) => {
1353            let stretch =
1354                options.stretch != Some(false) && (target_w, target_h) != (natural_w, natural_h);
1355            if !stretch {
1356                // Indexed path: rasterize straight onto the frame at the requested
1357                // cell box (the natural cell, or natural glyphs on a padded pitch
1358                // when `stretch: false`).
1359                let pixels = if doc {
1360                    render_doc_bitmap(&text, size, height, font, &grid, black_ink)
1361                } else {
1362                    render_bitmap(&text, size, height, font, &grid, black_ink)
1363                };
1364                return encode_indexed_png(&pixels, size, height, png::Compression::Best);
1365            }
1366
1367            // Stretch shape: rasterize at the font's natural cell on a tight canvas
1368            // (layout stays in character cells from the target grid), Lanczos3-
1369            // resample to the target cell, paste onto the white frame.
1370            let native = Grid {
1371                cell_w: natural_w,
1372                cell_h: natural_h,
1373                ..grid
1374            };
1375            let src_w = grid.cols * natural_w;
1376            let src_h = used * grid.repeat * natural_h;
1377            let dst_w = grid.cols * target_w;
1378            let dst_h = used * grid.repeat * target_h;
1379            let indexed = if doc {
1380                render_doc_bitmap(&text, src_w, src_h, font, &native, black_ink)
1381            } else {
1382                render_bitmap(&text, src_w, src_h, font, &native, black_ink)
1383            };
1384            let mut rgb = vec![0f32; src_w * src_h * 3];
1385            for (dst, &idx) in rgb.chunks_exact_mut(3).zip(&indexed) {
1386                let [r, g, b] = PALETTE[idx as usize];
1387                dst[0] = f32::from(r);
1388                dst[1] = f32::from(g);
1389                dst[2] = f32::from(b);
1390            }
1391            let resized = resize_rgb(&rgb, src_w, src_h, dst_w, dst_h);
1392            let mut frame = vec![255u8; size * dst_h * 3];
1393            for y in 0..dst_h {
1394                let src_row = &resized[y * dst_w * 3..(y + 1) * dst_w * 3];
1395                let dst_row = &mut frame[y * size * 3..];
1396                for (d, &s) in dst_row[..dst_w.min(size) * 3].iter_mut().zip(src_row) {
1397                    *d = s.round().clamp(0.0, 255.0) as u8;
1398                }
1399            }
1400            Ok(encode_rgb_png(&frame, size, dst_h, png::Compression::Best)?)
1401        }
1402    }
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407    use super::*;
1408
1409    fn opts(size: u32) -> SnapcompactRenderOptions {
1410        SnapcompactRenderOptions {
1411            size,
1412            ..Default::default()
1413        }
1414    }
1415
1416    #[test]
1417    fn fonts_parse_ascii_coverage() {
1418        for (font, ascent) in [(&*FONT_5X8, 7), (&*FONT_8X8, 7)] {
1419            assert_eq!(font.ascent, ascent);
1420            // Every printable ASCII char must have a glyph.
1421            for cp in 0x20u32..0x7f {
1422                assert!(
1423                    font.glyphs.contains_key(&cp),
1424                    "missing glyph for U+{cp:04X}"
1425                );
1426            }
1427        }
1428    }
1429
1430    #[test]
1431    fn silver_font_covers_cjk_scripts() {
1432        assert!(
1433            FONT_SILVER.supported.contains(&'こ'),
1434            "Silver must cover Japanese kana"
1435        );
1436        assert!(
1437            FONT_SILVER.supported.contains(&'你'),
1438            "Silver must cover Han text"
1439        );
1440        assert!(
1441            FONT_SILVER.supported.contains(&'안'),
1442            "Silver must cover Hangul syllables"
1443        );
1444    }
1445
1446    #[test]
1447    fn bitmap_inks_sentences_and_caps_capacity() {
1448        // 40px -> 8 cols x 5 rows = 40 cells (5x8 font).
1449        let grid = Grid {
1450            cols: 8,
1451            rows: 5,
1452            repeat: 1,
1453            cell_w: 5,
1454            cell_h: 8,
1455        };
1456        let pixels = render_bitmap("Hi. Ok.", 40, 40, &FONT_5X8, &grid, false);
1457        let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
1458        assert!(inks.contains(&1), "first sentence should use ink 1");
1459        assert!(inks.contains(&2), "second sentence should use ink 2");
1460        assert!(!inks.contains(&3), "no third sentence ink expected");
1461
1462        // Overflow input renders without panicking and stays in-bounds.
1463        let overflow = render_bitmap(&"x".repeat(100), 40, 40, &FONT_5X8, &grid, false);
1464        assert_eq!(overflow.len(), 40 * 40);
1465    }
1466
1467    #[test]
1468    fn bw_variant_prints_black_only() {
1469        let grid = Grid {
1470            cols: 8,
1471            rows: 8,
1472            repeat: 1,
1473            cell_w: 8,
1474            cell_h: 8,
1475        };
1476        let pixels = render_bitmap("Hi. Ok.", 64, 64, &FONT_8X8, &grid, true);
1477        let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
1478        assert!(!inks.is_empty());
1479        assert!(
1480            inks.iter().all(|&p| p == INK_BLACK),
1481            "bw must ink only black"
1482        );
1483    }
1484
1485    #[test]
1486    fn dim_markers_toggle_gray_without_consuming_cells() {
1487        let grid = Grid {
1488            cols: 8,
1489            rows: 8,
1490            repeat: 1,
1491            cell_w: 8,
1492            cell_h: 8,
1493        };
1494        let pixels = render_bitmap("\u{e}AB\u{f}CD", 64, 64, &FONT_8X8, &grid, true);
1495        let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
1496        assert!(inks.contains(&INK_DIM), "dim span must ink gray");
1497        assert!(
1498            inks.contains(&INK_BLACK),
1499            "post-span text must return to black"
1500        );
1501        // Markers are zero-width: glyphs land in the same cells as without them.
1502        let plain = render_bitmap("ABCD", 64, 64, &FONT_8X8, &grid, true);
1503        for (i, (a, b)) in pixels.iter().zip(&plain).enumerate() {
1504            assert_eq!(
1505                *a != 0,
1506                *b != 0,
1507                "cell layout must ignore markers (pixel {i})"
1508            );
1509        }
1510    }
1511
1512    #[test]
1513    fn line_repeat_duplicates_rows_on_highlight_bands() {
1514        // 64px, 8x8 font, repeat 2 -> 8 cols x 4 unique rows.
1515        let grid = Grid {
1516            cols: 8,
1517            rows: 4,
1518            repeat: 2,
1519            cell_w: 8,
1520            cell_h: 8,
1521        };
1522        let pixels = render_bitmap("ABCDEFGH", 64, 64, &FONT_8X8, &grid, true);
1523        // Copy band (rows 8..16) carries the highlight background.
1524        assert!(
1525            pixels[9 * 64..10 * 64].contains(&BG_REPEAT),
1526            "duplicate band must be highlighted"
1527        );
1528        // Identical glyph ink in both copies: compare full 8-row bands modulo
1529        // background.
1530        for y in 0..8 {
1531            for x in 0..64 {
1532                let a = pixels[y * 64 + x];
1533                let b = pixels[(y + 8) * 64 + x];
1534                assert_eq!(
1535                    a == INK_BLACK,
1536                    b == INK_BLACK,
1537                    "copy ink mismatch at ({x},{y})"
1538                );
1539            }
1540        }
1541    }
1542
1543    #[test]
1544    fn full_block_fills_cell_pitch_black() {
1545        let grid = Grid {
1546            cols: 8,
1547            rows: 4,
1548            repeat: 2,
1549            cell_w: 8,
1550            cell_h: 8,
1551        };
1552        // The block's black fill beats both the dim span and the sent hues.
1553        let pixels = render_bitmap("\u{e}a\u{2588}b\u{f}", 64, 64, &FONT_8X8, &grid, false);
1554        for copy in 0..2 {
1555            for y in copy * 8..(copy + 1) * 8 {
1556                for x in 8..16 {
1557                    assert_eq!(
1558                        pixels[y * 64 + x],
1559                        INK_BLACK,
1560                        "block pixel ({x},{y}) must be black"
1561                    );
1562                }
1563            }
1564        }
1565        assert!(pixels.contains(&INK_DIM), "neighbours keep their dim ink");
1566        let hued = render_bitmap("Hi.\u{2588}Ok.", 64, 64, &FONT_8X8, &grid, false);
1567        assert!(
1568            hued.contains(&2),
1569            "block must advance the sentence hue like a space"
1570        );
1571    }
1572
1573    #[test]
1574    fn doc_full_block_fills_cell() {
1575        // col_w = (13 - GUTTER) / 2 = 5; block at line 0, cell 1 -> x 8..16.
1576        let grid = Grid {
1577            cols: 13,
1578            rows: 2,
1579            repeat: 1,
1580            cell_w: 8,
1581            cell_h: 8,
1582        };
1583        let pixels = render_doc_bitmap("a\u{2588}b\nc", 104, 16, &FONT_8X8, &grid, true);
1584        for y in 0..8 {
1585            for x in 8..16 {
1586                assert_eq!(
1587                    pixels[y * 104 + x],
1588                    INK_BLACK,
1589                    "block pixel ({x},{y}) must be black"
1590                );
1591            }
1592        }
1593    }
1594
1595    /// Test helper: PNG bytes come back directly (no base64 hop).
1596    fn png_bytes(encoded: Vec<u8>) -> Vec<u8> {
1597        encoded
1598    }
1599
1600    #[test]
1601    fn render_native_is_indexed_and_stretch_is_rgb() {
1602        let native = png_bytes(
1603            render_snapcompact_png_sync(
1604                "Hello world. Again.".into(),
1605                SnapcompactRenderOptions {
1606                    size: 128,
1607                    font: Some("8x8".into()),
1608                    variant: Some("bw".into()),
1609                    line_repeat: Some(2),
1610                    ..Default::default()
1611                },
1612            )
1613            .unwrap(),
1614        );
1615        // PNG color type lives at byte 25 of the IHDR: 3 = indexed.
1616        assert_eq!(native[25], 3);
1617
1618        let stretched = png_bytes(
1619            render_snapcompact_png_sync(
1620                "Hello world. Again.".into(),
1621                SnapcompactRenderOptions {
1622                    size: 128,
1623                    font: Some("8x8".into()),
1624                    cell_width: Some(6),
1625                    cell_height: Some(6),
1626                    ..Default::default()
1627                },
1628            )
1629            .unwrap(),
1630        );
1631        // 2 = truecolor RGB.
1632        assert_eq!(stretched[25], 2);
1633        let legacy = png_bytes(render_snapcompact_png_sync("Hi. Ok.".into(), opts(40)).unwrap());
1634        assert_eq!(
1635            legacy[25], 3,
1636            "default shape stays the legacy 5x8 indexed path"
1637        );
1638
1639        let silver = png_bytes(
1640            render_snapcompact_png_sync(
1641                "こんにちは 你好 안녕".into(),
1642                SnapcompactRenderOptions {
1643                    size: 128,
1644                    font: Some("silver".into()),
1645                    cell_width: Some(16),
1646                    cell_height: Some(16),
1647                    variant: Some("bw".into()),
1648                    ..Default::default()
1649                },
1650            )
1651            .unwrap(),
1652        );
1653        assert_eq!(silver[25], 2, "TrueType frames render as RGB");
1654    }
1655
1656    #[test]
1657    fn indexed_png_narrows_palette_and_bit_depth() {
1658        // IHDR bit depth lives at byte 24; PLTE length is the chunk length
1659        // word 8 bytes after the "PLTE" tag position.
1660        fn depth_and_palette(png: &[u8]) -> (u8, usize) {
1661            let tag = png
1662                .windows(4)
1663                .position(|w| w == b"PLTE")
1664                .expect("PLTE chunk");
1665            let len = u32::from_be_bytes(png[tag - 4..tag].try_into().unwrap()) as usize;
1666            (png[24], len / 3)
1667        }
1668
1669        // Plain bw, no dim/band/repeat: background + black ink = 1-bit.
1670        let bw = png_bytes(
1671            render_snapcompact_png_sync(
1672                "Hello world. Again.".into(),
1673                SnapcompactRenderOptions {
1674                    size: 128,
1675                    font: Some("8x8".into()),
1676                    variant: Some("bw".into()),
1677                    ..Default::default()
1678                },
1679            )
1680            .unwrap(),
1681        );
1682        assert_eq!(depth_and_palette(&bw), (1, 2));
1683
1684        // bw with a dim span and repeat bands: 4 colors = 2-bit.
1685        let dim = png_bytes(
1686            render_snapcompact_png_sync(
1687                "Read \u{e}the dim part\u{f} now.".into(),
1688                SnapcompactRenderOptions {
1689                    size: 128,
1690                    font: Some("8x8".into()),
1691                    variant: Some("bw".into()),
1692                    line_repeat: Some(2),
1693                    ..Default::default()
1694                },
1695            )
1696            .unwrap(),
1697        );
1698        assert_eq!(depth_and_palette(&dim), (2, 4));
1699
1700        // Sentence hues exceed 4 colors: stays 4-bit, palette still narrowed
1701        // to the inks actually printed (bg + 2 hues here).
1702        let sent = png_bytes(
1703            render_snapcompact_png_sync(
1704                "Hi. Ok.".into(),
1705                SnapcompactRenderOptions {
1706                    size: 128,
1707                    font: Some("8x8".into()),
1708                    variant: Some("sent".into()),
1709                    ..Default::default()
1710                },
1711            )
1712            .unwrap(),
1713        );
1714        let (sent_depth, sent_colors) = depth_and_palette(&sent);
1715        assert_eq!(sent_depth, 2, "two hues + bg fit 2-bit");
1716        assert_eq!(sent_colors, 3);
1717    }
1718
1719    #[test]
1720    fn rejects_bad_shapes() {
1721        assert!(render_snapcompact_png_sync("x".into(), opts(0)).is_err());
1722        assert!(
1723            render_snapcompact_png_sync(
1724                "x".into(),
1725                SnapcompactRenderOptions {
1726                    size: 64,
1727                    font: Some("9x9".into()),
1728                    ..Default::default()
1729                }
1730            )
1731            .is_err()
1732        );
1733        assert!(
1734            render_snapcompact_png_sync(
1735                "x".into(),
1736                SnapcompactRenderOptions {
1737                    size: 64,
1738                    variant: Some("zebra".into()),
1739                    ..Default::default()
1740                }
1741            )
1742            .is_err()
1743        );
1744    }
1745
1746    #[test]
1747    fn xorg_fonts_parse_and_render() {
1748        for (font, ascent, name) in [(&*FONT_6X12, 10, "6x12"), (&*FONT_8X13, 11, "8x13")] {
1749            assert_eq!(font.ascent, ascent, "{name} ascent");
1750            for cp in 0x20u32..0x7f {
1751                assert!(
1752                    font.glyphs.contains_key(&cp),
1753                    "{name} missing glyph U+{cp:04X}"
1754                );
1755            }
1756        }
1757        for (name, size) in [("6x12", 60u32), ("8x13", 104u32)] {
1758            let png = png_bytes(
1759                render_snapcompact_png_sync(
1760                    "Hello world. Again!".into(),
1761                    SnapcompactRenderOptions {
1762                        size,
1763                        font: Some(name.into()),
1764                        ..Default::default()
1765                    },
1766                )
1767                .unwrap(),
1768            );
1769            assert_eq!(png[25], 3, "{name} natural cell must encode indexed");
1770        }
1771        // Non-blank: the raster must carry glyph ink.
1772        let grid = Grid {
1773            cols: 10,
1774            rows: 5,
1775            repeat: 1,
1776            cell_w: 6,
1777            cell_h: 12,
1778        };
1779        let pixels = render_bitmap("Hello", 60, 60, &FONT_6X12, &grid, true);
1780        assert!(pixels.contains(&INK_BLACK), "6x12 must ink pixels");
1781        let grid = Grid {
1782            cols: 8,
1783            rows: 8,
1784            repeat: 1,
1785            cell_w: 8,
1786            cell_h: 13,
1787        };
1788        let pixels = render_bitmap("Hello", 64, 104, &FONT_8X13, &grid, true);
1789        assert!(pixels.contains(&INK_BLACK), "8x13 must ink pixels");
1790    }
1791
1792    #[test]
1793    fn stretch_false_renders_natural_glyphs_on_padded_pitch() {
1794        let png = png_bytes(
1795            render_snapcompact_png_sync(
1796                "Hello there. General Kenobi!".into(),
1797                SnapcompactRenderOptions {
1798                    size: 128,
1799                    font: Some("8x13".into()),
1800                    cell_width: Some(8),
1801                    cell_height: Some(16),
1802                    stretch: Some(false),
1803                    variant: Some("bw".into()),
1804                    ..Default::default()
1805                },
1806            )
1807            .unwrap(),
1808        );
1809        assert_eq!(png[25], 3, "8on16 must stay indexed");
1810        // IHDR width/height live at bytes 16..24, big-endian.
1811        let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
1812        // "Hello there. General Kenobi!" is 28 chars on a 16-col grid: 2 rows
1813        // of the 16px pitch — the height hugs them instead of padding to 128.
1814        assert_eq!(
1815            (dim(16), dim(20)),
1816            (128, 32),
1817            "declared geometry must match"
1818        );
1819
1820        // Glyph ink must sit in the top 13px of every 16px pitch row.
1821        let grid = Grid {
1822            cols: 16,
1823            rows: 8,
1824            repeat: 1,
1825            cell_w: 8,
1826            cell_h: 16,
1827        };
1828        let pixels = render_bitmap(
1829            "Hgjpqy. Mixed descenders!",
1830            128,
1831            128,
1832            &FONT_8X13,
1833            &grid,
1834            true,
1835        );
1836        assert!(pixels.contains(&INK_BLACK));
1837        for (i, &p) in pixels.iter().enumerate() {
1838            if p == INK_BLACK {
1839                assert!(
1840                    (i / 128) % 16 < 13,
1841                    "ink leaked into pitch padding at y={}",
1842                    i / 128
1843                );
1844            }
1845        }
1846    }
1847
1848    #[test]
1849    fn doc_layout_flows_lines_into_second_column() {
1850        // 64px, 8x16 cells -> cols 8, rows 4, col_w = (8 - 3) / 2 = 2.
1851        let grid = Grid {
1852            cols: 8,
1853            rows: 4,
1854            repeat: 1,
1855            cell_w: 8,
1856            cell_h: 16,
1857        };
1858        let pixels = render_doc_bitmap("A\nB\nC\nD\nE", 64, 64, &FONT_8X13, &grid, true);
1859        // Line 4 (the rows+1-th) lands at the second column's x origin:
1860        // 1 * (col_w + GUTTER) * cell_w = 40, row band 0.
1861        let col2 = (0..13).any(|y| (40..48).any(|x| pixels[y * 64 + x] == INK_BLACK));
1862        assert!(
1863            col2,
1864            "fifth line must start at the second column's x origin"
1865        );
1866        // '\n' consumes no cell: line 1 starts back at x 0 in row band 1.
1867        let row1 = (16..29).any(|y| (0..8).any(|x| pixels[y * 64 + x] == INK_BLACK));
1868        assert!(
1869            row1,
1870            "second line must start at column 0 of the next row band"
1871        );
1872        // One-char lines leave the rest of column 0 and the gutter blank.
1873        for y in 0..64 {
1874            for x in 8..40 {
1875                assert_eq!(pixels[y * 64 + x], 0, "gutter must stay blank at ({x},{y})");
1876            }
1877        }
1878    }
1879
1880    #[test]
1881    fn doc_sentence_hue_advances_across_newline_boundary() {
1882        // 152px wide: cols 19, col_w = (19 - 3) / 2 = 8.
1883        let grid = Grid {
1884            cols: 19,
1885            rows: 4,
1886            repeat: 1,
1887            cell_w: 8,
1888            cell_h: 16,
1889        };
1890        let pixels = render_doc_bitmap("Hi.\nOk", 152, 64, &FONT_8X13, &grid, false);
1891        let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
1892        assert!(inks.contains(&1), "first sentence must use ink 1");
1893        assert!(
1894            inks.contains(&2),
1895            "hue must advance across the newline boundary"
1896        );
1897        assert!(!inks.contains(&3), "no third sentence ink expected");
1898
1899        // Grid mode keeps the space-only rule: no advance across '\n'.
1900        let gridmode = render_bitmap("Hi.\nOk", 152, 64, &FONT_8X13, &grid, false);
1901        let inks: Vec<u8> = gridmode.iter().copied().filter(|&p| p != 0).collect();
1902        assert!(inks.contains(&1));
1903        assert!(
1904            !inks.contains(&2),
1905            "grid mode must not advance hue across newline"
1906        );
1907    }
1908
1909    #[test]
1910    fn frame_height_hugs_used_rows() {
1911        let dims = |png: &[u8]| {
1912            let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
1913            (dim(16), dim(20))
1914        };
1915        let render = |text: &str, opts: SnapcompactRenderOptions| {
1916            png_bytes(render_snapcompact_png_sync(text.into(), opts).unwrap())
1917        };
1918        let opts_8x8 = || SnapcompactRenderOptions {
1919            size: 64,
1920            font: Some("8x8".into()),
1921            ..Default::default()
1922        };
1923        // 8 cols of 8x8 cells: 10 chars span 2 rows -> 16px tall.
1924        assert_eq!(dims(&render("0123456789", opts_8x8())), (64, 16));
1925        // Dim toggles are zero-width and must not add a row.
1926        assert_eq!(dims(&render("\u{e}01234567\u{f}", opts_8x8())), (64, 8));
1927        // Capacity-filling text keeps the full grid height.
1928        assert_eq!(dims(&render(&"x".repeat(64), opts_8x8())), (64, 64));
1929        // Repeat shapes hug `usedRows * repeat` copy bands.
1930        let repeated = render(
1931            "0123456789",
1932            SnapcompactRenderOptions {
1933                line_repeat: Some(2),
1934                ..opts_8x8()
1935            },
1936        );
1937        assert_eq!(dims(&repeated), (64, 32));
1938        // Doc layout counts `\n` lines down the first column.
1939        let doc = render(
1940            "Hello there.\nSecond line",
1941            SnapcompactRenderOptions {
1942                size: 256,
1943                font: Some("8x13".into()),
1944                cell_width: Some(8),
1945                cell_height: Some(16),
1946                stretch: Some(false),
1947                columns: Some(2),
1948                ..Default::default()
1949            },
1950        );
1951        assert_eq!(dims(&doc), (256, 32));
1952        // The stretch path hugs too (RGB output, 6x6 target cells).
1953        let stretched = render(
1954            "0123456789ab",
1955            SnapcompactRenderOptions {
1956                size: 60,
1957                font: Some("8x8".into()),
1958                cell_width: Some(6),
1959                cell_height: Some(6),
1960                ..Default::default()
1961            },
1962        );
1963        assert_eq!(dims(&stretched), (60, 12));
1964    }
1965
1966    #[test]
1967    fn columns_validates_and_renders_doc_frames() {
1968        assert!(
1969            render_snapcompact_png_sync(
1970                "x".into(),
1971                SnapcompactRenderOptions {
1972                    size: 64,
1973                    columns: Some(3),
1974                    ..Default::default()
1975                }
1976            )
1977            .is_err()
1978        );
1979        // Indexed doc frame (stretch: false on a padded pitch).
1980        let doc = png_bytes(
1981            render_snapcompact_png_sync(
1982                "Hello there.\nSecond line".into(),
1983                SnapcompactRenderOptions {
1984                    size: 256,
1985                    font: Some("8x13".into()),
1986                    cell_width: Some(8),
1987                    cell_height: Some(16),
1988                    stretch: Some(false),
1989                    columns: Some(2),
1990                    ..Default::default()
1991                },
1992            )
1993            .unwrap(),
1994        );
1995        assert_eq!(doc[25], 3, "8on16 doc frame must encode indexed");
1996        // Doc layout also applies on the stretch path (RGB output).
1997        let stretched = png_bytes(
1998            render_snapcompact_png_sync(
1999                "Hello there.\nSecond line".into(),
2000                SnapcompactRenderOptions {
2001                    size: 256,
2002                    font: Some("8x13".into()),
2003                    cell_width: Some(6),
2004                    cell_height: Some(12),
2005                    columns: Some(2),
2006                    ..Default::default()
2007                },
2008            )
2009            .unwrap(),
2010        );
2011        assert_eq!(stretched[25], 2, "stretched doc frame must encode RGB");
2012    }
2013}