mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
A PDF's DeviceCMYK image opened as RGB (the interpreter's pixels as a PNG), so every export wrote it as DeviceRGB or ICC RGB, silently. Images now keep their CMYK samples, as CMYK colours already did: - Model: CMYK images are blobs holding CMYK samples, a four-component JPEG (Adobe's inverted samples) or a Deflate CMYK TIFF. vectorcraft-doc's cmyk module reads their ink amounts (ImageBlob::is_cmyk, ::cmyk, capped at 64 megapixels) and writes the TIFF (ImageBlob::cmyk_tiff). - PDF import: DeviceCMYK, CalCMYK and four-component ICC images keep their samples. A JPEG with Decode [1 0 1 0 1 0 1 0] is kept byte for byte; any other is decoded (8 or 16 bits, its Decode array applied) and stored as a CMYK TIFF. Masked CMYK images and 1/2/4-bit ones open in RGB with a warning. Placing a CMYK TIFF keeps it CMYK too. - PDF export: CMYK images are written in CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged: PDF/X-3, PDF/X-4, PDF/A). A CMYK JPEG neither resampled nor recompressed passes through unchanged; others are resampled in CMYK and compressed as ZIP or as a CMYK JPEG (jpeg-encoder, which also replaces the ZIP fallback and its warning for images converted to CMYK). Output conversion treats them like CMYK colours: Preserve Numbers and PDF/X-1a keep their numbers, Convert to Destination converts them to another CMYK profile or to RGB through a 17^4 lookup table (cms::CmykLut, now also behind the renderer's ink table). A CMYK image that can't be decoded is reported in the warnings. - Canvas: CMYK images show through the working CMYK profile, as CMYK fills do, and paint their own inks in ink planes (CMYK raster exports, CMYK blending). SVG export writes TIFF images as PNG. Illustrator's Output › No Conversion keeps colour data as it is (https://helpx.adobe.com/illustrator/using/pdf-options.html). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
4.8 KiB
Rust
87 lines
4.8 KiB
Rust
//! SVG Options across each other: every styling, id mode and tspan mode reads back, and linked
|
||
//! images are named after their bytes (and are formats browsers show).
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, ImageBlob, ImageObject, Node, NodeKind, TextKind, TextObject, TextRun};
|
||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||
use vectorcraft_svg::{ExportOptions, ImageMode, ObjectIds, Styling, export, export_full, import};
|
||
|
||
/// A red rectangle with an awkward name, point type and area type of two paragraphs each.
|
||
fn doc() -> Document {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let red = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::BLACK), 2.0);
|
||
let mut a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), red);
|
||
a.name = Some("A \"quoted\" & <odd> 100%".into());
|
||
d.insert(Some(l), usize::MAX, a).unwrap();
|
||
let st = CharStyle { size: 12.0, font_family: "Source Sans 3".into(), fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
|
||
let mut t = TextObject::point(Point::new(20.0, 120.0), "", st.clone());
|
||
t.runs = vec![TextRun { text: "one two\nthree".into(), style: st.clone() }];
|
||
let i = d.alloc_id();
|
||
d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t.clone())))).unwrap();
|
||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 60.0)) };
|
||
t.xf = Affine::translate((20.0, 140.0));
|
||
t.runs = vec![TextRun { text: "para one\npara two".into(), style: st }];
|
||
let i = d.alloc_id();
|
||
d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t)))).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn every_combination_reads_back() {
|
||
let d = doc();
|
||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||
for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] {
|
||
for (fewer_tspans, minify) in [(false, false), (true, true)] {
|
||
let o = ExportOptions { styling, object_ids, fewer_tspans, minify, ..Default::default() };
|
||
let s = export(&d, &o);
|
||
let back = import(&s).unwrap_or_else(|e| panic!("{o:?}: {e}\n{s}"));
|
||
let mut red = false;
|
||
back.walk(|n| {
|
||
red |= n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint == Paint::solid(Color::rgb8(255, 0, 0))));
|
||
});
|
||
assert!(red, "{o:?}: the fill came back\n{s}");
|
||
// Paragraph breaks are new lines, never characters in a line.
|
||
assert!(!s.contains("\n</tspan>") && !s.contains("two\n"), "{o:?}\n{s}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A document with one embedded image under `key`.
|
||
fn image_doc(key: &str, bytes: Vec<u8>) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
d.images.insert(key.into(), ImageBlob::new("image/png", bytes));
|
||
let l = d.layers[0].id;
|
||
let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
|
||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn linked_images_are_named_after_their_bytes() {
|
||
let link = ExportOptions { images: ImageMode::Link, ..Default::default() };
|
||
// Keys such as "raster-1" repeat across documents: two exports into one folder must not clash.
|
||
let a = export_full(&image_doc("raster-1", vec![1, 2, 3]), &link, None);
|
||
let b = export_full(&image_doc("raster-1", vec![4, 5, 6]), &link, None);
|
||
assert_eq!((a.linked.len(), b.linked.len()), (1, 1));
|
||
assert_ne!(a.linked[0].name, b.linked[0].name);
|
||
assert!(a.linked[0].name.ends_with(".png") && a.svg.contains(&format!("href=\"{}\"", a.linked[0].name)), "{}", a.svg);
|
||
// The same bytes under another key are the same file.
|
||
assert_eq!(export_full(&image_doc("other", vec![1, 2, 3]), &link, None).linked[0].name, a.linked[0].name);
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_tiffs_are_written_as_png() {
|
||
let inks = vectorcraft_doc::cmyk::Inks::new(2, 2, [0u8, 0, 0, 0].repeat(4)).unwrap();
|
||
let mut d = image_doc("cmyk", vec![]);
|
||
d.images.insert("cmyk".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
|
||
// Browsers don't show TIFF: embedded and linked images are PNG.
|
||
assert!(export(&d, &ExportOptions::default()).contains("href=\"data:image/png;base64,"));
|
||
let linked = export_full(&d, &ExportOptions { images: ImageMode::Link, ..Default::default() }, None);
|
||
assert!(linked.linked[0].name.ends_with(".png"), "{}", linked.linked[0].name);
|
||
}
|