Skip to main content

semantic_operators/
image.rs

1//! Explicit request-owned images and frame sequences.
2//!
3//! Header inspection enforces the frozen Python 0.12 limits; it does not decode
4//! pixels or prove that a file is renderable. In particular, CRCs and compressed
5//! payloads are not verified. No resizing, EXIF correction, or implicit I/O occurs.
6//! The hosted service may have stricter limits (its 2026-10-17 documentation says
7//! 280 px minimum sides); image acceptance here is not service acceptance.
8
9use crate::{Error, ErrorKind, Question, Result, State};
10use base64::{Engine as _, engine::general_purpose::STANDARD};
11use indexmap::IndexMap;
12use serde_json::Value;
13use std::{
14    fmt,
15    io::Read,
16    path::Path,
17    sync::{
18        Arc,
19        atomic::{AtomicU64, Ordering},
20    },
21};
22
23/// Frozen minimum side, subject also to the minimum area.
24pub const MIN_SIDE: u32 = 256;
25/// Maximum side length in pixels.
26pub const MAX_SIDE: u32 = 1568;
27/// Minimum pixel area.
28pub const MIN_AREA: u64 = 78_400;
29/// Maximum pixel area.
30pub const MAX_AREA: u64 = 2_458_624;
31/// Maximum bytes of one image.
32pub const MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
33/// Maximum image bytes per request, counting every use.
34pub const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024;
35static NEXT_IMAGE: AtomicU64 = AtomicU64::new(1);
36
37/// Supported still-image encoding.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum ImageFormat {
40    /// Portable Network Graphics.
41    Png,
42    /// JPEG.
43    Jpeg,
44}
45impl ImageFormat {
46    /// Lowercase provider encoding name.
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Png => "png",
50            Self::Jpeg => "jpeg",
51        }
52    }
53    /// MIME type for a multipart upload.
54    pub const fn mime_type(self) -> &'static str {
55        match self {
56            Self::Png => "image/png",
57            Self::Jpeg => "image/jpeg",
58        }
59    }
60}
61
62/// Nonfatal metadata observations; the original bytes remain unchanged.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum ImageDiagnostic {
65    /// EXIF orientation other than 1; rotate pixels explicitly if necessary.
66    ExifOrientation(u16),
67    /// JPEG uses a non-baseline frame marker.
68    NonBaselineJpeg,
69}
70
71/// Immutable owned image bytes with a stable, process-unique reference.
72///
73/// Cloning preserves reference identity. References are opaque tokens, not
74/// security capabilities and not portable serialization identifiers.
75#[derive(Clone)]
76pub struct Image {
77    data: Arc<[u8]>,
78    format: ImageFormat,
79    width: u32,
80    height: u32,
81    placeholder: String,
82    diagnostics: Vec<ImageDiagnostic>,
83}
84impl fmt::Debug for Image {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("Image")
87            .field("format", &self.format)
88            .field("width", &self.width)
89            .field("height", &self.height)
90            .field("size", &self.size())
91            .field("diagnostics", &self.diagnostics)
92            .finish_non_exhaustive()
93    }
94}
95impl fmt::Display for Image {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(&self.placeholder)
98    }
99}
100impl Image {
101    /// Inspect owned bytes without decoding or modifying pixels.
102    ///
103    /// # Errors
104    /// Returns `InvalidImage` for unsupported signatures, malformed inspected
105    /// headers, animation, file/dimension/area limit violations, or exhausted
106    /// reference identities. Successful inspection does not prove pixel decodability.
107    pub fn from_bytes(data: impl Into<Arc<[u8]>>) -> Result<Self> {
108        let data = data.into();
109        if data.len() > MAX_FILE_BYTES {
110            return Err(invalid("image exceeds 16 MiB per-file limit"));
111        }
112        let (format, width, height, diagnostics) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
113            let (w, h) = png(&data)?;
114            (ImageFormat::Png, w, h, Vec::new())
115        } else if data.starts_with(b"\xff\xd8\xff") {
116            let (w, h, d) = jpeg(&data)?;
117            (ImageFormat::Jpeg, w, h, d)
118        } else {
119            return Err(invalid("image must be PNG or JPEG"));
120        };
121        if !(MIN_SIDE..=MAX_SIDE).contains(&width) || !(MIN_SIDE..=MAX_SIDE).contains(&height) {
122            return Err(invalid("image sides must be 256-1568 pixels"));
123        }
124        if !(MIN_AREA..=MAX_AREA).contains(&(u64::from(width) * u64::from(height))) {
125            return Err(invalid("image area must be 78400-2458624 pixels"));
126        }
127        // New Rust versions rename this to try_update; retain the Rust 1.85 API.
128        #[allow(deprecated)]
129        let id = NEXT_IMAGE
130            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_add(1))
131            .map_err(|_| invalid("image reference identity space exhausted"))?;
132        let placeholder = format!(
133            "image-00000000-0000-0000-{:04x}-{:012x}",
134            id >> 48,
135            id & 0xffff_ffff_ffff
136        );
137        Ok(Self {
138            data,
139            format,
140            width,
141            height,
142            placeholder,
143            diagnostics,
144        })
145    }
146    /// Read a caller-selected local path, with a bounded read and redacted errors.
147    ///
148    /// # Errors
149    /// Returns `InvalidImage` if the file cannot be opened/read or its bytes
150    /// fail [`Image::from_bytes`] validation. Errors omit the supplied path.
151    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
152        let file = std::fs::File::open(path).map_err(|_| invalid("cannot open image file"))?;
153        let mut data = Vec::new();
154        file.take((MAX_FILE_BYTES + 1) as u64)
155            .read_to_end(&mut data)
156            .map_err(|_| invalid("cannot read image file"))?;
157        Self::from_bytes(data)
158    }
159    /// Decode standard padded base64, ignoring whitespace; data URLs are rejected.
160    ///
161    /// # Errors
162    /// Returns `InvalidImage` for a data-URL prefix, invalid base64, excessive
163    /// encoded/decoded size, or any [`Image::from_bytes`] validation failure.
164    pub fn from_base64(text: &str) -> Result<Self> {
165        if text.starts_with("data:") {
166            return Err(invalid("base64 image must not have a data: URL prefix"));
167        }
168        let mut compact = String::new();
169        let max_encoded = MAX_FILE_BYTES.div_ceil(3) * 4;
170        for ch in text.chars().filter(|ch| !ch.is_whitespace()) {
171            if compact.len() >= max_encoded {
172                return Err(invalid("base64 image exceeds 16 MiB limit"));
173            }
174            compact.push(ch);
175        }
176        let bytes = STANDARD
177            .decode(compact)
178            .map_err(|_| invalid("invalid base64 image data"))?;
179        Self::from_bytes(bytes)
180    }
181    /// Original immutable bytes.
182    pub fn bytes(&self) -> &[u8] {
183        &self.data
184    }
185    /// Encoding discovered from the file signature.
186    pub fn format(&self) -> ImageFormat {
187        self.format
188    }
189    /// Width in pixels.
190    pub fn width(&self) -> u32 {
191        self.width
192    }
193    /// Height in pixels.
194    pub fn height(&self) -> u32 {
195        self.height
196    }
197    /// Original encoded byte length.
198    pub fn size(&self) -> usize {
199        self.data.len()
200    }
201    /// Opaque reference to embed in state or question strings.
202    pub fn placeholder(&self) -> &str {
203        &self.placeholder
204    }
205    /// Metadata observations that have not been applied to the pixels.
206    pub fn diagnostics(&self) -> &[ImageDiagnostic] {
207        &self.diagnostics
208    }
209    /// Standard padded base64 of the original bytes.
210    pub fn base64(&self) -> String {
211        STANDARD.encode(&self.data)
212    }
213}
214
215/// Request-owned association of references and images. Unused images are not sent.
216#[derive(Clone, Default)]
217pub struct Assets {
218    images: IndexMap<String, Image>,
219}
220impl fmt::Debug for Assets {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        f.debug_struct("Assets")
223            .field("image_count", &self.images.len())
224            .finish_non_exhaustive()
225    }
226}
227/// One referenced image, with every occurrence accounted for.
228#[derive(Debug)]
229pub struct ImageReference<'a> {
230    image: &'a Image,
231    uses: usize,
232}
233impl<'a> ImageReference<'a> {
234    /// Referenced image.
235    pub fn image(&self) -> &'a Image {
236        self.image
237    }
238    /// Number of occurrences in state and questions, including keys for baseline parity.
239    pub fn uses(&self) -> usize {
240        self.uses
241    }
242}
243impl Assets {
244    /// Create an empty request-owned registry.
245    pub fn new() -> Self {
246        Self::default()
247    }
248    /// Retain an image and return its stable reference. Reattaching clones is idempotent.
249    pub fn attach(&mut self, image: Image) -> String {
250        let key = image.placeholder.clone();
251        self.images.entry(key.clone()).or_insert(image);
252        key
253    }
254    /// Number of attached images (including currently unused images).
255    pub fn len(&self) -> usize {
256        self.images.len()
257    }
258    /// Whether the registry has no attached images.
259    pub fn is_empty(&self) -> bool {
260        self.images.is_empty()
261    }
262    /// Validate unknown references and repeated-use byte limits before I/O.
263    ///
264    /// # Errors
265    /// Returns the same `InvalidImage` failures as [`Assets::references`] for
266    /// unknown handles, size/count overflow, or excessive repeated image use.
267    pub fn validate(&self, state: &State, questions: &IndexMap<String, Question>) -> Result<()> {
268        self.references(state, questions).map(|_| ())
269    }
270    /// Collect referenced images in first-use order, preserving repeated-use counts.
271    ///
272    /// Object keys are scanned for Python 0.12 parity. The current hosted service
273    /// does not replace references inside keys; a key is not an image placement.
274    ///
275    /// # Errors
276    /// Returns `InvalidImage` for an unattached UUID-shaped image reference,
277    /// reference-count/size overflow, or more than 32 MiB counting every use.
278    pub fn references<'a>(
279        &'a self,
280        state: &State,
281        questions: &IndexMap<String, Question>,
282    ) -> Result<Vec<ImageReference<'a>>> {
283        let mut found = IndexMap::<String, usize>::new();
284        self.walk(state.as_value(), &mut found)?;
285        for (name, question) in questions {
286            self.text(name, &mut found)?;
287            self.text(question.instructions(), &mut found)?;
288            match question {
289                Question::Boolean(q) => {
290                    if let Some(text) = q.true_description() {
291                        self.text(text, &mut found)?;
292                    }
293                    if let Some(text) = q.false_description() {
294                        self.text(text, &mut found)?;
295                    }
296                }
297                Question::Choice(q) => {
298                    for (name, description) in q.options() {
299                        self.text(name, &mut found)?;
300                        if let Some(text) = description {
301                            self.text(text, &mut found)?;
302                        }
303                    }
304                }
305                Question::Score(q) => {
306                    for text in q.levels() {
307                        self.text(text, &mut found)?;
308                    }
309                }
310                Question::Box(_) => {}
311            }
312        }
313        let mut total = 0usize;
314        let mut result = Vec::new();
315        for (key, uses) in found {
316            let image = self
317                .images
318                .get(&key)
319                .ok_or_else(|| invalid("unknown image reference"))?;
320            total = image
321                .size()
322                .checked_mul(uses)
323                .and_then(|bytes| total.checked_add(bytes))
324                .ok_or_else(|| invalid("image request size overflow"))?;
325            if total > MAX_REQUEST_BYTES {
326                return Err(invalid(
327                    "images exceed 32 MiB request limit, counting every use",
328                ));
329            }
330            result.push(ImageReference { image, uses });
331        }
332        Ok(result)
333    }
334    /// Apply TypeSafe's documented 2026-10-17 preflight in addition to baseline limits.
335    ///
336    /// Current service sides start at 280 px and object keys are never image
337    /// placements. Processed image size and server decoding cannot be proved here.
338    ///
339    /// # Errors
340    /// Propagates baseline reference/size errors and returns `InvalidImage`
341    /// for referenced images with a side below 280 px or references in object keys.
342    pub fn validate_service_request(
343        &self,
344        state: &State,
345        questions: &IndexMap<String, Question>,
346    ) -> Result<()> {
347        for reference in self.references(state, questions)? {
348            if reference.image.width() < 280 || reference.image.height() < 280 {
349                return Err(invalid("TypeSafe image sides must be at least 280 pixels"));
350            }
351        }
352        let reject_key = |key: &str| -> Result<()> {
353            let mut found = IndexMap::new();
354            self.text(key, &mut found)?;
355            if !found.is_empty() {
356                return Err(invalid(
357                    "TypeSafe does not replace image references in object keys",
358                ));
359            }
360            Ok(())
361        };
362        let mut stack = vec![state.as_value()];
363        while let Some(value) = stack.pop() {
364            match value {
365                Value::Object(values) => {
366                    for (key, value) in values {
367                        reject_key(key)?;
368                        stack.push(value);
369                    }
370                }
371                Value::Array(values) => stack.extend(values),
372                _ => {}
373            }
374        }
375        for (name, question) in questions {
376            reject_key(name)?;
377            if let Question::Choice(choice) = question {
378                for name in choice.options().keys() {
379                    reject_key(name)?;
380                }
381            }
382        }
383        Ok(())
384    }
385    fn walk(&self, value: &Value, found: &mut IndexMap<String, usize>) -> Result<()> {
386        enum Item<'a> {
387            Value(&'a Value),
388            Key(&'a str),
389        }
390        let mut stack = vec![Item::Value(value)];
391        while let Some(item) = stack.pop() {
392            match item {
393                Item::Key(key) => self.text(key, found)?,
394                Item::Value(Value::String(text)) => self.text(text, found)?,
395                Item::Value(Value::Array(values)) => {
396                    stack.extend(values.iter().rev().map(Item::Value))
397                }
398                Item::Value(Value::Object(values)) => {
399                    for (key, value) in values.iter().rev() {
400                        stack.push(Item::Value(value));
401                        stack.push(Item::Key(key));
402                    }
403                }
404                _ => {}
405            }
406        }
407        Ok(())
408    }
409    fn text(&self, text: &str, found: &mut IndexMap<String, usize>) -> Result<()> {
410        for (start, _) in text.match_indices("image-") {
411            let Some(candidate) = text.get(start..start + 42) else {
412                continue;
413            };
414            if !is_reference(candidate) {
415                continue;
416            }
417            if !self.images.contains_key(candidate) {
418                return Err(invalid(
419                    "unknown image reference; attach its image to this request",
420                ));
421            }
422            let count = found.entry(candidate.to_owned()).or_default();
423            *count = count
424                .checked_add(1)
425                .ok_or_else(|| invalid("image reference count overflow"))?;
426        }
427        Ok(())
428    }
429}
430fn is_reference(text: &str) -> bool {
431    text.len() == 42
432        && text.as_bytes()[6..].iter().enumerate().all(|(i, b)| {
433            if [8, 13, 18, 23].contains(&i) {
434                *b == b'-'
435            } else {
436                b.is_ascii_digit() || (b'a'..=b'f').contains(b)
437            }
438        })
439}
440
441/// Build a frame array in caller-supplied oldest-first order and attach its images.
442///
443/// Timestamps are preserved, not sorted. Finite values are required by JSON;
444/// negative and nonmonotonic values remain allowed like the Python reference.
445/// Validation occurs before mutating the asset registry.
446///
447/// # Errors
448/// Returns `InvalidImage` for an empty image sequence, mismatched timestamp
449/// count, or nonfinite timestamps. These checks occur before assets are attached.
450pub fn frames(
451    assets: &mut Assets,
452    images: impl IntoIterator<Item = Image>,
453    seconds_ago: Option<&[f64]>,
454) -> Result<State> {
455    let images: Vec<_> = images.into_iter().collect();
456    if images.is_empty() {
457        return Err(invalid("frames needs at least one image"));
458    }
459    if let Some(times) = seconds_ago {
460        if times.len() != images.len() {
461            return Err(invalid("frames and seconds_ago lengths differ"));
462        }
463        if times.iter().any(|t| !t.is_finite()) {
464            return Err(invalid("frame timestamps must be finite"));
465        }
466    }
467    let frames = images
468        .into_iter()
469        .enumerate()
470        .map(|(i, image)| {
471            let mut value = serde_json::Map::new();
472            if let Some(times) = seconds_ago {
473                value.insert("seconds_ago".into(), Value::from(times[i]));
474            }
475            value.insert("frame".into(), Value::String(assets.attach(image)));
476            Value::Object(value)
477        })
478        .collect();
479    State::try_from(Value::Array(frames))
480}
481fn invalid(message: &str) -> Error {
482    Error::new(ErrorKind::InvalidImage, message)
483}
484fn be32(data: &[u8]) -> Option<u32> {
485    Some(u32::from_be_bytes(data.get(..4)?.try_into().ok()?))
486}
487fn be16(data: &[u8]) -> Option<u16> {
488    Some(u16::from_be_bytes(data.get(..2)?.try_into().ok()?))
489}
490fn png(data: &[u8]) -> Result<(u32, u32)> {
491    if data.len() < 33
492        || data.get(12..16) != Some(b"IHDR".as_slice())
493        || be32(&data[8..]) != Some(13)
494    {
495        return Err(invalid("malformed PNG: missing or invalid IHDR"));
496    }
497    let width = be32(&data[16..]).ok_or_else(|| invalid("malformed PNG width"))?;
498    let height = be32(&data[20..]).ok_or_else(|| invalid("malformed PNG height"))?;
499    let mut pos = 8usize;
500    while pos < data.len() {
501        let header = data
502            .get(pos..pos.saturating_add(8))
503            .ok_or_else(|| invalid("malformed PNG chunk header"))?;
504        let length = be32(header).ok_or_else(|| invalid("malformed PNG chunk size"))? as usize;
505        let end = pos
506            .checked_add(12)
507            .and_then(|p| p.checked_add(length))
508            .ok_or_else(|| invalid("malformed PNG chunk overflow"))?;
509        if end > data.len() {
510            return Err(invalid("malformed PNG: truncated chunk"));
511        }
512        let kind = &header[4..8];
513        if kind == b"acTL" {
514            return Err(invalid("animated PNGs are not accepted"));
515        }
516        if kind == b"IDAT" || kind == b"IEND" {
517            return Ok((width, height));
518        }
519        pos = end;
520    }
521    Err(invalid("malformed PNG: missing image data"))
522}
523fn jpeg(data: &[u8]) -> Result<(u32, u32, Vec<ImageDiagnostic>)> {
524    let mut pos = 2usize;
525    let mut diagnostics = Vec::new();
526    while let Some(header) = data.get(pos..pos.saturating_add(2)) {
527        if header[0] != 0xff {
528            return Err(invalid("malformed JPEG: bad marker"));
529        }
530        let marker = header[1];
531        if marker == 0xff {
532            pos += 1;
533            continue;
534        }
535        if matches!(marker, 0xd8 | 0x01 | 0xd0..=0xd7) {
536            pos += 2;
537            continue;
538        }
539        if matches!(marker, 0xd9 | 0xda) {
540            break;
541        }
542        let length = data
543            .get(pos + 2..)
544            .and_then(be16)
545            .ok_or_else(|| invalid("malformed JPEG segment length"))? as usize;
546        if length < 2 {
547            return Err(invalid("malformed JPEG segment length"));
548        }
549        let end = pos
550            .checked_add(2)
551            .and_then(|p| p.checked_add(length))
552            .ok_or_else(|| invalid("malformed JPEG segment overflow"))?;
553        let segment = data
554            .get(pos + 4..end)
555            .ok_or_else(|| invalid("malformed JPEG: truncated segment"))?;
556        if marker == 0xe1 && segment.starts_with(b"Exif\0\0") {
557            if let Some(orientation) = exif_orientation(&segment[6..]) {
558                if orientation != 1 {
559                    diagnostics.push(ImageDiagnostic::ExifOrientation(orientation));
560                }
561            }
562        }
563        if matches!(marker,0xc0..=0xc3|0xc5..=0xc7|0xc9..=0xcb|0xcd..=0xcf) {
564            let height = segment
565                .get(1..)
566                .and_then(be16)
567                .ok_or_else(|| invalid("malformed JPEG frame header"))?;
568            let width = segment
569                .get(3..)
570                .and_then(be16)
571                .ok_or_else(|| invalid("malformed JPEG frame header"))?;
572            if !matches!(marker, 0xc0 | 0xc1) {
573                diagnostics.push(ImageDiagnostic::NonBaselineJpeg);
574            }
575            return Ok((u32::from(width), u32::from(height), diagnostics));
576        }
577        pos = end;
578    }
579    Err(invalid("malformed JPEG: no frame header with size"))
580}
581fn exif_orientation(tiff: &[u8]) -> Option<u16> {
582    let little = match tiff.get(..2)? {
583        b"II" => true,
584        b"MM" => false,
585        _ => return None,
586    };
587    let u16_at = |pos: usize| -> Option<u16> {
588        let bytes = tiff.get(pos..pos.checked_add(2)?)?.try_into().ok()?;
589        Some(if little {
590            u16::from_le_bytes(bytes)
591        } else {
592            u16::from_be_bytes(bytes)
593        })
594    };
595    let bytes = tiff.get(4..8)?.try_into().ok()?;
596    let offset = if little {
597        u32::from_le_bytes(bytes)
598    } else {
599        u32::from_be_bytes(bytes)
600    } as usize;
601    let count = usize::from(u16_at(offset)?);
602    for i in 0..count {
603        let pos = offset.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
604        if u16_at(pos)? == 0x0112 {
605            return u16_at(pos.checked_add(8)?);
606        }
607    }
608    None
609}