Skip to content

fix(export): tag exported files with an absolute resolution - #980

Open
Icodextrin wants to merge 7 commits into
marcinz606:mainfrom
Icodextrin:fix/export-resolution-tags
Open

fix(export): tag exported files with an absolute resolution#980
Icodextrin wants to merge 7 commits into
marcinz606:mainfrom
Icodextrin:fix/export-resolution-tags

Conversation

@Icodextrin

@Icodextrin Icodextrin commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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

  1. If there is existing DPI information, and we're not resampling / resizing the image image. We should keep that DPI and write it out in the metadata on export.
  2. If the user changes that DPI information, either through resizing, or exporting for print where they input the DPI, we should respect that DPI and include it in the metadata.
  3. If there is no DPI information at all, we should use a sensible default that will not cause issues with editing software.
  4. This should be supported by all image formats.
  5. If the user selects protect original metadata we should respect that and not edit the DPI for that image, even if it does not exist or is wrong.

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.imwrite doesn't omit the resolution tags when resolution is unset — it writes (1, 1) with ResolutionUnit NONE, which Preview shows as "1 DPI" and Photoshop falls back to 72 for. TIFF, PNG, WebP and JXL are encoded inline in _encode_export and 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 XResolution was copied into exports untouched, so a 3600 dpi scan exported at 200 reported 3600. Worst on JPEG, the default format, where piexif.insert rebuilds the file without the JFIF segment holding the correct value, leaving only the source's EXIF.

Icodextrin and others added 7 commits August 24, 2026 14:49
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
Icodextrin marked this pull request as ready for review August 26, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Exported TIFFs report 1 DPI

1 participant