bread-theme: native palette extraction, replacing pywal #36

Merged
Breadway merged 7 commits from feature/native-palette into main 2026-09-29 12:52:09 +08:00
Owner

Removes the wal/ImageMagick subprocess dependency from bread-theme (epic PYWAL).

What changed

  • ECO-29: golden fixtures under bread-theme/tests/fixtures/, captured by bread-theme/scripts/capture-goldens.py: color1-6 from a real wal -i run in an isolated HOME, plus wal_colors, the full ordered list pywal's own backend (pywal.backends.wal.gen_colors) builds. Fixtures: the BOS default wallpaper (PNG, baseline JPEG, progressive JPEG with restart markers, lossy WebP, 24-bit BMP, palette BMP, two-frame GIF), dark/moody (RGBA), saturated/colourful (4-bit palette), bright/screenshot-like (RGBA), near-monochrome (8-bit gray) and near-monochrome-16bit (16-bit gray, see below).
  • ECO-30: palette_from_image is a native Rust extractor that reproduces wal -i exactly, keeping its public signature. bread-theme/src/extract.rs ports the ImageMagick 7.1.2 (Q16 HDRI) code behind pywal's magick "<img>[0]" -resize 25% -colors N -unique-colors txt:-:
    • resize: ParseMetaGeometry rounding, default filter selection (Lanczos, or Mitchell for images with alpha or PseudoClass images such as palette PNGs, 8-bit gray PNGs, gray JPEGs, GIFs and palette BMPs), two separable passes in ImageMagick's order, alpha-weighted colour channels, results kept as unclamped float quantums;
    • quantize: gray detection (IdentifyImageGray/SetGrayscaleImage), tree depth from N reduced for the default dither, ClassifyImageColors (full-depth phase until the colour count exceeds N, then PruneToCubeDepth), ReduceImageColors thresholds (QuantizeErrorFlatten, Reduce, PruneChild), DefineImageColormap means cast to float quantums, and the Riemersma Hilbert-curve dither with its 16-entry error queue and 6-bit closest-colour cache;
    • -unique-colors: histogram.c tree order (child id r | g<<1 | b<<2, so blue most significant and red least; first appearance within a leaf);
    • txt: + pywal's #.{6} regex, including its truncation of 16-bit output.
      JPEGs are decoded by bread-theme/src/jpeg.rs, a port of libjpeg-turbo 3.2's default decode path (islow IDCT, fancy upsampling, YCbCr tables, context-row edges, colour space guess), because zune-jpeg's samples differ from libjpeg-turbo's by up to 5 levels, which was enough to flip slots. It covers baseline and progressive Huffman JPEGs; arithmetic-coded, 12-bit, lossless, CMYK and block-smoothed progressive files fall back to the image crate. The image crate now also decodes WebP, GIF (first frame, at its own size, alpha only if it declares a transparent index) and BMP. A decode failure is still an Err. New public pywal_colors() returns the full list.
  • ECO-31: the session-global palette moves to a bread-owned cache. palette_cache_path() is $XDG_CACHE_HOME/bread/palette.json (wal colors.json shape), written atomically by write_global_palette. load_palette() reads that path first, falls back to ~/.cache/wal/colors.json for upgrading installs, then Palette::default(). palette_cache_dir() is also exported as a stable directory for a long-lived watcher (breadman) to watch.
  • ECO-32: write_global_palette also writes a terminal colour-sequences file (sequences_path(), OSC 4 for all 16 ANSI slots plus OSC 10/11/12 for fg/bg/cursor), so BOS's skel .zshrc (which cats this file) keeps working with no wal in the picture. Terminals now also get the fixed BOS dark background instead of a raw wallpaper-derived one.
  • ECO-33: drops python-pywal from bakery.toml's optional deps, rewords Cargo.toml/registry descriptions and CLI help, and updates README / BREAD_DESIGN_SYSTEM.md / docs/shell-themes.md / CHANGELOG to describe native extraction instead of pywal.

Fidelity against real pywal

Measured with bread-theme/scripts/compare-pywal.py (runs pywal's exact magick loop and regex against examples/pywal-colors.rs; ImageMagick 7.1.2-31 Q16-HDRI, libjpeg-turbo 3.2.0):

Set Images Accent slots (color1-6) exact Full colour list exact Within per-channel delta 2 Near-black accent sets
Committed fixtures 14 84/84 (100%) 243/243 (100%) 100% 0
~/Pictures/Backgrounds (21 decodable real wallpapers: 13 JPEG, 7 PNG incl. RGBA and palette, 1 WebP; up to 8192x5956) + BOS Bread Background.png 22 132/132 (100%) 352/352 (100%) 100% 0
Extra format/edge cases (not committed: GIF plain/transparent/offset first frame, BMP 24/8-bit, WebP lossy/lossless/alpha, PNG 16-bit RGB/RGBA, gray 8/16/alpha, palette with tRNS, 1-bit, odd sizes) 23 all 382/382 (100%) 100% 0

The one file in the corpus that fails, 1402516.gif, is a 0-byte file; wal fails on it too. The JPEG decoder was also checked byte-for-byte against ImageMagick's decode on all 13 corpus JPEGs and on 4:2:0, 4:2:2, 4:4:0, 4:1:1, progressive, restart-marker, grayscale and tiny (3x2, 7x5) JPEGs: 0 differing bytes.

16-bit quirk (ECO-29 review): ImageMagick's txt: prints 16-bit images as #RRRRGGGGBBBB, and pywal's #.{6} keeps #RRRRGG (red high byte, red low byte, green high byte). That is where the green/purple accents in the old near-monochrome golden came from, not dithering. The extractor reproduces it on purpose, since matching what users got from pywal is the requirement. The fixture is kept as near-monochrome-16bit.png to pin the quirk, and near-monochrome.png is now the same gradient at 8 bits (grey accents).

Remaining deviations: none measured. Known untested corners where the port falls back or could differ: JPEG features libjpeg-turbo supports but jpeg.rs hands to the image crate (arithmetic coding, 12-bit, lossless, CMYK/YCCK, progressive files with never-sent low-frequency coefficients, which libjpeg-turbo block-smooths), animated WebP, BMP variants with bitfield alpha, PNG gAMA=1.0 files (ImageMagick may tag them linear RGB), and the order of near-equal (within 1e-12) quantize errors, where ImageMagick's comparator and glibc qsort could pick a different element. Output is tied to ImageMagick 7.1.2's algorithms; a future ImageMagick that changes quantize.c would change pywal's own output too.

Timing (release, 16 threads for resize and IDCT, best of 3):

Image Native wal -i (uncached)
Gojo.png, 3840x2160 RGB 0.09 s 0.29 s
Goku Sunset.png, 3840x2160 RGBA 0.10 s 0.36 s
Goku UI Kamehameha.jpg, 3840x2160 0.16 s 0.30 s
1364638.jpeg, 8192x5956 progressive (needs 8 -colors retries) 2.5 s 9.9 s

Licensing

extract.rs ports ImageMagick logic (ImageMagick License) and jpeg.rs ports libjpeg-turbo code (IJG License and Modified BSD). Added bread-theme/NOTICE (what was ported, copyright lines), bread-theme/LICENSE-ImageMagick and bread-theme/LICENSE-libjpeg-turbo (verbatim licence texts). Because bakery ships only the bare binary, bread-theme notice prints NOTICE plus both licence texts from inside the binary (IJG's "based in part on the work of the Independent JPEG Group" statement is in it). bread-theme's crate licence is now MIT AND ImageMagick AND IJG AND BSD-3-Clause; the rest of the workspace stays MIT.

Verification

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --locked -- -D warnings and with --features gtk
  • cargo test --workspace --locked and with --features gtk (151 bread-theme tests, including exact per-slot and full-list golden tests for every fixture, determinism, 16-bit truncation, GIF first frame, WebP/BMP/JPEG decoding, JPEG bit-exactness against libjpeg-turbo references, no near-black accents on real photos, decode errors staying Err)
  • cargo build --workspace --locked, Cargo.lock updated for the webp/gif/bmp decoders
  • compare-pywal.py runs and timing as above

Notes for reviewers

  • The bread-ecosystem release that ships this needs to go out before any consumer (breadpaper, etc.) is updated to depend on the new API, since roughly a dozen apps pin bread-theme by tag. That release is intentionally not part of this PR; it's cut after merge.
  • write_wal_cache/wal_cache_json/wal_cache_path are kept as-is for third-party pywal-template users; nothing in bread depends on them anymore.
  • The palette_cache_dir() literal ~/.cache fallback nit from the last review is untouched (ECO-31 was approved as is).

Closes ECO-29
Closes ECO-30
Closes ECO-31
Closes ECO-32
Closes ECO-33

Removes the `wal`/ImageMagick subprocess dependency from bread-theme (epic PYWAL). ## What changed - **ECO-29**: golden fixtures under `bread-theme/tests/fixtures/`, captured by `bread-theme/scripts/capture-goldens.py`: color1-6 from a real `wal -i` run in an isolated `HOME`, plus `wal_colors`, the full ordered list pywal's own backend (`pywal.backends.wal.gen_colors`) builds. Fixtures: the BOS default wallpaper (PNG, baseline JPEG, progressive JPEG with restart markers, lossy WebP, 24-bit BMP, palette BMP, two-frame GIF), dark/moody (RGBA), saturated/colourful (4-bit palette), bright/screenshot-like (RGBA), near-monochrome (8-bit gray) and near-monochrome-16bit (16-bit gray, see below). - **ECO-30**: `palette_from_image` is a native Rust extractor that reproduces `wal -i` exactly, keeping its public signature. `bread-theme/src/extract.rs` ports the ImageMagick 7.1.2 (Q16 HDRI) code behind pywal's `magick "<img>[0]" -resize 25% -colors N -unique-colors txt:-`: - resize: `ParseMetaGeometry` rounding, default filter selection (Lanczos, or Mitchell for images with alpha or PseudoClass images such as palette PNGs, 8-bit gray PNGs, gray JPEGs, GIFs and palette BMPs), two separable passes in ImageMagick's order, alpha-weighted colour channels, results kept as unclamped `float` quantums; - quantize: gray detection (`IdentifyImageGray`/`SetGrayscaleImage`), tree depth from N reduced for the default dither, `ClassifyImageColors` (full-depth phase until the colour count exceeds N, then `PruneToCubeDepth`), `ReduceImageColors` thresholds (`QuantizeErrorFlatten`, `Reduce`, `PruneChild`), `DefineImageColormap` means cast to float quantums, and the Riemersma Hilbert-curve dither with its 16-entry error queue and 6-bit closest-colour cache; - `-unique-colors`: `histogram.c` tree order (child id `r | g<<1 | b<<2`, so blue most significant and red least; first appearance within a leaf); - `txt:` + pywal's `#.{6}` regex, including its truncation of 16-bit output. JPEGs are decoded by `bread-theme/src/jpeg.rs`, a port of libjpeg-turbo 3.2's default decode path (islow IDCT, fancy upsampling, YCbCr tables, context-row edges, colour space guess), because zune-jpeg's samples differ from libjpeg-turbo's by up to 5 levels, which was enough to flip slots. It covers baseline and progressive Huffman JPEGs; arithmetic-coded, 12-bit, lossless, CMYK and block-smoothed progressive files fall back to the `image` crate. The `image` crate now also decodes WebP, GIF (first frame, at its own size, alpha only if it declares a transparent index) and BMP. A decode failure is still an `Err`. New public `pywal_colors()` returns the full list. - **ECO-31**: the session-global palette moves to a bread-owned cache. `palette_cache_path()` is `$XDG_CACHE_HOME/bread/palette.json` (wal `colors.json` shape), written atomically by `write_global_palette`. `load_palette()` reads that path first, falls back to `~/.cache/wal/colors.json` for upgrading installs, then `Palette::default()`. `palette_cache_dir()` is also exported as a stable directory for a long-lived watcher (breadman) to watch. - **ECO-32**: `write_global_palette` also writes a terminal colour-sequences file (`sequences_path()`, OSC 4 for all 16 ANSI slots plus OSC 10/11/12 for fg/bg/cursor), so BOS's skel `.zshrc` (which cats this file) keeps working with no `wal` in the picture. Terminals now also get the fixed BOS dark background instead of a raw wallpaper-derived one. - **ECO-33**: drops `python-pywal` from `bakery.toml`'s optional deps, rewords Cargo.toml/registry descriptions and CLI help, and updates README / BREAD_DESIGN_SYSTEM.md / docs/shell-themes.md / CHANGELOG to describe native extraction instead of pywal. ## Fidelity against real pywal Measured with `bread-theme/scripts/compare-pywal.py` (runs pywal's exact `magick` loop and regex against `examples/pywal-colors.rs`; ImageMagick 7.1.2-31 Q16-HDRI, libjpeg-turbo 3.2.0): | Set | Images | Accent slots (color1-6) exact | Full colour list exact | Within per-channel delta 2 | Near-black accent sets | |---|---|---|---|---|---| | Committed fixtures | 14 | 84/84 (100%) | 243/243 (100%) | 100% | 0 | | `~/Pictures/Backgrounds` (21 decodable real wallpapers: 13 JPEG, 7 PNG incl. RGBA and palette, 1 WebP; up to 8192x5956) + BOS `Bread Background.png` | 22 | 132/132 (100%) | 352/352 (100%) | 100% | 0 | | Extra format/edge cases (not committed: GIF plain/transparent/offset first frame, BMP 24/8-bit, WebP lossy/lossless/alpha, PNG 16-bit RGB/RGBA, gray 8/16/alpha, palette with tRNS, 1-bit, odd sizes) | 23 | all | 382/382 (100%) | 100% | 0 | The one file in the corpus that fails, `1402516.gif`, is a 0-byte file; wal fails on it too. The JPEG decoder was also checked byte-for-byte against ImageMagick's decode on all 13 corpus JPEGs and on 4:2:0, 4:2:2, 4:4:0, 4:1:1, progressive, restart-marker, grayscale and tiny (3x2, 7x5) JPEGs: 0 differing bytes. **16-bit quirk (ECO-29 review):** ImageMagick's `txt:` prints 16-bit images as `#RRRRGGGGBBBB`, and pywal's `#.{6}` keeps `#RRRRGG` (red high byte, red low byte, green high byte). That is where the green/purple accents in the old near-monochrome golden came from, not dithering. The extractor reproduces it on purpose, since matching what users got from pywal is the requirement. The fixture is kept as `near-monochrome-16bit.png` to pin the quirk, and `near-monochrome.png` is now the same gradient at 8 bits (grey accents). **Remaining deviations:** none measured. Known untested corners where the port falls back or could differ: JPEG features libjpeg-turbo supports but `jpeg.rs` hands to the `image` crate (arithmetic coding, 12-bit, lossless, CMYK/YCCK, progressive files with never-sent low-frequency coefficients, which libjpeg-turbo block-smooths), animated WebP, BMP variants with bitfield alpha, PNG `gAMA`=1.0 files (ImageMagick may tag them linear RGB), and the order of near-equal (within 1e-12) quantize errors, where ImageMagick's comparator and glibc `qsort` could pick a different element. Output is tied to ImageMagick 7.1.2's algorithms; a future ImageMagick that changes quantize.c would change pywal's own output too. **Timing** (release, 16 threads for resize and IDCT, best of 3): | Image | Native | `wal -i` (uncached) | |---|---|---| | Gojo.png, 3840x2160 RGB | 0.09 s | 0.29 s | | Goku Sunset.png, 3840x2160 RGBA | 0.10 s | 0.36 s | | Goku UI Kamehameha.jpg, 3840x2160 | 0.16 s | 0.30 s | | 1364638.jpeg, 8192x5956 progressive (needs 8 `-colors` retries) | 2.5 s | 9.9 s | ## Licensing `extract.rs` ports ImageMagick logic (ImageMagick License) and `jpeg.rs` ports libjpeg-turbo code (IJG License and Modified BSD). Added `bread-theme/NOTICE` (what was ported, copyright lines), `bread-theme/LICENSE-ImageMagick` and `bread-theme/LICENSE-libjpeg-turbo` (verbatim licence texts). Because bakery ships only the bare binary, `bread-theme notice` prints NOTICE plus both licence texts from inside the binary (IJG's "based in part on the work of the Independent JPEG Group" statement is in it). bread-theme's crate licence is now `MIT AND ImageMagick AND IJG AND BSD-3-Clause`; the rest of the workspace stays MIT. ## Verification - `cargo fmt --all -- --check` - `cargo clippy --workspace --all-targets --locked -- -D warnings` and with `--features gtk` - `cargo test --workspace --locked` and with `--features gtk` (151 bread-theme tests, including exact per-slot and full-list golden tests for every fixture, determinism, 16-bit truncation, GIF first frame, WebP/BMP/JPEG decoding, JPEG bit-exactness against libjpeg-turbo references, no near-black accents on real photos, decode errors staying `Err`) - `cargo build --workspace --locked`, Cargo.lock updated for the webp/gif/bmp decoders - `compare-pywal.py` runs and timing as above ## Notes for reviewers - The bread-ecosystem release that ships this needs to go out **before** any consumer (breadpaper, etc.) is updated to depend on the new API, since roughly a dozen apps pin bread-theme by tag. That release is intentionally not part of this PR; it's cut after merge. - `write_wal_cache`/`wal_cache_json`/`wal_cache_path` are kept as-is for third-party pywal-template users; nothing in bread depends on them anymore. - The `palette_cache_dir()` literal `~/.cache` fallback nit from the last review is untouched (ECO-31 was approved as is). Closes ECO-29 Closes ECO-30 Closes ECO-31 Closes ECO-32 Closes ECO-33
Add five reference fixtures (the BOS default wallpaper downscaled, plus
four synthetic stand-ins covering dark/moody, saturated/colourful,
near-monochrome, and bright/screenshot-like cases) with their color1-6
captured from a real wal -i run in an isolated HOME. These feed the
native extractor's fidelity tests: not exact-match, but same hue family
or documented-divergence assertions.
Removes the wal/ImageMagick subprocess dependency from palette_from_image
(ECO-30): decode via the image crate, resize to 25% and quantize with a
from-scratch octree colour quantizer, then take color1-6 from the same
list position wal's own pipeline does (colors[8..14], verified against
the installed pywal source). This is a deliberate approximation of
ImageMagick's -colors/-unique-colors behaviour, not a byte-for-byte port:
ImageMagick also dithers by default, and its exact node-merge priority
and colormap traversal order are internal, version-specific C
implementation details. No more subprocess spawn, no more isolated
HOME/XDG_CACHE_HOME temp-dir dance, no more "wal is not installed" error
path. Deterministic: same image in, same accents out.

Moves the session-global palette to a bread-owned cache (ECO-31):
palette_cache_path() at $XDG_CACHE_HOME/bread/palette.json, written
atomically by write_global_palette. load_palette() reads that path
first, then falls back to ~/.cache/wal/colors.json for installs
upgrading from a wal-based setup, then Palette::default(). Also exports
palette_cache_dir() (a stable directory a long-lived watcher like
breadman can watch) and write_wal_cache stays available for third-party
pywal-template users.

write_global_palette also writes a terminal colour-sequences file
(ECO-32): sequences_path(), OSC 4 for all 16 ANSI slots plus OSC
10/11/12 for fg/bg/cursor, so BOS's skel .zshrc keeps working now that
nothing runs wal to produce it. Terminals also now get the fixed BOS
dark background instead of a raw wallpaper-derived one.

Golden-fixture tests measure hue-family fidelity against real wal
output, documenting where and how much the native pipeline diverges.
docs(bread-theme): drop pywal from packaging and docs
All checks were successful
check / check (push) Successful in 1m40s
cf61b0b95d
Once the native extractor, bread-owned cache, and terminal sequences
file land, nothing in bread-theme depends on pywal anymore:

- Remove python-pywal from bakery.toml's optional system deps.
- Reword Cargo.toml description/keywords and the bakery/registry
  descriptions away from "pywal-accented".
- Update CLI help in src/bin/bread-theme.rs: --image is native
  extraction now, not an isolated wal -i; --from-json still accepts
  wal-shaped JSON for backward compatibility.
- Update README, BREAD_DESIGN_SYSTEM.md, docs/shell-themes.md, and
  upgrade.md to describe native wallpaper-accent extraction instead of
  pywal.
- Add a bread-theme CHANGELOG entry for the whole PYWAL epic.

A bread-ecosystem release still needs to go out before any consumer
(breadpaper etc.) is updated to depend on this, since roughly a dozen
apps pin bread-theme by tag; that release is cut after this merges, not
as part of it.
Author
Owner

Review: changes requested (not merging)

CI is green and the PR is mergeable. The cache, sequences and docs work (ECO-31/32/33) is fine. The extractor (ECO-30) and one golden fixture (ECO-29) do not meet the requirement. The user asked for output "as close as possible to pywal's output for the same image", and this is not close.

Measured fidelity (reviewer re-run, ImageMagick 7.1.2-31 Q16-HDRI)

The harness runs pywal's exact pipeline (magick "<img>[0]" -resize 25% -colors N -unique-colors txt:-, N from 16 to 35, #.{6} regex, colors[8:14]) against bread-theme generate-output --image built from cf61b0b. Its wal side reproduces the committed goldens byte for byte, so the harness is sound.

Set Accents Exact Mean per-channel delta (0-255) Mean sum-RGB delta
Committed fixtures (5) 30 0 (0%) 41.6 125
~/Pictures/Backgrounds, 20 decodable real wallpapers 120 0 (0%) 90.1 270
BOS Bread Background.png 6 0 9.5 28

Also found:

  • 7 of 20 real wallpapers get near-black accents (#000002 #000003 #000102 ...), and the same six values come back for several unrelated images (Beast Gohan SBC.png, Megumi Purple.jpeg, 1341491.png).
  • 2 of 22 files fail outright. Spirit Bomb.webp works with wal but errors here. 1402516.gif errors too.

Change requests

  • bread-theme/src/extract.rs:175-199 (quantize_like_wal) has a degenerate fallthrough. Photos often have no octree level whose bucket count is between 16 and the target. Depth 1 has 8 buckets or fewer, and depth 2 is often over 35. When that happens the loop never returns, and the function returns by_depth[8], which is every distinct colour. accents_from_colors then takes indices 8..13 of that Z-ordered list: the darkest near-black raw colours. The comment at 193-198 assumes that list is short. It is not. This is the cause of the near-black accents above. It goes away if the quantizer is replaced (next item). Whatever lands must have a regression test on a real photo that asserts the accents are not near-black.
  • Port ImageMagick's pipeline instead of approximating it. ImageMagick is open source, so "undocumented internals" is not a reason to approximate. Read the source for the installed version (7.1.2) and port:
    • MagickCore/quantize.c:
      • QuantizeImage: how the tree depth is derived from the colour count, and the depth reduction when dithering is on.
      • ClassifyImageColors: per-node sums and quantize_error, and the pruning when the node count exceeds its cap.
      • ReduceImageColors / Reduce / PruneChild: the pruning threshold that grows until the leaf count fits.
      • AssignImageColors / DefineImageColormap: colormap entry = node mean, with IM's rounding. Also the default Riemersma dither, which decides which colormap entries end up used by at least one pixel. That is what -unique-colors lists.
      • Which colourspace classification runs in by default.
    • MagickCore/histogram.c UniqueImageColors / ColorToNodeId: the traversal order. The current bucket_key (extract.rs:127-138) puts red in the most significant bit of each level. The real wal output is ordered with blue as the most significant bit and red as the least (ColorToNodeId builds id as red bit | green bit << 1 | blue bit << 2). Evidence from Beast Gohan SBC.png: wal's list #562C61 #4A4261 #A33962 #325696 #5C609B #4E46AB #B34DBA #59AEDA #9DA2E7 ... has top-bit node ids 0,0,1,4,4,4,5,6,7, which is monotonic only with R as the low bit. Even a perfect quantizer picks the wrong slots for color1..6 with the current ordering.
    • MagickCore/resize.c: the default filter IM picks for a 25% downscale, and how -resize 25% rounds the target geometry. Confirm Lanczos3 on real 1920x1080 inputs, not only the 96x96 fixture.
    • IM computes in Q16 HDRI floats and only scales to 8 bits on output. Match the rounding (ScaleQuantumToChar).
  • Decoder coverage (Cargo.toml:25). Enable at least webp, gif, bmp on the image crate, since breadpaper accepts all of them (breadpaper/src/lib.rs:24). image::open on a GIF decodes the first frame, which matches wal's [0]. Add a webp and a gif fixture test. Decode failure already returns Err (extract.rs:62-66), which is the right contract for an io::Result. Keep it that way; do not make it fall back to defaults.
  • ECO-29: tests/fixtures/golden/near-monochrome.json is corrupt, and the test comment explaining it is wrong. The fixture is a 16-bit grayscale PNG. For 16-bit input IM's txt: output prints 12-digit hex (#78F078F078F0), and pywal's #.{6} regex truncates that to #78F078. That is where the "green/purple" comes from. It is not a dithering artifact (output.rs:658-668). Either regenerate this fixture as 8-bit, or keep it and document it as a pywal parsing bug that bread-theme deliberately does not reproduce. Fix the explanation either way.
  • Tests do not measure fidelity. assert_hue_family (output.rs:600-617) passes if any one of six accents is within 45 degrees of the golden mean hue. An extractor returning six unrelated colours can pass it. Replace it with per-slot assertions against the goldens.
  • Measurable target. The rework is done when:
    1. Exact hex match on every committed 8-bit fixture, all 6 slots.
    2. On a corpus of at least 20 real wallpapers (use ~/Pictures/Backgrounds plus the BOS default), at least 90% of accent slots within a per-channel delta of 2, and zero degenerate near-black outputs.
    3. The comparison script is committed (e.g. under bread-theme/tests/ or scripts/), so anyone with magick installed can re-run it. Numbers go in the PR body.
    4. Timing is re-measured on a 4K image.
  • Stale doc, output.rs:164-168. The palette_from_image doc still says "median cut ... six accent hues spread across the hue wheel". Update it to describe what the code does.

Nits (non-blocking)

  • palette_cache_dir() falls back to PathBuf::from("~/.cache") (output.rs:181-185). A literal tilde is never expanded. It copies the existing wal_path pattern, but consider $HOME/.cache or an error.

Acceptance criteria as I read them

  1. ECO-29: goldens are real wal output for fixed images. Met, except near-monochrome (corrupt, see above).
  2. ECO-30: palette_from_image is native, keeps its signature, and matches wal as closely as possible. Not met (fidelity and degenerate fallthrough).
  3. ECO-31: bread-owned palette.json cache, atomic write, load_palette reads it first and falls back to ~/.cache/wal. Met: output.rs:193,238-246, palette.rs:81-92, with tests.
  4. ECO-32: sequences file at sequences_path(). Met: output.rs:202-236, tested.
  5. ECO-33: pywal dropped from packaging and docs. Met.

Rework happens on the existing branch feature/native-palette, and this PR stays open. Do not open a second PR for these keys. No release or tag until this is resolved. Consumers (breadpaper#6, breadpad#9, bos-settings#18) should re-point to the release tag only after that.

## Review: changes requested (not merging) CI is green and the PR is mergeable. The cache, sequences and docs work (ECO-31/32/33) is fine. The extractor (ECO-30) and one golden fixture (ECO-29) do not meet the requirement. The user asked for output "as close as possible to pywal's output for the same image", and this is not close. ### Measured fidelity (reviewer re-run, ImageMagick 7.1.2-31 Q16-HDRI) The harness runs pywal's exact pipeline (`magick "<img>[0]" -resize 25% -colors N -unique-colors txt:-`, N from 16 to 35, `#.{6}` regex, `colors[8:14]`) against `bread-theme generate-output --image` built from cf61b0b. Its wal side reproduces the committed goldens byte for byte, so the harness is sound. | Set | Accents | Exact | Mean per-channel delta (0-255) | Mean sum-RGB delta | |---|---|---|---|---| | Committed fixtures (5) | 30 | 0 (0%) | 41.6 | 125 | | ~/Pictures/Backgrounds, 20 decodable real wallpapers | 120 | 0 (0%) | 90.1 | 270 | | BOS `Bread Background.png` | 6 | 0 | 9.5 | 28 | Also found: - **7 of 20 real wallpapers get near-black accents** (`#000002 #000003 #000102 ...`), and the same six values come back for several unrelated images (Beast Gohan SBC.png, Megumi Purple.jpeg, 1341491.png). - **2 of 22 files fail outright.** `Spirit Bomb.webp` works with wal but errors here. `1402516.gif` errors too. ### Change requests - [ ] **`bread-theme/src/extract.rs:175-199` (`quantize_like_wal`) has a degenerate fallthrough.** Photos often have no octree level whose bucket count is between 16 and the target. Depth 1 has 8 buckets or fewer, and depth 2 is often over 35. When that happens the loop never returns, and the function returns `by_depth[8]`, which is every distinct colour. `accents_from_colors` then takes indices 8..13 of that Z-ordered list: the darkest near-black raw colours. The comment at 193-198 assumes that list is short. It is not. This is the cause of the near-black accents above. It goes away if the quantizer is replaced (next item). Whatever lands must have a regression test on a real photo that asserts the accents are not near-black. - [ ] **Port ImageMagick's pipeline instead of approximating it.** ImageMagick is open source, so "undocumented internals" is not a reason to approximate. Read the source for the installed version (7.1.2) and port: - `MagickCore/quantize.c`: - `QuantizeImage`: how the tree depth is derived from the colour count, and the depth reduction when dithering is on. - `ClassifyImageColors`: per-node sums and `quantize_error`, and the pruning when the node count exceeds its cap. - `ReduceImageColors` / `Reduce` / `PruneChild`: the pruning threshold that grows until the leaf count fits. - `AssignImageColors` / `DefineImageColormap`: colormap entry = node mean, with IM's rounding. Also the default Riemersma dither, which decides which colormap entries end up used by at least one pixel. That is what `-unique-colors` lists. - Which colourspace classification runs in by default. - `MagickCore/histogram.c` `UniqueImageColors` / `ColorToNodeId`: the traversal order. The current `bucket_key` (`extract.rs:127-138`) puts **red** in the most significant bit of each level. The real wal output is ordered with **blue as the most significant bit and red as the least** (`ColorToNodeId` builds `id` as red bit | green bit << 1 | blue bit << 2). Evidence from Beast Gohan SBC.png: wal's list `#562C61 #4A4261 #A33962 #325696 #5C609B #4E46AB #B34DBA #59AEDA #9DA2E7 ...` has top-bit node ids 0,0,1,4,4,4,5,6,7, which is monotonic only with R as the low bit. Even a perfect quantizer picks the wrong slots for color1..6 with the current ordering. - `MagickCore/resize.c`: the default filter IM picks for a 25% downscale, and how `-resize 25%` rounds the target geometry. Confirm Lanczos3 on real 1920x1080 inputs, not only the 96x96 fixture. - IM computes in Q16 HDRI floats and only scales to 8 bits on output. Match the rounding (`ScaleQuantumToChar`). - [ ] **Decoder coverage (`Cargo.toml:25`).** Enable at least `webp`, `gif`, `bmp` on the `image` crate, since breadpaper accepts all of them (`breadpaper/src/lib.rs:24`). `image::open` on a GIF decodes the first frame, which matches wal's `[0]`. Add a webp and a gif fixture test. Decode failure already returns `Err` (`extract.rs:62-66`), which is the right contract for an `io::Result`. Keep it that way; do not make it fall back to defaults. - [ ] **ECO-29: `tests/fixtures/golden/near-monochrome.json` is corrupt, and the test comment explaining it is wrong.** The fixture is a 16-bit grayscale PNG. For 16-bit input IM's `txt:` output prints 12-digit hex (`#78F078F078F0`), and pywal's `#.{6}` regex truncates that to `#78F078`. That is where the "green/purple" comes from. It is not a dithering artifact (`output.rs:658-668`). Either regenerate this fixture as 8-bit, or keep it and document it as a pywal parsing bug that bread-theme deliberately does not reproduce. Fix the explanation either way. - [ ] **Tests do not measure fidelity.** `assert_hue_family` (`output.rs:600-617`) passes if any one of six accents is within 45 degrees of the golden mean hue. An extractor returning six unrelated colours can pass it. Replace it with per-slot assertions against the goldens. - [ ] **Measurable target.** The rework is done when: 1. Exact hex match on every committed 8-bit fixture, all 6 slots. 2. On a corpus of at least 20 real wallpapers (use ~/Pictures/Backgrounds plus the BOS default), at least 90% of accent slots within a per-channel delta of 2, and zero degenerate near-black outputs. 3. The comparison script is committed (e.g. under `bread-theme/tests/` or `scripts/`), so anyone with `magick` installed can re-run it. Numbers go in the PR body. 4. Timing is re-measured on a 4K image. - [ ] **Stale doc, `output.rs:164-168`.** The `palette_from_image` doc still says "median cut ... six accent hues spread across the hue wheel". Update it to describe what the code does. ### Nits (non-blocking) - `palette_cache_dir()` falls back to `PathBuf::from("~/.cache")` (`output.rs:181-185`). A literal tilde is never expanded. It copies the existing `wal_path` pattern, but consider `$HOME/.cache` or an error. ### Acceptance criteria as I read them 1. ECO-29: goldens are real wal output for fixed images. Met, except `near-monochrome` (corrupt, see above). 2. ECO-30: `palette_from_image` is native, keeps its signature, and matches wal as closely as possible. **Not met** (fidelity and degenerate fallthrough). 3. ECO-31: bread-owned `palette.json` cache, atomic write, `load_palette` reads it first and falls back to `~/.cache/wal`. Met: `output.rs:193,238-246`, `palette.rs:81-92`, with tests. 4. ECO-32: sequences file at `sequences_path()`. Met: `output.rs:202-236`, tested. 5. ECO-33: pywal dropped from packaging and docs. Met. Rework happens on the existing branch `feature/native-palette`, and this PR stays open. Do not open a second PR for these keys. No release or tag until this is resolved. Consumers (breadpaper#6, breadpad#9, bos-settings#18) should re-point to the release tag only after that.
Goldens are now captured by scripts/capture-goldens.py: color1..color6
from real `wal -i` in an isolated HOME, plus "wal_colors", the full
ordered list pywal's backend built (pywal.backends.wal.gen_colors), so
tests can assert every slot rather than a hue family.

near-monochrome.png was a 16-bit grayscale PNG. ImageMagick's txt: output
prints 16-bit images as #RRRRGGGGBBBB and pywal's `#.{6}` regex keeps
"#RRRRGG", which is where its green/purple accents came from (not
dithering). It is kept as near-monochrome-16bit.png to pin that quirk, and
near-monochrome.png is now the same gradient at 8 bits (grey accents).

New fixtures derived from the BOS default wallpaper cover baseline and
progressive (with restart markers) JPEG, lossy WebP, 24-bit and palette
BMP, and a two-frame GIF; tests/fixtures/jpeg holds two small odd-sized
JPEGs with ImageMagick's own decode as PPM references.
feat(bread-theme): port pywal's ImageMagick pipeline for exact palette parity
All checks were successful
check / check (push) Successful in 1m44s
3758f0c3b4
The native extractor now reproduces `wal -i` output exactly instead of
approximating it. extract.rs is a port of the ImageMagick 7.1.2 (Q16 HDRI)
code behind pywal's `magick "<img>[0]" -resize 25% -colors N
-unique-colors txt:-`:

- resize: ParseMetaGeometry rounding, default filter selection (Lanczos,
  or Mitchell for alpha/PseudoClass images), two separable passes in
  ImageMagick's order with alpha-weighted colour channels and unclamped
  float quantums.
- quantize: gray detection, tree depth from N (reduced for the default
  dither), ClassifyImageColors with its full-depth phase and
  PruneToCubeDepth, ReduceImageColors thresholds, DefineImageColormap
  means cast to float quantums, and the Riemersma Hilbert-curve dither
  with its error queue and 6-bit closest-colour cache.
- unique colours: histogram.c tree order (blue most significant, red
  least; first appearance within a leaf).
- output: txt: hex formatting, including pywal's `#.{6}` truncation of
  16-bit output.

JPEGs are decoded by a new jpeg.rs, a port of libjpeg-turbo's default path
(islow IDCT, fancy upsampling, YCbCr tables), because zune-jpeg's samples
differ by a few levels and that was enough to flip slots. The image crate
now also decodes WebP, GIF (first frame, at its own size) and BMP.

Tests assert exact per-slot and full-list equality with the goldens, plus
determinism, 16-bit truncation, GIF first frame, decoder bit-exactness,
no near-black accents on real photos, and decode errors staying Err.
scripts/compare-pywal.py and examples/pywal-colors.rs compare against a
real magick at dev time. pywal_colors() exposes the full list.

The ported code is under the ImageMagick License and the IJG/BSD-3
licences: NOTICE, LICENSE-ImageMagick and LICENSE-libjpeg-turbo are added,
`bread-theme notice` prints them for binary-only installs, and the crate
licence is now "MIT AND ImageMagick AND IJG AND BSD-3-Clause".
Author
Owner

Rework for the ECO-29/ECO-30 change requests (#issuecomment-1075)

Two new commits on feature/native-palette. The ECO-31/32/33 work is unchanged.

  • 6e34f3b test(bread-theme): exact pywal goldens with full colour lists and format fixtures
  • 3758f0c feat(bread-theme): port pywal's ImageMagick pipeline for exact palette parity

Checklist

  • Degenerate fallthrough (quantize_like_wal): removed along with the octree approximation. real_photo_accents_are_never_near_black guards it on real photo fixtures, and the corpus run below has 0 near-black accent sets (was 7 of 20).
  • Port instead of approximate. extract.rs ports ImageMagick 7.1.2 Q16-HDRI from source: ParseMetaGeometry rounding; ResizeImage filter choice (Lanczos, or Mitchell for alpha or PseudoClass input) and two-pass order with alpha blending and unclamped float quantums; QuantizeImage depth (4, minus 1 for dither = 3, or 8 for gray); ClassifyImageColors (both phases, run-length IsPixelEquivalent, quantize_error accumulation); ReduceImageColors/Reduce/PruneChild; DefineImageColormap; Riemersma dither including the 6-bit CacheOffset colour cache (this is what decides which entries survive into -unique-colors); histogram.c ordering (confirmed: r | g<<1 | b<<2, so blue is most significant); ScaleQuantumToChar float rounding.
  • Resize filter: Lanczos is only the default for opaque DirectClass images. Palette/gray PNGs, gray JPEGs, GIFs, palette BMPs and anything with alpha get Mitchell, so PseudoClass status is sniffed from the file header.
  • JPEG: after the port, the only mismatches left were in JPEGs. zune-jpeg decodes up to 5 levels away from libjpeg-turbo on 1-13% of pixels. jpeg.rs ports libjpeg-turbo 3.2's default decode and is byte-identical to ImageMagick's decode on all 13 corpus JPEGs and on synthetic 4:2:0/4:2:2/4:4:0/4:1:1/progressive/restart/gray/tiny files.
  • Decoders: webp, gif and bmp enabled. For GIF, the first frame is used at its own size, with alpha only if it declares a transparent index (ImageMagick's [0]). Decode failure stays an Err, and a test covers garbage, truncated and missing files.
  • ECO-29 near-monochrome: diagnosis confirmed. It is pywal's #.{6} truncating 16-bit #RRRRGGGGBBBB output. Since the requirement is pywal's output, the extractor reproduces the quirk. The fixture is kept as near-monochrome-16bit.png (tested), near-monochrome.png is now 8-bit, and the wrong dithering comment is gone.
  • Tests: assert_hue_family is replaced by exact per-slot equality on color1-6 and on the full wal_colors list for 12 golden fixtures, plus tests for determinism, 16-bit truncation, GIF frame 0 (with a check that frame 1 would differ), JPEG bit-exactness against IM-decoded PPM references, histogram order, geometry rounding and filter kernels.
  • Comparison script: bread-theme/scripts/compare-pywal.py [--full] IMAGE_OR_DIR..., which needs magick at dev time only. scripts/capture-goldens.py regenerates goldens from real wal.
  • Stale docs: the palette_from_image doc, the module docs and the CHANGELOG are rewritten.
  • Licence: NOTICE, LICENSE-ImageMagick, LICENSE-libjpeg-turbo, and bread-theme notice (the binary ships without the source tree). The crate licence is MIT AND ImageMagick AND IJG AND BSD-3-Clause.

Measurements (compare-pywal.py, IM 7.1.2-31 Q16-HDRI)

Set Accents exact Full list exact Within delta 2 Near-black sets
Committed fixtures (14 files) 84/84 243/243 100% 0
21 real wallpapers + BOS Bread Background.png 132/132 352/352 100% 0
Before this rework (reviewer numbers) 0/30 fixtures, 0/120 real n/a n/a 7

4K timing (release, best of 3), native vs uncached wal -i: 3840x2160 PNG 0.09 s vs 0.29 s, 3840x2160 RGBA PNG 0.10 s vs 0.36 s, 3840x2160 JPEG 0.16 s vs 0.30 s, 8192x5956 progressive JPEG (8 retries) 2.5 s vs 9.9 s.

Remaining deviations: none measured. The PR body lists the untested corners (JPEG variants that fall back to zune, and exotic BMP/PNG variants).

Verification: fmt check, clippy -D warnings with and without --features gtk, cargo test --workspace --locked with and without gtk (151 bread-theme tests), and cargo build --workspace --locked, all green locally.

## Rework for the ECO-29/ECO-30 change requests (#issuecomment-1075) Two new commits on `feature/native-palette`. The ECO-31/32/33 work is unchanged. - `6e34f3b` test(bread-theme): exact pywal goldens with full colour lists and format fixtures - `3758f0c` feat(bread-theme): port pywal's ImageMagick pipeline for exact palette parity ### Checklist - [x] **Degenerate fallthrough** (`quantize_like_wal`): removed along with the octree approximation. `real_photo_accents_are_never_near_black` guards it on real photo fixtures, and the corpus run below has 0 near-black accent sets (was 7 of 20). - [x] **Port instead of approximate.** `extract.rs` ports ImageMagick 7.1.2 Q16-HDRI from source: ParseMetaGeometry rounding; ResizeImage filter choice (Lanczos, or Mitchell for alpha or PseudoClass input) and two-pass order with alpha blending and unclamped float quantums; QuantizeImage depth (4, minus 1 for dither = 3, or 8 for gray); ClassifyImageColors (both phases, run-length `IsPixelEquivalent`, `quantize_error` accumulation); ReduceImageColors/Reduce/PruneChild; DefineImageColormap; Riemersma dither including the 6-bit `CacheOffset` colour cache (this is what decides which entries survive into `-unique-colors`); histogram.c ordering (confirmed: `r | g<<1 | b<<2`, so blue is most significant); ScaleQuantumToChar float rounding. - [x] **Resize filter**: Lanczos is only the default for opaque DirectClass images. Palette/gray PNGs, gray JPEGs, GIFs, palette BMPs and anything with alpha get Mitchell, so PseudoClass status is sniffed from the file header. - [x] **JPEG**: after the port, the only mismatches left were in JPEGs. zune-jpeg decodes up to 5 levels away from libjpeg-turbo on 1-13% of pixels. `jpeg.rs` ports libjpeg-turbo 3.2's default decode and is byte-identical to ImageMagick's decode on all 13 corpus JPEGs and on synthetic 4:2:0/4:2:2/4:4:0/4:1:1/progressive/restart/gray/tiny files. - [x] **Decoders**: `webp`, `gif` and `bmp` enabled. For GIF, the first frame is used at its own size, with alpha only if it declares a transparent index (ImageMagick's `[0]`). Decode failure stays an `Err`, and a test covers garbage, truncated and missing files. - [x] **ECO-29 near-monochrome**: diagnosis confirmed. It is pywal's `#.{6}` truncating 16-bit `#RRRRGGGGBBBB` output. Since the requirement is pywal's output, the extractor reproduces the quirk. The fixture is kept as `near-monochrome-16bit.png` (tested), `near-monochrome.png` is now 8-bit, and the wrong dithering comment is gone. - [x] **Tests**: `assert_hue_family` is replaced by exact per-slot equality on color1-6 **and** on the full `wal_colors` list for 12 golden fixtures, plus tests for determinism, 16-bit truncation, GIF frame 0 (with a check that frame 1 would differ), JPEG bit-exactness against IM-decoded PPM references, histogram order, geometry rounding and filter kernels. - [x] **Comparison script**: `bread-theme/scripts/compare-pywal.py [--full] IMAGE_OR_DIR...`, which needs `magick` at dev time only. `scripts/capture-goldens.py` regenerates goldens from real `wal`. - [x] **Stale docs**: the `palette_from_image` doc, the module docs and the CHANGELOG are rewritten. - [x] **Licence**: `NOTICE`, `LICENSE-ImageMagick`, `LICENSE-libjpeg-turbo`, and `bread-theme notice` (the binary ships without the source tree). The crate licence is `MIT AND ImageMagick AND IJG AND BSD-3-Clause`. ### Measurements (compare-pywal.py, IM 7.1.2-31 Q16-HDRI) | Set | Accents exact | Full list exact | Within delta 2 | Near-black sets | |---|---|---|---|---| | Committed fixtures (14 files) | 84/84 | 243/243 | 100% | 0 | | 21 real wallpapers + BOS `Bread Background.png` | 132/132 | 352/352 | 100% | 0 | | Before this rework (reviewer numbers) | 0/30 fixtures, 0/120 real | n/a | n/a | 7 | 4K timing (release, best of 3), native vs uncached `wal -i`: 3840x2160 PNG 0.09 s vs 0.29 s, 3840x2160 RGBA PNG 0.10 s vs 0.36 s, 3840x2160 JPEG 0.16 s vs 0.30 s, 8192x5956 progressive JPEG (8 retries) 2.5 s vs 9.9 s. Remaining deviations: none measured. The PR body lists the untested corners (JPEG variants that fall back to zune, and exotic BMP/PNG variants). Verification: fmt check, clippy `-D warnings` with and without `--features gtk`, `cargo test --workspace --locked` with and without gtk (151 bread-theme tests), and `cargo build --workspace --locked`, all green locally.
Author
Owner

Second-round review: one robustness fix requested (release-sensitive repo, not merging)

CI is green on 3758f0c (check / check, run 214) and the PR is mergeable. cargo fmt --check, clippy -p bread-theme --all-targets -D warnings and cargo test -p bread-theme (139 passed) are clean locally.

Parity: independently confirmed

I did not use compare-pywal.py. My own harness runs real wal -i <img> -n -q -s -t -e (pywal 3.3.0, ImageMagick 7.1.2-31 Q16-HDRI) with HOME/XDG_CACHE_HOME pointed at a fresh temp dir per image, reads colors.json color1..6, and compares them to bread-theme generate-output --image (release build, isolated XDG_RUNTIME_DIR). It also compares the full list from examples/pywal-colors with the raw magick ... txt:- list.

Set Accents exact Full list exact
Fresh corpus, 48 images never used in development 286/288 47/48
~/Pictures/Backgrounds (21 decodable files) 126/126 21/21
Committed fixtures (incl. jpeg/ odd-420/odd-422) + BOS default (repo copy and /usr/share copy) 96/96 16/16

The fresh corpus is real photos and screenshots from ~/Downloads and ~/Pictures (phone JPEGs, posters, a 1.7 MB GIF, a removebg RGBA PNG) plus generated variants: odd 1001x667, 4K and 8K JPEG, progressive, 4:4:4/4:2:2 with restart interval/4:1:1/4:4:0 subsampling, quality 5, 50% alpha, gradient alpha on a palette PNG, palette PNG, gray PNG/JPEG, 16-bit PNG, lossy and lossless WebP, GIF, 24-bit and 8-bit BMP, plasma, random noise. The one mismatch is a CMYK JPEG: 4/6 accents exact, and the other two are off by one level in one or two channels (#89774b vs #88774b, #669fb3 vs #66a0b4). That is the documented image crate fallback. Where wal gives up (1x1, 7x5, 3x2, solid, black, gradient, checkerboard, fully transparent), bread-theme returns the curated defaults, as designed.

Timing: 4K JPEG 0.21 s vs 0.44 s for wal, 8K JPEG 0.50 s vs 1.06 s. Peak RSS on the 8K JPEG is 337 MB, vs 516 MB for magick alone.

The round-1 findings are resolved: parity, near-black accents, unique-colour ordering, exact tests, webp/gif/bmp.

Robustness

A fuzz pass mutated 9 sources (baseline, progressive and restart JPEG, CMYK JPEG, palette PNG, 16-bit PNG, WebP, GIF, 8-bit BMP) 60 times each: truncation, random byte flips and header byte flips. That is 540 cases with no panic and no hang. Zero-width and zero-height JPEG/PNG return Err, and oversized PNG IHDRs return Err("Memory limit exceeded").

Change requests

  • Unbounded allocation driven by the JPEG header, bread-theme/src/jpeg.rs:355-365 (and :439, :480). The SOF handler sizes c.coefs = vec![[0i16; 64]; c.bw * c.bh] from the declared width and height, before any scan data is read, with no cap. To test it I took a 22 KB baseline JPEG and changed only its SOF dimensions to 65500x65500. examples/pywal-colors on that file reached 11.4 GB RSS within 1.6 s and was then killed by SIGTERM (oomd) on this 32 GB machine. extract.rs:204 tries this decoder before image::load_from_memory (extract.rs:217), so it bypasses the image crate's default 512 MiB allocation limit, which is what protects the PNG/WebP/GIF/BMP paths. breadpaper calls this in-process on a user-chosen file. Fix: in the SOF branch, check the total coefficient and output buffer size against a documented budget before allocating. Use checked multiplication and a named constant sized for real wallpapers, for example 8K and 12000x8000 photos. Return None (or an error) when the budget is exceeded. Add a test that builds a tiny JPEG with a huge SOF and asserts Err without allocating.
  • (nit) Match that budget on the image path. Right now a legitimately huge 16-bit RGBA PNG (about 12000x8000) fails with "Memory limit exceeded", where wal succeeds. Set explicit image::Limits so both decoders share one documented ceiling. A doc comment stating the limit is also fine.
  • (nit) Truncated JPEGs. A baseline JPEG cut at 20 KB of 22 KB returns Ok with 1/6 accents matching wal. Wal also succeeds on it, because libjpeg pads the missing data. The data is neither exact nor an Err. Either reproduce libjpeg's premature-EOF fill, or return Err so breadpaper's extract-before-apply refuses the file. Pick one and test it (the current truncation test only covers a 40-byte file).
  • (nit) Leftover pywal wording in comments/assets: src/adw.rs:21, src/gtk.rs:121,351, src/layerrules.rs:204, src/shell/mod.rs:1178,1328,1465, src/shell/style.rs:37, src/shell/types.rs:796, and the four assets/shell/*/theme.toml comments. This is ECO-33 scope creep, so it is optional, but it is cheap to fix while the branch is open.

Licensing: needs the user's decision, not a code change

  • The SPDX expression MIT AND ImageMagick AND IJG AND BSD-3-Clause uses valid identifiers. However, libjpeg-turbo's LICENSE.md applies BSD-3-Clause only to the TurboJPEG API, libspng and the build system. The files ported here (jidctint.c, jdsample.c, jdcolor.c, jdhuff.c/jdphuff.c, jdmaster.c, jdmainct.c, jdapimin.c) are libjpeg API code, which is IJG-only. So BSD-3-Clause is over-inclusive. Keeping it is harmless and conservative. Dropping it is more accurate.
  • The ImageMagick License is Apache-2.0-style (clause 4). Derivative works must carry the licence and state their changes (done), and must reproduce the attribution notices from ImageMagick's own NOTICE file. bread-theme/NOTICE has the copyright line but not ImageMagick's upstream NOTICE text. Check whether that text should be copied in verbatim.
  • Consumers. Every binary that links palette_from_image now statically contains ImageMagick- and IJG-derived code: breadpaper today, and any app that links the whole crate unless the code is dead-stripped. The IJG licence requires that an executable-only distribution state "this software is based in part on the work of the Independent JPEG Group" in its documentation. bread-theme notice covers the bread-theme binary only. The breadpaper and other consumer packages are still MIT with license_file = "LICENSE". Options: (a) add the IJG/ImageMagick notice to consumers that call the extractor (breadpaper first), (b) put the extractor behind a cargo feature so only opted-in consumers link it, or (c) accept that the crate is no longer plain MIT and document it ecosystem-wide.
  • bread-theme/bakery.toml has no license_file, and bakery ships only the binary. bread-theme notice covers the obligation for the CLI. Confirm bakery does not need licence metadata.

Acceptance criteria as I read them

  1. ECO-29: exact golden fixtures captured from real wal. Met (tests/fixtures/golden/*, output.rs golden tests assert every slot and the full list).
  2. ECO-30: native extractor reproducing wal's output as closely as possible, for png/jpg/webp/gif/bmp, with no subprocess. Met on parity, blocked on the JPEG allocation cap above.
  3. ECO-31: bread-owned cache $XDG_CACHE_HOME/bread/palette.json, with ~/.cache/wal/colors.json fallback. Met (output.rs palette_cache_path, palette.rs load_palette, with a precedence test).
  4. ECO-32: terminal sequences written alongside the cache. Met (output.rs sequences_string/write_global_palette).
  5. ECO-33: python-pywal dropped from packaging and docs. Met (bakery.toml, registry, docs), apart from the optional comment nit.

Rework happens on the existing branch feature/native-palette, and PR #36 stays open. Do not open a second PR for these keys. After it lands, the consumer PRs (breadpaper#6, breadpad#9, bos-settings#18) must be repinned from branch = "feature/native-palette" to the release tag. The merge will delete the branch, and their locked git dependencies would stop resolving.

## Second-round review: one robustness fix requested (release-sensitive repo, not merging) CI is green on 3758f0c (`check / check`, run 214) and the PR is mergeable. `cargo fmt --check`, `clippy -p bread-theme --all-targets -D warnings` and `cargo test -p bread-theme` (139 passed) are clean locally. ### Parity: independently confirmed I did not use `compare-pywal.py`. My own harness runs real `wal -i <img> -n -q -s -t -e` (pywal 3.3.0, ImageMagick 7.1.2-31 Q16-HDRI) with `HOME`/`XDG_CACHE_HOME` pointed at a fresh temp dir per image, reads `colors.json` color1..6, and compares them to `bread-theme generate-output --image` (release build, isolated `XDG_RUNTIME_DIR`). It also compares the full list from `examples/pywal-colors` with the raw `magick ... txt:-` list. | Set | Accents exact | Full list exact | |---|---|---| | Fresh corpus, 48 images never used in development | 286/288 | 47/48 | | ~/Pictures/Backgrounds (21 decodable files) | 126/126 | 21/21 | | Committed fixtures (incl. jpeg/ odd-420/odd-422) + BOS default (repo copy and /usr/share copy) | 96/96 | 16/16 | The fresh corpus is real photos and screenshots from ~/Downloads and ~/Pictures (phone JPEGs, posters, a 1.7 MB GIF, a removebg RGBA PNG) plus generated variants: odd 1001x667, 4K and 8K JPEG, progressive, 4:4:4/4:2:2 with restart interval/4:1:1/4:4:0 subsampling, quality 5, 50% alpha, gradient alpha on a palette PNG, palette PNG, gray PNG/JPEG, 16-bit PNG, lossy and lossless WebP, GIF, 24-bit and 8-bit BMP, plasma, random noise. The one mismatch is a **CMYK JPEG**: 4/6 accents exact, and the other two are off by one level in one or two channels (`#89774b` vs `#88774b`, `#669fb3` vs `#66a0b4`). That is the documented `image` crate fallback. Where wal gives up (1x1, 7x5, 3x2, solid, black, gradient, checkerboard, fully transparent), bread-theme returns the curated defaults, as designed. Timing: 4K JPEG 0.21 s vs 0.44 s for wal, 8K JPEG 0.50 s vs 1.06 s. Peak RSS on the 8K JPEG is 337 MB, vs 516 MB for `magick` alone. The round-1 findings are resolved: parity, near-black accents, unique-colour ordering, exact tests, webp/gif/bmp. ### Robustness A fuzz pass mutated 9 sources (baseline, progressive and restart JPEG, CMYK JPEG, palette PNG, 16-bit PNG, WebP, GIF, 8-bit BMP) 60 times each: truncation, random byte flips and header byte flips. That is 540 cases with no panic and no hang. Zero-width and zero-height JPEG/PNG return `Err`, and oversized PNG IHDRs return `Err("Memory limit exceeded")`. ### Change requests - [ ] **Unbounded allocation driven by the JPEG header, `bread-theme/src/jpeg.rs:355-365` (and `:439`, `:480`).** The SOF handler sizes `c.coefs = vec![[0i16; 64]; c.bw * c.bh]` from the declared width and height, before any scan data is read, with no cap. To test it I took a 22 KB baseline JPEG and changed only its SOF dimensions to 65500x65500. `examples/pywal-colors` on that file reached 11.4 GB RSS within 1.6 s and was then killed by SIGTERM (oomd) on this 32 GB machine. `extract.rs:204` tries this decoder before `image::load_from_memory` (`extract.rs:217`), so it bypasses the `image` crate's default 512 MiB allocation limit, which is what protects the PNG/WebP/GIF/BMP paths. breadpaper calls this in-process on a user-chosen file. Fix: in the SOF branch, check the total coefficient and output buffer size against a documented budget before allocating. Use checked multiplication and a named constant sized for real wallpapers, for example 8K and 12000x8000 photos. Return `None` (or an error) when the budget is exceeded. Add a test that builds a tiny JPEG with a huge SOF and asserts `Err` without allocating. - [ ] **(nit) Match that budget on the `image` path.** Right now a legitimately huge 16-bit RGBA PNG (about 12000x8000) fails with "Memory limit exceeded", where wal succeeds. Set explicit `image::Limits` so both decoders share one documented ceiling. A doc comment stating the limit is also fine. - [ ] **(nit) Truncated JPEGs.** A baseline JPEG cut at 20 KB of 22 KB returns `Ok` with 1/6 accents matching wal. Wal also succeeds on it, because libjpeg pads the missing data. The data is neither exact nor an `Err`. Either reproduce libjpeg's premature-EOF fill, or return `Err` so breadpaper's extract-before-apply refuses the file. Pick one and test it (the current truncation test only covers a 40-byte file). - [ ] **(nit) Leftover pywal wording in comments/assets:** `src/adw.rs:21`, `src/gtk.rs:121,351`, `src/layerrules.rs:204`, `src/shell/mod.rs:1178,1328,1465`, `src/shell/style.rs:37`, `src/shell/types.rs:796`, and the four `assets/shell/*/theme.toml` comments. This is ECO-33 scope creep, so it is optional, but it is cheap to fix while the branch is open. ### Licensing: needs the user's decision, not a code change - The SPDX expression `MIT AND ImageMagick AND IJG AND BSD-3-Clause` uses valid identifiers. However, libjpeg-turbo's LICENSE.md applies BSD-3-Clause only to the TurboJPEG API, libspng and the build system. The files ported here (jidctint.c, jdsample.c, jdcolor.c, jdhuff.c/jdphuff.c, jdmaster.c, jdmainct.c, jdapimin.c) are libjpeg API code, which is IJG-only. So `BSD-3-Clause` is over-inclusive. Keeping it is harmless and conservative. Dropping it is more accurate. - The ImageMagick License is Apache-2.0-style (clause 4). Derivative works must carry the licence and state their changes (done), and must reproduce the attribution notices from ImageMagick's own NOTICE file. `bread-theme/NOTICE` has the copyright line but not ImageMagick's upstream NOTICE text. Check whether that text should be copied in verbatim. - **Consumers.** Every binary that links `palette_from_image` now statically contains ImageMagick- and IJG-derived code: breadpaper today, and any app that links the whole crate unless the code is dead-stripped. The IJG licence requires that an executable-only distribution state "this software is based in part on the work of the Independent JPEG Group" in its documentation. `bread-theme notice` covers the bread-theme binary only. The breadpaper and other consumer packages are still `MIT` with `license_file = "LICENSE"`. Options: (a) add the IJG/ImageMagick notice to consumers that call the extractor (breadpaper first), (b) put the extractor behind a cargo feature so only opted-in consumers link it, or (c) accept that the crate is no longer plain MIT and document it ecosystem-wide. - `bread-theme/bakery.toml` has no `license_file`, and bakery ships only the binary. `bread-theme notice` covers the obligation for the CLI. Confirm bakery does not need licence metadata. ### Acceptance criteria as I read them 1. ECO-29: exact golden fixtures captured from real wal. Met (`tests/fixtures/golden/*`, `output.rs` golden tests assert every slot and the full list). 2. ECO-30: native extractor reproducing wal's output as closely as possible, for png/jpg/webp/gif/bmp, with no subprocess. Met on parity, blocked on the JPEG allocation cap above. 3. ECO-31: bread-owned cache `$XDG_CACHE_HOME/bread/palette.json`, with `~/.cache/wal/colors.json` fallback. Met (`output.rs palette_cache_path`, `palette.rs load_palette`, with a precedence test). 4. ECO-32: terminal sequences written alongside the cache. Met (`output.rs sequences_string`/`write_global_palette`). 5. ECO-33: python-pywal dropped from packaging and docs. Met (bakery.toml, registry, docs), apart from the optional comment nit. Rework happens on the existing branch `feature/native-palette`, and PR #36 stays open. Do not open a second PR for these keys. After it lands, the consumer PRs (breadpaper#6, breadpad#9, bos-settings#18) must be repinned from `branch = "feature/native-palette"` to the release tag. The merge will delete the branch, and their locked git dependencies would stop resolving.
Licensing (user decision): move the ImageMagick/libjpeg-turbo port
(extract.rs, jpeg.rs, their fixtures/tests/scripts, NOTICE and the two
third-party LICENSE files) into a new workspace crate, bread-palette-extract,
licensed "ImageMagick AND IJG" (drops BSD-3-Clause: the ported libjpeg-turbo
files belong to its core libjpeg API library, which the upstream license
text says is IJG-only). bread-theme itself goes back to plain MIT and only
depends on bread-palette-extract behind a new, off-by-default "extract"
feature: palette_from_image, pywal_colors, generate_output, and
generate-output --image / notice's third-party section are the only things
gated by it. Public paths under the feature are unchanged for consumers.
The bakery/pacman build of the bread-theme binary enables the feature, ships
a combined LICENSE (MIT plus the bundled third-party terms), and CI now
runs fmt/clippy/test/build with and without it. NOTICE also reproduces the
one entry of ImageMagick's own upstream NOTICE file that pertains to the
ported code, per the ImageMagick License's redistribution clause. Reworded
the remaining "pywal" comments in adw.rs/gtk.rs/layerrules.rs/shell/*/
assets/shell/*/theme.toml to describe bread-theme's own palette, keeping
the mentions where pywal-compatibility is the actual point (palette.rs,
output.rs, the wal-shaped colors.json cache format, ANSI slot naming).

ECO-30 hardening (second review round): the JPEG port sized its
coefficient/plane/output buffers straight from the untrusted SOF header, so
a 22 KB file declaring 65500x65500 drove RSS to 11.4 GB. All three
allocations now check against one documented 2 GiB budget
(MAX_DECODE_BUDGET_BYTES) before allocating anything, comfortably covering
a real 16K (15360x8640) 16-bit RGBA wallpaper; the image-crate path
(png/webp/gif/bmp) uses the same figure as an explicit image::Limits
instead of that crate's undocumented 512 MiB default, which was in fact too
small for a real 12000x8000 16-bit RGBA PNG. A JPEG truncated mid-scan now
fails outright (UnexpectedEof) instead of silently returning
wrong-but-plausible colours from the ported decoder's zero-padding past the
cut.

Parity re-verified after the move: 84/84 accents and 243/243 full-list
entries exact on the committed fixtures (now bread-palette-extract's own
tests/golden.rs), matching the pre-move numbers.
Author
Owner

Round-3 update, addressing the second-round review plus the user's licensing decision:

Licensing (user decision: keep the port, isolate behind a feature)
Moved the extractor (extract.rs, jpeg.rs, fixtures/tests/scripts, NOTICE, LICENSE-ImageMagick, LICENSE-libjpeg-turbo) into a new workspace crate, bread-palette-extract, licensed ImageMagick AND IJG (dropped BSD-3-Clause: the ported libjpeg-turbo files belong to its core libjpeg API library, which upstream's own license text says is IJG-only; the Modified BSD terms cover the separate TurboJPEG API/cjpeg/djpeg/build system, none of which is used here). bread-theme is plain MIT again and only depends on bread-palette-extract behind a new, off-by-default extract feature: palette_from_image, pywal_colors, generate_output, and generate-output --image/notice's third-party section are the only things gated (public paths unchanged when the feature is on). The bakery/pacman build of the bread-theme binary now enables extract and ships a combined LICENSE; CI runs fmt/clippy/test/build with and without the feature. NOTICE also now reproduces the one entry of ImageMagick's own upstream NOTICE file that pertains to the ported code (its License's redistribution clause requires this).

ECO-30 hardening

  • Allocation cap: all three buffers the JPEG port sizes from the untrusted SOF header (coefficients, planes, output) now check against one documented 2 GiB budget before allocating anything. A 22 KB file declaring 65500x65500 is now rejected before allocating (regression test included); a real 16K (15360x8640) 16-bit RGBA wallpaper still decodes (verified with a heavy, #[ignore]d test).
  • The image-crate path (png/webp/gif/bmp) now uses the same 2 GiB figure as an explicit image::Limits instead of that crate's undocumented 512 MiB default, which was in fact too small for a real 12000x8000 16-bit RGBA PNG.
  • A JPEG truncated mid-scan now fails outright (UnexpectedEof) instead of silently returning wrong-but-plausible colours from the ported decoder's zero-padding past the cut; the image crate fallback also errors on it.
  • Reworded the remaining "pywal" comments in adw.rs/gtk.rs/layerrules.rs/shell/*/assets/shell/*/theme.toml to describe bread-theme's own palette, keeping the mentions where pywal-compatibility is the actual point (palette.rs's wal-shaped colors.json cache format, ANSI slot naming heritage).

Parity re-verified after the move: 84/84 accents and 243/243 full-list entries exact on the committed fixtures (now bread-palette-extract/tests/golden.rs), matching the pre-move numbers exactly.

CI: green (https://git.breadway.dev/Breadway/bread-ecosystem/actions/runs/215).

Round-3 update, addressing the second-round review plus the user's licensing decision: **Licensing (user decision: keep the port, isolate behind a feature)** Moved the extractor (`extract.rs`, `jpeg.rs`, fixtures/tests/scripts, `NOTICE`, `LICENSE-ImageMagick`, `LICENSE-libjpeg-turbo`) into a new workspace crate, `bread-palette-extract`, licensed `ImageMagick AND IJG` (dropped `BSD-3-Clause`: the ported libjpeg-turbo files belong to its core libjpeg API library, which upstream's own license text says is IJG-only; the Modified BSD terms cover the separate TurboJPEG API/cjpeg/djpeg/build system, none of which is used here). `bread-theme` is plain `MIT` again and only depends on `bread-palette-extract` behind a new, off-by-default `extract` feature: `palette_from_image`, `pywal_colors`, `generate_output`, and `generate-output --image`/`notice`'s third-party section are the only things gated (public paths unchanged when the feature is on). The bakery/pacman build of the `bread-theme` binary now enables `extract` and ships a combined `LICENSE`; CI runs fmt/clippy/test/build with and without the feature. `NOTICE` also now reproduces the one entry of ImageMagick's own upstream NOTICE file that pertains to the ported code (its License's redistribution clause requires this). **ECO-30 hardening** - Allocation cap: all three buffers the JPEG port sizes from the untrusted SOF header (coefficients, planes, output) now check against one documented 2 GiB budget before allocating anything. A 22 KB file declaring 65500x65500 is now rejected before allocating (regression test included); a real 16K (15360x8640) 16-bit RGBA wallpaper still decodes (verified with a heavy, `#[ignore]`d test). - The `image`-crate path (png/webp/gif/bmp) now uses the same 2 GiB figure as an explicit `image::Limits` instead of that crate's undocumented 512 MiB default, which was in fact too small for a real 12000x8000 16-bit RGBA PNG. - A JPEG truncated mid-scan now fails outright (`UnexpectedEof`) instead of silently returning wrong-but-plausible colours from the ported decoder's zero-padding past the cut; the `image` crate fallback also errors on it. - Reworded the remaining "pywal" comments in `adw.rs`/`gtk.rs`/`layerrules.rs`/`shell/*`/`assets/shell/*/theme.toml` to describe bread-theme's own palette, keeping the mentions where pywal-compatibility is the actual point (palette.rs's wal-shaped `colors.json` cache format, ANSI slot naming heritage). Parity re-verified after the move: 84/84 accents and 243/243 full-list entries exact on the committed fixtures (now `bread-palette-extract/tests/golden.rs`), matching the pre-move numbers exactly. CI: green (https://git.breadway.dev/Breadway/bread-ecosystem/actions/runs/215).
Author
Owner

Third-round review (round-3 commit c29a829): two small change requests plus a rebase (release-sensitive repo, not merging)

CI was green on c29a829 (run 215). The PR is currently not mergeable: main gained #37, #39, #40, #41.

Rebase: straightforward

Tested in a throwaway clone (not pushed). Commits 1 to 5 replay cleanly; c29a829 conflicts only in .forgejo/workflows/check.yml, and only in the header comment: #39's lint-pins paragraph vs this PR's --features extract paragraph. Keep both paragraphs. The step lists merge without conflict: lint-pins runs first, then the existing fmt/clippy/test steps, then your extract steps. On the rebased tree: lint-pins.sh passes (11 manifests, all tag-pinned; bread-palette-extract has no git deps), fmt is clean, clippy -D warnings is clean for the workspace and for -p bread-theme with no features, gtk, extract and gtk,extract, and cargo test --workspace passes with no features, extract and gtk,extract.

What checks out

  • Feature gating. cargo tree -p bread-theme -e normal with default features and with gtk shows no image and no bread-palette-extract. Only breadpaper calls the gated functions (grepped every checkout). A no-extract build of the binary exits 1 with a clear message on generate-output --image, and notice prints the MIT-only text.
  • Workflows. Only dev/rc/release-bread-theme.yml build the shipped binary. All three pass --features extract and copy the combined bread-theme/LICENSE, which bakery.toml names as license_file. There is no pacman build of bread-theme (packaging/arch/PKGBUILD builds only bakery), so the "bakery/pacman" wording in bread-theme/Cargo.toml:51 and CHANGELOG.md:65 should just say bakery.
  • Parity after the move. The goldens pass. On the 21 real wallpapers in ~/Pictures/Backgrounds, the rebased extract build of the bread-theme binary (generate-output --image, isolated XDG dirs) matches real wal -i -n -q in an isolated HOME on color1..6 for 21/21. The 22nd file is a 0-byte .gif that both sides reject. compare-pywal.py --full gives 336/336 exact.
  • Licence layout. ImageMagick AND IJG is correct. libjpeg-turbo's LICENSE.md limits the Modified BSD licence to the TurboJPEG API, cjpeg/djpeg, libspng and the build system. Every file ported here (jidctint, jdsample, jdcolor, jdmaster, jdmainct, jdapimin, jdhuff/jdphuff) is part of the libjpeg API library, whose headers point only at README.ijg. The NOTICE carries the IJG sentence and the relevant ImageMagick NOTICE entry. Nit: IJG clause 1 literally asks for "this README file" in source distributions. You reproduce only its LEGAL ISSUES section, which is common practice but not verbatim compliance.

Change requests

  • Over-budget JPEGs bypass the budget through the fallback (bread-palette-extract/src/jpeg.rs:423-425, src/extract.rs:201-237). When the port rejects a header for the budget, decode returns None with truncated == false, so Source::open falls through to the image crate. image::Limits::max_alloc only bounds that decoder's output buffer. I measured the bos-default.jpg fixture (22 KB) with its SOF patched to 20000x20000: 1.9 GB peak RSS, 21 s of CPU, exit 0, and a palette made of garbage. At 14000x14000 it was 0.94 GB and 8.6 s. Make the budget verdict a distinct outcome, like truncated_out, and have Source::open return an InvalidData "exceeds decode budget" error instead of falling back. Extend huge_sof_dimensions_are_rejected_before_allocating (jpeg.rs:1146) into a Source::open / wal_colors level test with a mid-size lie (for example 20000x20000 with real scan data) that asserts Err. The current test asserts only is_none(), which would also pass if a lazily zeroed allocation had happened first.
  • bread-theme has no test of its own gated code any more. cargo test -p bread-theme runs 118 tests with or without --features extract. The old golden_fixture_tests called crate::palette_from_image. The new bread-palette-extract/tests/golden.rs:58-62,129-133 re-implements the [8..14] slice itself, so the actual consumer API is untested: output.rs palette_from_image, including its None => default_accents() fallback. That leaves CI's new test (extract) step with nothing extra to run for bread-theme. Add a #[cfg(all(test, feature = "extract"))] module in output.rs that runs palette_from_image on one fixture (path via CARGO_MANIFEST_DIR/../bread-palette-extract/tests/fixtures) and asserts its golden accents, plus one image under 16 colours that asserts the default accents.

Nits (non-blocking)

  • The budget sum at jpeg.rs:407-422 leaves out upsample's full-size per-component outputs (full, jpeg.rs:531-535), which are live alongside planes and then rgb. Real peak is roughly budget plus 3wh. Either count them or reword the "all three buffers" doc.
  • Truncation: only EOF-without-marker sets truncated. A scan cut short by a marker still zero-fills silently. This matches libjpeg/ImageMagick behaviour (real wal also exits 0 on such a file), so it is fine, but the CHANGELOG sentence "a JPEG truncated mid-scan now fails outright" is broader than the code.
  • bread-theme/LICENSE is a hand-maintained copy of bread-palette-extract's NOTICE plus both licences. Consider concatenating them in the release workflows so they cannot drift.
  • CHANGELOG ECO-29/30 bullets still name extract.rs, bread-theme/tests/fixtures/ and scripts/compare-pywal.py under bread-theme. output.rs:195 has the same stale script path.
  • Gating palette_from_image/generate_output behind a feature breaks the API for any caller. Tag this as a minor bump (v0.8.0), not a patch.

Acceptance criteria (my reading, round 3)

  1. The extractor lives in its own crate under ImageMagick AND IJG, and bread-theme stays MIT unless extract is on: met (Cargo.toml changes, tree check above).
  2. Every shipped bread-theme binary enables extract and ships the third-party terms: met (the three workflows).
  3. CI covers both feature states: met for compile/clippy. Test coverage of the gated bread-theme API is missing (change request 2).
  4. The decode budget is enforced before large allocations on every path: met in the port, bypassed through the fallback (change request 1).
  5. Output is unchanged by the move: met (parity above).

Rework happens on the existing branch feature/native-palette, and PR #36 stays open. Do not open a second PR. Rebase onto main as described above when pushing the rework.

## Third-round review (round-3 commit c29a829): two small change requests plus a rebase (release-sensitive repo, not merging) CI was green on c29a829 (run 215). The PR is currently **not mergeable**: main gained #37, #39, #40, #41. ### Rebase: straightforward Tested in a throwaway clone (not pushed). Commits 1 to 5 replay cleanly; c29a829 conflicts only in `.forgejo/workflows/check.yml`, and only in the header comment: #39's lint-pins paragraph vs this PR's `--features extract` paragraph. Keep both paragraphs. The step lists merge without conflict: lint-pins runs first, then the existing fmt/clippy/test steps, then your extract steps. On the rebased tree: `lint-pins.sh` passes (11 manifests, all tag-pinned; bread-palette-extract has no git deps), fmt is clean, clippy `-D warnings` is clean for the workspace and for `-p bread-theme` with no features, `gtk`, `extract` and `gtk,extract`, and `cargo test --workspace` passes with no features, `extract` and `gtk,extract`. ### What checks out - **Feature gating.** `cargo tree -p bread-theme -e normal` with default features and with `gtk` shows no `image` and no `bread-palette-extract`. Only breadpaper calls the gated functions (grepped every checkout). A no-extract build of the binary exits 1 with a clear message on `generate-output --image`, and `notice` prints the MIT-only text. - **Workflows.** Only dev/rc/release-bread-theme.yml build the shipped binary. All three pass `--features extract` and copy the combined `bread-theme/LICENSE`, which bakery.toml names as `license_file`. There is no pacman build of bread-theme (packaging/arch/PKGBUILD builds only bakery), so the "bakery/pacman" wording in `bread-theme/Cargo.toml:51` and `CHANGELOG.md:65` should just say bakery. - **Parity after the move.** The goldens pass. On the 21 real wallpapers in ~/Pictures/Backgrounds, the rebased extract build of the `bread-theme` binary (`generate-output --image`, isolated XDG dirs) matches real `wal -i -n -q` in an isolated HOME on color1..6 for 21/21. The 22nd file is a 0-byte .gif that both sides reject. `compare-pywal.py --full` gives 336/336 exact. - **Licence layout.** `ImageMagick AND IJG` is correct. libjpeg-turbo's LICENSE.md limits the Modified BSD licence to the TurboJPEG API, cjpeg/djpeg, libspng and the build system. Every file ported here (jidctint, jdsample, jdcolor, jdmaster, jdmainct, jdapimin, jdhuff/jdphuff) is part of the libjpeg API library, whose headers point only at README.ijg. The NOTICE carries the IJG sentence and the relevant ImageMagick NOTICE entry. Nit: IJG clause 1 literally asks for "this README file" in source distributions. You reproduce only its LEGAL ISSUES section, which is common practice but not verbatim compliance. ### Change requests - [ ] **Over-budget JPEGs bypass the budget through the fallback** (`bread-palette-extract/src/jpeg.rs:423-425`, `src/extract.rs:201-237`). When the port rejects a header for the budget, `decode` returns `None` with `truncated == false`, so `Source::open` falls through to the `image` crate. `image::Limits::max_alloc` only bounds that decoder's output buffer. I measured the bos-default.jpg fixture (22 KB) with its SOF patched to 20000x20000: 1.9 GB peak RSS, 21 s of CPU, exit 0, and a palette made of garbage. At 14000x14000 it was 0.94 GB and 8.6 s. Make the budget verdict a distinct outcome, like `truncated_out`, and have `Source::open` return an `InvalidData` "exceeds decode budget" error instead of falling back. Extend `huge_sof_dimensions_are_rejected_before_allocating` (jpeg.rs:1146) into a `Source::open` / `wal_colors` level test with a mid-size lie (for example 20000x20000 with real scan data) that asserts `Err`. The current test asserts only `is_none()`, which would also pass if a lazily zeroed allocation had happened first. - [ ] **bread-theme has no test of its own gated code any more.** `cargo test -p bread-theme` runs 118 tests with or without `--features extract`. The old `golden_fixture_tests` called `crate::palette_from_image`. The new `bread-palette-extract/tests/golden.rs:58-62,129-133` re-implements the `[8..14]` slice itself, so the actual consumer API is untested: `output.rs` `palette_from_image`, including its `None => default_accents()` fallback. That leaves CI's new `test (extract)` step with nothing extra to run for bread-theme. Add a `#[cfg(all(test, feature = "extract"))]` module in `output.rs` that runs `palette_from_image` on one fixture (path via `CARGO_MANIFEST_DIR/../bread-palette-extract/tests/fixtures`) and asserts its golden accents, plus one image under 16 colours that asserts the default accents. ### Nits (non-blocking) - The budget sum at jpeg.rs:407-422 leaves out `upsample`'s full-size per-component outputs (`full`, jpeg.rs:531-535), which are live alongside `planes` and then `rgb`. Real peak is roughly budget plus 3*w*h. Either count them or reword the "all three buffers" doc. - Truncation: only EOF-without-marker sets `truncated`. A scan cut short by a marker still zero-fills silently. This matches libjpeg/ImageMagick behaviour (real wal also exits 0 on such a file), so it is fine, but the CHANGELOG sentence "a JPEG truncated mid-scan now fails outright" is broader than the code. - `bread-theme/LICENSE` is a hand-maintained copy of bread-palette-extract's NOTICE plus both licences. Consider concatenating them in the release workflows so they cannot drift. - CHANGELOG ECO-29/30 bullets still name `extract.rs`, `bread-theme/tests/fixtures/` and `scripts/compare-pywal.py` under bread-theme. `output.rs:195` has the same stale script path. - Gating `palette_from_image`/`generate_output` behind a feature breaks the API for any caller. Tag this as a minor bump (v0.8.0), not a patch. ### Acceptance criteria (my reading, round 3) 1. The extractor lives in its own crate under ImageMagick AND IJG, and bread-theme stays MIT unless `extract` is on: met (Cargo.toml changes, tree check above). 2. Every shipped bread-theme binary enables `extract` and ships the third-party terms: met (the three workflows). 3. CI covers both feature states: met for compile/clippy. Test coverage of the gated bread-theme API is missing (change request 2). 4. The decode budget is enforced before large allocations on every path: met in the port, bypassed through the fallback (change request 1). 5. Output is unchanged by the move: met (parity above). Rework happens on the existing branch `feature/native-palette`, and PR #36 stays open. Do not open a second PR. Rebase onto main as described above when pushing the rework.
Breadway force-pushed feature/native-palette from c29a829b80
All checks were successful
check / check (push) Successful in 1m52s
to 4c51998e0c
All checks were successful
check / check (push) Successful in 2m22s
2026-09-29 08:28:37 +08:00
Compare
Author
Owner

Round-4 update, addressing the third-round review (#issuecomment-1377):

  • Over-budget JPEGs no longer bypass the budget through the fallback (jpeg.rs, extract.rs). jpeg::decode now reports an over-budget SOF header through a distinct over_budget_out flag, separate from truncated_out and from a plain unsupported-feature None. Source::open turns that into a hard io::ErrorKind::InvalidData error instead of falling back to the image crate, which was decoding the same oversized claim a second time with its own unbounded allocator. Added a wal_colors-level regression test with a real JPEG whose SOF lies about a 20000x20000 image (real scan data behind it, not just a crafted header), asserting Err quickly rather than just decode(..).is_none().
  • The decode budget sum now also counts the JPEG port's per-component upsample output buffers, closing the gap where real peak usage could run about 3 * width * height bytes past the documented budget.
  • bread-theme now tests its own gated API. output.rs gained an extract_feature_tests module that calls the real palette_from_image public function (not bread-palette-extract's internal slice) against the golden fixtures, the default-accent fallback for a sub-16-colour image, and a decode error.
  • Added a test pinning that bread-theme/LICENSE (the file bakery actually ships) still carries the ImageMagick and libjpeg-turbo/IJG licence texts verbatim, so the hand-maintained combined file can't silently drift from its sources.
  • Nits: fixed the "bakery/pacman" wording (there's no pacman build of bread-theme itself), fixed stale bread-theme/scripts/bread-theme/tests/fixtures paths in CHANGELOG and output.rs now that the extractor lives in bread-palette-extract, and clarified that only an EOF-without-marker truncation fails outright (a scan cut short at a marker boundary still zero-fills, matching libjpeg-turbo/ImageMagick).
  • Rebased onto origin/main. The only conflict was .forgejo/workflows/check.yml's header comment (lint-pins paragraph vs the --features extract paragraph); kept both, in the order you confirmed (lint-pins, then fmt/clippy/test, then extract steps).

Verified on the rebased tree: bash scripts/lint-pins.sh (11 manifests, tag-pinned), cargo fmt --all -- --check, cargo clippy --workspace --all-targets --locked -- -D warnings with no features, gtk, extract, and gtk,extract, cargo test --workspace --locked for the same four combinations (all green, including the new tests), and cargo build --workspace --locked with and without extract. Force-pushed to feature/native-palette; CI run is in progress.

Round-4 update, addressing the third-round review (#issuecomment-1377): - **Over-budget JPEGs no longer bypass the budget through the fallback** (`jpeg.rs`, `extract.rs`). `jpeg::decode` now reports an over-budget SOF header through a distinct `over_budget_out` flag, separate from `truncated_out` and from a plain unsupported-feature `None`. `Source::open` turns that into a hard `io::ErrorKind::InvalidData` error instead of falling back to the `image` crate, which was decoding the same oversized claim a second time with its own unbounded allocator. Added a `wal_colors`-level regression test with a real JPEG whose SOF lies about a 20000x20000 image (real scan data behind it, not just a crafted header), asserting `Err` quickly rather than just `decode(..).is_none()`. - The decode budget sum now also counts the JPEG port's per-component upsample output buffers, closing the gap where real peak usage could run about `3 * width * height` bytes past the documented budget. - **bread-theme now tests its own gated API.** `output.rs` gained an `extract_feature_tests` module that calls the real `palette_from_image` public function (not `bread-palette-extract`'s internal slice) against the golden fixtures, the default-accent fallback for a sub-16-colour image, and a decode error. - Added a test pinning that `bread-theme/LICENSE` (the file bakery actually ships) still carries the ImageMagick and libjpeg-turbo/IJG licence texts verbatim, so the hand-maintained combined file can't silently drift from its sources. - Nits: fixed the "bakery/pacman" wording (there's no pacman build of bread-theme itself), fixed stale `bread-theme/scripts`/`bread-theme/tests/fixtures` paths in CHANGELOG and `output.rs` now that the extractor lives in `bread-palette-extract`, and clarified that only an EOF-without-marker truncation fails outright (a scan cut short at a marker boundary still zero-fills, matching libjpeg-turbo/ImageMagick). - Rebased onto origin/main. The only conflict was `.forgejo/workflows/check.yml`'s header comment (lint-pins paragraph vs the `--features extract` paragraph); kept both, in the order you confirmed (lint-pins, then fmt/clippy/test, then extract steps). Verified on the rebased tree: `bash scripts/lint-pins.sh` (11 manifests, tag-pinned), `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings` with no features, `gtk`, `extract`, and `gtk,extract`, `cargo test --workspace --locked` for the same four combinations (all green, including the new tests), and `cargo build --workspace --locked` with and without `extract`. Force-pushed to `feature/native-palette`; CI run is in progress.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Breadway/bread-ecosystem!36
No description provided.