Skip to content

Add resolution support for image formats (JPEG, TIFF, PNG, BMP) along with unit parsing#165

Open
cmrd-senya wants to merge 5 commits intosdsykes:masterfrom
cmrd-senya:resolution-support
Open

Add resolution support for image formats (JPEG, TIFF, PNG, BMP) along with unit parsing#165
cmrd-senya wants to merge 5 commits intosdsykes:masterfrom
cmrd-senya:resolution-support

Conversation

@cmrd-senya
Copy link
Copy Markdown

@cmrd-senya cmrd-senya commented Apr 2, 2026

Solution for #164

Summary

  • Adds FastImage.resolution(uri) class method returning [[x, y], :units] or nil
  • Adds #resolution and #resolution_units instance methods returning the components separately
  • Supports JPEG (JFIF APP0), PNG (pHYs chunk), BMP (DIB header), and TIFF (EXIF IFD rational tags)
  • Extends GoodFixtures with a resolution column and adds a test_should_report_resolution_correctly sweep test covering all fixture files

Supported formats and units

Format Source Possible units
JPEG JFIF APP0 segment :inches, :centimeters, :no_units
PNG pHYs chunk :meters, :no_units
BMP DIB header (XPelsPerMeter/YPelsPerMeter) :meters
TIFF EXIF XResolution/YResolution rational tags :inches, :centimeters, :no_units

Formats without resolution metadata (GIF, WebP, SVG, HEIC, AVIF, JXL, PSD, ICO) return nil for both methods.

API

# Class method — returns [[x, y], :units] or nil
FastImage.resolution("image.jpg")  # => [[72, 72], :inches]
FastImage.resolution("image.png")  # => nil

# Instance methods
fi = FastImage.new("image.tiff")
fi.resolution        # => [72.0, 72.0]
fi.resolution_units  # => :inches

Notes

  • TIFF resolution values are Float (stored as RATIONAL numerator/denominator in EXIF); other formats return Integer
  • BMP returns nil when both PPM values are zero (unspecified) or when the file uses the older BITMAPCOREHEADER which has no resolution fields
  • The EXIF parser was extended with named tag/data-type constants and a two-pass approach to read RATIONAL offsets after the IFD scan

# FastImage.resolution("test/fixtures/test.png")
# => nil
#
def self.resolution(uri, options={})
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This returns both resolution and units. Instance methods return just one or another. It's inconsistent between each other but with class method allows to parse just once. And for instance methods it is parsed just once anyway

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds resolution (pixel density) extraction to FastImage for common formats, extending the existing minimal-read parsing approach to support reading format-specific metadata for JPEG, PNG, BMP, and TIFF.

Changes:

  • Adds FastImage.resolution(uri) plus instance methods #resolution and #resolution_units.
  • Implements per-format resolution parsing for JPEG (JFIF), PNG (pHYs), BMP (DIB header PelsPerMeter), and TIFF (EXIF rational tags), including EXIF parser enhancements.
  • Extends fixture expectations and adds a sweep test validating resolution across the fixture set.

Reviewed changes

Copilot reviewed 9 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/fastimage/fastimage.rb Adds class/instance resolution APIs and wires resolution parsing into the fetch/parse pipeline.
lib/fastimage/fastimage_parsing/image_base.rb Adds a default resolution hook for parsers.
lib/fastimage/fastimage_parsing/jpeg.rb Adds JFIF APP0 density parsing and refactors marker constants used in JPEG parsing.
lib/fastimage/fastimage_parsing/png.rb Adds pHYs chunk parsing to return pixels-per-unit + units.
lib/fastimage/fastimage_parsing/bmp.rb Adds DIB header resolution parsing via XPelsPerMeter/YPelsPerMeter.
lib/fastimage/fastimage_parsing/tiff.rb Adds TIFF resolution extraction via EXIF tags and unit mapping.
lib/fastimage/fastimage_parsing/exif.rb Extends EXIF parsing with tag/type constants and a second pass for RATIONAL resolution values.
test/test.rb Adds fixture resolution expectations and tests for the new API.
test/fixtures/test_with_resolution.png Adds a PNG fixture containing pHYs resolution metadata.
test/fixtures/test_no_resolution_unit.png Adds a PNG fixture with pHYs unit set to “unknown” (no-units).
README.md Documents the new resolution feature and adds example usage.
.gitignore Ignores JetBrains .idea directory.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +258 to +267
def resolution
@property = :resolution
fetch unless defined?(@resolution)
@resolution
end

def resolution_units
resolution unless defined?(@resolution)
@resolution_units
end
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

resolution_units calls resolution again when @resolution was previously evaluated to nil (because defined?(@resolution) stays false unless the ivar is assigned). This can trigger a second fetch/parse for formats without resolution metadata (common case) and defeats memoization. Consider caching the “resolution not present” result (e.g., assign @resolution = nil / @resolution_units = nil after the first attempt, or track a separate boolean) so subsequent calls don’t re-fetch.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm not sure if it's a real issue here

Comment thread README.md
Comment on lines +76 to +80
=> [[72, 72], :inches]
FastImage.new("https://switchstep.com/images/ss_logo.png").resolution
=> [72, 72]
FastImage.new("https://switchstep.com/images/ss_logo.png").resolution_units
=> :inches
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The README example shows FastImage.resolution for a .png returning :inches, but the implementation returns PNG pHYs values in pixels-per-meter (:meters) or :no_units (and does not convert to DPI). Please update the example/output (or clarify/conform the API) so the documented units match the actual behavior.

Suggested change
=> [[72, 72], :inches]
FastImage.new("https://switchstep.com/images/ss_logo.png").resolution
=> [72, 72]
FastImage.new("https://switchstep.com/images/ss_logo.png").resolution_units
=> :inches
=> [[2835, 2835], :meters] # PNG pHYs values are returned as pixels per unit; PNG uses pixels per meter or :no_units
FastImage.new("https://switchstep.com/images/ss_logo.png").resolution
=> [2835, 2835]
FastImage.new("https://switchstep.com/images/ss_logo.png").resolution_units
=> :meters

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed

Comment thread test/test.rb Outdated

def test_should_report_resolution_correctly
GoodFixtures.each do |fn, info|
assert_equal info[2], FastImage.resolution(TestUrl + fn), "resolution for #{fn} must be #{info[2].inspect}"
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

Test coverage for resolution currently only exercises HTTP/FakeWeb paths. Since FastImage explicitly supports local file paths and IO objects (and the suite already sweeps those for type/size), it would be good to add analogous sweeps for FastImage.resolution to ensure resolution parsing works consistently across fetch modes.

Suggested change
assert_equal info[2], FastImage.resolution(TestUrl + fn), "resolution for #{fn} must be #{info[2].inspect}"
path = File.join(FixturePath, fn)
assert_equal info[2], FastImage.resolution(TestUrl + fn), "resolution for #{fn} via URL must be #{info[2].inspect}"
assert_equal info[2], FastImage.resolution(path), "resolution for #{fn} via file path must be #{info[2].inspect}"
File.open(path, "rb") do |io|
assert_equal info[2], FastImage.resolution(io), "resolution for #{fn} via IO must be #{info[2].inspect}"
end

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed

@cmrd-senya
Copy link
Copy Markdown
Author

cmrd-senya commented Apr 6, 2026

Hey!

Sorry, I've just found a case in JPEG files that I missed in initial version, so I'm going to make some changes now

@cmrd-senya cmrd-senya force-pushed the resolution-support branch from ab3e005 to 1f87442 Compare April 6, 2026 10:57
@cmrd-senya cmrd-senya force-pushed the resolution-support branch from 1f87442 to 85a490d Compare April 6, 2026 11:01
@cmrd-senya cmrd-senya force-pushed the resolution-support branch from 67b3d63 to 8fc7cd3 Compare April 6, 2026 13:45
@cmrd-senya
Copy link
Copy Markdown
Author

Okay, build for Ruby 2.3-2.5 have been fixed

@cmrd-senya
Copy link
Copy Markdown
Author

Hey @sdsykes !

Anything else I could do here?

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.

2 participants