1use 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
35pub trait ImageLoader {
40 fn load(&self, url: &str) -> Option<Vec<u8>>;
43}
44
45pub struct NoImages;
47
48impl ImageLoader for NoImages {
49 fn load(&self, _url: &str) -> Option<Vec<u8>> {
50 None
51 }
52}
53
54impl 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
63pub const CSS_DPI: f32 = 96.0;
66
67#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
70pub struct Intrinsic {
71 pub width: u32,
73 pub height: u32,
75 pub dpi_x: f32,
77 pub dpi_y: f32,
79}
80
81impl Intrinsic {
82 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct Asset {
98 pub url: String,
100 pub intrinsic: Intrinsic,
102}
103
104#[derive(Debug, Default)]
109pub struct Assets {
110 assets: Vec<Asset>,
111 files: Vec<Vec<u8>>,
115 hashes: Vec<u64>,
119 refused: BTreeSet<String>,
123 warnings: Vec<Warning>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum Added {
129 Unchanged(u32),
131 Replaced {
137 index: u32,
139 previous: Option<Intrinsic>,
141 current: Intrinsic,
143 },
144 Refused,
146}
147
148fn content_hash(bytes: &[u8]) -> u64 {
151 let mut hasher = DefaultHasher::new();
152 bytes.hash(&mut hasher);
153 hasher.finish()
154}
155
156impl Assets {
157 pub fn none() -> Assets {
159 Assets::default()
160 }
161
162 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(§ion.blocks, loader);
173 }
174 for background in backgrounds(styles) {
175 assets.take(background, loader);
176 }
177 assets
178 }
179
180 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 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 pub fn probed(&self, url: &str) -> bool {
252 self.refused.contains(url) || self.lookup(url).is_some()
253 }
254
255 pub fn assets(&self) -> &[Asset] {
257 &self.assets
258 }
259
260 pub fn bytes(&self, index: u32) -> Option<&[u8]> {
263 self.files.get(index as usize).map(Vec::as_slice)
264 }
265
266 pub fn digest(&self, index: u32) -> Option<u64> {
270 self.hashes.get(index as usize).copied()
271 }
272
273 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 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
344pub 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#[derive(Debug, Default)]
374pub struct Contours {
375 traced: BTreeMap<u32, Traced>,
376 warned: BTreeSet<String>,
377 warnings: Vec<Warning>,
378}
379
380fn traceable(style: &ComputedStyle) -> bool {
386 style.shape_outside == ShapeOutside::Auto && style.excludes()
387}
388
389#[derive(Debug)]
391struct Traced {
392 digest: u64,
393 contour: Option<Contour>,
394}
395
396impl Contours {
397 pub fn none() -> Contours {
400 Contours::default()
401 }
402
403 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 §ion.blocks,
466 section.source.as_deref(),
467 styles,
468 assets,
469 &mut traced,
470 );
471 }
472 traced
473 }
474
475 pub fn get(&self, asset: u32) -> Option<&Contour> {
478 self.traced.get(&asset)?.contour.as_ref()
479 }
480
481 pub fn traces(&self, asset: u32) -> bool {
484 self.traced.contains_key(&asset)
485 }
486
487 pub fn warnings(&self) -> &[Warning] {
489 &self.warnings
490 }
491}
492
493#[derive(Debug, Clone, PartialEq)]
500pub struct Contour {
501 pub rings: Vec<Vec<[f32; 2]>>,
503}
504
505const ROWS: u32 = 128;
508
509const CEILING: u64 = 32 * 1024 * 1024;
513
514struct Mask {
516 width: u32,
517 height: u32,
518 alpha: Vec<u8>,
519}
520
521impl Mask {
522 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
536pub 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
568fn 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 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
613fn 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
624fn 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
649fn 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
665fn 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
692pub 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
716fn 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 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
751fn 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 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 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
808fn 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
821fn 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 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 #[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 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]); 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 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]); 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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]); webp.extend([0x3F, 0x00, 0x00, 0x1F, 0x00, 0x00]); assert_eq!(probe(&webp).map(|i| (i.width, i.height)), Some((64, 32)));
1091 }
1092
1093 #[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 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 #[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 #[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 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 assert_eq!(assets.add("c.txt", b"still not".to_vec()), Added::Refused);
1167 assert_eq!(assets.warnings().len(), 1);
1168 }
1169
1170 #[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 #[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 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 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, ®istry);
1303 assert!(
1304 styles.warnings().is_empty(),
1305 "the sheet is in the subset: {:?}",
1306 styles.warnings(),
1307 );
1308 styles
1309 }
1310
1311 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 #[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 #[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 #[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 #[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}