Skip to content

M10: exported images keep the page's CMYK and ICC colours (#2269) - #3023

Open
guillehr2 wants to merge 2 commits into
storytold:mainfrom
guillehr2:fix/2269-exported-image-colour
Open

guillehr2 wants to merge 2 commits into
storytold:mainfrom
guillehr2:fix/2269-exported-image-colour

Conversation

@guillehr2

@guillehr2 guillehr2 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Fixes #2269.

The issue doesn't include the comparison image, so I built a synthetic PDF for each common kind of image. For each one I compared the centre pixel the page renderer draws with the same pixel in the exported file. Export all images, Save Image As and Word export all write images through pdfcraft_create::image_file, so they had the same problem.

Two things were wrong:

  • CMYK. Export converted CMYK with its own formula, (1 − c)(1 − k). The page converts it through the renderer's CMYK profile. Cyan ink came out as (0, 255, 255) where the page draws (0, 174, 239). Magenta was up to 115 levels off, and black ink came out pure black.
  • ICC profiles. Export ignored ICCBased profiles: an Adobe RGB or Display P3 image came out as if it were sRGB, about 30 levels off.

Some JPEGs were also written as they are, so viewers and Word showed them in other colours: CMYK JPEGs, and JPEGs drawn through an ICC profile the file doesn't carry.

The fix reuses the renderer's own conversion rather than writing a second one:

  • Vendored hayro-interpret patch (39). color::device_cmyk_to_srgb and color::IccToSrgb make public the conversions the renderer already uses for DeviceCMYK and ICCBased. Both check that input and output hold the same number of pixels.

  • pdfcraft-render wrapper. pdfcraft_render::cmyk_to_srgb and IccProfile wrap them, catching panics like the rest of the crate. This keeps hayro behind pdfcraft-render, the bootstrap rule (ADR-0004).

  • pdfcraft-create (extract.rs). Image export now depends on pdfcraft-render (L4 → L3, which xtask layers allows) and converts through it:

    • DeviceCMYK;
    • ICCBased grey, RGB and CMYK;
    • indexed palettes over those spaces, converted once;
    • 16-bit and inverted CMYK;
    • /Matte colours, now converted like the image, the way the renderer does.
  • JPEGs written as they are. A JPEG is still written unchanged when the file shows the page's colours:

    • grey or RGB in a device space or an sRGB profile. Since the second commit, such a file is written without any ICC profile it carries itself (its APP2 ICC_PROFILE segments): the page ignores that profile, and a viewer would apply it. Every other byte is kept, so it stays a lossless JPEG;
    • a file that carries the very ICC profile the page uses (for CMYK, with its samples stored inverted, as JPEG files store CMYK). PdfCraft's own image → PDF → images round trip therefore still gives back the original JPEGs.

    CMYK JPEGs, and JPEGs drawn through a profile the file doesn't carry, are now decoded and written as PNG in the page's colours.

Cases tested (renderer centre pixel against export, before → after)

Image Before After
DeviceRGB, DeviceGray, 4-bit RGB, 16-bit RGB exact exact
RGB and grey JPEG (written as is) exact exact
/Decode [1 0 1 0 1 0] on RGB exact exact
Indexed over DeviceRGB exact exact
RGB with a soft mask exact exact
DeviceCMYK: cyan, magenta, yellow, black, mixed inks wrong, up to 115 levels (naive formula) exact
16-bit CMYK; CMYK stored inverted with an inverting /Decode wrong, 24 levels exact
CMYK JPEG; YCCK JPEG wrong, 24–81 levels exact
Inverted Adobe CMYK JPEG with /Decode [1 0 …] (written as is) wrong, 23–81 levels in the viewer exact (PNG)
ICCBased Adobe RGB, Display P3 wrong, 30–32 levels (profile ignored) exact
ICCBased CMYK; CMYK JPEG under ICCBased wrong, 24 levels exact
RGB JPEG under ICCBased Adobe RGB, profile not in the file (written as is) wrong, 31 levels exact (PNG)
Indexed over DeviceCMYK; over Adobe RGB wrong, 24–32 levels exact
CMYK with a soft mask; with /Matte wrong, 12–55 levels exact
ICCBased with a sRGB or grey gamma 2.2 profile exact exact
CalRGB, CalGray wrong, 6–78 levels unchanged (follow-up)
Lab skipped ("can't be exported yet") unchanged (follow-up)

CalGray/CalRGB and Lab images need more of the renderer's conversions made public (they are built from their dictionaries). They are left for a follow-up to keep this change focused, and the parity notes say so.

How you verified it

  • cargo fmt --check
  • clippy with -D warnings on the crates I changed: cargo clippy -p pdfcraft-create -p pdfcraft-render --all-targets -- -D warnings
  • tests pass on the crates I changed:
    • cargo test -p pdfcraft-create: 34 passed;
    • cargo test -p pdfcraft-render: 302 passed, plus 4 in search and 1 ignored in jbig2_invariants, as before.
  • cargo xtask layers (OK, 37 crates), cargo xtask assets (ok), cargo xtask parity (ok)

Tests. All of them compare against the page renderer as the oracle:

  • create::tests::exported_images_show_the_colours_the_page_draws: draws and exports 17 kinds of image and checks the colours agree within one level.
  • create::tests::jpegs_that_carry_the_profile_the_page_uses_are_written_unchanged: the JPEG round trip, and the cases that still need converting.
  • create::tests::device_jpegs_are_written_without_the_profile_the_page_ignores (second commit, after review): a DeviceRGB or DeviceGray JPEG carrying an ICC profile is written as the same JPEG without it. The cases are a whole profile, a profile split into two chunks, and a profile next to an APP2 that isn't one, which stays. A JPEG without a profile stays byte for byte.
  • create::tests::damaged_and_mismatched_icc_profiles_export_as_the_page_draws_them: garbage, truncated and empty profiles, and profiles with a different number of components than /N. The export follows the page and nothing panics.
  • cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows (Some images are exported as negative #1640) now checks the page's cyan, not pure (0, 255, 255).
  • render::colour::tests::{cmyk,icc}_samples_convert_to_the_colours_pages_draw cover patch 39.

I checked that the tests fail before the fix by reverting it in place:

  • main's extract.rs. The four create tests go red:
    • in the 17-case table, all 14 CMYK and ICC cases are wrong, while the three controls (DeviceRGB, DeviceGray, an RGB JPEG) agree;
    • in the damaged-profile test, "an RGB profile for four components" is wrong: the page reads it through the profile, while export used the naive CMYK formula.
  • Patch 39 swapped for the naive formula and a pass-through. Both render tests go red.
  • The second commit's extract.rs reverted. device_jpegs_are_written_without_the_profile_the_page_ignores goes red at its first case: the exported JPEG still carries the Adobe RGB profile.

Not run locally: the wasm check and the workspace-wide sweep (the disk is shared; CI runs both). Word export calls the same image_file, at crates/engine/src/compare.rs:195, and I didn't add an engine-level test for it.

Never-crash

  • No new unwrap/expect/panic!/unreachable!/todo! outside tests.

  • No indexing with input-derived positions:

    • palette entries use get;
    • the zero fill for a short table slices a 4-byte array by the base space's component count (1, 3 or 4 by construction, as before).

    Buffers stay bounded by MAX_PIXELS as before. The decoded samples are freed before the conversion, so peak memory doesn't grow.

  • No unsafe.

  • Untrusted ICC profiles now reach moxcms from export, as they already did from the renderer. The wrapper catches panics, and lengths are checked before every conversion. Unreadable or mismatched profiles fall back as the page does: the device space of /N components.

Provenance

  • No asset from an Adobe product; nothing added to the repo's assets. The tests read the CGATS CMYK profile already vendored with hayro-interpret, and build Adobe RGB and Display P3 profiles with moxcms (a dev-dependency already in the lock).
  • No font files.
  • No GPL/AGPL code.
  • Behaviour comes from ISO 32000-1 §8.6.5.5 (ICCBased), §8.6.4.4 (DeviceCMYK), §8.6.6.3 (Indexed) and §11.6.5.3 (Matte), and from comparing against PdfCraft's own renderer.
  • No new assets, so no ATTRIBUTION.toml change.

Security

  • No paths, MCP, control channel or network touched.

Parity and reach

  • parity/acrobat-features.toml:
    • create.export-all-images: notes and evidence updated; the notes now say what is still missing (CalGray/CalRGB written as device spaces, Lab skipped);
    • edit.image-extract: notes and evidence updated.
  • The existing headless tools doc_export_all_images, image_save and doc_export_office all go through the changed path.
  • Docs:
    • vendor/README.md lists patch (39) and its tests;
    • ROADMAP.md progress log and docs/roadmap.md revision history updated (estimates unchanged).

Anything a reviewer should look at first

…2269)

Export all images, Save Image As and Word export (all through
pdfcraft_create::image_file) converted colours their own way: CMYK with
(1 - c)(1 - k) instead of the renderer's CMYK profile (cyan ink came out
(0, 255, 255) where the page draws (0, 174, 239), magenta 115 levels off,
black ink pure black), and ICCBased images without their profile (an Adobe
RGB or Display P3 image as if it were sRGB). JPEGs with CMYK samples or an
ICC profile the file doesn't carry were written as is, so viewers and Word
showed those in other colours too.

The renderer's own conversion is now public (vendored hayro-interpret
patch 39: color::device_cmyk_to_srgb and color::IccToSrgb), wrapped by
pdfcraft-render (cmyk_to_srgb, IccProfile, panics caught), and image export
converts through it: DeviceCMYK, ICCBased grey/RGB/CMYK, indexed palettes
over them (converted once), 16-bit and inverted CMYK, and /Matte colours.
CMYK JPEGs, and JPEGs drawn through an ICC profile the file doesn't carry
itself, are written as PNG in the page's colours; a JPEG that carries the
page's profile (inverted, for CMYK) is still written unchanged.

Still missing: CalGray/CalRGB images are written as device grey/RGB and Lab
images are skipped.

@mtlcl410 mtlcl410 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the head on Windows 11 x64 (debug) with doc_export_all_images. I compared the page render's centre pixel with what a colour-managed viewer shows for the exported file (Pillow + LittleCMS, converting any embedded ICC profile to sRGB). These are JPEG cases the PR body's table doesn't list:

PDF image main: page → exported file this PR: page → exported file
/DeviceRGB, plain JPEG (30,160,60) → JPEG, no ICC, (30,160,60) same
[/ICCBased AdobeRGB], JPEG without a profile (0,161,46) → JPEG, no ICC, (30,160,60) (0,161,46) → PNG, (0,161,46) ✔
[/ICCBased AdobeRGB], JPEG carrying the same AdobeRGB profile (0,161,46) → JPEG + ICC, (0,161,47) same, passed through ✔
/DeviceRGB, JPEG carrying an AdobeRGB profile (APP2) (30,160,60) → JPEG + ICC, (0,161,47) same: (0,161,47)

The last row is the gap I'd point out:

  • The page draws the samples as DeviceRGB and ignores the profile inside the JPEG stream.
  • The JPEG is written unchanged under the "grey or RGB in a device space" rule.
  • A viewer then applies the JPEG's own APP2 profile, so the exported picture shows different colours from the page.

This isn't a regression (main does the same), but it is the case the PR's rule ("a JPEG is still written unchanged when the file shows the page's colours") is meant to exclude. Producers that embed camera or Adobe RGB JPEGs as /DeviceRGB do this routinely.

Two ways to close it:

  • Pass a device-space JPEG through only when its stream has no ICC APP2 segment, or one that is sRGB.
  • Otherwise drop the APP2 segments when copying, which keeps the file a JPEG and lossless.

Everything else I tried behaves as described. My earlier render-side encoding probes (CMYK JPEG with and without an inverting /Decode, ICC CMYK raw and JPEG) agree between page and renderer, which is what this PR now reuses.

…age ignores

The page draws a DeviceGray or DeviceRGB JPEG's samples as they are, but
a JPEG file can carry an ICC profile of its own (an Adobe RGB APP2 from a
camera or editor), which viewers apply: the exported file showed other
colours than the page. Such a JPEG is now written without its APP2
ICC_PROFILE segments, every other byte as it was, so it stays a lossless
JPEG. If the segments before the first scan can't be read, the file is
written as before.
@guillehr2

Copy link
Copy Markdown
Contributor Author

Thanks, that's a real gap, and a common one, as you say. I went with your second option, so the file stays a lossless JPEG. b6a497f writes a DeviceGray or DeviceRGB JPEG without its APP2 ICC_PROFILE segments, all chunks of them, and copies every other byte as it is, scans included. A viewer then shows the samples as the page draws them. An APP2 that isn't a profile (MPF, say) is kept. If the segments before the first scan can't be read, the file is written as before.

device_jpegs_are_written_without_the_profile_the_page_ignores checks the exported bytes against the original JPEG without the segment. It covers:

  • DeviceRGB with an Adobe RGB profile, whole and split into two chunks;
  • the profile next to an MPF segment;
  • DeviceGray with a grey profile;
  • a JPEG without a profile, which stays byte for byte.

It fails on the previous head. cargo test -p pdfcraft-create: 34 passed. Clippy -D warnings: clean.

@mtlcl410

Copy link
Copy Markdown
Contributor

Confirmed on b6a497f with the same probe. The /DeviceRGB JPEG that carries an Adobe RGB profile now exports as a JPEG without the profile, and a colour-managed viewer shows (30,160,60), the page's colour, where before it showed (0,161,47). The other three rows are unchanged: the matching-profile JPEG still passes through with its profile, the profile-less ICC case goes to PNG, and the plain JPEG stays as it is. Thanks for the quick turnaround.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Export image colour incorrect

2 participants