zxing_ffi 0.1.0-x86_64-linux-gnu → 0.2.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fca126ce5bfcd5cacc39797bffa92d5324af1f85434ac357a9a9844de7f7243f
4
- data.tar.gz: 5ce4ae0174c6fe83c24cc8f7abf9b82a0f1f0566053b7072aa97731546b4a03a
3
+ metadata.gz: d5e54739e8076eaa1e643e6ba3d419f720c028ad8ec026c54ea8ed3263cfceec
4
+ data.tar.gz: c48499d48a7f48c180b308d56d85e92abc4d37576459fbf8f806e17a58f10550
5
5
  SHA512:
6
- metadata.gz: 8fd94ac067999ae90ab17346307ed4097cfd48e08d7c90ae0ce04d6c830b024d42be4ab3d6ab69f36b62a011bc562de6cd09b49b3c3fb3ebcbb19c11432ba4f3
7
- data.tar.gz: 99f04a6996a1474ed98501c43ec040f5981cd217366fba76b69f38faf596689c69bba6cc59e0a96586dd0387ccded21241449110bbfb1c864ab243dc39d5e9e0
6
+ metadata.gz: 80f27a58a5646eef35917f8c5583c1a9ec84d6149ea507b590580ea55a34c3c27e2e51a2b1d3f14aaa123efa857a0855761de95485531912b6daf0f2f5f43a4d
7
+ data.tar.gz: 4a2725b55ad246777da0add216cf90a9fbe28554376340ac78a4cc6cd4ac359a8545d549d34842289ac21734fcedce3beac6fc64f63329046bbb0436bc4fcb1d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-08)
4
+
5
+ - **`oversize: :downscale`** (scan option and `config.oversize`): rasters over `max_pixels` are decoded smaller to fit
6
+ instead of raising `LimitExceeded`, by the libvips loader (JPEG and WebP shrink while decoding, other formats
7
+ stream), up to the new `max_source_pixels` (default 1 GP; larger ones still raise). Positions and page sizes stay
8
+ in the original's pixels and `PageResult#metadata[:downscaled]` records the scale. The default stays `:raise`.
9
+ - **`high_res` pass**: rasters are upscaled with a centered bicubic (Catmull-Rom) filter instead of pixel
10
+ replication, which kept the aliasing of codes rasterized at 1–2 px per module. QR codes at ~1.5 px per module
11
+ that every pass missed are now found at `effort: :normal`. Both transformers' `resize` upscale this way at any
12
+ scale above 1.
13
+ - **Tooling**: `rake release` publishes a release from `main`: it tags the commit, waits for the Platform gems
14
+ workflow, checks the seven gems that workflow built, pushes them to RubyGems (asking for the OTP) and creates the
15
+ GitHub release. It replaces Bundler's `rake release`, which pushes a locally built ruby gem only.
16
+
3
17
  ## 0.1.0 (2026-10-01)
4
18
 
5
19
  First release candidate of `zxing_ffi`, a replacement for the unmaintained `zbar` gem.
data/README.md CHANGED
@@ -134,7 +134,8 @@ page in page order (or returns an Enumerator). Inputs can be a path (`String`/`P
134
134
  | `min_length` | `{}` | minimum text length per format, e.g. `{itf: 6}` |
135
135
  | `timeout` | — | per-page budget in seconds: caps renders (re-renders get what is left) and stops escalation (the first pass always runs) |
136
136
  | `instrument` | — | callable receiving `(event, payload)` for `:page_loaded`, `:pass_completed`, `:page_completed` |
137
- | `max_pixels`, `max_pages` | config | per-call limits |
137
+ | `oversize` | `:raise` | rasters over `max_pixels`: `:raise` raises `LimitExceeded`; `:downscale` decodes them smaller to fit (libvips loader only: JPEG and WebP shrink while decoding, other formats stream), up to `max_source_pixels`. Positions and `PageResult#width`/`height` stay in the original's pixels and `PageResult#metadata[:downscaled]` holds the scale; codes left under ~2 px per module are lost |
138
+ | `max_pixels`, `max_source_pixels`, `max_pages` | config | per-call limits |
138
139
 
139
140
  Every `ZXingFFI.read` option below is accepted too and applies to every pass.
140
141
 
@@ -145,7 +146,7 @@ Every `ZXingFFI.read` option below is accepted too and applies to every pass.
145
146
  | `base` | library defaults + your options | fast, normal, thorough |
146
147
  | `inverted` | decodes a pixel-inverted copy for white-on-black codes (zxing-cpp's `try_invert` only covers 2D codes, so with it on this pass handles the linear formats) | normal, thorough |
147
148
  | `global_binarizer` | global-histogram binarizer | normal, thorough |
148
- | `high_res` | PDF re-rendered at 2× DPI; small rasters upscaled 2× | normal, thorough |
149
+ | `high_res` | PDF re-rendered at 2× DPI; rasters under 2000 px upscaled 2× (bicubic, so codes at 1–2 px per module resolve) | normal, thorough |
149
150
  | `tiles` | overlapping 1024–1536 px tiles, no downscaling (tiny codes on big pages) | thorough |
150
151
  | `rotated_45` | image rotated 45° for linear codes at odd angles (also covers 135°/225°/315°) | thorough |
151
152
  | `denoise` | `try_denoise: true` (libZXing built with `ZXING_EXPERIMENTAL_API` only) | thorough |
@@ -216,6 +217,8 @@ ZXingFFI.configure do |c|
216
217
  c.default_dpi = 300
217
218
  c.max_dpi = 600
218
219
  c.max_pixels = 64_000_000
220
+ c.oversize = :raise # or :downscale (see the scan options)
221
+ c.max_source_pixels = 1_000_000_000 # with oversize: :downscale, larger rasters still raise
219
222
  c.max_pages = nil
220
223
  c.render_timeout = 60
221
224
  c.subprocess_memory_limit = 2 * 1024**3
@@ -237,7 +240,9 @@ The gem is meant to process untrusted documents:
237
240
  limit (`subprocess_memory_limit`, enforced on Linux only — macOS ignores `RLIMIT_AS`). Passwords are only ever passed
238
241
  as the argument after `-upw`.
239
242
  - **Pixel cap**: every bitmap is checked against `max_pixels` before it is rendered or decoded. PDF pages that would
240
- exceed it are rendered at a lower (possibly fractional) DPI; rasters raise `ZXingFFI::LimitExceeded`.
243
+ exceed it are rendered at a lower (possibly fractional) DPI; rasters raise `ZXingFFI::LimitExceeded`, unless
244
+ `oversize: :downscale` has libvips decode them smaller to fit (never holding them at full size), in which case the
245
+ header check holds them to `max_source_pixels` instead.
241
246
  - **No format confusion**: inputs are identified by magic bytes, never by extension. ImageMagick always reads with an
242
247
  explicit coder (`png:/path`), never sees PDF, PostScript, SVG, MVG, MSL or text, and gets special characters in
243
248
  paths neutralized. libvips is called with the loader for the sniffed type, and operations libvips flags as
@@ -34,6 +34,11 @@ module ZXingFFI
34
34
  attr_accessor :max_dpi
35
35
  # @return [Integer] maximum pixels per bitmap
36
36
  attr_accessor :max_pixels
37
+ # @return [Integer, nil] with +oversize: :downscale+, the most pixels a raster may have; larger ones still raise
38
+ attr_accessor :max_source_pixels
39
+ # @return [Symbol] rasters over +max_pixels+: +:raise+ ({LimitExceeded}) or +:downscale+ (the libvips loader decodes
40
+ # them smaller to fit; positions stay in the original's pixels)
41
+ attr_accessor :oversize
37
42
  # @return [Integer, nil] maximum number of pages per document (nil = unlimited)
38
43
  attr_accessor :max_pages
39
44
  # @return [Numeric] seconds allowed per page render
@@ -53,6 +58,8 @@ module ZXingFFI
53
58
  @default_dpi = 300
54
59
  @max_dpi = 600
55
60
  @max_pixels = 64_000_000
61
+ @max_source_pixels = 1_000_000_000
62
+ @oversize = :raise
56
63
  @max_pages = nil
57
64
  @render_timeout = 60
58
65
  @subprocess_memory_limit = 2 * 1024**3
@@ -89,6 +96,8 @@ module ZXingFFI
89
96
  default_dpi: default_dpi,
90
97
  max_dpi: max_dpi,
91
98
  max_pixels: max_pixels,
99
+ max_source_pixels: max_source_pixels,
100
+ oversize: oversize,
92
101
  max_pages: max_pages,
93
102
  render_timeout: render_timeout,
94
103
  subprocess_memory_limit: subprocess_memory_limit,
@@ -35,15 +35,17 @@ module ZXingFFI
35
35
  end
36
36
 
37
37
  # Raises when the header declares more than +max_pixels+ pixels.
38
+ # @param limit [Symbol] the setting +max_pixels+ comes from, for the error (+:max_source_pixels+ when the image
39
+ # would be downscaled to fit)
38
40
  # @raise [LimitExceeded]
39
- def check!(path, kind, max_pixels)
41
+ def check!(path, kind, max_pixels, limit: :max_pixels)
40
42
  return unless max_pixels
41
43
 
42
44
  width, height = dimensions(path, kind)
43
45
  return unless width && height && width * height > max_pixels
44
46
 
45
47
  raise LimitExceeded.new("#{File.basename(path)} declares #{width}x#{height} (#{width * height} pixels), " \
46
- "exceeding max_pixels #{max_pixels}", limit: :max_pixels, value: width * height)
48
+ "exceeding #{limit} #{max_pixels}", limit: limit, value: width * height)
47
49
  end
48
50
 
49
51
  private
@@ -18,8 +18,10 @@ module ZXingFFI
18
18
  # @!attribute number [Integer] 1-based page/frame number
19
19
  # @!attribute image [Image] normalized 8-bit luminance image
20
20
  # @!attribute dpi [Integer, nil] render DPI (PDF) or nil for rasters
21
- # @!attribute scale_to_base [Float] factor mapping this image's pixels to the page's base image (1.0 for the base)
22
- # @!attribute metadata [Hash] what was applied, e.g. +orientation_applied:+, +aspect_corrected:+, +dpi_capped:+
21
+ # @!attribute scale_to_base [Float] factor mapping this image's pixels to the page's base image: 1.0, or above 1
22
+ # for a raster decoded downscaled, whose positions are reported in the original's pixels
23
+ # @!attribute metadata [Hash] what was applied, e.g. +orientation_applied:+, +aspect_corrected:+, +dpi_capped:+,
24
+ # +downscaled:+ (+{from: [width, height], scale:}+)
23
25
  Page = Data.define(:number, :image, :dpi, :scale_to_base, :metadata)
24
26
 
25
27
  # Base class for loaders. Subclasses implement the class-level capability checks and {#open}.
@@ -69,6 +71,13 @@ module ZXingFFI
69
71
  ""
70
72
  end
71
73
 
74
+ # Whether this loader decodes rasters over +max_pixels+ smaller to fit when +oversize+ is +:downscale+
75
+ # (the others raise {LimitExceeded} regardless).
76
+ # @return [Boolean]
77
+ def downscales?
78
+ false
79
+ end
80
+
72
81
  # Tool versions and capabilities, for {ZXingFFI.diagnostics}.
73
82
  # @return [Hash]
74
83
  def diagnostics
@@ -11,6 +11,10 @@ module ZXingFFI
11
11
  # Consequently vips renders PDFs only after opting in with +vips_block_untrusted = false+ — and a crash in
12
12
  # libvips or poppler-glib then takes down the Ruby process.
13
13
  #
14
+ # With +oversize: :downscale+, rasters over +max_pixels+ (up to +max_source_pixels+) are decoded smaller to fit,
15
+ # without ever holding them at full size: JPEG decodes at 1/2, 1/4 or 1/8 and WebP at any scale directly, other
16
+ # formats stream through the resize.
17
+ #
14
18
  # Documents are safe to render concurrently (each render builds its own libvips pipeline).
15
19
  class VipsLoader < Base
16
20
  # Sniffed kind => libvips load operation.
@@ -40,6 +44,9 @@ module ZXingFFI
40
44
  "install libvips (brew install vips / apt install libvips-tools) and the ruby-vips gem"
41
45
  end
42
46
 
47
+ # (see Base.downscales?)
48
+ def downscales? = true
49
+
43
50
  # Whether this libvips can load +kind+ right now (operation present and, unless opted in, trusted).
44
51
  def supports?(kind)
45
52
  operation = OPERATIONS[kind.to_sym]
@@ -150,9 +157,15 @@ module ZXingFFI
150
157
  raise RenderError.new("libvips failed on #{source.name}: #{Pipeline.error_summary(e)}", stderr: e.message[0, 4096])
151
158
  end
152
159
 
153
- # Flattens alpha onto white, converts to 8-bit single-band luminance and copies it into an Image.
160
+ # Converts to 8-bit single-band luminance and copies it into an Image.
154
161
  def to_image(vimage)
155
162
  check_pixels!(vimage.width, vimage.height)
163
+ vimage = gray(vimage)
164
+ Image.new(vimage.write_to_memory, width: vimage.width, height: vimage.height)
165
+ end
166
+
167
+ # Alpha flattened onto white, then 8-bit single-band luminance.
168
+ def gray(vimage)
156
169
  if vimage.has_alpha?
157
170
  white = %i[ushort short].include?(vimage.format) ? 65_535 : 255
158
171
  vimage = vimage.flatten(background: [white])
@@ -160,7 +173,7 @@ module ZXingFFI
160
173
  vimage = vimage.colourspace(:b_w) unless vimage.interpretation == :"b-w" && vimage.format == :uchar
161
174
  vimage = vimage.extract_band(0) if vimage.bands > 1
162
175
  vimage = vimage.cast(:uchar) unless vimage.format == :uchar
163
- Image.new(vimage.write_to_memory, width: vimage.width, height: vimage.height)
176
+ vimage
164
177
  end
165
178
 
166
179
  def check_pixels!(width, height)
@@ -195,31 +208,81 @@ module ZXingFFI
195
208
  end
196
209
 
197
210
  # Applies the normalization contract: aspect correction (on the stored axes, because libvips'
198
- # autorot does not swap xres/yres), EXIF orientation, alpha → white, 16-bit scaling, 8-bit gray.
211
+ # autorot does not swap xres/yres), EXIF orientation, alpha → white, 16-bit scaling, 8-bit gray. A frame over
212
+ # max_pixels raises, or with +oversize: :downscale+ is decoded smaller to fit.
199
213
  def render(number, dpi: nil, timeout: nil) # timeout: in-process, cannot be enforced
200
214
  check_page!(number)
201
215
  vips_call do
202
216
  vimage = load(number - 1)
203
- vimage, aspect = correct_aspect(vimage)
217
+ width, height = corrected_size(vimage)
218
+ scale = fit_scale(width, height)
219
+ vimage, aspect, scale = (scale < 1) ? downscaled(number - 1, vimage, scale) : [*correct_aspect(vimage), 1.0]
204
220
  orientation = orientation_of(vimage)
205
221
  orientation = nil if orientation == 1 # "normal": nothing to apply
206
222
  vimage = vimage.autorot if orientation
207
- image = to_image(vimage)
208
- Page.new(number: number, image: image, dpi: nil, scale_to_base: 1.0, metadata: {
209
- loader: :vips, orientation_applied: orientation, aspect_corrected: aspect
210
- })
223
+ metadata = {loader: :vips, orientation_applied: orientation, aspect_corrected: aspect}
224
+ if scale < 1
225
+ width, height = height, width if [5, 6, 7, 8].include?(orientation)
226
+ metadata[:downscaled] = {from: [width, height], scale: scale}
227
+ end
228
+ Page.new(number: number, image: to_image(vimage), dpi: nil, scale_to_base: 1.0 / scale, metadata: metadata)
211
229
  end
212
230
  end
213
231
 
214
232
  private
215
233
 
216
- def load(index)
234
+ def load(index, **options)
217
235
  vips_call do
218
- options = PAGED.include?(@operation) ? {page: index} : {}
236
+ options[:page] = index if PAGED.include?(@operation)
219
237
  Pipeline.load_fresh(@operation, source.path, **options)
220
238
  end
221
239
  end
222
240
 
241
+ # 1.0 when a frame of +width+ x +height+ fits max_pixels. Otherwise it raises LimitExceeded, unless
242
+ # +oversize: :downscale+ lets it be decoded smaller (up to max_source_pixels): then the scale that fits.
243
+ def fit_scale(width, height)
244
+ pixels = width * height
245
+ return 1.0 unless @config.max_pixels && pixels > @config.max_pixels
246
+
247
+ check_pixels!(width, height) unless @config.oversize == :downscale
248
+ ceiling = @config.max_source_pixels
249
+ if ceiling && pixels > ceiling
250
+ raise LimitExceeded.new("#{width}x#{height} (#{pixels} pixels) exceeds max_source_pixels #{ceiling}",
251
+ limit: :max_source_pixels, value: pixels)
252
+ end
253
+ Math.sqrt(@config.max_pixels.to_f / pixels)
254
+ end
255
+
256
+ # Frame +index+ (+header+ is its lazily loaded image) decoded at about +scale+ and aspect-corrected, as gray
257
+ # in memory, without holding it at full size. Returns the image, the aspect correction applied and the exact
258
+ # scale, lowered until the rounded size fits max_pixels.
259
+ def downscaled(index, header, scale)
260
+ hscale, vscale = aspect_scale(header) || [1.0, 1.0]
261
+ loaded, options = shrink_on_load(scale * [hscale, vscale].max)
262
+ options[:access] = :sequential if [hscale, vscale].max * scale <= loaded # an axis to upsample needs random access
263
+ vimage = gray(load(index, **options))
264
+ scale *= 0.9999 while (vimage.width * hscale * scale / loaded).ceil * (vimage.height * vscale * scale / loaded).ceil > @config.max_pixels
265
+ x = hscale * scale / loaded
266
+ y = vscale * scale / loaded
267
+ vimage = vimage.resize(x, vscale: y) unless x == 1 && y == 1
268
+ aspect = resolutions(header).map { |r| r.round(1) } unless hscale == 1 && vscale == 1
269
+ [vimage.copy_memory, aspect, scale]
270
+ end
271
+
272
+ # Load options that make the decoder itself shrink by about +scale+, never below it: JPEG by 1/2, 1/4 or 1/8,
273
+ # WebP by any factor. Returns the factor they apply and the options.
274
+ def shrink_on_load(scale)
275
+ case @operation
276
+ when "jpegload"
277
+ shrink = [8, 4, 2].find { |factor| factor * scale <= 1 }
278
+ shrink ? [1.0 / shrink, {shrink: shrink}] : [1.0, {}]
279
+ when "webpload"
280
+ ::Vips::Introspect.get("webpload").optional_input.key?("scale") ? [scale, {scale: scale}] : [1.0, {}]
281
+ else
282
+ [1.0, {}]
283
+ end
284
+ end
285
+
223
286
  def build_info(number)
224
287
  vimage = load(number - 1)
225
288
  width, height = corrected_size(vimage)
@@ -6,8 +6,8 @@ module ZXingFFI
6
6
  # @!attribute page [Integer] 1-based
7
7
  # @!attribute barcodes [Array<Barcode>] ordered top-to-bottom, left-to-right
8
8
  # @!attribute dpi [Integer, nil] base render DPI (PDF) or nil
9
- # @!attribute width [Integer, nil] base image width in pixels
10
- # @!attribute height [Integer, nil] base image height in pixels
9
+ # @!attribute width [Integer, nil] base image width in pixels (the original's for a raster decoded downscaled)
10
+ # @!attribute height [Integer, nil] base image height in pixels (the original's for a raster decoded downscaled)
11
11
  # @!attribute passes_run [Array<Symbol>]
12
12
  # @!attribute skipped_passes [Hash{Symbol => String}] passes that could not run, with the reason
13
13
  # @!attribute duration [Float] seconds spent on the page
@@ -20,8 +20,8 @@ module ZXingFFI
20
20
  class Scanner
21
21
  # Options that only the scanner understands.
22
22
  SCAN_OPTIONS = %i[
23
- effort passes stop dpi max_dpi max_pixels max_pages pages password threads on_page_error loader
24
- min_length timeout instrument
23
+ effort passes stop dpi max_dpi max_pixels max_source_pixels oversize max_pages pages password threads
24
+ on_page_error loader min_length timeout instrument
25
25
  ].freeze
26
26
 
27
27
  # Every option +scan+/+scan_pages+ accept: the scanner's plus every {ZXingFFI.read} option.
@@ -30,6 +30,9 @@ module ZXingFFI
30
30
  # Accepted values of +on_page_error:+.
31
31
  ON_PAGE_ERROR = %i[raise skip].freeze
32
32
 
33
+ # Accepted values of +oversize:+.
34
+ OVERSIZE = %i[raise downscale].freeze
35
+
33
36
  # @param options [Hash] see {ZXingFFI.scan}
34
37
  # @raise [ArgumentError] for unknown or invalid options (before any input is opened)
35
38
  def initialize(**options)
@@ -51,6 +54,12 @@ module ZXingFFI
51
54
  raise ArgumentError, "instrument must respond to #call" if @instrument && !@instrument.respond_to?(:call)
52
55
 
53
56
  @config = effective_config(options)
57
+ raise ArgumentError, "oversize must be :raise or :downscale, got #{@config.oversize.inspect}" unless OVERSIZE.include?(@config.oversize)
58
+ source_pixels = @config.max_source_pixels
59
+ if source_pixels && !(source_pixels.is_a?(Integer) && source_pixels.positive?)
60
+ raise ArgumentError, "max_source_pixels must be a positive Integer or nil, got #{source_pixels.inspect}"
61
+ end
62
+
54
63
  @reader_options = Reader::Options.new(options.slice(*Reader::OPTION_KEYS))
55
64
  @min_length = validate_min_length(options.fetch(:min_length, {}))
56
65
  @notify_mutex = Mutex.new
@@ -102,7 +111,10 @@ module ZXingFFI
102
111
  private
103
112
 
104
113
  def scan_source(source, &block)
105
- HeaderProbe.check!(source.path, source.kind, @config.max_pixels) unless source.kind == :pdf
114
+ unless source.kind == :pdf
115
+ pixels, limit = raster_limit(source.kind)
116
+ HeaderProbe.check!(source.path, source.kind, pixels, limit: limit)
117
+ end
106
118
  loader_class = Loaders::Registry.loader_for(source.kind, config: @config, loader: @loader)
107
119
  document = loader_class.new(@config).open(source, password: @password)
108
120
  begin
@@ -176,10 +188,11 @@ module ZXingFFI
176
188
  rerender = pdf ? ->(dpi) { document.render(number, dpi: dpi, timeout: deadline && [deadline - monotonic, 0.05].max) } : nil
177
189
  result = strategy.run(page, rerender: rerender, page_info: info, deadline: deadline,
178
190
  notify: ->(event, payload) { notify(event, page: number, **payload) })
179
- barcodes = Scanner.order(result.barcodes.map { |barcode| finalize(barcode, number, page.dpi) })
191
+ barcodes = Scanner.order(result.barcodes.map { |barcode| finalize(barcode, number, page) })
180
192
  metadata = page.metadata.merge(choice ? {dpi_source: choice.source, dpi_capped: choice.capped} : {})
193
+ width, height = page.metadata.dig(:downscaled, :from) || [page.image.width, page.image.height]
181
194
  page_result = PageResult.new(
182
- page: number, barcodes: barcodes, dpi: page.dpi, width: page.image.width, height: page.image.height,
195
+ page: number, barcodes: barcodes, dpi: page.dpi, width: width, height: height,
183
196
  passes_run: result.passes_run, skipped_passes: result.skipped_passes, duration: monotonic - started,
184
197
  error: nil, metadata: metadata
185
198
  )
@@ -206,10 +219,26 @@ module ZXingFFI
206
219
  default_dpi: @config.default_dpi, max_dpi: @config.max_dpi, max_pixels: @config.max_pixels)
207
220
  end
208
221
 
209
- # Adds page metadata and PDF page coordinates (points from the top-left of the displayed page).
210
- def finalize(barcode, number, dpi)
211
- page_position = dpi && barcode.position.map { |p| Geometry::Point.new((p.x * 72.0 / dpi).round(2), (p.y * 72.0 / dpi).round(2)) }
212
- barcode.with(page: number, dpi: dpi, page_position: page_position)
222
+ # Adds page metadata and PDF page coordinates (points from the top-left of the displayed page); positions in a
223
+ # raster decoded downscaled are scaled back to the original's pixels.
224
+ def finalize(barcode, number, page)
225
+ dpi = page.dpi
226
+ position = barcode.position.transform(Geometry::Affine.scale(page.scale_to_base)).round
227
+ page_position = dpi && position.map { |p| Geometry::Point.new((p.x * 72.0 / dpi).round(2), (p.y * 72.0 / dpi).round(2)) }
228
+ barcode.with(position: position, page: number, dpi: dpi, page_position: page_position)
229
+ end
230
+
231
+ # The pixels a raster may declare, with the limit's name: max_source_pixels when +oversize: :downscale+ and its
232
+ # loader can decode it smaller to fit, otherwise max_pixels.
233
+ def raster_limit(kind)
234
+ return [@config.max_pixels, :max_pixels] unless @config.oversize == :downscale
235
+
236
+ downscales = begin
237
+ Loaders::Registry.loader_for(kind, config: @config, loader: @loader).downscales?
238
+ rescue LoaderUnavailable
239
+ false
240
+ end
241
+ downscales ? [@config.max_source_pixels, :max_source_pixels] : [@config.max_pixels, :max_pixels]
213
242
  end
214
243
 
215
244
  def select_pages(count)
@@ -239,7 +268,7 @@ module ZXingFFI
239
268
  end
240
269
 
241
270
  def effective_config(options)
242
- overrides = options.slice(:max_dpi, :max_pixels, :max_pages)
271
+ overrides = options.slice(:max_dpi, :max_pixels, :max_source_pixels, :oversize, :max_pages)
243
272
  if @timeout
244
273
  overrides[:render_timeout] = [ZXingFFI.config.render_timeout, @timeout].compact.min
245
274
  end
@@ -7,6 +7,9 @@ module ZXingFFI
7
7
  # Geometry contract (shared with {Geometry.rotation_canvas}): {#rotate} turns the image clockwise as
8
8
  # displayed (y axis down) about its center and expands the canvas to the rotated bounding box, filling
9
9
  # uncovered areas with +background+. {#resize} scales both axes by +scale+ (output side = round(side × scale)).
10
+ # Upscales are bicubic (Catmull-Rom) with pixel centers aligned: output pixel x samples the input at
11
+ # (x + 0.5) / f - 0.5, f being output side / input side. Replicating pixels would keep the aliasing of codes
12
+ # rasterized at 1-2 px per module, which the high_res upscale is there to resolve.
10
13
  class Base
11
14
  class << self
12
15
  # @return [Symbol] registry name
@@ -5,8 +5,8 @@ module ZXingFFI
5
5
  # Resize and rotate by piping PGM through ImageMagick.
6
6
  #
7
7
  # +-rotate+ turns clockwise and expands the canvas (ImageMagick's canvas can be 1 px larger than
8
- # {Geometry.rotation_canvas}; the strategy re-centers on the actual size). Integer upscales use +-sample+
9
- # (pixel replication); other scales use +-resize+.
8
+ # {Geometry.rotation_canvas}; the strategy re-centers on the actual size). Upscales use +-resize+ with the
9
+ # Catmull-Rom filter (+Catrom+); downscales use +-resize+ with its default filter.
10
10
  class ImageMagickTransformer < Base
11
11
  class << self
12
12
  def transformer_name = :image_magick
@@ -36,8 +36,8 @@ module ZXingFFI
36
36
 
37
37
  width = [(image.width * scale).round, 1].max
38
38
  height = [(image.height * scale).round, 1].max
39
- operation = (scale == scale.to_i && scale >= 1) ? "-sample" : "-resize"
40
- pipe(image, [operation, "#{width}x#{height}!"], width, height)
39
+ filter = (scale > 1) ? %w[-filter Catrom] : []
40
+ pipe(image, [*filter, "-resize", "#{width}x#{height}!"], width, height)
41
41
  end
42
42
 
43
43
  def rotate(image, degrees, background: 255)
@@ -6,8 +6,8 @@ module ZXingFFI
6
6
  #
7
7
  # Quarter turns use +rot+ (exact); other angles use libvips' +rotate+, which turns clockwise about the center
8
8
  # and expands the canvas like {Geometry.rotation_canvas} (canvas sizes may differ by 1 px because libvips rounds
9
- # where the geometry rounds up; the strategy re-centers on the actual output size). Integer upscales replicate
10
- # pixels (+zoom+), which keeps bar edges crisp; other scales use a linear kernel.
9
+ # where the geometry rounds up; the strategy re-centers on the actual output size). Upscales go through
10
+ # +affine+ with the bicubic interpolator; downscales use +resize+ with a linear kernel.
11
11
  class VipsTransformer < Base
12
12
  QUARTER_TURNS = {90 => :d90, 180 => :d180, 270 => :d270}.freeze
13
13
 
@@ -39,13 +39,7 @@ module ZXingFFI
39
39
  raise ArgumentError, "scale must be positive, got #{scale.inspect}" unless scale.is_a?(Numeric) && scale.positive?
40
40
 
41
41
  vimage = to_vips(image)
42
- out =
43
- if scale == scale.to_i && scale >= 1
44
- vimage.zoom(scale.to_i, scale.to_i)
45
- else
46
- vimage.resize(scale, kernel: :linear)
47
- end
48
- to_image(out)
42
+ to_image((scale > 1) ? upscale(vimage, scale) : vimage.resize(scale, kernel: :linear))
49
43
  end
50
44
 
51
45
  def rotate(image, degrees, background: 255)
@@ -66,6 +60,17 @@ module ZXingFFI
66
60
 
67
61
  private
68
62
 
63
+ # Not +resize+: its upscales sample a quarter input pixel off center, so at 2x the pixel straddling each
64
+ # black/white edge comes out mid-gray, right at the binarizer's threshold (crisp EAN-13s in the corpus misread).
65
+ def upscale(vimage, scale)
66
+ width = (vimage.width * scale).round
67
+ height = (vimage.height * scale).round
68
+ fx = width.to_f / vimage.width
69
+ fy = height.to_f / vimage.height
70
+ vimage.affine([fx, 0, 0, fy], interpolate: ::Vips::Interpolate.new("bicubic"), oarea: [0, 0, width, height],
71
+ idx: 0.5 - 0.5 / fx, idy: 0.5 - 0.5 / fy, extend: :copy)
72
+ end
73
+
69
74
  def to_vips(image)
70
75
  raise ArgumentError, "expected a :lum image, got #{image.format.inspect}" unless image.format == :lum
71
76
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZXingFFI
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -1,4 +1,4 @@
1
- Bundled libZXing: zxing_ffi 0.1.0 (x86_64-linux-gnu)
1
+ Bundled libZXing: zxing_ffi 0.2.0 (x86_64-linux-gnu)
2
2
 
3
3
  libZXing.so in this directory is zxing-cpp 3.1.1 (https://github.com/zxing-cpp/zxing-cpp),
4
4
  compiled from the release asset zxing-cpp-3.1.1.tar.gz (SHA-256 c3c02c29c0b519de7bd4e25b376e606e87f0761befd1282815642a2246613d14)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zxing_ffi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Will Hibbard