fix(export): tag exported files with an absolute resolution - #980
Open
Icodextrin wants to merge 7 commits into
Open
fix(export): tag exported files with an absolute resolution#980Icodextrin wants to merge 7 commits into
Icodextrin wants to merge 7 commits into
Conversation
An exported TIFF reported 1 DPI in macOS Preview and fell back to 72 in Photoshop. tifffile does not omit the resolution tags when `resolution` is unset — it writes XResolution (1, 1) with ResolutionUnit NONE, an aspect ratio with no absolute unit. Readers show the numerator. A 6000 px scan therefore read as a 6000 inch print, and some tools clamp or reject that instead of falling back to 72. JPEG was unaffected: it encodes through _save_to_pil_buffer, the only branch that passed a dpi. TIFF, PNG, WebP and JXL are encoded inline in _encode_export and passed none. TIFF and PNG now tag PrintService.effective_dpi, so a Pixels-mode export tags the DPI its own long edge implies rather than the value stranded in the Print-only spinbox; JPEG moves to the same source for that reason. The value is floored at 1 because the tag now asserts inches and a persisted config can hold a 0 the spinbox cannot. Both metadata rewrite paths dropped the tags they re-encoded past: _rewrite_tiff_preserve carried photometric, compression and ICC but not resolution, and PIL writes a PNG's pHYs only from a save kwarg, never from the info dict it read it into. Both now carry it. Linear masters get a nominal 300 DPI: no print intent, but they need an absolute unit for the same reason. WebP and JXL are unchanged — neither format has a resolution field, so both would need an EXIF block, and neither writes a wrong value today. Closes marcinz606#939 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A source scan's XResolution describes the scanner, not the file NegPy writes. It was copied to exports untouched, so a frame scanned at 3600 dpi and exported at 200 reported 3600. TIFF escaped this because _TIFFFILE_RESERVED_TAGS keeps 282/283/296 out of the extratags, leaving tifffile's own resolution= to win. The rest did not: - JPEG lost the correct value outright. The encoder wrote JFIF density, then piexif.insert rebuilt the file without the APP0 segment, leaving the source's EXIF as the only resolution record. - PNG ended up self-contradicting: a correct pHYs beside a stale eXIf. - JPEG XL and WebP carry resolution only in EXIF, so the stale value was all they had. embed_metadata, preserve_source_metadata and export_embed_plan now take a dpi and stamp it into IFD0 for JPEG, PNG and JPEG XL. The export worker passes PrintService.resolution_tag_dpi. The override is opt-in: without a dpi the EXIF is untouched, so callers with no export settings are unaffected. It applies under Protect original metadata too — that copies the source verbatim, but a resolution describing a different pixel count is not worth preserving. WebP is deliberately left as-is. The format has no resolution field, browsers ignore the EXIF one, and nothing in a web-delivery path reads it; a test pins the choice so it does not drift by accident. The DPI floor moves from image_processor to PrintService.resolution_tag_dpi, now that the export worker needs the same value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The resolution override treated the toggle as advisory. It is not: Protect original metadata promises the source EXIF reaches the export untouched, and resolution is part of that promise. It was also wrong on its own terms. An export that was not resampled keeps the source's sampling density, so the scanner's DPI is the correct answer for it — and Original mode hides the DPI spinbox, so the value replacing it was a stale leftover. A 3600 dpi scan exported at full size reported 300. preserve_source_metadata drops the dpi argument, and export_embed_plan skips the override on its protect branch. The embed path is unchanged: NegPy is authoring metadata there, and a resized file should not claim its source's DPI. TIFF is structurally exempt either way. 282/283/296 are filtered out of the extratags, so its baseline tag is the file's own record rather than copied metadata, and it always describes the export. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Original mode resamples nothing, so the exported pixels keep the source's sampling density and the source's DPI still describes them. Overwriting it with the export setting replaced a known-correct value with a guess: the DPI field is hidden outside Print mode, so a full-size export of a 3600 dpi scan reported whatever Print had last been left at. The rule is now the resampling, not the format or the metadata mode: - Original keeps the source's DPI, falling back to the DPI field when the source declares none. - Print and Pixels use the size the user asked for. - Protect original metadata keeps the source's DPI in every mode, including a resized one. Two deliberate choices conflict there and the metadata toggle is the more specific; a source declaring no DPI still falls back, because a TIFF baseline resolution is a required field. source_dpi_from_exif reads it, converting a centimetre unit and rejecting ResolutionUnit 1, which is an aspect ratio rather than a resolution. The decision is made once in the export worker and passed to both the encode and the metadata write, so the native tag and the EXIF cannot disagree. WebP now carries the resolution too, in the EXIF block that is its only possible carrier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er it lives Four gaps in the resolution rules, from review. Protect original metadata was normalising what it claimed to preserve. The source's resolution was read into a single rounded integer, so asymmetric axes collapsed, a fractional value rounded and a centimetre unit became inches, and a source declaring no resolution gained one from the export setting. A new Resolution type carries the rationals and the unit as written, and None now means "make no claim", so nothing is invented for a source that has none. Source resolution was read only from the cached EXIF, which session.py fills in select_file. A batch export of frames the user never opened therefore saw nothing and fell back to the export setting, silently losing the DPI of every untouched frame. resolution.read_source consults the cache, then the file. It also read EXIF alone. A lab JPEG commonly states its density in the JFIF header and carries no EXIF at all, so it looked untagged; the container's own record is now the second place to look, which covers JFIF, PNG pHYs and TIFF baseline tags in one step. Linear output resamples nothing but hard-coded 300 dpi, and its JPEG XL form stated no resolution at all. Both now follow the same preserve-or-default rule as the main export path. The IR JPEG XL sidecar stays bare: it is an auxiliary data plane rather than a picture, and test_jxl_ir_sidecar_stays_untagged pins that decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps under Protect original metadata, from review. A JPEG commonly states its density in the JFIF header and carries no EXIF resolution at all. read_source found it, but nothing carried it: the JFIF segment does not survive piexif.insert, and WebP and JPEG XL have no field of their own, so a protected export of such a source landed with no resolution in any of the three. preserve_source_metadata now takes the source's own resolution and writes it into IFD0, which is where those formats keep it. It stays None when the source declares none, so nothing is invented. from_container read Pillow's dpi, which converts a per-centimetre density to inches: a 100x50 dpcm source came back as 254x127 dpi. The same resolution, but not the numbers the file states, and protection has to hand back what is actually there. The JFIF record is now read in its own units, with unit 0 treated as the aspect ratio it is rather than a resolution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Protect original metadata could not be honoured exactly for a source that declares no resolution: TIFF has no way to say "none", and omitting the tags makes tifffile write XResolution (1, 1) with ResolutionUnit NONE, which readers report as 1 DPI. Preserving nothing meant asserting something false. Where protection cannot be met exactly, the export's own resolution is written instead. Being unable to stay silent is not a licence to state something wrong. TIFF is the only format this reaches. PNG, JPEG, WebP and JPEG XL can all leave the record out, so they still do, and nothing is invented for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Icodextrin
marked this pull request as ready for review
August 26, 2026 14:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #939
Exported files reported the wrong resolution. This PR the missing tags along with some rules about when to write DPI.
Rules I set for exporting DPI
There is one edge case to this which is that in the event of the protect original metadata being checked on a tiff file with no DPI, we set it to the correct DPI. This is because the tiff file needs that tag so we can either knowingly tag the wrong metadata, or just write it in correctly. So I opted for writing it correctly, even if the user requests no metadata change.
What was wrong
Missing tags.
tifffile.imwritedoesn't omit the resolution tags whenresolutionis unset — it writes(1, 1)withResolutionUnit NONE, which Preview shows as "1 DPI" and Photoshop falls back to 72 for. TIFF, PNG, WebP and JXL are encoded inline in_encode_exportand were passed no resolution; only JPEG had one, via_save_to_pil_buffer. Both metadata rewrite paths then stripped back off whatever did get written.Stale source resolution. A scan's
XResolutionwas copied into exports untouched, so a 3600 dpi scan exported at 200 reported 3600. Worst on JPEG, the default format, wherepiexif.insertrebuilds the file without the JFIF segment holding the correct value, leaving only the source's EXIF.