diff --git a/Cargo.lock b/Cargo.lock index 37c5b42bc..b8cbdd0df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,13 +161,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -644,9 +659,9 @@ version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", + "brotli-decompressor 5.0.3", ] [[package]] @@ -655,8 +670,18 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -1273,9 +1298,9 @@ dependencies = [ [[package]] name = "craft-image-codecs" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" +source = "git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f#cc23ffd4d26f3074808e2187d332564817624f8f" dependencies = [ - "craft-jpeg 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "craft-jpeg 0.1.0 (git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f)", "exr", "flate2", "half", @@ -1295,9 +1320,10 @@ dependencies = [ [[package]] name = "craft-image-codecs" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f#cc23ffd4d26f3074808e2187d332564817624f8f" +source = "git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6#ea1c84290dadec636900b4d1bce107e59ca3f0d6" dependencies = [ - "craft-jpeg 0.1.0 (git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f)", + "craft-jpeg 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6)", + "craft-metadata", "exr", "flate2", "half", @@ -1347,7 +1373,7 @@ dependencies = [ [[package]] name = "craft-jpeg" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" +source = "git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f#cc23ffd4d26f3074808e2187d332564817624f8f" dependencies = [ "thiserror 2.0.21", ] @@ -1355,7 +1381,7 @@ dependencies = [ [[package]] name = "craft-jpeg" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f#cc23ffd4d26f3074808e2187d332564817624f8f" +source = "git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6#ea1c84290dadec636900b4d1bce107e59ca3f0d6" dependencies = [ "thiserror 2.0.21", ] @@ -1389,6 +1415,18 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-metadata" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6#ea1c84290dadec636900b4d1bce107e59ca3f0d6" +dependencies = [ + "brotli-decompressor 6.3.1", + "chrono", + "craft-tiff", + "quick-xml 0.41.0", + "serde", +] + [[package]] name = "craft-offcrypto" version = "0.1.0" @@ -1573,6 +1611,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-tiff" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6#ea1c84290dadec636900b4d1bce107e59ca3f0d6" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1990,7 +2037,7 @@ name = "designcraft-cli" version = "0.20.1" dependencies = [ "craft-file-formats", - "craft-image-codecs 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "craft-image-codecs 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6)", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -2165,7 +2212,7 @@ dependencies = [ "craft-cms", "craft-eps 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf)", "craft-file-formats", - "craft-image-codecs 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "craft-image-codecs 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6)", "craft-psd", "craft-svg 0.1.0 (git+https://github.com/storytold/craft-libs?rev=cc23ffd4d26f3074808e2187d332564817624f8f)", "designcraft-color", @@ -2199,7 +2246,7 @@ version = "0.20.1" dependencies = [ "chrono", "craft-cms", - "craft-image-codecs 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "craft-image-codecs 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6)", "craft-pdf", "craft-prepress", "designcraft-color", @@ -2224,7 +2271,7 @@ name = "designcraft-pptx" version = "0.20.1" dependencies = [ "chrono", - "craft-image-codecs 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "craft-image-codecs 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6)", "craft-opc 0.1.0 (git+https://github.com/storytold/craft-libs?rev=8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551)", "craft-pptx", "designcraft-color", @@ -2245,7 +2292,7 @@ name = "designcraft-render" version = "0.20.1" dependencies = [ "bytemuck", - "craft-image-codecs 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "craft-image-codecs 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ea1c84290dadec636900b4d1bce107e59ca3f0d6)", "craft-pdf", "designcraft-color", "designcraft-compose", diff --git a/Cargo.toml b/Cargo.toml index bb521f1c4..7f6d06e81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "2b9a325f5 craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "387c64892b30fdad491b263cc3d9eac660d88dcc", version = "=0.1.0" } -craft-image-codecs = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "2e670cf9377b523112f3fe4b409399772c745895", version = "=0.1.0" } +craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "ea1c84290dadec636900b4d1bce107e59ca3f0d6", version = "=0.1.0" } craft-psd = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "2e670cf9377b523112f3fe4b409399772c745895", version = "=0.1.0" } craft-file-formats = { git = "https://github.com/storytold/craft-libs", rev = "eed3c28cf016723d004d6f2c79c61424c15d4558", version = "=0.1.0" } craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551", version = "=0.1.0" } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d42c3d25f..e038a6e64 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -59,6 +59,16 @@ pub fn write(out: &mut W, doc: &Document, pre craft_designcraft_format::write_to(out, &compact, concat!("DesignCraft ", env!("CARGO_PKG_VERSION")), preview, &assets, &proxies) } +/// [`save_with_preview`] into `out`, in chunks, so a writer that stops on cancellation stops the +/// write at its next chunk. +pub fn write(out: &mut W, doc: &Document, preview: Option<&[u8]>) -> Result<(), FormatError> { + let bytes = save_with_preview(doc, preview)?; + for chunk in bytes.chunks(1 << 20) { + out.write_all(chunk).map_err(|e| FormatError::Io(e.to_string()))?; + } + Ok(()) +} + /// Required native format version for a document. Kept for app-side compatibility; inference is /// performed by the shared codec over the serialized neutral document schema. #[cfg(test)] diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index cc13301aa..1017a34d8 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -583,22 +583,31 @@ pub struct CmykRaster { pub alpha: Option>, } +/// The TIFF page that decoding opens; `None` for other formats. +fn default_tiff_page(bytes: &[u8]) -> Option { + let mut info = craft_image_codecs::tiff_info(bytes).ok()?; + let page = info.default_page()?; + (page < info.pages.len()).then(|| info.pages.swap_remove(page)) +} + /// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks). pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { - craft_image_codecs::tiff_info(bytes) - .ok() - .and_then(|info| info.default_page().and_then(|i| info.pages.get(i).map(|page| (page.photometric, page.samples_per_pixel)))) - .is_some_and(|(photometric, samples)| photometric == Some(5) && matches!(samples, 4 | 5)) + default_tiff_page(bytes).is_some_and(|page| page.photometric == Some(5) && matches!(page.samples_per_pixel, 4 | 5)) } /// The ink values of a CMYK TIFF, which [`decode_rgba`] converts to RGB. 8- or 16-bit CMYK, -/// pixels interleaved, opaque or with unassociated alpha. `None` for anything else — another -/// format, another ink set (InkSet 2), planar storage or premultiplied alpha — and for files -/// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files. +/// opaque or with alpha (associated alpha is made straight). `None` for anything else — +/// another format or another ink set (InkSet 2) — and for files over the decoder's 256 MiB +/// limit; [`is_cmyk_tiff`] tells those apart from RGB files. pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { if too_large_to_decode(bytes) { return None; } + // InkSet (tag 332): absent or 1 = CMYK; 2 = the inks named in InkNames; other values are + // undefined. Only CMYK samples are C, M, Y and K; reading others as such prints wrong colours. + if default_tiff_page(bytes).is_some_and(|page| page.ink_set.is_some_and(|set| set != craft_image_codecs::InkSet::Cmyk)) { + return None; + } let image = craft_image_codecs::decode_with(bytes, &decode_options()).ok()?; image.layout().is_cmyk().then(|| cmyk_raster(&image, bytes)).flatten() } diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 5a29f0dd1..92baa75d3 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -17,8 +17,7 @@ pub(super) struct ConvertedImage { pub(super) warning: Option<&'static str>, } -pub(super) const CMYK_FALLBACK_WARNING: &str = - "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)"; +pub(super) const CMYK_FALLBACK_WARNING: &str = "converted to RGB (a CMYK TIFF that uses other inks or is over 256 MiB)"; #[cfg(not(test))] pub(super) type ImageContentCache = HashMap>, ConvertedImage>; @@ -84,13 +83,10 @@ pub(super) fn jpeg(data: krilla::Data, icc: impl FnOnce() -> Option>, mo } /// Whether krilla would write this PNG, GIF or WebP as untagged RGB: not grey, and no profile of -/// its own. +/// its own. The header inspection doesn't read WebP, which has no grey form, so a file it can't +/// read counts as colour. fn untagged_rgb(data: &[u8]) -> bool { - let Some(info) = craft_image_codecs::inspect_raster(data).ok().flatten() else { return false }; - !matches!( - info.color_type, - craft_image_codecs::RasterColorType::Bitmap | craft_image_codecs::RasterColorType::Gray | craft_image_codecs::RasterColorType::GrayAlpha - ) && embedded_icc(data).is_none() + !is_grey(data) && embedded_icc(data).is_none() } pub(super) fn lossless(data: &Arc>, fmt: Option, mode: RasterMode) -> Option { @@ -401,28 +397,18 @@ mod image_content_cache_tests { let opts = PdfOptions { standard, ..Default::default() }; let mut ex = exporter(&d, &cache, &opts); ex.rgb_only = standard == Standard::PdfA2b; - // Unassociated alpha (2) decodes as inks, and as RGB for RGB-only exports. - // Associated alpha is rejected by both paths: retain both failure names. - let succeeds = alpha.is_none() || alpha == Some(2); - let first = ex.load_image(a); - assert_eq!(first.is_some(), succeeds, "{alpha:?} {standard:?}"); - if alpha.is_none() { - let (space, samples) = image_color_samples(first.unwrap()); - assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" }); - assert_eq!(samples, if ex.rgb_only { screen_rgb.clone() } else { crate::image_fixtures::INKS.to_vec() }); - } - assert_eq!(ex.load_image(b).is_some(), succeeds, "{alpha:?} {standard:?}"); - assert_eq!(ex.load_image(a).is_some(), succeeds); - assert_eq!(ex.image_conversions, if succeeds { 1 } else { 2 }); - assert_eq!(ex.images_by_content.len(), usize::from(succeeds)); - if succeeds { - assert!(ex.warnings.is_empty()); - } else { - for name in ["a.tif", "b.tif"] { - assert!(ex.warnings.contains(&format!("image `{name}` could not be decoded and was skipped"))); - assert_eq!(ex.warnings.contains(&format!("{name}: {CMYK_FALLBACK_WARNING}")), !ex.rgb_only); - } - } + // Every alpha kind decodes as inks, and as RGB for RGB-only exports; associated + // alpha (1) is made straight, so it gives the same inks as unassociated (2). + let first = ex.load_image(a).unwrap(); + let (space, samples) = image_color_samples(first); + assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" }, "{alpha:?} {standard:?}"); + let want = if ex.rgb_only { screen_rgb.clone() } else { crate::image_fixtures::INKS.to_vec() }; + assert_eq!(samples, want, "{alpha:?} {standard:?}"); + assert!(ex.load_image(b).is_some(), "{alpha:?} {standard:?}"); + assert!(ex.load_image(a).is_some()); + assert_eq!(ex.image_conversions, 1); + assert_eq!(ex.images_by_content.len(), 1); + assert!(ex.warnings.is_empty()); } } } @@ -430,7 +416,7 @@ mod image_content_cache_tests { // Serialize the converted raster without an archival validator so the RGB-only pixel // contract stays independent of main's existing PDF/A interpolation validation failure. fn image_color_samples(image: Image) -> (String, Vec) { - use craft_pdf::syntax::object::Name; + use craft_pdf::syntax::object::{Name, Stream}; let mut pdf = krilla::Document::new(); let size = Size::from_wh(20.0, 20.0).unwrap(); let mut page = pdf.start_page_with(PageSettings::new(size)); @@ -444,7 +430,9 @@ mod image_content_cache_tests { .objects() .into_iter() .filter_map(|o| o.into_stream()) - .find(|s| s.dict().get::("Subtype").is_some_and(|n| n.as_str() == "Image")) + .filter(|s| s.dict().get::("Subtype").is_some_and(|n| n.as_str() == "Image")) + // An image with alpha comes with its soft mask, another image; take the masked one. + .max_by_key(|s| s.dict().get::>("SMask").is_some()) .unwrap(); (stream.dict().get::("ColorSpace").unwrap().as_str().to_owned(), stream.decoded().unwrap().into_owned()) } diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 9a9691ee5..74431e039 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -240,20 +240,22 @@ fn successful_fallback_warns_for_both_names_and_preserves_rgb_samples() { for (name, x) in [("other-inks-a.tif", 36.0), ("other-inks-b.tif", 66.0)] { place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0); } - let reason = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)"; + let reason = "converted to RGB (a CMYK TIFF that uses other inks or is over 256 MiB)"; let expected_warnings = [format!("other-inks-a.tif: {reason}"), format!("other-inks-b.tif: {reason}")]; + // The fallback is the RGB the screen shows: converted through the working CMYK space. + let rgb: Vec = designcraft_images::decode_rgba(&bytes).unwrap().pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(); for compress_images in [false, true] { let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); assert_eq!(report.warnings, expected_warnings); let images = image_objects(&report.bytes); assert_eq!(images.len(), 1); assert_eq!(images[0].space, "DeviceRGB"); - assert_eq!(images[0].samples, [0, 0, 0, 255, 0, 0]); + assert_eq!(images[0].samples, rgb); assert_eq!(images[0].size, (2, 1)); } let booklet = export_booklet(&d, &Cache::new(), &BookletOptions::default()).unwrap(); assert_eq!(booklet.warnings, expected_warnings); - assert_eq!(image_objects(&booklet.bytes)[0].samples, [0, 0, 0, 255, 0, 0]); + assert_eq!(image_objects(&booklet.bytes)[0].samples, rgb); } #[test] @@ -282,19 +284,20 @@ fn pdfa_rgb_conversion_does_not_poison_later_cmyk_exports() { } #[test] -fn unsupported_associated_alpha_keeps_each_asset_failure_warning() { +fn associated_alpha_exports_straight_inks_once() { + // The fixture stores the inks multiplied by alpha; the export carries them made straight. let bytes = fixtures::cmyk_tiff(Some(1), false); let mut d = Document::new(&Default::default()); for (name, x) in [("alpha-a.tif", 36.0), ("alpha-b.tif", 66.0)] { place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0); } let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); - assert!(image_objects(&report.bytes).is_empty()); - assert_eq!(report.warnings.len(), 4); - for name in ["alpha-a.tif", "alpha-b.tif"] { - assert!(report.warnings.iter().any(|w| w.starts_with(&format!("{name}: converted to RGB")))); - assert!(report.warnings.contains(&format!("image `{name}` could not be decoded and was skipped"))); - } + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let images = image_objects(&report.bytes); + let cmyk: Vec<_> = images.iter().filter(|i| i.space == "DeviceCMYK").collect(); + assert_eq!(cmyk.len(), 1, "{images:?}"); + assert_eq!(cmyk[0].samples, fixtures::INKS); + assert_eq!(cmyk[0].alpha.as_deref(), Some(&fixtures::ALPHA[..])); } fn two_page_pdf() -> Vec { diff --git a/crates/pdf/tests/support/image_fixtures.rs b/crates/pdf/tests/support/image_fixtures.rs index 02dafae20..c59a02e0c 100644 --- a/crates/pdf/tests/support/image_fixtures.rs +++ b/crates/pdf/tests/support/image_fixtures.rs @@ -11,7 +11,9 @@ pub fn cmyk_tiff(alpha: Option, other_inks: bool) -> Vec { if let Some(kind) = alpha { let mut image = encoder.new_image::(2, 1).unwrap(); image.encoder().write_tag(Tag::ExtraSamples, &[kind][..]).unwrap(); - image.write_data(&[0, 0, 0, 255, ALPHA[0], 0, 255, 255, 0, ALPHA[1]]).unwrap(); + // Associated alpha (1) stores each ink multiplied by alpha: 255 at alpha 128 is 128. + let ink = if kind == 1 { 128 } else { 255 }; + image.write_data(&[0, 0, 0, 255, ALPHA[0], 0, ink, ink, 0, ALPHA[1]]).unwrap(); } else { let mut image = encoder.new_image::(2, 1).unwrap(); if other_inks {