mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
The Links panel lists every image (thumbnail, name, page, missing/modified/embedded badge) with Show filters, sorting and thumbnail sizes; its buttons and flyout relink (to a file or a folder), go to, update, edit the original, embed and unembed, show in folder and open Placement Options. Link Info shows format, colour space, location, ppi and effective ppi, dimensions, scale, rotation, size and dates. Edit > Edit Original and the Properties panel's image section reach the same commands. Engine: links.list, links.goTo, links.embed, links.unembed, links.info and links.placementOptions; links.relink and links.update now place the new file as the image's Placement Options say (transforms, bounds, file dimensions, fit, fill, the alignment point, clip to the old bounds). Model: ImageObject.placement. UI: links.editOriginal, links.reveal, ui.placementOptionsDialog; Services.open_file and pick_folder on the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
5.1 KiB
Rust
99 lines
5.1 KiB
Rust
//! Image compression and downsampling: images above the threshold resolution are resampled to the
|
||
//! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the
|
||
//! codecs the writer lacks are written as ZIP with a warning.
|
||
|
||
use std::io::Cursor;
|
||
|
||
use image::{DynamicImage, ImageFormat};
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||
use vectorcraft_geom::Affine;
|
||
|
||
use crate::*;
|
||
|
||
fn encoded(img: DynamicImage, format: ImageFormat) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
img.write_to(&mut Cursor::new(&mut out), format).unwrap();
|
||
out
|
||
}
|
||
|
||
/// A 600 × 600 colour photo-like image.
|
||
fn colour() -> DynamicImage {
|
||
image::RgbImage::from_fn(600, 600, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x * y) % 251) as u8])).into()
|
||
}
|
||
|
||
/// A document with image `bytes` (600 × 600 pixels) placed one inch wide: 600 ppi.
|
||
fn doc(bytes: Vec<u8>, mime: &str) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
d.images.insert("img".into(), ImageBlob::new(mime, bytes));
|
||
let xf = Affine::scale(72.0 / 600.0);
|
||
let n = Node::new(
|
||
d.alloc_id(),
|
||
NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() }),
|
||
);
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
d
|
||
}
|
||
|
||
/// The PDF of `d` with Compression options `compression` (content streams left readable) and its
|
||
/// warnings.
|
||
fn pdf(d: &Document, compression: Value) -> (String, Vec<String>) {
|
||
let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap();
|
||
settings.compression.compress_text = false;
|
||
let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap();
|
||
(String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings)
|
||
}
|
||
|
||
#[test]
|
||
fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
|
||
let d = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||
for method in ["average", "subsample", "bicubic"] {
|
||
let (text, warnings) = pdf(&d, json!({"color": {"downsample": method, "ppi": 150, "abovePpi": 225}}));
|
||
assert!(text.contains("/Width 150") && text.contains("/Height 150"), "{method}");
|
||
assert!(warnings.is_empty(), "{warnings:?}");
|
||
}
|
||
// Below the threshold, or without downsampling, the image keeps its pixels.
|
||
for c in [json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 700}}), json!({})] {
|
||
assert!(pdf(&d, c.clone()).0.contains("/Width 600"), "{c}");
|
||
}
|
||
// Greyscale images take the greyscale settings.
|
||
let grey = doc(encoded(colour().grayscale(), ImageFormat::Png), "image/png");
|
||
let (text, _) = pdf(&grey, json!({"color": {"downsample": "bicubic", "ppi": 150}, "gray": {"downsample": "bicubic", "ppi": 100}}));
|
||
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
|
||
}
|
||
|
||
#[test]
|
||
fn jpeg_zip_and_automatic_compression() {
|
||
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||
let jpeg = doc(encoded(colour(), ImageFormat::Jpeg), "image/jpeg");
|
||
let dct = |d: &Document, c: Value| pdf(d, c).0.contains("/DCTDecode");
|
||
assert!(dct(&png, json!({"color": {"compression": "jpeg", "quality": "low"}})), "JPEG compression");
|
||
assert!(!dct(&png, json!({"color": {"compression": "zip"}})) && pdf(&png, json!({})).0.contains("/FlateDecode"), "ZIP");
|
||
assert!(!dct(&png, json!({})), "automatic: lossless for a PNG");
|
||
assert!(dct(&jpeg, json!({})) && dct(&jpeg, json!({"color": {"downsample": "average", "ppi": 150}})), "automatic: a JPEG stays JPEG");
|
||
assert!(!dct(&jpeg, json!({"color": {"compression": "zip"}})), "ZIP re-encodes a JPEG losslessly");
|
||
// JPEG has no transparency: an image with some stays lossless.
|
||
let mut clear = colour().to_rgba8();
|
||
clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0]));
|
||
let clear = doc(encoded(clear.into(), ImageFormat::Png), "image/png");
|
||
let (text, _) = pdf(&clear, json!({"color": {"compression": "jpeg"}}));
|
||
assert!(!text.contains("/DCTDecode") && text.contains("/SMask"));
|
||
}
|
||
|
||
#[test]
|
||
fn codecs_the_writer_lacks_are_zip_with_a_warning() {
|
||
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||
let (text, warnings) = pdf(&png, json!({"color": {"compression": "jpeg2000"}}));
|
||
assert!(!text.contains("/JPXDecode") && text.contains("/FlateDecode"));
|
||
assert!(warnings.len() == 1 && warnings[0].contains("JPEG 2000"), "{warnings:?}");
|
||
// Black-and-white images are monochrome: their settings apply, CCITT becomes ZIP.
|
||
let bw = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 7 + y / 5) % 2 == 0 { 0 } else { 255 }]));
|
||
let bw = doc(encoded(bw.into(), ImageFormat::Png), "image/png");
|
||
let (text, warnings) = pdf(&bw, json!({"mono": {"downsample": "bicubic", "ppi": 300, "abovePpi": 450, "compression": "ccittG4"}}));
|
||
assert!(text.contains("/Width 300") && !text.contains("/CCITTFaxDecode"));
|
||
assert!(warnings.len() == 1 && warnings[0].contains("CCITT"), "{warnings:?}");
|
||
// The codecs of other kinds of images say nothing.
|
||
assert!(pdf(&png, json!({"mono": {"compression": "ccittG4"}})).1.is_empty());
|
||
}
|