1use 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#[derive(Debug, thiserror::Error)]
53pub enum SnapcompactError {
54 #[error("{0}")]
56 Render(String),
57 #[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
74const MAX_FRAME_SIZE: u32 = 16384;
77
78const PALETTE: [[u8; 3]; 10] = [
83 [255, 255, 255],
84 [109, 2, 2], [109, 53, 2], [24, 109, 2], [2, 109, 109], [2, 32, 109], [75, 2, 109], [0, 0, 0], [255, 247, 194], [128, 128, 128], ];
94const INK_COLORS: usize = 6;
95const INK_BLACK: u8 = 7;
96const BG_REPEAT: u8 = 8;
97const INK_DIM: u8 = 9;
98const DIM_ON: u32 = 0x0e;
100const DIM_OFF: u32 = 0x0f;
101const 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 w: u8,
118 h: i32,
120 xoff: i32,
121 yoff: i32,
122 rows: Vec<u8>,
124}
125
126struct Font {
127 glyphs: HashMap<u32, Glyph>,
129 ascent: i32,
130 cell_w: usize,
132 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
196fn 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
293struct Grid {
297 cols: usize,
298 rows: usize,
299 repeat: usize,
300 cell_w: usize,
302 cell_h: usize,
304}
305
306const 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
330const 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
342const 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; }
360 Some((cell, units, cell + units))
361}
362
363fn 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
383fn 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
398fn 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
428fn 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
500fn 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 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 } 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
619fn 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]; 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
809const GUTTER: usize = 3;
811
812fn 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]; 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; }
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; }
882 col = cell + units;
883 if cell + units > col_w {
884 continue; }
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
1012fn 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
1028fn 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
1057fn 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
1089fn 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
1116fn 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 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
1170fn 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#[derive(Default, Clone)]
1202pub struct SnapcompactRenderOptions {
1203 pub size: u32,
1207 pub font: Option<String>,
1210 pub cell_width: Option<u32>,
1213 pub cell_height: Option<u32>,
1215 pub variant: Option<String>,
1218 pub line_repeat: Option<u32>,
1221 pub stretch: Option<bool>,
1227 pub columns: Option<u32>,
1230}
1231
1232pub 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
1255pub 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 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 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 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 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 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 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 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 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 assert!(
1525 pixels[9 * 64..10 * 64].contains(&BG_REPEAT),
1526 "duplicate band must be highlighted"
1527 );
1528 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 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 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 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 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 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 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 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 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 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 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 let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
1812 assert_eq!(
1815 (dim(16), dim(20)),
1816 (128, 32),
1817 "declared geometry must match"
1818 );
1819
1820 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 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 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 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 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 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 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 assert_eq!(dims(&render("0123456789", opts_8x8())), (64, 16));
1925 assert_eq!(dims(&render("\u{e}01234567\u{f}", opts_8x8())), (64, 8));
1927 assert_eq!(dims(&render(&"x".repeat(64), opts_8x8())), (64, 64));
1929 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 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 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 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 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}