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