Files
vectorcraft/crates/doc/src/links.rs
T
dotCooCoo c5269bd096 .ai import: version 7 linked images open as linked images with a placeholder
A legacy .ai in the version 7 form names a linked image's file with
`(path) modified XG` and draws the image with `XF`, which takes the
operands `XI` takes and has no samples after it (Adobe Illustrator File
Format Specification 7.0, sections 8.2 and 8.3). The reader ignored both
operators, so the image was dropped without a warning.

- `XF` after `XG` becomes a linked image of that file, its pixels placed
  as an `XI` image of the same operands is placed. The file keeps no
  pixels of it, so a placeholder stands in until the file is relinked in
  the Links panel, with a note in the open result. No pixels are read,
  so the image is not held to the pixel limit of `XI`'s samples.
- An `XF` without a file name before it, or with operands `XI` couldn't
  read (no matrix, too few numbers, no pixels, or samples of other than
  1 or 8 bits), is left out with the existing "an image VectorCraft
  can't read was left out" warning. An `XG` applies only to the image
  after it in the same raster object; `() 1 XG` before an embedded `XI`
  keeps that image embedded.
- A PostScript page draws nothing for `XF`, so there the placeholder's
  area is left out of the comparison of the layers with the page, and
  the file keeps its layers. A PDF part draws the image's pixels, so a
  file with one is compared with it there as before, and its layers are
  not used without the PDF part.
- Linked images of different files get image keys of their own even
  when they show the same pixels (the placeholder, or two identical
  placed-file previews), so updating or exporting one file's image
  reads that file only.
- `vectorcraft_doc::links::missing_placeholder` draws the placeholder: a
  light tile, crossed out, as SVG import shows for a missing file.

Tests: reader tests for the placement, the image keys and the cases left
out, an end-to-end test of a legacy file with a linked image, a test of
a file with a PDF part, and a fuzz case for damaged files with `XG` and
`XF`. The new note has a row in each catalog that translates the
placed-file note.
2026-11-05 17:02:57 +07:00

584 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Linked files: where a placed file lives and what it was when last read ([`LinkInfo`]), the
//! low-resolution preview a linked image keeps for when its file can't be read
//! ([`ImageBlob::proxy`]), and every image object of a document ([`Document::visit_images`]).
use std::collections::{BTreeMap, BTreeSet};
use std::io::Cursor;
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize};
use crate::{Document, ImageBlob, ImageObject, Node, NodeKind};
/// The longest side of a linked image's preview (pixels). Smaller images keep no preview: their
/// full pixels are saved instead.
pub const PROXY_SIZE: u32 = 256;
/// The MIME type of previews.
pub const PROXY_MIME: &str = "image/png";
/// A linked file: an image object shows the file at `path` (File → Place with Link) and the
/// document saves only a preview of it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct LinkInfo {
/// The file's absolute path.
pub path: String,
/// The path relative to the document's folder (`/` separators), written on save: how the file
/// is found after the document and its links move together.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative: Option<String>,
/// The file's modification time when last read (milliseconds since the Unix epoch).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<u64>,
/// The file's size when last read (bytes).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
/// The hash of the file's bytes when last read ([`hash_bytes`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
/// The page shown, for a linked document page (1-based).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
}
impl LinkInfo {
/// A link to `path` with nothing known about the file.
pub fn new(path: impl Into<String>) -> Self {
Self { path: path.into(), ..Self::default() }
}
/// The file name: the path's last component, whichever system's separators it uses.
pub fn name(&self) -> &str {
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
}
}
impl From<&str> for LinkInfo {
fn from(path: &str) -> Self {
Self::new(path)
}
}
/// [`ImageObject::link`] from a link object or, as files saved before links had details, a path.
pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Path(String),
Info(LinkInfo),
}
Ok(Option::<Repr>::deserialize(d)?.map(|r| match r {
Repr::Path(path) => LinkInfo::new(path),
Repr::Info(info) => info,
}))
}
/// The FNV-1a offset basis: the hash of no bytes ([`fnv1a`]).
const FNV_BASIS: u64 = 0xcbf29ce484222325;
/// The 64-bit FNV-1a hash `h` of some bytes, carried on over `bytes`.
fn fnv1a(h: u64, bytes: &[u8]) -> u64 {
bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
}
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
pub fn hash_bytes(bytes: &[u8]) -> String {
format!("img{:016x}", fnv1a(FNV_BASIS, bytes))
}
/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time.
pub fn hash_reader(r: impl std::io::Read) -> std::io::Result<String> {
hash_reader_within(r, u64::MAX).map(|(hash, _)| hash)
}
/// [`hash_reader`] of at most `max` bytes, and how many it read: `max` + 1 when there were more (the
/// hash then covers only the first `max` + 1). A file that grew since its size was looked at isn't
/// read to its new end.
pub fn hash_reader_within(r: impl std::io::Read, max: u64) -> std::io::Result<(String, u64)> {
use std::io::Read;
let mut r = r.take(max.saturating_add(1));
let mut buf = vec![0; 1 << 16];
let (mut h, mut read) = (FNV_BASIS, 0u64);
loop {
match r.read(&mut buf) {
Ok(0) => return Ok((format!("img{h:016x}"), read)),
Ok(n) => {
h = fnv1a(h, buf.get(..n).unwrap_or_default());
read = read.saturating_add(n as u64);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}
impl ImageBlob {
/// A blob of encoded image `bytes` of type `mime`, without a preview.
pub fn new(mime: impl Into<String>, bytes: Vec<u8>) -> Self {
Self { mime: mime.into(), bytes: Arc::new(bytes), proxy: None }
}
/// This blob with a preview of at most [`PROXY_SIZE`] pixels a side (PNG), when the image is
/// larger than that and decodes; otherwise unchanged.
pub fn with_proxy(mut self) -> Self {
if self.proxy.is_none() {
self.proxy = proxy_png(&self.bytes).map(Arc::new);
}
self
}
/// This blob after its pixels are decoded once, within [`crate::MAX_RASTER_ALLOC`], to check
/// that they can be read; with `proxy`, it also gets the preview [`Self::with_proxy`] makes,
/// made from that decode. `None` when the pixels can't be read, as with a file cut short that
/// has a header but not all its pixels. An image whose pixels would take more than the
/// allowance as RGBA is not decoded, and one whose decode needs more than the allowance stops
/// decoding; either is returned as it is.
pub fn checked_with_proxy(self, proxy: bool) -> Option<Self> {
self.checked_with_pixels(proxy).map(|(blob, _)| blob)
}
/// [`Self::checked_with_proxy`], with the pixels it decoded: `None` beside the blob when it
/// decoded none (an image past the allowance).
pub fn checked_with_pixels(mut self, proxy: bool) -> Option<(Self, Option<image::DynamicImage>)> {
let max = crate::MAX_RASTER_ALLOC;
let reader = || image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok();
let (w, h) = reader()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) > max / 4 {
return Some((self, None));
}
let mut r = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
r.limits(limits);
let img = match r.decode() {
Ok(img) => img,
Err(image::ImageError::Limits(_)) => return Some((self, None)),
Err(_) => return None,
};
if proxy && self.proxy.is_none() && w.max(h) > PROXY_SIZE {
self.proxy = proxy_of(&img).map(Arc::new);
}
Some((self, Some(img)))
}
/// Are the bytes the preview (the linked file hasn't been read)?
pub fn is_proxy(&self) -> bool {
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
}
}
/// The side of [`missing_placeholder`]'s image, in pixels.
pub const MISSING_SIDE: u32 = 64;
/// What a linked image shows when an importer has no pixels for it (its file is missing or isn't
/// in the data): a light tile, crossed out, [`MISSING_SIDE`] pixels a side, as a PNG. `None` when
/// it can't be encoded.
pub fn missing_placeholder() -> Option<Vec<u8>> {
let last = MISSING_SIDE - 1;
let img = image::RgbaImage::from_fn(MISSING_SIDE, MISSING_SIDE, |x, y| {
let edge = x == 0 || y == 0 || x == last || y == last;
let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1;
image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] })
});
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
/// undecodable.
fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if w.max(h) <= PROXY_SIZE {
return None;
}
// The proxy decodes within the project's raster cap, counted as RGBA like every other
// decode path (#3597): the header alone decides whether the pixels could ever fit, and the
// decoder gets the same budget instead of image's own default.
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
return None;
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
reader.limits(limits);
proxy_of(&reader.decode().ok()?)
}
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
let mut png = Vec::new();
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// `n` and every descendant, opacity-mask art included.
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if let Some(m) = &n.extras().mask {
walk_all(&m.art, f);
}
for c in n.children().into_iter().flatten() {
walk_all(c, f);
}
}
impl Document {
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
/// opacity-mask art included.
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
let roots = self
.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| &p.art));
for root in roots {
walk_all(root, &mut |n| {
if let NodeKind::Image(im) = &n.kind {
f(n, im);
}
});
}
}
/// The keys of image blobs only linked images (or placed documents, always linked) show: what a save writes as their preview.
pub fn linked_only_images(&self) -> BTreeSet<String> {
if self.images.values().all(|b| b.proxy.is_none()) {
return BTreeSet::new();
}
// Per key: (shown linked, shown embedded).
let mut uses: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
self.visit_images(|_, im| {
let u = uses.entry(im.key.as_str()).or_default();
if im.link.is_some() {
u.0 = true;
} else {
u.1 = true;
}
});
// A placed document saves its file's preview the same way.
self.visit_placed(|_, p| uses.entry(p.key.as_str()).or_default().0 = true);
uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect()
}
}
/// Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of
/// the art it replaces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlacementOptions {
#[serde(default)]
pub preserve: Preserve,
/// Where the new art sits in the old bounds (all but [`Preserve::Bounds`]).
#[serde(default)]
pub align: Align,
/// Clip the new art to the old bounds where it is larger.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub clip: bool,
}
/// What a relinked image keeps of the art it replaces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Preserve {
/// Its scale (relative to each file's 100% size), rotation and position.
Transforms,
/// Its bounds: the new art is stretched into them.
#[default]
Bounds,
/// Nothing but its position: the new art at 100%, unrotated.
FileDimensions,
/// Its bounds: the new art scaled proportionally to fit inside them.
Fit,
/// Its bounds: the new art scaled proportionally to fill them.
Fill,
}
impl Preserve {
pub const ALL: [Preserve; 5] = [Preserve::Transforms, Preserve::Bounds, Preserve::FileDimensions, Preserve::Fit, Preserve::Fill];
/// The id commands use (`transforms`, `bounds`, `fileDimensions`, `fit`, `fill`).
pub fn id(self) -> &'static str {
match self {
Preserve::Transforms => "transforms",
Preserve::Bounds => "bounds",
Preserve::FileDimensions => "fileDimensions",
Preserve::Fit => "fit",
Preserve::Fill => "fill",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.id() == id)
}
}
/// A point of the 3×3 alignment grid, row by row from the top left.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Align {
TopLeft,
Top,
TopRight,
Left,
#[default]
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl Align {
pub const ALL: [Align; 9] =
[Align::TopLeft, Align::Top, Align::TopRight, Align::Left, Align::Center, Align::Right, Align::BottomLeft, Align::Bottom, Align::BottomRight];
/// The id commands use (`topLeft` … `bottomRight`).
pub fn id(self) -> &'static str {
match self {
Align::TopLeft => "topLeft",
Align::Top => "top",
Align::TopRight => "topRight",
Align::Left => "left",
Align::Center => "center",
Align::Right => "right",
Align::BottomLeft => "bottomLeft",
Align::Bottom => "bottom",
Align::BottomRight => "bottomRight",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id() == id)
}
/// The fractions (0, ½ or 1) of the free room left of and above the new art.
pub fn fractions(self) -> (f64, f64) {
let i = Self::ALL.iter().position(|a| *a == self).unwrap_or(4);
((i % 3) as f64 / 2.0, (i / 3) as f64 / 2.0)
}
}
impl Document {
/// Rewrite linked file locations in both raster images and placed VectorCraft documents,
/// including those inside symbols, patterns and opacity masks. Used to make packages portable.
pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) {
fn walk(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut LinkInfo)) {
let n = Arc::make_mut(n);
match &mut n.kind {
NodeKind::Image(image) => {
if let Some(link) = &mut image.link {
f(link);
}
}
NodeKind::PlacedDocument(placed) => f(&mut placed.link),
_ => {}
}
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
walk(&mut mask.art, f);
}
for child in n.children_mut().into_iter().flatten() {
walk(child, f);
}
}
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
walk(root, &mut f);
}
}
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
/// art included) for which `pick` holds; only the subtrees holding one are copied.
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
fn holds(n: &Node, pick: &dyn Fn(&ImageObject) -> bool) -> bool {
let mut hit = false;
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::Image(im) if pick(im)));
hit
}
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&ImageObject) -> bool, f: &mut dyn FnMut(&mut ImageObject)) {
if !holds(n, pick) {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::Image(im) = &mut n.kind
&& pick(im)
{
f(im);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
update(&mut m.art, pick, f);
}
for c in n.children_mut().into_iter().flatten() {
update(c, pick, f);
}
}
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NodeId;
use vectorcraft_geom::Affine;
fn png(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) -> NodeId {
let id = d.alloc_id();
let layer = d.layers[0].id;
d.insert(
Some(layer),
0,
Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: key.into(),
width: 4,
height: 4,
xf: Affine::IDENTITY,
link,
placement: Default::default(),
pixels: None,
})),
),
)
.unwrap()
}
#[test]
fn the_placeholder_is_a_png() {
let png = missing_placeholder().unwrap();
assert_eq!(image::load_from_memory(&png).unwrap().width(), MISSING_SIDE);
}
#[test]
fn a_path_string_from_older_files_reads_as_a_link() {
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
let link = old.link.unwrap();
assert_eq!((link.path.as_str(), link.name(), link.hash.as_deref()), ("C:\\art\\photo.png", "photo.png", None));
let new: ImageObject =
serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":{"path":"/a/b.png","size":9,"page":2}}"#).unwrap();
assert_eq!(new.link, Some(LinkInfo { size: Some(9), page: Some(2), ..LinkInfo::new("/a/b.png") }));
let none: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0]}"#).unwrap();
assert_eq!(none.link, None);
// Written as an object, with only what is known.
let v = serde_json::to_value(&new).unwrap();
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
}
#[test]
fn a_reader_hashes_as_its_bytes_do() {
// Several 64 KB reads, and none.
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes));
assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string()));
// Within a size: the whole file at that size; one byte past it when it is longer.
assert_eq!(hash_reader_within(&bytes[..], bytes.len() as u64).unwrap(), (hash_bytes(&bytes), bytes.len() as u64));
assert_eq!(hash_reader_within(&bytes[..], 3).unwrap().1, 4);
}
#[test]
fn large_images_get_a_small_png_preview() {
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
assert_eq!((p.width(), p.height()), (256, 128));
assert!(!big.is_proxy(), "the bytes are the full image");
assert!(ImageBlob::new("image/png", png(40, 20)).with_proxy().proxy.is_none(), "small images keep their pixels");
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
}
/// A checked image's pixels are decoded once: the preview comes from that decode, and an image
/// whose header reads but whose pixels don't is refused.
#[test]
fn a_checked_image_is_refused_when_its_pixels_cant_be_read() {
let full = png(600, 300);
let big = ImageBlob::new("image/png", full.clone()).checked_with_proxy(true).unwrap();
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
assert_eq!((p.width(), p.height()), (256, 128));
assert!(ImageBlob::new("image/png", full.clone()).checked_with_proxy(false).unwrap().proxy.is_none(), "no preview asked for");
assert!(ImageBlob::new("image/png", png(40, 20)).checked_with_proxy(true).unwrap().proxy.is_none(), "small images keep their pixels");
let cut = full[..full.len() / 2].to_vec();
assert_eq!(image::ImageReader::new(Cursor::new(&cut)).with_guessed_format().unwrap().into_dimensions().unwrap(), (600, 300));
assert!(ImageBlob::new("image/png", cut).checked_with_proxy(true).is_none(), "cut to half its length");
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).checked_with_proxy(false).is_none(), "not an image");
}
/// A checked image hands on the pixels it decoded, so they need not be decoded again.
#[test]
fn a_checked_image_hands_on_its_pixels() {
let full = png(600, 300);
let (blob, pixels) = ImageBlob::new("image/png", full.clone()).checked_with_pixels(true).unwrap();
assert!(blob.proxy.is_some(), "the preview is made as checked_with_proxy makes it");
assert_eq!(pixels.map(|p| p.to_rgba8()), Some(image::load_from_memory(&full).unwrap().to_rgba8()));
}
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & u32::from(crc & 1 != 0).wrapping_neg());
}
}
!crc
}
/// A PNG header and nothing else, declaring `w` × `h` 8-bit gray pixels.
fn png_header_declaring(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
let mut chunk = Vec::new();
chunk.extend(b"IHDR");
chunk.extend(w.to_be_bytes());
chunk.extend(h.to_be_bytes());
chunk.extend([8, 0, 0, 0, 0]);
let crc = crc32(&chunk);
out.extend(13u32.to_be_bytes());
out.extend(&chunk);
out.extend(crc.to_be_bytes());
out
}
/// A linked image past the raster cap gets no proxy: the header alone decides (#3597).
#[test]
fn an_image_past_the_raster_cap_gets_no_proxy() {
// 65536 × 65536 gray pixels are 4 GiB as RGBA, past the 2 GiB cap on every target.
let huge = png_header_declaring(1 << 16, 1 << 16);
assert!(proxy_png(&huge).is_none());
// A header within the cap but with no image data decodes to no preview, as before.
let truncated = png_header_declaring(600, 300);
assert!(proxy_png(&truncated).is_none());
}
#[test]
fn only_blobs_no_embedded_image_shows_are_linked_only() {
let mut d = Document::new(100.0, 100.0);
for k in ["a", "b", "c"] {
d.images.insert(k.into(), ImageBlob::new("image/png", png(300, 10)).with_proxy());
}
image(&mut d, "a", Some(LinkInfo::new("/a.png")));
image(&mut d, "b", Some(LinkInfo::new("/b.png")));
image(&mut d, "b", None);
image(&mut d, "c", None);
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
}
}