Skip to content

fix: keep the bytes after a Hue/Saturation block's range records (#645) - #794

Merged
kyamagu merged 5 commits into
psd-tools:mainfrom
MaxFreedomPollard:fix/hue-saturation-trailing-bytes
Sep 9, 2026
Merged

kyamagu merged 5 commits into
psd-tools:mainfrom
MaxFreedomPollard:fix/hue-saturation-trailing-bytes

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

Saving a file whose Hue/Saturation layers came from a recent Photoshop drops 36 bytes out of each hue2 block. The block Photoshop writes is 136 bytes; psd-tools writes 100.

HueSaturation.read() at src/psd_tools/psd/adjustments.py:515 reads exactly the 100 bytes the format documents: version and colorization flag, the colorization triple, the master triple, and six range records of four range values plus three settings. It never touches the rest of the stream, and BaseElement.frombytes() at src/psd_tools/psd/base.py:73 hands read() a BytesIO over the whole block and throws away what is left in it, so the trailing bytes are gone by the time write() at src/psd_tools/psd/adjustments.py:533 runs, and the block comes back out 36 bytes shorter.

Those 36 bytes are six (hue, 100, 50) triples, one per range, and they are not constant. Across the 16 Hue/Saturation blocks in tests/psd_files/adjustments/ each triple's hue is the midpoint of that range's inner band, which moves when the user drags the range sliders: in huesaturation_saturation_rgb.psd one layer's first range is (103, 111, 157, 165) and its first trailing triple is (134, 100, 50). That is why #645 came out 36 bytes per layer short, and it is data rather than the padding difference the issue was closed as.

The fix adds an unknown: bytes field that captures the remainder and writes it back, the same way ChannelMixer two structures above in the same file already handles its own trailer. Files from older Photoshop stop at 100 bytes and leave the field empty, so nothing about them changes.

To check that no other block was doing this, I monkeypatched TaggedBlock.read to re-encode every block it parsed and compare against the raw bytes, then ran it over all 295 fixture files. Before the change: 16 HUE_SATURATION blocks shrinking 136 to 100, plus 4 UNICODE_LAYER_NAME blocks growing by 2 bytes, which is the known non-Adobe padding already listed in SKIP_BYTE_ROUND_TRIP. After the change: only the 4 UNICODE_LAYER_NAME blocks.

Verification, all on Python 3.11 with uv sync --group dev --extra composite:

uv run pytest --no-cov -q gives 2890 passed, 3 skipped, 21 xfailed, 2 xpassed against 2883 passed, 3 skipped, 25 xfailed, 2 xpassed on main. The four fewer xfails are the four huesaturation fixtures, which now reproduce byte for byte, so test_psd_read_write no longer needs HUESATURATION_MISMATCH.

uv run pytest --no-cov -q tests/psd_tools/psd/test_adjustments.py -k hue_saturation gives 3 passed. Reverting only src/psd_tools/psd/adjustments.py and rerunning gives 2 failed, 1 passed: test_hue_saturation_rw with the trailer fails with 136 vs 100, and test_hue_saturation_keeps_trailing_bytes fails with AttributeError: 'HueSaturation' object has no attribute 'unknown'. The parametrised case with no trailer passes either way, which is the older 100-byte shape staying put.

uv run ruff check gives All checks passed!, uv run ruff format --check . gives 110 files already formatted, and uv run mypy gives Success: no issues found in 109 source files.

Changelog entry added under a new 1.19.1 (unreleased) heading.

Photoshop writes a 136-byte 'hue2' block. HueSaturation.read() in
src/psd_tools/psd/adjustments.py models the 100 bytes the format
documents -- version, colorization, master and six range records -- and
BaseElement.frombytes() drops whatever is left in the block's stream, so
write() emitted 100 bytes and the remaining 36 were lost on every save.

Those 36 bytes are six (hue, 100, 50) triples, one per range, and they
vary with the layer: across the 16 Hue/Saturation blocks in the fixtures
each triple's hue is the midpoint of that range's inner band, which the
user moves. They are data, not padding, which is what #645 read them as.

Capture them in an `unknown: bytes` field and write them back, the same
way ChannelMixer two structures above already handles its own trailer.
Files written by older Photoshop stop at 100 bytes and leave the field
empty, so they are unaffected.

Sweeping every tagged block in all 295 fixture files through read then
write, HUE_SATURATION was the only key whose bytes changed apart from
the known 2-byte UNICODE_LAYER_NAME padding of the non-Adobe files
already in SKIP_BYTE_ROUND_TRIP. The four huesaturation fixtures now
reproduce byte for byte, so test_psd_read_write drops its xfail.
@kyamagu kyamagu added the bug label Sep 6, 2026

@kyamagu kyamagu 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.

Thanks for this — I checked the claim independently and it holds: decoding every hue2 block under tests/psd_files/adjustments/ gives 16 blocks of 136 bytes whose 36-byte tail is six (hue, 100, 50) triples, and the hue values genuinely vary per layer (e.g. huesaturation_saturation_rgb.psd layer 1 → 134 for inner band 111..157). So it is data, not padding, and preserving it verbatim is the right call. Full suite on the branch: 2890 passed, 3 skipped, 21 xfailed, 2 xpassed — the four HUESATURATION_MISMATCH xfails are legitimately gone.

One small thing about the write path, inline.

Comment thread src/psd_tools/psd/adjustments.py Resolved
docs/changelog.rst was the only conflict: both sides opened a
1.19.1 (unreleased) section. Kept every entry, with the two stroke
entries from main above this branch's Hue/Saturation entry, which
matches how main prepends the newest entry to the section.
`unknown` is read with `fp.read()` to the end of the block, so it
already carries whatever padding the producer wrote. Padding again
after writing it grew a block that was not already 4-byte aligned: a
102-byte `hue2` payload came back 104. Write the bytes and stop, the
way `ChannelMixer.write` does with the same `unknown: bytes` field.
The existing cases are 100 and 136 bytes, both already aligned, so
neither caught the write path adding padding of its own. 102 and 103
bytes both came back 104 before the previous commit.

Copilot AI 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.

🟢 Approval recommended

The code change is small, targeted, and is backed by regression tests that demonstrate the previously failing byte-for-byte round-trip now passes.

Pull request overview

Fixes byte-for-byte round-tripping for modern Photoshop Hue/Saturation adjustment tagged blocks by preserving the trailing bytes that psd-tools previously dropped during parse/serialize.

Changes:

  • Capture and re-emit the remaining bytes after Hue/Saturation range records via a new unknown: bytes field.
  • Add/extend regression tests for Hue/Saturation block round-tripping (with and without trailing bytes).
  • Remove the now-unnecessary xfail exceptions for Hue/Saturation fixtures in byte-for-byte PSD round-trip tests and document the fix in the changelog.
File summaries
File Description
src/psd_tools/psd/adjustments.py Preserve trailing Hue/Saturation block bytes by reading/writing an unknown byte payload.
tests/psd_tools/psd/test_adjustments.py Add Hue/Saturation block fixtures/tests to ensure trailing bytes are preserved.
tests/psd_tools/psd/test_psd.py Remove Hue/Saturation-specific xfail logic from byte-for-byte round-trip tests.
docs/changelog.rst Add a 1.19.1 (unreleased) entry describing the Hue/Saturation round-trip fix.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/changelog.rst Resolved
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
@kyamagu
kyamagu merged commit 3c0fb79 into psd-tools:main Sep 9, 2026
20 checks passed
@kyamagu

kyamagu commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@MaxFreedomPollard Thanks!

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants