Skip to main content

fleuron/
images.rs

1//! Image sizing, and the contour a sheet asks to be traced.
2//!
3//! Layout needs one thing from an image — how big it is — and getting
4//! it by decoding would put a pixel buffer in the layout pass. So the
5//! engine reads the header: PNG's `IHDR` and `pHYs`, JPEG's `SOFn` and
6//! JFIF density, GIF's screen descriptor, WebP's chunk headers. The
7//! file is kept as it arrived, and a painter is what decodes it.
8//!
9//! A url reaches the table from two places. The content tree names
10//! one for every image the manuscript places, and the style tree
11//! names one for every box the sheet puts art behind, so probing
12//! takes both.
13//!
14//! One thing else decodes it: `shape-outside: auto`, which sets prose
15//! around the shape of an image rather than around its box. That is
16//! not reachable from where probing happens, because probing has not
17//! seen the style tree, so it is a stage of its own. [`Contours`] is
18//! what that stage keeps: it walks the cascade for the nodes that ask
19//! for a traced contour, and traces each asset once.
20//!
21//! The engine opens nothing itself, the same as with fonts. Whatever string
22//! the content tree writes is the name an image is matched under. It
23//! never has to be a real URL; the host is what turns a name into
24//! bytes.
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::hash::{DefaultHasher, Hash, Hasher};
28
29use serde::{Deserialize, Serialize};
30
31use crate::Warning;
32use crate::content::{Block, Book};
33use crate::style::{ComputedStyle, ShapeOutside, StyleTree, Url};
34
35/// Resolves image urls to bytes.
36///
37/// The whole file, not a prefix: the header sizes the box and the
38/// same bytes are what a painter embeds.
39pub trait ImageLoader {
40    /// The bytes behind one url, or `None` when the host cannot
41    /// resolve it.
42    fn load(&self, url: &str) -> Option<Vec<u8>>;
43}
44
45/// A loader that resolves nothing.
46pub struct NoImages;
47
48impl ImageLoader for NoImages {
49    fn load(&self, _url: &str) -> Option<Vec<u8>> {
50        None
51    }
52}
53
54/// The images a host already handed over answer by the url they
55/// were registered under, with the bytes as they arrived.
56impl ImageLoader for Assets {
57    fn load(&self, url: &str) -> Option<Vec<u8>> {
58        let (index, _) = self.lookup(url)?;
59        self.bytes(index).map(<[u8]>::to_vec)
60    }
61}
62
63/// What CSS calls one pixel: 1/96th of an inch. An image whose header
64/// declares no resolution is measured at this one.
65pub const CSS_DPI: f32 = 96.0;
66
67/// An image's own idea of its size: pixels, and the resolution they
68/// are meant to be shown at.
69#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
70pub struct Intrinsic {
71    /// Width in pixels.
72    pub width: u32,
73    /// Height in pixels.
74    pub height: u32,
75    /// Horizontal resolution in pixels per inch.
76    pub dpi_x: f32,
77    /// Vertical resolution in pixels per inch.
78    pub dpi_y: f32,
79}
80
81impl Intrinsic {
82    /// The intrinsic size in points, at the header's own resolution.
83    pub fn size(self) -> (f32, f32) {
84        (
85            self.width as f32 / self.dpi_x * 72.0,
86            self.height as f32 / self.dpi_y * 72.0,
87        )
88    }
89}
90
91/// One image the book refers to, sized from its header.
92///
93/// What `DrawItem::Image.asset` indexes, and all the display structure
94/// says about an image: painters take the url back to their own
95/// pixels.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct Asset {
98    /// The url the content tree named it by.
99    pub url: String,
100    /// What the header says it is.
101    pub intrinsic: Intrinsic,
102}
103
104/// Every image one book refers to, probed once and indexed.
105///
106/// `DrawItem::Image.asset` indexes this: layout places an image by
107/// number, and painters resolve the number back to bytes.
108#[derive(Debug, Default)]
109pub struct Assets {
110    assets: Vec<Asset>,
111    /// The file each asset was probed from, in the same order.
112    /// Layout read the header out of these; the PDF writer embeds
113    /// them.
114    files: Vec<Vec<u8>>,
115    /// A hash of each file, in the same order: what tells a url
116    /// registered again with the same bytes from one registered
117    /// again with different ones.
118    hashes: Vec<u64>,
119    /// Urls that were offered and could not be sized, so that a url
120    /// nothing has ever answered for can be told apart from one
121    /// already complained about.
122    refused: BTreeSet<String>,
123    warnings: Vec<Warning>,
124}
125
126/// What registering an image did.
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum Added {
129    /// The bytes already registered at this url, unchanged.
130    Unchanged(u32),
131    /// New bytes at this index: the url's first registration, or
132    /// different bytes replacing what was there. `previous` is the
133    /// intrinsic size that answered for it before, `None` for a
134    /// fresh url — what a session reads to decide whether the box an
135    /// image takes moved.
136    Replaced {
137        /// The index the bytes answer for.
138        index: u32,
139        /// What the same index reported before, if anything did.
140        previous: Option<Intrinsic>,
141        /// What it reports now.
142        current: Intrinsic,
143    },
144    /// The bytes did not probe: no size could be read from them.
145    Refused,
146}
147
148/// What one file hashes to, for telling a registration of the same
149/// bytes from one of different bytes at the same url.
150fn content_hash(bytes: &[u8]) -> u64 {
151    let mut hasher = DefaultHasher::new();
152    bytes.hash(&mut hasher);
153    hasher.finish()
154}
155
156impl Assets {
157    /// No images at all: what a host with no loader supplies.
158    pub fn none() -> Assets {
159        Assets::default()
160    }
161
162    /// Probes every image the book places and every image its sheet
163    /// puts behind a box, the manuscript's own first. A url the
164    /// loader cannot resolve, or bytes no probe recognises, is a
165    /// diagnostic and no asset.
166    ///
167    /// A sheet that names no background image offers the loader
168    /// nothing beyond what the manuscript named.
169    pub fn probe(book: &Book, styles: &StyleTree, loader: &dyn ImageLoader) -> Assets {
170        let mut assets = Assets::default();
171        for section in &book.sections {
172            assets.walk(&section.blocks, loader);
173        }
174        for background in backgrounds(styles) {
175            assets.take(background, loader);
176        }
177        assets
178    }
179
180    /// Registers one image the host handed over, and what that did:
181    /// the same bytes it already had, new bytes at a fresh or
182    /// existing index, or bytes no probe recognises.
183    ///
184    /// This is the door for a host that pushes: a worker has no
185    /// loader to reach back through, so images cross the wall the
186    /// way font files do. A url pushed again with the bytes it
187    /// already answers for costs nothing; pushed again with
188    /// different bytes, it replaces them in place, keeping the index
189    /// `DrawItem::Image.asset` already names.
190    pub fn add(&mut self, url: &str, bytes: Vec<u8>) -> Added {
191        let hash = content_hash(&bytes);
192        if let Some(index) = self.assets.iter().position(|asset| asset.url == url) {
193            if self.hashes[index] == hash {
194                return Added::Unchanged(index as u32);
195            }
196            return match probe(&bytes) {
197                Some(intrinsic) => {
198                    let previous = self.assets[index].intrinsic;
199                    self.assets[index] = Asset {
200                        url: url.to_string(),
201                        intrinsic,
202                    };
203                    self.files[index] = bytes;
204                    self.hashes[index] = hash;
205                    self.refused.remove(url);
206                    Added::Replaced {
207                        index: index as u32,
208                        previous: Some(previous),
209                        current: intrinsic,
210                    }
211                }
212                None => {
213                    self.refuse(url, None);
214                    Added::Refused
215                }
216            };
217        }
218        match probe(&bytes) {
219            Some(intrinsic) => {
220                self.refused.remove(url);
221                self.assets.push(Asset {
222                    url: url.to_string(),
223                    intrinsic,
224                });
225                self.files.push(bytes);
226                self.hashes.push(hash);
227                Added::Replaced {
228                    index: self.assets.len() as u32 - 1,
229                    previous: None,
230                    current: intrinsic,
231                }
232            }
233            None => {
234                self.refuse(url, None);
235                Added::Refused
236            }
237        }
238    }
239
240    /// The asset registered for a url, and its index.
241    pub fn lookup(&self, url: &str) -> Option<(u32, Intrinsic)> {
242        self.assets
243            .iter()
244            .position(|asset| asset.url == url)
245            .map(|index| (index as u32, self.assets[index].intrinsic))
246    }
247
248    /// Whether a url has been probed: an asset came of it, or a
249    /// complaint about why none did. A url nobody offered is neither,
250    /// and is what layout reports.
251    pub fn probed(&self, url: &str) -> bool {
252        self.refused.contains(url) || self.lookup(url).is_some()
253    }
254
255    /// Every asset, in the order `DrawItem::Image.asset` indexes them.
256    pub fn assets(&self) -> &[Asset] {
257        &self.assets
258    }
259
260    /// The file one asset was probed from: the bytes a painter
261    /// embeds, unchanged from what the host handed over.
262    pub fn bytes(&self, index: u32) -> Option<&[u8]> {
263        self.files.get(index as usize).map(Vec::as_slice)
264    }
265
266    /// What one asset's file hashes to: what tells a trace of the
267    /// bytes an index holds now from a trace of the bytes it held
268    /// before.
269    pub fn digest(&self, index: u32) -> Option<u64> {
270        self.hashes.get(index as usize).copied()
271    }
272
273    /// What probing had to complain about.
274    pub fn warnings(&self) -> &[Warning] {
275        &self.warnings
276    }
277
278    fn refuse(&mut self, url: &str, origin: Option<String>) {
279        if self.refused.insert(url.to_string()) {
280            self.warnings.push(Warning {
281                message: format!("Image {url} has no size in it. The image is skipped."),
282                origin,
283            });
284        }
285    }
286
287    /// Probes one url the sheet named, at the position it was
288    /// written.
289    fn take(&mut self, url: &Url, loader: &dyn ImageLoader) {
290        if self.probed(&url.value) {
291            return;
292        }
293        match loader.load(&url.value) {
294            Some(bytes) => match probe(&bytes) {
295                Some(intrinsic) => {
296                    self.assets.push(Asset {
297                        url: url.value.clone(),
298                        intrinsic,
299                    });
300                    self.hashes.push(content_hash(&bytes));
301                    self.files.push(bytes);
302                }
303                None => self.refuse(&url.value, url.origin.clone()),
304            },
305            None => self.refuse(&url.value, url.origin.clone()),
306        }
307    }
308
309    fn walk(&mut self, blocks: &[Block], loader: &dyn ImageLoader) {
310        for block in blocks {
311            match block {
312                Block::Image { url, position, .. } => {
313                    if self.probed(url) {
314                        continue;
315                    }
316                    let origin = Some(crate::content::origin(None, *position));
317                    match loader.load(url) {
318                        Some(bytes) => match probe(&bytes) {
319                            Some(intrinsic) => {
320                                self.assets.push(Asset {
321                                    url: url.clone(),
322                                    intrinsic,
323                                });
324                                self.hashes.push(content_hash(&bytes));
325                                self.files.push(bytes);
326                            }
327                            None => self.refuse(url, origin),
328                        },
329                        None => self.refuse(url, origin),
330                    }
331                }
332                Block::Blockquote { blocks, .. } => self.walk(blocks, loader),
333                Block::Table { head, body, .. } => {
334                    for blocks in crate::content::cell_blocks(head, body) {
335                        self.walk(blocks, loader);
336                    }
337                }
338                _ => {}
339            }
340        }
341    }
342}
343
344/// Every image a book's sheet puts behind a box: one per block style
345/// and one per page master, in a stable order.
346pub fn backgrounds(styles: &StyleTree) -> Vec<&Url> {
347    styles
348        .styles()
349        .iter()
350        .filter_map(|style| style.background.image.as_ref())
351        .chain(
352            styles
353                .masters()
354                .iter()
355                .filter_map(|master| master.style.background.image.as_ref()),
356        )
357        .collect()
358}
359
360/// Every contour a book's sheet asked for, traced once and kept by
361/// asset.
362///
363/// This is the trace stage. Nothing is traced because it is an image.
364/// It is traced because a rule that matched it resolved to
365/// `shape-outside: auto`, and that is not reachable from where
366/// probing happens: probing takes a book and a loader, and neither
367/// has seen the style tree.
368///
369/// The stage sits above line breaking, because a contour that moved
370/// is a measure that moved. It keeps what an earlier run traced, so a
371/// sheet edit that leaves the contours where they were decodes
372/// nothing.
373#[derive(Debug, Default)]
374pub struct Contours {
375    traced: BTreeMap<u32, Traced>,
376    warned: BTreeSet<String>,
377    warnings: Vec<Warning>,
378}
379
380/// Whether one node's styling asks for a contour the flow can read.
381///
382/// Both halves matter. A sheet that names `auto` on an element it
383/// leaves in the flow asks for a contour nothing sets beside, and an
384/// image is not decoded to answer that.
385fn traceable(style: &ComputedStyle) -> bool {
386    style.shape_outside == ShapeOutside::Auto && style.excludes()
387}
388
389/// One asset's trace, and the bytes it was traced from.
390#[derive(Debug)]
391struct Traced {
392    digest: u64,
393    contour: Option<Contour>,
394}
395
396impl Contours {
397    /// Nothing traced: what a book whose sheet names no contour
398    /// carries.
399    pub fn none() -> Contours {
400        Contours::default()
401    }
402
403    /// Traces every asset the cascade asks for and this table does not
404    /// already answer for, and reports how many images it decoded.
405    ///
406    /// A book whose sheet names no contour decodes nothing, and so
407    /// does one whose contours are all traced already.
408    pub fn update(&mut self, book: &Book, styles: &StyleTree, assets: &Assets) -> u32 {
409        fn walk(
410            contours: &mut Contours,
411            blocks: &[Block],
412            source: Option<&str>,
413            styles: &StyleTree,
414            assets: &Assets,
415            traced: &mut u32,
416        ) {
417            for block in blocks {
418                match block {
419                    Block::Blockquote { blocks, .. } => {
420                        walk(contours, blocks, source, styles, assets, traced)
421                    }
422                    Block::Table { head, body, .. } => {
423                        for blocks in crate::content::cell_blocks(head, body) {
424                            walk(contours, blocks, source, styles, assets, traced);
425                        }
426                    }
427                    Block::Image {
428                        id, url, position, ..
429                    } if traceable(styles.style(*id)) => {
430                        let Some(((index, _), digest)) = assets
431                            .lookup(url)
432                            .and_then(|found| Some((found, assets.digest(found.0)?)))
433                        else {
434                            continue;
435                        };
436                        if contours
437                            .traced
438                            .get(&index)
439                            .is_some_and(|kept| kept.digest == digest)
440                        {
441                            continue;
442                        }
443                        let contour = assets.bytes(index).and_then(trace);
444                        *traced += 1;
445                        if contour.is_none() && contours.warned.insert(url.clone()) {
446                            let origin = crate::content::origin(source, *position);
447                            contours.warnings.push(Warning {
448                                message: format!(
449                                    "Missing alpha channel in {url}. Text wraps around the \
450                                     image rectangle."
451                                ),
452                                origin: (!origin.is_empty()).then_some(origin),
453                            });
454                        }
455                        contours.traced.insert(index, Traced { digest, contour });
456                    }
457                    _ => {}
458                }
459            }
460        }
461        let mut traced = 0;
462        for section in &book.sections {
463            walk(
464                self,
465                &section.blocks,
466                section.source.as_deref(),
467                styles,
468                assets,
469                &mut traced,
470            );
471        }
472        traced
473    }
474
475    /// The contour one asset traced to, and `None` where it traced to
476    /// nothing or was never asked for.
477    pub fn get(&self, asset: u32) -> Option<&Contour> {
478        self.traced.get(&asset)?.contour.as_ref()
479    }
480
481    /// Whether this table holds a trace of one asset, whatever the
482    /// trace found.
483    pub fn traces(&self, asset: u32) -> bool {
484        self.traced.contains_key(&asset)
485    }
486
487    /// What tracing had to complain about.
488    pub fn warnings(&self) -> &[Warning] {
489        &self.warnings
490    }
491}
492
493/// The outline prose sets around, in the image's own box.
494///
495/// A ring is a closed loop of points, `(0, 0)` at the top left of the
496/// image and `(1, 1)` at its bottom right. An image whose alpha
497/// leaves two shapes with clear space between them traces to two
498/// rings, and the prose sets through the space.
499#[derive(Debug, Clone, PartialEq)]
500pub struct Contour {
501    /// The rings, each closed by its own first point.
502    pub rings: Vec<Vec<[f32; 2]>>,
503}
504
505/// How many rows a trace reports, so that the outline one image
506/// traces to is the same size whatever the image's pixel count is.
507const ROWS: u32 = 128;
508
509/// The largest image the tracer decodes. A file above this contributes
510/// its box, because the buffer a decode wants is the picture again in
511/// memory and a contour is not worth that.
512const CEILING: u64 = 32 * 1024 * 1024;
513
514/// The alpha channel of one image, one byte to a pixel.
515struct Mask {
516    width: u32,
517    height: u32,
518    alpha: Vec<u8>,
519}
520
521impl Mask {
522    /// The first and last covered pixel of every row, `None` for a
523    /// row nothing covers.
524    fn rows(&self) -> Vec<Option<(u32, u32)>> {
525        (0..self.height)
526            .map(|y| {
527                let row = &self.alpha[(y * self.width) as usize..][..self.width as usize];
528                let first = row.iter().position(|alpha| *alpha > 0)? as u32;
529                let last = row.iter().rposition(|alpha| *alpha > 0)? as u32;
530                Some((first, last + 1))
531            })
532            .collect()
533    }
534}
535
536/// Traces one image's alpha channel. `None` where the format carries
537/// no alpha, where the file does not decode, or where it is larger
538/// than the tracer decodes.
539///
540/// The outline is reported over a fixed number of bands rather than
541/// over the image's own rows, so two images of the same shape and
542/// different resolutions trace to the same size of outline.
543pub fn trace(bytes: &[u8]) -> Option<Contour> {
544    let mask = mask(bytes)?;
545    let rows = mask.rows();
546    let bands: Vec<Option<(f32, f32)>> = (0..ROWS)
547        .map(|band| {
548            let from = (band as u64 * mask.height as u64 / ROWS as u64) as usize;
549            let to = ((band as u64 + 1) * mask.height as u64 / ROWS as u64).max(from as u64 + 1);
550            rows.get(from..(to as usize).min(rows.len()))?
551                .iter()
552                .flatten()
553                .copied()
554                .reduce(|(left, right), (start, end)| (left.min(start), right.max(end)))
555                .map(|(left, right)| {
556                    (
557                        left as f32 / mask.width as f32,
558                        right as f32 / mask.width as f32,
559                    )
560                })
561        })
562        .collect();
563    Some(Contour {
564        rings: rings(&bands),
565    })
566}
567
568/// One ring for every run of bands the alpha covers, in reading
569/// order. A band nothing covers ends the ring above it, which is what
570/// lets prose set through a gap in the image.
571fn rings(bands: &[Option<(f32, f32)>]) -> Vec<Vec<[f32; 2]>> {
572    let mut rings = Vec::new();
573    let mut run: Vec<(usize, (f32, f32))> = Vec::new();
574    let height = bands.len() as f32;
575    let close = |run: &mut Vec<(usize, (f32, f32))>, rings: &mut Vec<Vec<[f32; 2]>>| {
576        if run.is_empty() {
577            return;
578        }
579        let edge = |side: fn(&(f32, f32)) -> f32, run: &[(usize, (f32, f32))]| {
580            let mut points: Vec<[f32; 2]> = Vec::new();
581            for (band, span) in run {
582                let (top, bottom) = (*band as f32 / height, (*band + 1) as f32 / height);
583                let x = side(span);
584                // A band no wider than the one above it needs no
585                // corner of its own.
586                match points.last_mut() {
587                    Some(last) if last[0] == x => last[1] = bottom,
588                    _ => {
589                        points.push([x, top]);
590                        points.push([x, bottom]);
591                    }
592                }
593            }
594            points
595        };
596        let mut ring = edge(|span| span.0, run);
597        let mut right = edge(|span| span.1, run);
598        right.reverse();
599        ring.append(&mut right);
600        rings.push(ring);
601        run.clear();
602    };
603    for (band, span) in bands.iter().enumerate() {
604        match span {
605            Some(span) => run.push((band, *span)),
606            None => close(&mut run, &mut rings),
607        }
608    }
609    close(&mut run, &mut rings);
610    rings
611}
612
613/// The alpha channel of one file, for the formats that carry one.
614fn mask(bytes: &[u8]) -> Option<Mask> {
615    let intrinsic = probe(bytes)?;
616    if intrinsic.width as u64 * intrinsic.height as u64 > CEILING {
617        return None;
618    }
619    png_mask(bytes)
620        .or_else(|| webp_mask(bytes))
621        .or_else(|| gif_mask(bytes))
622}
623
624/// PNG. `normalize_to_color8` expands a palette and a `tRNS` chunk,
625/// so an alpha channel arrives however the file wrote it.
626fn png_mask(bytes: &[u8]) -> Option<Mask> {
627    let mut decoder = png::Decoder::new(bytes);
628    decoder.set_transformations(png::Transformations::normalize_to_color8());
629    let mut reader = decoder.read_info().ok()?;
630    let (channels, offset) = match reader.output_color_type() {
631        (png::ColorType::Rgba, _) => (4, 3),
632        (png::ColorType::GrayscaleAlpha, _) => (2, 1),
633        _ => return None,
634    };
635    let mut buffer = vec![0u8; reader.output_buffer_size()];
636    let info = reader.next_frame(&mut buffer).ok()?;
637    Some(Mask {
638        width: info.width,
639        height: info.height,
640        alpha: buffer[..(info.width * info.height * channels) as usize]
641            .iter()
642            .skip(offset)
643            .step_by(channels as usize)
644            .copied()
645            .collect(),
646    })
647}
648
649/// WebP, when the file declares an alpha channel.
650fn webp_mask(bytes: &[u8]) -> Option<Mask> {
651    let mut decoder = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).ok()?;
652    if !decoder.has_alpha() {
653        return None;
654    }
655    let (width, height) = decoder.dimensions();
656    let mut buffer = vec![0u8; decoder.output_buffer_size()?];
657    decoder.read_image(&mut buffer).ok()?;
658    Some(Mask {
659        width,
660        height,
661        alpha: buffer.iter().skip(3).step_by(4).copied().collect(),
662    })
663}
664
665/// GIF, whose transparency is one palette entry. The first frame is
666/// the image, and a frame smaller than the screen leaves the rest of
667/// it clear.
668fn gif_mask(bytes: &[u8]) -> Option<Mask> {
669    let mut options = gif::DecodeOptions::new();
670    options.set_color_output(gif::ColorOutput::RGBA);
671    let mut decoder = options.read_info(bytes).ok()?;
672    let (width, height) = (decoder.width() as u32, decoder.height() as u32);
673    let frame = decoder.read_next_frame().ok()??;
674    let mut alpha = vec![0u8; (width * height) as usize];
675    for y in 0..frame.height as u32 {
676        for x in 0..frame.width as u32 {
677            let (at_x, at_y) = (x + frame.left as u32, y + frame.top as u32);
678            if at_x >= width || at_y >= height {
679                continue;
680            }
681            alpha[(at_y * width + at_x) as usize] =
682                frame.buffer[((y * frame.width as u32 + x) * 4 + 3) as usize];
683        }
684    }
685    Some(Mask {
686        width,
687        height,
688        alpha,
689    })
690}
691
692/// Reads an image's size from its header. `None` for a format no
693/// probe recognises, or a header too short to read one from.
694pub fn probe(bytes: &[u8]) -> Option<Intrinsic> {
695    png(bytes)
696        .or_else(|| jpeg(bytes))
697        .or_else(|| gif(bytes))
698        .or_else(|| webp(bytes))
699}
700
701fn be32(bytes: &[u8], at: usize) -> Option<u32> {
702    let slice: [u8; 4] = bytes.get(at..at + 4)?.try_into().ok()?;
703    Some(u32::from_be_bytes(slice))
704}
705
706fn be16(bytes: &[u8], at: usize) -> Option<u16> {
707    let slice: [u8; 2] = bytes.get(at..at + 2)?.try_into().ok()?;
708    Some(u16::from_be_bytes(slice))
709}
710
711fn le16(bytes: &[u8], at: usize) -> Option<u16> {
712    let slice: [u8; 2] = bytes.get(at..at + 2)?.try_into().ok()?;
713    Some(u16::from_le_bytes(slice))
714}
715
716/// PNG: `IHDR` is always the first chunk, and `pHYs` — when it is
717/// there — says how many pixels go to a metre.
718fn png(bytes: &[u8]) -> Option<Intrinsic> {
719    if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.get(12..16)? != b"IHDR" {
720        return None;
721    }
722    let mut intrinsic = Intrinsic {
723        width: be32(bytes, 16)?,
724        height: be32(bytes, 20)?,
725        dpi_x: CSS_DPI,
726        dpi_y: CSS_DPI,
727    };
728    let mut at = 8usize;
729    while let (Some(length), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) {
730        if kind == b"pHYs" {
731            let data = at + 8;
732            // Unit 1 is the metre; anything else is an aspect ratio
733            // with no absolute size in it.
734            if bytes.get(data + 8) == Some(&1) {
735                let (x, y) = (be32(bytes, data)?, be32(bytes, data + 4)?);
736                if x > 0 && y > 0 {
737                    intrinsic.dpi_x = x as f32 * 0.0254;
738                    intrinsic.dpi_y = y as f32 * 0.0254;
739                }
740            }
741            break;
742        }
743        if kind == b"IDAT" {
744            break;
745        }
746        at = at.checked_add(12)?.checked_add(length as usize)?;
747    }
748    Some(intrinsic)
749}
750
751/// JPEG: walk the marker segments to the frame header, picking up the
752/// JFIF density on the way.
753fn jpeg(bytes: &[u8]) -> Option<Intrinsic> {
754    if !bytes.starts_with(&[0xFF, 0xD8]) {
755        return None;
756    }
757    let (mut dpi_x, mut dpi_y) = (CSS_DPI, CSS_DPI);
758    let mut at = 2usize;
759    loop {
760        if bytes.get(at) != Some(&0xFF) {
761            return None;
762        }
763        let marker = *bytes.get(at + 1)?;
764        // Padding fill bytes, and the standalone markers with no
765        // segment at all.
766        if marker == 0xFF {
767            at += 1;
768            continue;
769        }
770        if (0xD0..=0xD9).contains(&marker) || marker == 0x01 {
771            at += 2;
772            continue;
773        }
774        let length = be16(bytes, at + 2)? as usize;
775        let data = at + 4;
776        if marker == 0xE0 && bytes.get(data..data + 5) == Some(b"JFIF\0") {
777            let (x, y) = (be16(bytes, data + 8)?, be16(bytes, data + 10)?);
778            let per_inch = match bytes.get(data + 7) {
779                Some(1) => Some(1.0),
780                Some(2) => Some(2.54),
781                _ => None,
782            };
783            if let Some(scale) = per_inch
784                && x > 0
785                && y > 0
786            {
787                dpi_x = x as f32 * scale;
788                dpi_y = y as f32 * scale;
789            }
790        }
791        // SOF0-SOF15, less the three markers that share their range
792        // and are not frame headers.
793        if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
794            return Some(Intrinsic {
795                width: be16(bytes, data + 3)? as u32,
796                height: be16(bytes, data + 1)? as u32,
797                dpi_x,
798                dpi_y,
799            });
800        }
801        if marker == 0xDA {
802            return None;
803        }
804        at = at.checked_add(2)?.checked_add(length)?;
805    }
806}
807
808/// GIF: the logical screen descriptor, which has no resolution in it.
809fn gif(bytes: &[u8]) -> Option<Intrinsic> {
810    if !bytes.starts_with(b"GIF87a") && !bytes.starts_with(b"GIF89a") {
811        return None;
812    }
813    Some(Intrinsic {
814        width: le16(bytes, 6)? as u32,
815        height: le16(bytes, 8)? as u32,
816        dpi_x: CSS_DPI,
817        dpi_y: CSS_DPI,
818    })
819}
820
821/// WebP: the extended header when there is one, the lossy or lossless
822/// frame header otherwise. None of the three records a resolution.
823fn webp(bytes: &[u8]) -> Option<Intrinsic> {
824    if !bytes.starts_with(b"RIFF") || bytes.get(8..12)? != b"WEBP" {
825        return None;
826    }
827    let size = |width: u32, height: u32| {
828        Some(Intrinsic {
829            width,
830            height,
831            dpi_x: CSS_DPI,
832            dpi_y: CSS_DPI,
833        })
834    };
835    match bytes.get(12..16)? {
836        b"VP8X" => {
837            let at = 24;
838            let three = |from: usize| -> Option<u32> {
839                let bytes = bytes.get(from..from + 3)?;
840                Some(u32::from(bytes[0]) | u32::from(bytes[1]) << 8 | u32::from(bytes[2]) << 16)
841            };
842            size(three(at)? + 1, three(at + 3)? + 1)
843        }
844        b"VP8 " => {
845            // The keyframe's start code, then the dimensions with two
846            // scale bits above each of them.
847            if bytes.get(23..26)? != [0x9D, 0x01, 0x2A] {
848                return None;
849            }
850            size(
851                (le16(bytes, 26)? & 0x3FFF) as u32,
852                (le16(bytes, 28)? & 0x3FFF) as u32,
853            )
854        }
855        b"VP8L" => {
856            if bytes.get(20) != Some(&0x2F) {
857                return None;
858            }
859            let bits = u32::from_le_bytes(bytes.get(21..25)?.try_into().ok()?);
860            size((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)
861        }
862        _ => None,
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use crate::content::Attributes;
870
871    /// A table loads what the host handed over, by its url, and
872    /// nothing it refused.
873    #[test]
874    fn a_table_loads_the_bytes_a_url_was_registered_with() {
875        let mut assets = Assets::none();
876        let png = png_bytes(4, 2, None);
877        assets.add("a.png", png.clone());
878        assets.add("b.bmp", b"BM....".to_vec());
879        assert_eq!(ImageLoader::load(&assets, "a.png"), Some(png));
880        assert_eq!(ImageLoader::load(&assets, "b.bmp"), None);
881        assert_eq!(ImageLoader::load(&assets, "c.png"), None);
882    }
883
884    /// A PNG header: signature, `IHDR`, and optionally a `pHYs`
885    /// declaring pixels per metre.
886    fn png_bytes(width: u32, height: u32, ppm: Option<u32>) -> Vec<u8> {
887        let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
888        bytes.extend(13u32.to_be_bytes());
889        bytes.extend(b"IHDR");
890        bytes.extend(width.to_be_bytes());
891        bytes.extend(height.to_be_bytes());
892        bytes.extend([8, 6, 0, 0, 0]);
893        bytes.extend([0, 0, 0, 0]); // crc
894        if let Some(ppm) = ppm {
895            bytes.extend(9u32.to_be_bytes());
896            bytes.extend(b"pHYs");
897            bytes.extend(ppm.to_be_bytes());
898            bytes.extend(ppm.to_be_bytes());
899            bytes.push(1);
900            bytes.extend([0, 0, 0, 0]);
901        }
902        bytes.extend(0u32.to_be_bytes());
903        bytes.extend(b"IDAT");
904        bytes
905    }
906
907    /// A JPEG header: an optional JFIF density, then a baseline frame.
908    fn jpeg_bytes(width: u16, height: u16, density: Option<u16>) -> Vec<u8> {
909        let mut bytes = vec![0xFF, 0xD8];
910        if let Some(density) = density {
911            bytes.extend([0xFF, 0xE0, 0x00, 0x10]);
912            bytes.extend(b"JFIF\0");
913            bytes.extend([1, 2, 1]); // version, units: inches
914            bytes.extend(density.to_be_bytes());
915            bytes.extend(density.to_be_bytes());
916            bytes.extend([0, 0]);
917        }
918        bytes.extend([0xFF, 0xC0, 0x00, 0x11, 0x08]);
919        bytes.extend(height.to_be_bytes());
920        bytes.extend(width.to_be_bytes());
921        bytes
922    }
923
924    /// An RGBA PNG whose alpha `covered` decides, pixel by pixel.
925    fn rgba_png(width: u32, height: u32, covered: impl Fn(u32, u32) -> bool) -> Vec<u8> {
926        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
927        for y in 0..height {
928            for x in 0..width {
929                pixels.extend([0x33, 0x44, 0x55, if covered(x, y) { 0xFF } else { 0 }]);
930            }
931        }
932        let mut bytes = Vec::new();
933        let mut encoder = png::Encoder::new(&mut bytes, width, height);
934        encoder.set_color(png::ColorType::Rgba);
935        encoder.set_depth(png::BitDepth::Eight);
936        let mut writer = encoder.write_header().expect("the header writes");
937        writer.write_image_data(&pixels).expect("the pixels write");
938        writer.finish().expect("the file closes");
939        bytes
940    }
941
942    /// The leftmost and rightmost point a contour reaches between two
943    /// heights, as a fraction of the image's width.
944    fn span(contour: &Contour, top: f32, bottom: f32) -> Option<(f32, f32)> {
945        let mut reach: Option<(f32, f32)> = None;
946        for ring in &contour.rings {
947            for point in ring {
948                if point[1] < top || point[1] > bottom {
949                    continue;
950                }
951                reach = Some(match reach {
952                    None => (point[0], point[0]),
953                    Some((left, right)) => (left.min(point[0]), right.max(point[0])),
954                });
955            }
956        }
957        reach
958    }
959
960    /// The alpha channel decides the contour: a shape that covers half
961    /// of every row traces to a contour half the width of the image,
962    /// and one that widens down the image traces to a contour that
963    /// widens with it.
964    #[test]
965    fn an_alpha_channel_traces_to_the_shape_it_covers() {
966        let half = trace(&rgba_png(64, 64, |x, _| x < 32)).expect("a PNG with alpha traces");
967        assert_eq!(half.rings.len(), 1);
968        assert_eq!(span(&half, 0.0, 1.0), Some((0.0, 0.5)));
969
970        // A wedge: one pixel wide at the top, the whole width at the
971        // bottom.
972        let wedge = trace(&rgba_png(64, 64, |x, y| x <= y)).expect("a PNG with alpha traces");
973        let (_, top) = span(&wedge, 0.0, 0.1).expect("the top of the wedge");
974        let (_, bottom) = span(&wedge, 0.9, 1.0).expect("the foot of the wedge");
975        assert!(top < 0.2, "the wedge opens narrow: {top}");
976        assert!(bottom > 0.9, "and closes wide: {bottom}");
977    }
978
979    /// Clear space across the image ends one ring and opens another,
980    /// so the prose can set through the gap.
981    #[test]
982    fn clear_space_across_an_image_splits_the_contour() {
983        let split = trace(&rgba_png(64, 64, |_, y| !(24..40).contains(&y)))
984            .expect("a PNG with alpha traces");
985        assert_eq!(split.rings.len(), 2, "{:?}", split.rings.len());
986        assert_eq!(span(&split, 0.4, 0.6), None, "nothing covers the gap");
987    }
988
989    /// An image with nothing in it traces to no rings at all, which is
990    /// a contour the prose sets straight through.
991    #[test]
992    fn an_empty_alpha_channel_traces_to_nothing() {
993        let empty = trace(&rgba_png(16, 16, |_, _| false)).expect("a PNG with alpha traces");
994        assert!(empty.rings.is_empty());
995    }
996
997    /// A format that carries no alpha traces to nothing, and its box
998    /// is what the prose keeps clear of. So do bytes that do not
999    /// decode.
1000    #[test]
1001    fn a_format_without_alpha_traces_to_nothing() {
1002        assert!(trace(&jpeg_bytes(64, 64, None)).is_none());
1003        assert!(trace(&png_bytes(64, 64, None)).is_none(), "a bare header");
1004        assert!(trace(b"not an image").is_none());
1005        assert!(trace(b"").is_none());
1006    }
1007
1008    /// The same picture in two formats traces to the same contour:
1009    /// the outline is the image's shape, not the file's.
1010    #[test]
1011    fn one_shape_in_two_formats_traces_the_same() {
1012        let png = trace(include_bytes!("../../../fixtures/images/fleuron.png"))
1013            .expect("the ornament traces");
1014        let webp = trace(include_bytes!("../../../fixtures/images/fleuron.webp"))
1015            .expect("the ornament traces");
1016        assert_eq!(png.rings.len(), 1, "the ornament is one shape");
1017        for (top, bottom) in [(0.0, 0.25), (0.25, 0.5), (0.5, 0.75), (0.75, 1.0)] {
1018            let (one, two) = (span(&png, top, bottom), span(&webp, top, bottom));
1019            let (one, two) = (one.expect("the PNG covers it"), two.expect("the WebP does"));
1020            assert!(
1021                (one.0 - two.0).abs() < 0.02 && (one.1 - two.1).abs() < 0.02,
1022                "between {top} and {bottom}: {one:?} against {two:?}",
1023            );
1024        }
1025        // The ornament is set on a clear ground, so its contour is
1026        // narrower than its box.
1027        let widest = (0..8)
1028            .filter_map(|band| span(&png, band as f32 / 8.0, (band as f32 + 1.0) / 8.0))
1029            .fold(0.0f32, |widest, (left, right)| widest.max(right - left));
1030        assert!(widest < 0.95, "the contour is the box: {widest}");
1031    }
1032
1033    /// A GIF's transparent palette entry is an alpha channel like any
1034    /// other.
1035    #[test]
1036    fn a_transparent_palette_entry_traces_like_an_alpha_channel() {
1037        let (width, height) = (32u16, 32u16);
1038        let indices: Vec<u8> = (0..height)
1039            .flat_map(|y| (0..width).map(move |x| u8::from(x >= y)))
1040            .collect();
1041        let mut bytes = Vec::new();
1042        {
1043            let mut encoder =
1044                gif::Encoder::new(&mut bytes, width, height, &[0, 0, 0, 0x33, 0x44, 0x55])
1045                    .expect("the header writes");
1046            let frame = gif::Frame {
1047                width,
1048                height,
1049                buffer: std::borrow::Cow::Borrowed(&indices),
1050                transparent: Some(0),
1051                ..gif::Frame::default()
1052            };
1053            encoder.write_frame(&frame).expect("the frame writes");
1054        }
1055        let traced = trace(&bytes).expect("a GIF with a transparent entry traces");
1056        assert_eq!(traced.rings.len(), 1);
1057        let (left, _) = span(&traced, 0.0, 0.1).expect("the top of the wedge");
1058        let (foot, _) = span(&traced, 0.9, 1.0).expect("the foot of it");
1059        assert!(left < 0.1, "the wedge opens at the left edge: {left}");
1060        assert!(foot > 0.8, "and closes at the right: {foot}");
1061    }
1062
1063    /// Two runs over one image trace the same bytes to the same
1064    /// contour.
1065    #[test]
1066    fn tracing_is_deterministic() {
1067        let bytes = rgba_png(48, 32, |x, y| (x + y) % 17 < 9);
1068        assert_eq!(trace(&bytes), trace(&bytes));
1069    }
1070
1071    /// Every format the probe recognises, read back at its declared size.
1072    #[test]
1073    fn headers_give_up_their_dimensions() {
1074        assert_eq!(
1075            probe(&png_bytes(640, 480, None)).map(|i| (i.width, i.height)),
1076            Some((640, 480)),
1077        );
1078        assert_eq!(
1079            probe(&jpeg_bytes(1200, 900, None)).map(|i| (i.width, i.height)),
1080            Some((1200, 900)),
1081        );
1082        let mut gif = b"GIF89a".to_vec();
1083        gif.extend(320u16.to_le_bytes());
1084        gif.extend(200u16.to_le_bytes());
1085        assert_eq!(probe(&gif).map(|i| (i.width, i.height)), Some((320, 200)));
1086
1087        let mut webp = b"RIFF\0\0\0\0WEBPVP8X".to_vec();
1088        webp.extend([0, 0, 0, 0, 0, 0, 0, 0]); // chunk size and flags
1089        webp.extend([0x3F, 0x00, 0x00, 0x1F, 0x00, 0x00]); // 64 x 32, less one
1090        assert_eq!(probe(&webp).map(|i| (i.width, i.height)), Some((64, 32)));
1091    }
1092
1093    /// A header with no resolution in it is measured at 96dpi, and one
1094    /// with a resolution is measured at that: the same pixels, a
1095    /// different number of points.
1096    #[test]
1097    fn resolution_decides_the_intrinsic_size() {
1098        let bare = probe(&png_bytes(192, 96, None)).unwrap();
1099        assert_eq!(bare.size(), (144.0, 72.0));
1100        // 11811 pixels per metre is 300dpi.
1101        let dense = probe(&png_bytes(600, 300, Some(11811))).unwrap();
1102        assert!((dense.dpi_x - 300.0).abs() < 0.1, "{}", dense.dpi_x);
1103        assert!((dense.size().0 - 144.0).abs() < 0.1, "{:?}", dense.size());
1104        let inches = probe(&jpeg_bytes(600, 300, Some(300))).unwrap();
1105        assert_eq!(inches.dpi_x, 300.0);
1106        assert!((inches.size().0 - 144.0).abs() < 0.1);
1107    }
1108
1109    /// Bytes that are not an image, and truncated headers, are `None`
1110    /// rather than a panic.
1111    #[test]
1112    fn unknown_and_truncated_bytes_probe_to_nothing() {
1113        assert!(probe(b"").is_none());
1114        assert!(probe(b"not an image at all").is_none());
1115        let png = png_bytes(4, 4, None);
1116        for cut in 0..png.len() {
1117            let _ = probe(&png[..cut]);
1118        }
1119        let jpeg = jpeg_bytes(4, 4, Some(72));
1120        for cut in 0..jpeg.len() {
1121            let _ = probe(&jpeg[..cut]);
1122        }
1123    }
1124
1125    /// A host that pushes gets the same table a loader would have
1126    /// filled: the header sizes the image, the file is kept for the
1127    /// painter, and bytes no probe recognises are one complaint and no
1128    /// asset.
1129    #[test]
1130    fn pushed_images_are_probed_and_kept() {
1131        let mut assets = Assets::none();
1132        let png = png_bytes(96, 48, None);
1133        let a = assets.add("a.png", png.clone());
1134        assert!(matches!(
1135            a,
1136            Added::Replaced {
1137                index: 0,
1138                previous: None,
1139                ..
1140            }
1141        ));
1142        let b = assets.add("b.jpg", jpeg_bytes(200, 100, None));
1143        assert!(matches!(
1144            b,
1145            Added::Replaced {
1146                index: 1,
1147                previous: None,
1148                ..
1149            }
1150        ));
1151        // The same url and the same bytes twice is the same asset,
1152        // not a second copy, and costs nothing.
1153        assert_eq!(assets.add("a.png", png.clone()), Added::Unchanged(0));
1154        assert_eq!(assets.bytes(0), Some(png.as_slice()));
1155        assert_eq!(assets.lookup("a.png").map(|(index, _)| index), Some(0));
1156
1157        assert_eq!(
1158            assets.add("c.txt", b"not an image".to_vec()),
1159            Added::Refused
1160        );
1161        assert!(assets.probed("c.txt"), "a refusal counts as probed");
1162        assert!(!assets.probed("d.png"), "a url nobody offered does not");
1163        assert_eq!(assets.warnings().len(), 1);
1164        assert!(assets.warnings()[0].message.contains("c.txt"));
1165        // Offered twice, complained about once.
1166        assert_eq!(assets.add("c.txt", b"still not".to_vec()), Added::Refused);
1167        assert_eq!(assets.warnings().len(), 1);
1168    }
1169
1170    /// The same url registered again with different bytes replaces
1171    /// them in place, keeping the index `DrawItem::Image.asset`
1172    /// already names, and says what the size was before so a caller
1173    /// can tell whether the box an image takes moved.
1174    #[test]
1175    fn different_bytes_at_the_same_url_replace_it_in_place() {
1176        let mut assets = Assets::none();
1177        assets.add("a.png", png_bytes(96, 48, None));
1178
1179        let same_size = assets.add("a.png", png_bytes(96, 48, Some(150)));
1180        let Added::Replaced {
1181            index,
1182            previous,
1183            current,
1184        } = same_size
1185        else {
1186            panic!("different bytes at a registered url did not replace it: {same_size:?}");
1187        };
1188        assert_eq!(index, 0, "the index moved");
1189        assert_eq!(previous.map(|i| (i.width, i.height)), Some((96, 48)));
1190        assert_eq!((current.width, current.height), (96, 48));
1191        assert_eq!(
1192            assets.bytes(0),
1193            Some(png_bytes(96, 48, Some(150)).as_slice())
1194        );
1195
1196        let resized = assets.add("a.png", png_bytes(200, 100, None));
1197        let Added::Replaced {
1198            index,
1199            previous,
1200            current,
1201        } = resized
1202        else {
1203            panic!("a resize did not replace the asset: {resized:?}");
1204        };
1205        assert_eq!(index, 0);
1206        assert_eq!(previous.map(|i| (i.width, i.height)), Some((96, 48)));
1207        assert_eq!((current.width, current.height), (200, 100));
1208    }
1209
1210    /// The book's images are probed once each, in document order,
1211    /// through the host's loader; one the loader cannot resolve is a
1212    /// diagnostic and no asset.
1213    #[test]
1214    fn assets_index_the_book_in_document_order() {
1215        struct Two;
1216        impl ImageLoader for Two {
1217            fn load(&self, url: &str) -> Option<Vec<u8>> {
1218                match url {
1219                    "a.png" => Some(png_bytes(96, 48, None)),
1220                    "b.jpg" => Some(jpeg_bytes(200, 100, None)),
1221                    _ => None,
1222                }
1223            }
1224        }
1225
1226        let image = |url: &str| Block::Image {
1227            id: crate::content::NodeId::UNASSIGNED,
1228            url: url.into(),
1229            alt: String::new(),
1230            attributes: Attributes::default(),
1231            position: None,
1232            span: None,
1233        };
1234        let mut book = Book {
1235            metadata: Default::default(),
1236            sections: vec![crate::content::Section {
1237                attributes: Default::default(),
1238                blocks: vec![
1239                    image("a.png"),
1240                    Block::Blockquote {
1241                        id: crate::content::NodeId::UNASSIGNED,
1242                        blocks: vec![image("b.jpg")],
1243                        attributes: Attributes::default(),
1244                        position: None,
1245                        span: None,
1246                    },
1247                    image("a.png"),
1248                    image("missing.png"),
1249                ],
1250                ..Default::default()
1251            }],
1252        };
1253        book.assign_node_ids();
1254        let styles = crate::style::defaults(
1255            &book,
1256            crate::fonts::bundled_registry()
1257                .as_ref()
1258                .expect("the bundled face parses"),
1259        );
1260        let assets = Assets::probe(&book, &styles, &Two);
1261        assert_eq!(assets.assets().len(), 2, "a.png was probed twice");
1262        assert_eq!(assets.lookup("a.png").map(|(index, _)| index), Some(0));
1263        assert_eq!(assets.lookup("b.jpg").map(|(index, _)| index), Some(1));
1264        assert_eq!(assets.lookup("missing.png"), None);
1265        assert_eq!(assets.warnings().len(), 1);
1266        assert!(assets.warnings()[0].message.contains("missing.png"));
1267    }
1268
1269    /// A book of one paragraph, with no image in it.
1270    fn plain_book() -> Book {
1271        let mut book = Book {
1272            metadata: Default::default(),
1273            sections: vec![crate::content::Section {
1274                attributes: Default::default(),
1275                blocks: vec![Block::Paragraph {
1276                    id: crate::content::NodeId::UNASSIGNED,
1277                    inlines: vec![crate::content::Inline::Text {
1278                        id: crate::content::NodeId::UNASSIGNED,
1279                        value: "Nothing is placed here.".into(),
1280                        attributes: Attributes::default(),
1281                        position: None,
1282                        span: None,
1283                    }],
1284                    attributes: Attributes::default(),
1285                    position: None,
1286                    span: None,
1287                }],
1288                ..Default::default()
1289            }],
1290        };
1291        book.assign_node_ids();
1292        book
1293    }
1294
1295    /// One book under one sheet.
1296    fn styled(book: &Book, css: &str) -> StyleTree {
1297        let registry = crate::fonts::bundled_registry().expect("the bundled face parses");
1298        let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1299            "background.css",
1300            css,
1301        )])
1302        .compile(book, &registry);
1303        assert!(
1304            styles.warnings().is_empty(),
1305            "the sheet is in the subset: {:?}",
1306            styles.warnings(),
1307        );
1308        styles
1309    }
1310
1311    /// A loader that counts what it was asked for.
1312    struct Counting(std::cell::RefCell<Vec<String>>);
1313
1314    impl ImageLoader for Counting {
1315        fn load(&self, url: &str) -> Option<Vec<u8>> {
1316            self.0.borrow_mut().push(url.to_string());
1317            (url != "missing.png").then(|| png_bytes(96, 48, None))
1318        }
1319    }
1320
1321    /// A url the sheet names reaches the asset table, from a block
1322    /// rule and from `@page` alike, and the manuscript's own images
1323    /// are indexed first.
1324    #[test]
1325    fn a_url_the_sheet_names_reaches_the_asset_table() {
1326        let book = plain_book();
1327        let styles = styled(
1328            &book,
1329            "@page { background-image: url(\"scan.png\") }\n\
1330             p { background-image: url(tint.png) }",
1331        );
1332        let loader = Counting(std::cell::RefCell::new(Vec::new()));
1333        let assets = Assets::probe(&book, &styles, &loader);
1334
1335        let mut named: Vec<&str> = assets.assets().iter().map(|a| a.url.as_str()).collect();
1336        named.sort_unstable();
1337        assert_eq!(named, ["scan.png", "tint.png"]);
1338        assert!(assets.warnings().is_empty(), "{:?}", assets.warnings());
1339    }
1340
1341    /// A book whose sheet names no background image offers the loader
1342    /// nothing of its own: the cascade is read, and nothing is probed
1343    /// for it.
1344    #[test]
1345    fn a_sheet_that_names_no_background_probes_nothing() {
1346        let book = plain_book();
1347        let styles = styled(&book, "p { background-color: #f4f1ea }");
1348        let loader = Counting(std::cell::RefCell::new(Vec::new()));
1349        let assets = Assets::probe(&book, &styles, &loader);
1350
1351        assert!(
1352            loader.0.borrow().is_empty(),
1353            "the loader was asked for {:?}",
1354            loader.0.borrow(),
1355        );
1356        assert!(assets.assets().is_empty());
1357    }
1358
1359    /// A url nothing resolves warns, and the warning names the line
1360    /// and column the sheet wrote it at.
1361    #[test]
1362    fn a_url_nothing_resolves_names_where_it_was_written() {
1363        let book = plain_book();
1364        let styles = styled(&book, "p {\n  background-image: url(missing.png);\n}");
1365        let loader = Counting(std::cell::RefCell::new(Vec::new()));
1366        let assets = Assets::probe(&book, &styles, &loader);
1367
1368        assert!(assets.assets().is_empty());
1369        assert_eq!(assets.warnings().len(), 1);
1370        assert!(assets.warnings()[0].message.contains("missing.png"));
1371        assert_eq!(
1372            assets.warnings()[0].origin.as_deref(),
1373            Some("background.css:2:3"),
1374        );
1375    }
1376
1377    /// An asset probed through a loader hashes the same as one pushed
1378    /// through `add`: registering it again, with the bytes it already
1379    /// has or with different ones, does not panic and answers the
1380    /// same way either door would have.
1381    #[test]
1382    fn probed_and_pushed_assets_share_one_hash_table() {
1383        struct One;
1384        impl ImageLoader for One {
1385            fn load(&self, url: &str) -> Option<Vec<u8>> {
1386                (url == "a.png").then(|| png_bytes(96, 48, None))
1387            }
1388        }
1389
1390        let image = |url: &str| Block::Image {
1391            id: crate::content::NodeId::UNASSIGNED,
1392            url: url.into(),
1393            alt: String::new(),
1394            attributes: Attributes::default(),
1395            position: None,
1396            span: None,
1397        };
1398        let mut book = Book {
1399            metadata: Default::default(),
1400            sections: vec![crate::content::Section {
1401                attributes: Default::default(),
1402                blocks: vec![image("a.png")],
1403                ..Default::default()
1404            }],
1405        };
1406        book.assign_node_ids();
1407        let styles = crate::style::defaults(
1408            &book,
1409            crate::fonts::bundled_registry()
1410                .as_ref()
1411                .expect("the bundled face parses"),
1412        );
1413        let mut assets = Assets::probe(&book, &styles, &One);
1414
1415        assert_eq!(
1416            assets.add("a.png", png_bytes(96, 48, None)),
1417            Added::Unchanged(0),
1418            "the same bytes probed and pushed hash the same"
1419        );
1420        let resized = assets.add("a.png", png_bytes(200, 100, None));
1421        assert!(
1422            matches!(resized, Added::Replaced { index: 0, .. }),
1423            "different bytes replaced the probed asset in place: {resized:?}"
1424        );
1425    }
1426}