Merge remote-tracking branch 'upstream/main' into image-perf

# Conflicts:
#	crates/render/src/decode.rs
This commit is contained in:
aiongg committed 2026-11-14 12:47:03 +00:00
commit 829e5b86c3
7 files changed
+127 -68

No files matched your search

Generated
+64 -17
View File
@@ -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",
+1 -1
View File
@@ -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" }
+10
View File
@@ -59,6 +59,16 @@ pub fn write<W: std::io::Write + std::io::Seek>(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<W: std::io::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)]
+16 -7
View File
@@ -583,22 +583,31 @@ pub struct CmykRaster {
pub alpha: Option<Vec<u8>>,
}
/// The TIFF page that decoding opens; `None` for other formats.
fn default_tiff_page(bytes: &[u8]) -> Option<craft_image_codecs::TiffPage> {
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<CmykRaster> {
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()
}
+20 -32
View File
@@ -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<Arc<Vec<u8>>, ConvertedImage>;
@@ -84,13 +83,10 @@ pub(super) fn jpeg(data: krilla::Data, icc: impl FnOnce() -> Option<Vec<u8>>, 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<Vec<u8>>, fmt: Option<craft_image_codecs::Format>, mode: RasterMode) -> Option<Image> {
@@ -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<u8>) {
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::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.filter(|s| s.dict().get::<Name>("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::<Stream<'_>>("SMask").is_some())
.unwrap();
(stream.dict().get::<Name>("ColorSpace").unwrap().as_str().to_owned(), stream.decoded().unwrap().into_owned())
}
@@ -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<u8> = 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<u8> {
+3 -1
View File
@@ -11,7 +11,9 @@ pub fn cmyk_tiff(alpha: Option<u16>, other_inks: bool) -> Vec<u8> {
if let Some(kind) = alpha {
let mut image = encoder.new_image::<CMYKA8>(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::<CMYK8>(2, 1).unwrap();
if other_inks {