Skip to main content

letratag_render/
lib.rs

1//! Deterministic image rendering. The canvas is feed columns by 32 head rows.
2//!
3//! Active rows are a design choice, not a physical printhead calibration. Images
4//! keep their aspect ratio (rounded down to whole pixels, with a one-pixel
5//! minimum), including any existing white margins, and are centered on the full
6//! canvas. An odd spare pixel goes on the right or bottom. Scaling uses nearest
7//! neighbors sampled at pixel centers. No text, font, or PDF rendering is done.
8#![doc = include_str!("../README.md")]
9
10use std::io::Cursor;
11
12use image::{
13    DynamicImage, ExtendedColorType, GenericImageView, ImageDecoder, ImageEncoder, Limits,
14    codecs::png::{PngDecoder, PngEncoder},
15};
16use letratag_protocol::{HEAD_ROWS, MAX_COLUMNS, Raster};
17
18pub const MAX_PNG_BYTES: usize = 8 * 1024 * 1024;
19pub const MAX_SOURCE_DIMENSION: usize = 8192;
20pub const MAX_SOURCE_PIXELS: usize = 16_000_000;
21pub const MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct RenderOptions {
25    pub width: usize,
26    pub active_rows: usize,
27    /// Luminance strictly below this value becomes black.
28    pub threshold: u8,
29    /// Apply a deterministic 4x4 Bayer threshold offset, anchored to the canvas.
30    pub dither: bool,
31}
32
33impl Default for RenderOptions {
34    fn default() -> Self {
35        Self {
36            width: 400,
37            active_rows: 30,
38            threshold: 128,
39            dither: false,
40        }
41    }
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum Error {
46    #[error("output width {width} is outside 1..={MAX_COLUMNS}")]
47    InvalidOutputWidth { width: usize },
48    #[error("active row count {active_rows} is outside 1..={HEAD_ROWS}")]
49    InvalidActiveRows { active_rows: usize },
50    #[error("source dimensions {width}x{height} are outside 1..={MAX_SOURCE_DIMENSION}")]
51    InvalidSourceDimensions { width: usize, height: usize },
52    #[error("source has {pixels} pixels, exceeding {MAX_SOURCE_PIXELS}")]
53    TooManyPixels { pixels: usize },
54    #[error("grayscale data has {actual} bytes, expected {expected}")]
55    GrayscaleLength { expected: usize, actual: usize },
56    #[error("PNG has {bytes} bytes, exceeding {MAX_PNG_BYTES}")]
57    FileTooLarge { bytes: usize },
58    #[error("missing or invalid PNG signature or IHDR header")]
59    InvalidPngHeader,
60    #[error("decoded image needs {bytes} bytes, exceeding {MAX_DECODE_BYTES}")]
61    AllocationLimit { bytes: u64 },
62    #[error("image size arithmetic overflow")]
63    ArithmeticOverflow,
64    #[error(transparent)]
65    Image(#[from] image::ImageError),
66    #[error("invalid protocol raster: {0}")]
67    Raster(#[source] letratag_protocol::Error),
68}
69
70/// Decode only PNG. Header dimensions are checked before decoder construction;
71/// `Limits` bounds its buffers and decoded storage is checked before allocation.
72/// Alpha is composited on white before integer Rec. 709 luminance conversion.
73/// For APNG, only the default PNG image is rendered, not animation frames.
74pub fn render_png(data: &[u8], options: RenderOptions) -> Result<Raster, Error> {
75    validate_options(options)?;
76    if data.len() > MAX_PNG_BYTES {
77        return Err(Error::FileTooLarge { bytes: data.len() });
78    }
79    let (width, height) = validate_png_header(data)?;
80    let mut limits = Limits::default();
81    limits.max_image_width = Some(MAX_SOURCE_DIMENSION as u32);
82    limits.max_image_height = Some(MAX_SOURCE_DIMENSION as u32);
83    limits.max_alloc = Some(MAX_DECODE_BYTES);
84    let mut decoder = PngDecoder::with_limits(Cursor::new(data), limits.clone())?;
85    let bytes = decoder.total_bytes();
86    if bytes > MAX_DECODE_BYTES {
87        return Err(Error::AllocationLimit { bytes });
88    }
89    // Reserve the decoded buffer as ImageReader does, leaving the remaining
90    // budget for decoder work. Construction already had its own bounded limit.
91    limits.reserve(bytes)?;
92    decoder.set_limits(limits)?;
93    let image = DynamicImage::from_decoder(decoder)?;
94    render_samples(width, height, options, |x, y| {
95        let [r, g, b, a] = image.get_pixel(x as u32, y as u32).0;
96        let composite =
97            |c: u8| (u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255;
98        ((2126 * composite(r) + 7152 * composite(g) + 722 * composite(b) + 5000) / 10_000) as u8
99    })
100}
101
102/// Render row-major 8-bit grayscale: zero is black and 255 is white.
103pub fn render_gray(
104    width: usize,
105    height: usize,
106    pixels: &[u8],
107    options: RenderOptions,
108) -> Result<Raster, Error> {
109    validate_options(options)?;
110    let expected = validate_source(width, height)?;
111    if pixels.len() != expected {
112        return Err(Error::GrayscaleLength {
113            expected,
114            actual: pixels.len(),
115        });
116    }
117    render_samples(width, height, options, |x, y| pixels[y * width + x])
118}
119
120/// Encode the actual full-height raster, without rescaling or trimming.
121pub fn preview_png(raster: &Raster) -> Result<Vec<u8>, Error> {
122    let pixels: Vec<u8> = raster
123        .pixels()
124        .iter()
125        .map(|&pixel| if pixel == 0 { 255 } else { 0 })
126        .collect();
127    let mut output = Vec::new();
128    PngEncoder::new(&mut output).write_image(
129        &pixels,
130        raster.width() as u32,
131        HEAD_ROWS as u32,
132        ExtendedColorType::L8,
133    )?;
134    Ok(output)
135}
136
137fn validate_options(options: RenderOptions) -> Result<(), Error> {
138    if !(1..=MAX_COLUMNS).contains(&options.width) {
139        return Err(Error::InvalidOutputWidth {
140            width: options.width,
141        });
142    }
143    if !(1..=HEAD_ROWS).contains(&options.active_rows) {
144        return Err(Error::InvalidActiveRows {
145            active_rows: options.active_rows,
146        });
147    }
148    Ok(())
149}
150
151fn validate_source(width: usize, height: usize) -> Result<usize, Error> {
152    if !(1..=MAX_SOURCE_DIMENSION).contains(&width) || !(1..=MAX_SOURCE_DIMENSION).contains(&height)
153    {
154        return Err(Error::InvalidSourceDimensions { width, height });
155    }
156    let pixels = width.checked_mul(height).ok_or(Error::ArithmeticOverflow)?;
157    if pixels > MAX_SOURCE_PIXELS {
158        return Err(Error::TooManyPixels { pixels });
159    }
160    Ok(pixels)
161}
162
163fn validate_png_header(data: &[u8]) -> Result<(usize, usize), Error> {
164    if data.len() < 33
165        || &data[..8] != b"\x89PNG\r\n\x1a\n"
166        || data[8..12] != 13_u32.to_be_bytes()
167        || &data[12..16] != b"IHDR"
168    {
169        return Err(Error::InvalidPngHeader);
170    }
171    let width = u32::from_be_bytes(data[16..20].try_into().unwrap()) as usize;
172    let height = u32::from_be_bytes(data[20..24].try_into().unwrap()) as usize;
173    let pixels = validate_source(width, height)?;
174    // Sub-byte palette/grayscale data expands to 8 bits. PNG palette+tRNS can
175    // expand to RGBA, so account for four channels even before reading tRNS.
176    let channels = match data[25] {
177        0 => 1_u64,
178        2 => 3,
179        3 | 6 => 4,
180        4 => 2,
181        _ => return Err(Error::InvalidPngHeader),
182    };
183    let sample_bytes = match data[24] {
184        1 | 2 | 4 | 8 => 1,
185        16 => 2,
186        _ => return Err(Error::InvalidPngHeader),
187    };
188    let bytes = (pixels as u64)
189        .checked_mul(channels)
190        .and_then(|bytes| bytes.checked_mul(sample_bytes))
191        .ok_or(Error::ArithmeticOverflow)?;
192    if bytes > MAX_DECODE_BYTES {
193        return Err(Error::AllocationLimit { bytes });
194    }
195    Ok((width, height))
196}
197
198fn render_samples(
199    source_width: usize,
200    source_height: usize,
201    options: RenderOptions,
202    sample: impl Fn(usize, usize) -> u8,
203) -> Result<Raster, Error> {
204    let width_fit = source_width
205        .checked_mul(options.active_rows)
206        .ok_or(Error::ArithmeticOverflow)?;
207    let height_fit = source_height
208        .checked_mul(options.width)
209        .ok_or(Error::ArithmeticOverflow)?;
210    let (scaled_width, scaled_height) = if width_fit >= height_fit {
211        (options.width, (height_fit / source_width).max(1))
212    } else {
213        ((width_fit / source_height).max(1), options.active_rows)
214    };
215    let offset_x = (options.width - scaled_width) / 2;
216    let offset_y = (HEAD_ROWS - scaled_height) / 2;
217    let length = options
218        .width
219        .checked_mul(HEAD_ROWS)
220        .ok_or(Error::ArithmeticOverflow)?;
221    let mut pixels = vec![0; length];
222    // Validated dimensions bound the following indexing and center-sampling
223    // products below 2 * MAX_COLUMNS * MAX_SOURCE_DIMENSION, even on 32-bit.
224    for y in 0..scaled_height {
225        let source_y = (2 * y + 1) * source_height / (2 * scaled_height);
226        for x in 0..scaled_width {
227            let source_x = (2 * x + 1) * source_width / (2 * scaled_width);
228            let canvas_x = x + offset_x;
229            let canvas_y = y + offset_y;
230            let threshold = if options.dither {
231                const BAYER: [[i16; 4]; 4] =
232                    [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]];
233                (i16::from(options.threshold) + BAYER[canvas_y % 4][canvas_x % 4] * 16 + 8 - 128)
234                    .clamp(0, 255) as u8
235            } else {
236                options.threshold
237            };
238            pixels[canvas_y * options.width + canvas_x] =
239                u8::from(sample(source_x, source_y) < threshold);
240        }
241    }
242    Raster::new(options.width, pixels).map_err(Error::Raster)
243}