claricle 0.1.0 → 0.2.0

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
@@ -0,0 +1,961 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ require_relative "base"
6
+ require_relative "../models/inspection"
7
+ require_relative "../models/issue"
8
+ require_relative "../models/report"
9
+ require_relative "pdf_arlington"
10
+ require_relative "pdf_profiles"
11
+
12
+ module Claricle
13
+ module Handlers
14
+ # pdfrb's line-oriented readers use IO#gets, whose default separator
15
+ # recognises LF but not PDF's equally valid bare CR line ending. Keep
16
+ # every byte-oriented operation on the original IO -- especially reads
17
+ # of arbitrary binary stream bodies -- and adapt only that line API.
18
+ class UniversalLineReader
19
+ def initialize(io)
20
+ @io = io
21
+ end
22
+
23
+ def gets
24
+ line = +"".b
25
+ while (byte = @io.getbyte)
26
+ line << byte
27
+ next unless [10, 13].include?(byte)
28
+
29
+ consume_lf_after_cr(line) if byte == 13
30
+ break
31
+ end
32
+ line unless line.empty?
33
+ end
34
+
35
+ def method_missing(name, *, &)
36
+ return super unless @io.respond_to?(name)
37
+
38
+ @io.public_send(name, *, &)
39
+ end
40
+
41
+ def respond_to_missing?(name, include_private = false)
42
+ @io.respond_to?(name, include_private) || super
43
+ end
44
+
45
+ private
46
+
47
+ def consume_lf_after_cr(line)
48
+ byte = @io.getbyte
49
+ return unless byte
50
+ return line << byte if byte == 10
51
+
52
+ @io.seek(-1, IO::SEEK_CUR)
53
+ end
54
+ end
55
+ private_constant :UniversalLineReader
56
+
57
+ # Reports a PDF's version and, when it can be read honestly, its
58
+ # DECLARED page count. Dimensions are deliberately absent: reaching
59
+ # the first page's box safely needs a cycle-guarded page-tree walk in
60
+ # both directions, per-element dereferencing and corner
61
+ # normalisation, none of which pdfrb offers -- measured on 0.7.23 and
62
+ # re-verified on 0.7.49, which is what resolves here,
63
+ # `page.media_box` hangs forever on a self-referential `/Parent`, and
64
+ # `[0 0]` reads back as a 0x0 page invented whole.
65
+ #
66
+ # PDF conformance is `pdfrb`'s pre-write structural check
67
+ # (`Validator.validate`) -- catalog, pages, MediaBox, and reference
68
+ # resolution -- plus the Arlington walk in `PdfArlington` (the issue's
69
+ # "Arlington predicates"), not full ISO 32000 conformance
70
+ # (03-conform.md, D16). There
71
+ # is no separate structural pre-pass for PDF the way PNG, SVG and EMF
72
+ # have one (D23): the plan's own Design table names
73
+ # `Validator.validate` as the whole structural check, so this handler
74
+ # adds nothing on top of it.
75
+ #
76
+ # This handler implements `conform` only. `inspect` for PDF is a
77
+ # different unit of work.
78
+ class Pdf < Base
79
+ formats :pdf
80
+
81
+ profiles(*PdfProfiles.profiles)
82
+
83
+ def self.levels_for(profile) = PdfProfiles.levels_for(profile)
84
+
85
+ # Builds the `Report` a conform operation returns. A sibling class
86
+ # rather than instance methods, matching `Handlers::Png`'s own
87
+ # `ConformanceMapper` -- the mapping owes nothing to any metadata
88
+ # interpretation this handler might grow later.
89
+ class ConformanceMapper
90
+ # Measured against the installed pdfrb gem (0.7.49, resolved from
91
+ # claricle.gemspec's `~> 0.7.23`) rather than assumed --
92
+ # 03-conform.md's own summary is a starting point, not a source,
93
+ # and it is what this class's own tests pin.
94
+ #
95
+ # A catalog-less document (no `/Root` in the trailer, or a
96
+ # `/Root` pointing at an oid absent from the xref) raises
97
+ # `Pdfrb::Error` ("Document has no Catalog"). For THAT shape it
98
+ # is not `check_catalog` that raises -- it only appends a string
99
+ # to the error array and returns; the raise comes from
100
+ # `check_pages` walking into `Document::Pages#pages_root`, which
101
+ # re-derives the catalog and raises when it is nil.
102
+ #
103
+ # `check_catalog` DOES raise directly for a related but distinct
104
+ # shape: a `/Root` reference the xref marks "in use" but whose
105
+ # bytes cannot be found anywhere in the file (a corrupt offset
106
+ # with no matching "N G obj" elsewhere either). There,
107
+ # `document.catalog` itself raises `Pdfrb::MalformedPdfError` --
108
+ # a `Pdfrb::Error` subclass, not the base class, and not the
109
+ # "Document has no Catalog" message -- from
110
+ # `ObjectReader#recover_parse`. Rescuing the base `Pdfrb::Error`
111
+ # class (not just literal instances of it) is what catches this,
112
+ # and every other subclass the gem may raise, on the same
113
+ # reasoning: pdfrb's own error hierarchy reporting a structural
114
+ # fault is the same nonconformance an ordinary returned issue
115
+ # would carry.
116
+ #
117
+ # A `/Pages` reference to a missing object raises `NoMethodError:
118
+ # undefined method '[]' for nil` from `Document::Pages#walk`,
119
+ # which subscripts the unresolved node directly.
120
+ #
121
+ # `Document.open` itself can ALSO raise: a `startxref` pointer
122
+ # that resolves to neither an `xref` keyword nor a parseable
123
+ # object -- an ordinary corrupt-file shape, not one any fixture
124
+ # here had tried -- reaches `Document#find_xref_keyword`'s
125
+ # `chunk.rindex(...)` with `chunk` nil (a short/absent read past
126
+ # EOF), raising `NoMethodError: undefined method 'rindex' for
127
+ # nil`. Measured live; an earlier version of this handler left
128
+ # `Document.open` outside the rescue on the strength of "every
129
+ # malformed shape measured ... opens without raising", which
130
+ # this shape falsifies -- a contract measured on the shapes
131
+ # tried and wrongly generalised to "every shape". `issues_for`
132
+ # below wraps both pdfrb calls for exactly this reason.
133
+ #
134
+ # Scoped to just those two calls, not to Claricle's own code
135
+ # (03-conform.md: "wrapping the call rather than allowlisting
136
+ # NoMethodError globally") -- `report_for`, `Image#with_path`,
137
+ # and Report/Issue construction are outside `issues_for`'s
138
+ # rescue, so a `NoMethodError` from a real defect in THIS
139
+ # handler's own code, or in `Image`, still propagates to exit 4
140
+ # rather than reading as an ordinary nonconformant file.
141
+ #
142
+ # `SystemStackError` is not in the plan's own table -- found by
143
+ # this handler's own adversarial pass: a `/Pages` node whose own
144
+ # `/Kids` cites itself sends `Document::Pages#walk` into infinite
145
+ # recursion. `SystemStackError` is not a `StandardError`
146
+ # subclass, so the rescue below needs it named explicitly to
147
+ # catch it. Rescuing it here is safe -- Ruby's stack is usable
148
+ # again the moment the exception has unwound to this frame
149
+ # (measured) -- and the user-visible meaning is the same
150
+ # nonconformance a returned issue would carry, so it goes on the
151
+ # allowlist for the same reason the plan puts EMF's truncation
152
+ # `FormatError` on its own: the exit code follows the meaning,
153
+ # not the delegate's reporting style. This does not conflict
154
+ # with the Runner's own `SystemStackError` -> 4 mapping
155
+ # (01-core.md, `Cli::Runner.run`): that is the default for a
156
+ # `SystemStackError` no handler has positively identified, the
157
+ # same role `StandardError` -> 4 plays for `NoMethodError`
158
+ # everywhere this handler does NOT rescue it. A handler
159
+ # reclassifying a MEASURED, deterministic raise as nonconformance
160
+ # is the pattern the plan already uses for EMF's `FormatError`,
161
+ # not an exception to it.
162
+ #
163
+ # Named directly in the `rescue` clause below rather than in a
164
+ # frozen constant: `Pdfrb::Error` does not exist until `require
165
+ # "pdfrb"` has run (D5 -- the gem is loaded lazily, inside
166
+ # `conformance_report`), and a class-level constant is evaluated
167
+ # when this file is required, well before that -- measured, that
168
+ # ordering raised `NameError: uninitialized constant Pdfrb` at
169
+ # load time. A `rescue` clause's exception list is evaluated only
170
+ # when `issues_for` actually runs, by which point the require has
171
+ # already happened.
172
+
173
+ # The code and message 03-conform.md assigns the STRING mapping
174
+ # case -- a returned error, not a raised one.
175
+ STRUCTURE_CODE = "PDF_STRUCTURE"
176
+
177
+ # A sibling, stable code for the cases pdfrb reports by raising
178
+ # instead of returning. Distinct from STRUCTURE_CODE so a caller
179
+ # can still tell "the delegate told us this directly" from "the
180
+ # delegate's own check could not run at all" -- and a fixed
181
+ # message rather than the raised exception's own text, which for
182
+ # `NoMethodError` ("undefined method '[]' for nil") would leak a
183
+ # Ruby internal rather than say anything about the PDF.
184
+ UNREADABLE_CODE = "PDF_STRUCTURE_UNREADABLE"
185
+ UNREADABLE_MESSAGE = "PDF structure could not be validated"
186
+
187
+ def self.report(image, profile: nil, level: nil)
188
+ # `image.with_path`, not `image.with_source`: `Document.open`
189
+ # takes a path (or an IO through its block form).
190
+ issues = image.with_path { |path| issues_for(path, profile: profile, level: level) }
191
+ report_for(image, issues, profile: profile)
192
+ end
193
+
194
+ def self.report_for(image, issues, profile:)
195
+ Models::Report.new(
196
+ source_path: image.path,
197
+ format: image.format.to_s,
198
+ issues: issues,
199
+ profile: profile&.to_s,
200
+ validator_version: profile ? ::Pdfrb::VERSION : nil
201
+ )
202
+ end
203
+
204
+ # The two pdfrb calls the structural check makes, each with its own
205
+ # rescue -- see the class comment above for why both need one.
206
+ # `issue_from` (Claricle's own `Models::Issue` construction) runs in
207
+ # `issues_for` below, OUTSIDE these rescues: a `NoMethodError` from
208
+ # this handler's own code propagates, rather than reading as an
209
+ # ordinary nonconformant file.
210
+ def self.open_document(path)
211
+ ::Pdfrb::Document.open(path)
212
+ rescue ::Pdfrb::Error, NoMethodError, SystemStackError
213
+ nil
214
+ end
215
+
216
+ def self.structure_errors(document)
217
+ ::Pdfrb::Validator.validate(document)
218
+ rescue ::Pdfrb::Error, NoMethodError, SystemStackError
219
+ :malformed
220
+ end
221
+
222
+ # Both checks, structural issues first. The Arlington walk runs
223
+ # FIRST and on the untouched document: `Validator.validate` repairs
224
+ # what it inspects (measured: a Catalog with no /Pages gains one),
225
+ # which would hide the very key the walk reports. It also runs when
226
+ # the structural check raises -- a Catalog with no /Pages makes it
227
+ # raise -- because the Arlington issue is what names the key.
228
+ def self.issues_for(path, profile:, level:)
229
+ document = open_document(path)
230
+ return [unreadable_issue] unless document
231
+
232
+ arlington = PdfArlington.issues(document)
233
+ errors = structure_errors(document)
234
+ structural = errors == :malformed ? [unreadable_issue] : errors.map { |message| issue_from(message) }
235
+ structural.concat(arlington)
236
+ return structural if profile.nil? || structural.any?
237
+
238
+ PdfProfiles.issues(document, profile, level)
239
+ end
240
+
241
+ def self.issue_from(message)
242
+ Models::Issue.new(severity: "error", code: STRUCTURE_CODE, message: message, location: nil)
243
+ end
244
+
245
+ def self.unreadable_issue
246
+ Models::Issue.new(severity: "error", code: UNREADABLE_CODE,
247
+ message: UNREADABLE_MESSAGE, location: nil)
248
+ end
249
+
250
+ private_class_method :report_for, :open_document, :structure_errors, :issues_for,
251
+ :issue_from, :unreadable_issue
252
+ end
253
+
254
+ private_constant :ConformanceMapper
255
+
256
+ def conformance_report(image, profile: nil, level: nil)
257
+ require "pdfrb"
258
+
259
+ ConformanceMapper.report(image, profile: profile, level: level)
260
+ end
261
+
262
+ # A PDF header line longer than a kilobyte is not a header. The
263
+ # read is HEADER_SCAN_BYTES + 1 so "the first line is exactly 1024
264
+ # bytes and ends" is distinguishable from "the first line is at
265
+ # least 1025 bytes" -- measured: `read(1025)` returns 1024 bytes at
266
+ # a 1024-byte EOF and 1025 otherwise.
267
+ HEADER_SCAN_BYTES = 1024
268
+
269
+ # Body-derived values get the same order-of-magnitude publication
270
+ # bound as the header read. The page-count comparison is numeric so
271
+ # refusing an attacker-sized Integer never converts it back to an
272
+ # attacker-sized String.
273
+ #
274
+ # This bounds publication, not parsing. pdfrb has already
275
+ # materialised the Integer or Symbol before this handler sees it;
276
+ # bounding those source tokens would require replacing its tokenizer
277
+ # and compressed-object-stream path rather than enforcing our output
278
+ # contract here.
279
+ MAX_PAGE_COUNT = (10**HEADER_SCAN_BYTES) - 1
280
+
281
+ # An interactive budget, NOT a safe upper bound, and the difference
282
+ # is the whole of the tradeoff.
283
+ #
284
+ # It bounds TIME, not memory. Measured: a 190 KB file whose object
285
+ # stream decodes to 200 MB allocated +190.8 MB *inside* a
286
+ # five-second window and then timed out, so the deadline fired and
287
+ # the memory was spent anyway.
288
+ #
289
+ # It is justified by the inputs that are actually slow rather than
290
+ # by any claim about all of them. Measured: a `/Prev` pointing at
291
+ # its own xref offset never returns at all; resolving one Catalog
292
+ # from that 190 KB file took 29.61 s. A legitimately slow document
293
+ # -- a large file on slow storage -- is reported "failed" when it
294
+ # was merely slow, and that is the accepted cost. The alternative
295
+ # is a CLI that never returns.
296
+ DEADLINE_SECONDS = 5
297
+
298
+ HEADER_CODE = "pdf.header_unreadable"
299
+ OPEN_CODE = "pdf.unreadable"
300
+ STRUCTURE_CODE = "pdf.structure_unreadable"
301
+ TIMEOUT_CODE = "pdf.timeout"
302
+
303
+ # One code per cause. The four sibling handlers each ship exactly
304
+ # one, because each has exactly one failure event. This handler has
305
+ # four a consumer can tell apart: the header never parsed, the file
306
+ # would not open, the structure would not resolve, and the clock
307
+ # ran out. Collapsing them reported "PDF structure could not be
308
+ # read" for files whose structure was never reached.
309
+ #
310
+ # All FOUR are listed here, so a reader scanning this sees every
311
+ # failure mode. The
312
+ # timeout message interpolates `DEADLINE_SECONDS`, which is defined
313
+ # above, so the number can never drift from the value that produced
314
+ # it -- and there is only ever one deadline, so building the string
315
+ # per call bought nothing but a method and a branch.
316
+ MESSAGES = {
317
+ HEADER_CODE => "PDF header is not a valid version declaration",
318
+ OPEN_CODE => "PDF could not be opened",
319
+ STRUCTURE_CODE => "PDF structure could not be read",
320
+ TIMEOUT_CODE => "PDF could not be read within #{DEADLINE_SECONDS} seconds"
321
+ }.freeze
322
+
323
+ # What a `/Version` Name must look like to be believed. Measured,
324
+ # `catalog.value[:Version]` comes back as a Symbol for a Name, but
325
+ # also as a String, a Float, an Array, a Reference or nil depending
326
+ # on what the file wrote -- and as `:"1.7.2"`, `:X` or `:""` for a
327
+ # Name that is not a version. Only a Symbol within the publication
328
+ # bound and matching this is taken, so both readings of the version
329
+ # refuse `%PDF-1.7.2` for the same reason.
330
+ VERSION_TOKEN = /\A\d+\.\d+\z/
331
+
332
+ private_constant :HEADER_SCAN_BYTES, :MAX_PAGE_COUNT, :DEADLINE_SECONDS,
333
+ :HEADER_CODE, :OPEN_CODE, :STRUCTURE_CODE, :TIMEOUT_CODE,
334
+ :MESSAGES, :VERSION_TOKEN
335
+
336
+ # The file's own bytes decide the version, never the delegate.
337
+ # `Document.open` succeeds on an empty file and the document it
338
+ # returns still REPORTS a version -- measured, `"1.4"` for empty,
339
+ # for garbage and for a `%PDF-x.y` header alike. A fabricated
340
+ # default is worse than no answer, so the gate reads the
341
+ # declaration itself and reports the token it captured.
342
+ #
343
+ # The header is only HALF the answer. A PDF may raise its version
344
+ # in the Catalog's `/Version`, and the reported version is the
345
+ # NUMERIC MAXIMUM of the two -- see `highest_version`.
346
+ module VersionGate
347
+ # A COMPLETE declaration: the first line carries the version and
348
+ # nothing after it but optional spaces or tabs.
349
+ #
350
+ # WHAT IT REFUSES. `\A%PDF-(\d+\.\d+)` alone validates a numeric
351
+ # PREFIX -- measured, it captures "1.7" for both `%PDF-1.7junk`
352
+ # and `%PDF-1.7.2`, neither of which is version 1.7.
353
+ #
354
+ # WHAT IT TOLERATES, and this is a MEASURED CONCESSION rather
355
+ # than a loosened guard. A real file on this machine --
356
+ # `duck-small.pdf`, written by ImageMagick 4.2.8 in 2001 --
357
+ # begins `%PDF-1.1` then a SPACE then LF. pdfrb reads it, poppler
358
+ # reads it and calls it version 1.1, and this handler used to
359
+ # report `pdf.header_unreadable` for it: a false refusal of a
360
+ # perfectly readable document. Trailing horizontal whitespace is
361
+ # therefore accepted.
362
+ #
363
+ # `[ \t]*(?:[\r\n]|\z)` and NOT the one-character class
364
+ # `[ \t\r\n]`, because the shorter form only has to match the
365
+ # single byte after the digits. Measured, it accepts BOTH
366
+ # `%PDF-1.4 junk` and `%PDF-1.4\tjunk` and reports "1.4" -- the
367
+ # general junk relaxation this gate exists to prevent. Requiring
368
+ # the LINE to end is what keeps the two apart.
369
+ #
370
+ # Space and tab only. PDF's whitespace table also lists NUL and
371
+ # FF; no measured real file needs them here, and generalising
372
+ # past the evidence is the defect this handler keeps paying for.
373
+ GATE = /\A%PDF-(\d+\.\d+)(?=[ \t]*(?:[\r\n]|\z))/
374
+ TERMINATOR = /[\r\n]/
375
+ private_constant :GATE, :TERMINATOR
376
+
377
+ module_function
378
+
379
+ # The capture holds digits and a dot only, so re-tagging it UTF-8
380
+ # is always valid -- confirmed against content carrying 0xFF 0xFE
381
+ # 0x80 right behind the header.
382
+ def version(path)
383
+ prefix = read(path)
384
+ return unless terminated?(prefix)
385
+
386
+ match = GATE.match(prefix)
387
+ match && match[1].force_encoding(Encoding::UTF_8)
388
+ end
389
+
390
+ # The YIELDED path, not `image.content` and not `with_source`.
391
+ # For a content-born image `with_source` hands over the bytes in
392
+ # memory while pdfrb reads the tempfile `with_path` wrote, so the
393
+ # gate and the delegate would be reading two different objects.
394
+ # Opening the yielded path keeps every read on the same bytes.
395
+ def read(path)
396
+ File.open(path, "rb") { |file| file.read(HEADER_SCAN_BYTES + 1) } || "".b
397
+ end
398
+
399
+ # `\z` anchors to the end of the STRING SUPPLIED, not to physical
400
+ # EOF, so without this a bounded read would let the anchor pass
401
+ # on a line it only saw part of. Measured, and the third row is
402
+ # the dangerous one: nine bytes of `%PDF-12.345` capture "12.3"
403
+ # -- not a refusal, a plausible WRONG version reported silently.
404
+ #
405
+ # A short read is a real end of file, so `\z` means what it says
406
+ # there. A full read with no terminator in it is a first line of
407
+ # at least HEADER_SCAN_BYTES + 1 bytes, and that is refused.
408
+ def terminated?(prefix)
409
+ prefix.bytesize <= HEADER_SCAN_BYTES || prefix.match?(TERMINATOR)
410
+ end
411
+ end
412
+ private_constant :VersionGate
413
+
414
+ # THE DELEGATE BOUNDARY: every call that crosses into pdfrb, and the
415
+ # single rescue that covers them.
416
+ #
417
+ # Grouped here so the rescue can sit around ONE delegate expression
418
+ # at a time. While `guarded` wrapped whole handler methods, the
419
+ # allowlist also covered Claricle's own code, and a bug in this file
420
+ # was reported as a corrupt PDF instead of crashing.
421
+ #
422
+ # Included rather than `module_function`, so `guarded` is one
423
+ # instance method every read here and in `MetadataGate` shares.
424
+ #
425
+ # pdfrb resolves by object NUMBER alone -- measured, `/MediaBox 4 9 R`
426
+ # returns object `4 0`, and a compressed object addressed `1 1 R`
427
+ # resolves happily because `add_compressed` never records a
428
+ # generation for the reader to compare. So every reference this
429
+ # handler follows is checked against the xref before resolution,
430
+ # and that check is the entire generation guarantee.
431
+ module Resolver
432
+ RESOLVABLE = %i[in_use compressed].freeze
433
+ private_constant :RESOLVABLE
434
+
435
+ private
436
+
437
+ # One list, named once, because it is one policy. NOT a hoisted
438
+ # constant: `::Pdfrb::Error` cannot be resolved at class-definition
439
+ # time while the require is lazy -- measured, a
440
+ # `PARSE_FAILURES = [::Pdfrb::Error, ...]` constant makes
441
+ # `require "claricle"` itself die with
442
+ # `uninitialized constant Pdfrb (NameError)`. The sibling
443
+ # `metafile.rb:239` can hold its equivalent as a constant only
444
+ # because `metafile.rb:3` requires `emf` at the top of the file.
445
+ # A method defers the lookup to call time, when pdfrb is loaded.
446
+ #
447
+ # `Errno::EINVAL` is deliberately NOT in the list. It is reachable
448
+ # only from a negative `/Prev` consumed during `open`, so it
449
+ # belongs to that rescue alone; adding it to `guarded` would widen
450
+ # the gates for a class they cannot see.
451
+ def parse_failures
452
+ [::Pdfrb::Error, NoMethodError, TypeError, RangeError,
453
+ ArgumentError, SystemStackError]
454
+ end
455
+
456
+ # Returns nil when the DELEGATE could not read what was asked for.
457
+ #
458
+ # Every call wraps ONE pdfrb expression. The shape checks are what
459
+ # make that possible: `typed` refuses anything whose `.value` is
460
+ # not a Hash rather than letting `7[](:Type)` raise, and the `&.`
461
+ # on every value `resolve` may refuse stops those raising at all.
462
+ #
463
+ # `NoMethodError` stays on the list for pdfrb itself. Everywhere
464
+ # else it signals a broken delegate; here pdfrb raises it for
465
+ # ordinary corrupt files -- a free object-stream xref entry, a
466
+ # trailing backslash in a compressed body -- and nothing at the
467
+ # call site distinguishes the two. Crashing on a corrupt PDF is
468
+ # worse.
469
+ def guarded
470
+ yield
471
+ rescue *parse_failures
472
+ nil
473
+ end
474
+
475
+ # What proves a document exists at all. A header-only `%PDF-1.4\n`
476
+ # passes the version gate -- the version is genuinely readable --
477
+ # and opens without complaint; only this refuses it.
478
+ #
479
+ # The trailer nil check is part of the gate, not a rescue.
480
+ # Measured: `document.trailer` is nil on a header-only file, on an
481
+ # empty one, on garbage and on one truncated mid-body, so
482
+ # `trailer[:Root]` raises `NoMethodError` before any check runs.
483
+ # Resting the refusal on that would rest it on this handler's own
484
+ # missing nil check rather than on pdfrb's error handling.
485
+ #
486
+ # Returns BOTH checked objects, so the Catalog survives to be
487
+ # asked for its `/Version` instead of being resolved a second
488
+ # time.
489
+ #
490
+ # The node stays the flag, and the DESTRUCTURE at the call site is
491
+ # what keeps it so: `catalog, progress.node =` reads nil out of a
492
+ # nil return and out of `[catalog, nil]` alike, so a Catalog that
493
+ # resolves while its `/Pages` does not still leaves `progress.node`
494
+ # nil and reports "failed".
495
+ def structure_gate(document)
496
+ trailer = guarded { document.trailer }
497
+ return unless trailer
498
+
499
+ catalog = typed(resolve(document, guarded { trailer[:Root] }), :Catalog)
500
+ return unless catalog
501
+
502
+ [catalog, typed(resolve(document, catalog.value[:Pages]), :Pages)]
503
+ end
504
+
505
+ # The `&.` is load-bearing, not defensive noise: `resolve` returns
506
+ # nil on every refusal, and a bare `.value` would raise
507
+ # `NoMethodError`.
508
+ #
509
+ # The Hash check is what keeps this handler's own logic out of
510
+ # `guarded`. A dictionary's `.value` is a plain Hash, but an
511
+ # indirect `/Root` resolving to a scalar used to make this
512
+ # `7[](:Type)` -- measured, a `TypeError` raised by THIS handler
513
+ # and swallowed as if pdfrb had refused the file. The outcome is
514
+ # the same `structure_unreadable` either way; the difference is
515
+ # that a real bug here now escapes instead of hiding behind it.
516
+ def typed(object, name)
517
+ value = object&.value
518
+ return unless value.is_a?(::Hash)
519
+
520
+ object if value[:Type] == name
521
+ end
522
+
523
+ # Anything that is not a Reference is refused DETERMINISTICALLY
524
+ # rather than left to raise downstream. `Document#object` returns
525
+ # a non-Reference unchanged -- measured, `object(nil)` is nil,
526
+ # `object(7)` is 7 -- so an absent, dangling or scalar `/Pages`
527
+ # used to reach a `NoMethodError` on the `.value` after it and be
528
+ # right by accident.
529
+ #
530
+ # A `/Pages` written as a DIRECT dictionary is refused too. PDF
531
+ # requires it to be indirect and every file measured here writes
532
+ # it that way, but a producer that inlined it would be reported
533
+ # "failed". Accepted, and recorded rather than hidden.
534
+ #
535
+ # For a COMPRESSED reference `document.object` alone cannot prove
536
+ # the object it received is the object the file recorded at that
537
+ # index: pdfrb's object-stream reader discards the number stored
538
+ # there and wraps the value with the number that was asked for.
539
+ # `compressed_oid_matches?` below is what closes that gap by
540
+ # reading the ObjStm's own header directly.
541
+ def resolve(document, value)
542
+ guarded { checked(document, value) }
543
+ end
544
+
545
+ def checked(document, value)
546
+ return unless value.is_a?(::Pdfrb::Model::Reference)
547
+
548
+ entry = document.xref&.[](value.oid)
549
+ return unless entry && RESOLVABLE.include?(entry.type)
550
+ return unless entry.gen.to_i == value.gen
551
+ return unless resolvable_compressed?(document, entry, value.oid)
552
+
553
+ document.object(value)
554
+ end
555
+
556
+ # A no-op for every non-compressed entry, so `checked` pays this
557
+ # branch's cost without paying `compressed_oid_matches?`'s.
558
+ def resolvable_compressed?(document, entry, oid)
559
+ entry.type != :compressed || compressed_oid_matches?(document, entry, oid)
560
+ end
561
+
562
+ # ISO 32000-1 7.5.7: the xref's declared object number for a
563
+ # compressed object and the number the object stream's OWN header
564
+ # records at that index shall agree. pdfrb's
565
+ # `ObjectReader#load_from_objstm` (measured against pdfrb-0.7.49
566
+ # lib/pdfrb/source/object_reader.rb:119-129) discards the stored
567
+ # number and relabels whatever value it finds at the index with
568
+ # the oid the caller asked for -- so an xref entry whose `index`
569
+ # disagrees with what the stream itself declares silently returns
570
+ # a different object's value under the right object's number.
571
+ # Measured: an otherwise well-formed compressed Pages node with
572
+ # `/Count 1` swapped for one at another index declaring `/Count
573
+ # 999` resolves and publishes 999 with no error anywhere.
574
+ #
575
+ # `container&.type == :in_use` is checked FIRST and separately
576
+ # from the oid comparison below. An object stream is never itself
577
+ # stored inside another object stream, so if the xref claims the
578
+ # CONTAINING stream is itself `:compressed`, the `document.object`
579
+ # call a few lines down would recurse into pdfrb's own
580
+ # compressed-resolution path a second time with none of this
581
+ # method's guards applied to that inner hop -- measured, without
582
+ # this check that recursion runs until `SystemStackError`, which
583
+ # `guarded` happens to catch but only after paying for the whole
584
+ # stack. Refusing immediately is cheaper and does not depend on a
585
+ # rescue two frames away.
586
+ #
587
+ # Re-parses only the ObjStm's HEADER (the `oid offset` pairs
588
+ # before `/First`), not the values: `ObjectStreamReader.read`
589
+ # (pdfrb-0.7.49 lib/pdfrb/source/object_stream_reader.rb:18-21)
590
+ # parses the header exactly this way, but it also eagerly parses
591
+ # every declared VALUE, which pdfrb already does once per ObjStm
592
+ # and caches privately (`@objstm_cache`, unreachable from here).
593
+ # Calling that method a second time here would double the
594
+ # value-parsing cost of every compressed resolution; this reads
595
+ # only the bytes needed to answer the oid question.
596
+ def compressed_oid_matches?(document, entry, expected_oid)
597
+ pairs = objstm_header_pairs(document, entry.obj_stm_oid)
598
+ return false unless pairs
599
+
600
+ slot = entry.index.to_i * 2
601
+ return false unless slot >= 0 && slot < pairs.length
602
+
603
+ pairs[slot].to_i == expected_oid
604
+ end
605
+
606
+ # Memoised per `obj_stm_oid`, on the handler instance. `Image#handler`
607
+ # (image.rb) builds a fresh handler per call and this method's own
608
+ # instance runs exactly one `Pdfrb::Document.open` (`open_document`
609
+ # above), so the cache cannot outlive, or leak across, the one
610
+ # document it was built for.
611
+ #
612
+ # Without this, a shared object stream pays `objstm_header_tokens`'s
613
+ # decode once per COMPRESSED OBJECT the handler resolves out of it,
614
+ # not once per stream. Measured: instrumenting
615
+ # `Pdfrb::Model::Cos::Stream#decoded_stream` (which pdfrb does not
616
+ # memoise -- confirmed against pdfrb-0.7.49
617
+ # lib/pdfrb/model/cos/stream.rb, every call re-runs
618
+ # `Pdfrb::Filter.apply`) on the default 3-object fixture, where the
619
+ # handler resolves 2 distinct compressed oids (Catalog, Pages) from
620
+ # one ObjStm, gave 4 decodes -- 1 for the xref stream, 2 for this
621
+ # method (one per resolve), 1 more inside pdfrb's own
622
+ # `ObjectReader#load_from_objstm`. The comment on
623
+ # `compressed_oid_matches?` above already accounts for paying that
624
+ # last one twice (ours plus pdfrb's, once); it did not account for
625
+ # multiplying by every compressed object touched, and the file's own
626
+ # `DEADLINE_SECONDS` comment treats decode cost as a resource this
627
+ # handler cannot let scale with attacker-controlled shape.
628
+ def objstm_header_pairs(document, obj_stm_oid)
629
+ cache = (@objstm_header_pairs ||= {})
630
+ return cache[obj_stm_oid] if cache.key?(obj_stm_oid)
631
+
632
+ cache[obj_stm_oid] = uncached_objstm_header_pairs(document, obj_stm_oid)
633
+ end
634
+
635
+ # Returns nil for anything that keeps the header from being read
636
+ # safely: the declared container is not a genuine in-use stream
637
+ # (see `compressed_oid_matches?`'s comment on why that is checked
638
+ # before touching the delegate at all), or `/First` does not
639
+ # describe a real prefix of the decoded bytes.
640
+ def uncached_objstm_header_pairs(document, obj_stm_oid)
641
+ container = document.xref&.[](obj_stm_oid)
642
+ return unless container&.type == :in_use
643
+
644
+ objstm = document.object(::Pdfrb::Model::Reference.new(obj_stm_oid, 0))
645
+ return unless objstm.is_a?(::Pdfrb::Model::Cos::Stream)
646
+
647
+ objstm_header_tokens(objstm)
648
+ end
649
+
650
+ # The type/sign check on `first` runs BEFORE `decoded_stream` --
651
+ # an invalid `/First` is refused without paying for a decode at
652
+ # all, rather than decoding first and discarding the result.
653
+ def objstm_header_tokens(objstm)
654
+ first = objstm.value[:First]
655
+ return unless first.is_a?(::Integer) && !first.negative?
656
+
657
+ decoded = objstm.decoded_stream
658
+ return unless decoded && first <= decoded.bytesize
659
+
660
+ decoded.byteslice(0, first).split(/\s+/)
661
+ end
662
+ end
663
+ include Resolver
664
+
665
+ private_constant :Resolver
666
+
667
+ # The publication boundary for both body-derived metadata values.
668
+ # Kept as private instance methods, so each read reaches the
669
+ # handler's own guarded `resolve` and can still fail independently.
670
+ #
671
+ # A module rather than two more methods in the class body: this
672
+ # file's other two helper groups are modules for the same reason,
673
+ # and folding these back into `Pdf` puts it over the project's
674
+ # RuboCop class-length budget.
675
+ module MetadataGate
676
+ private
677
+
678
+ # The Catalog's own claim, believed only when it is a well-formed
679
+ # Name. Read RAW through `.value`, like every other key here: the
680
+ # typed `Dictionary#[]` coerces and mutates the node in place.
681
+ #
682
+ # A Reference goes through the guarded `resolve`, never
683
+ # `document.object`, or the generation guard is bypassed for this
684
+ # key alone -- pdfrb resolves by object number and would hand back
685
+ # an object the file never authorised.
686
+ #
687
+ # `Document#version` and `Catalog#version_override` are both
688
+ # refused: the first fabricates "1.4" for an unreadable header,
689
+ # which is the whole reason this handler reads the bytes itself.
690
+ def catalog_version(document, catalog)
691
+ raw = catalog.value[:Version]
692
+ raw = resolve(document, raw)&.value if raw.is_a?(::Pdfrb::Model::Reference)
693
+ return unless raw.is_a?(::Symbol)
694
+
695
+ token = raw.name
696
+ token if token.bytesize <= HEADER_SCAN_BYTES && token.match?(VERSION_TOKEN)
697
+ end
698
+
699
+ # `pages.count` is never called. Measured, it is not a declaration:
700
+ # sometimes the declared `/Count`, sometimes computed by walking
701
+ # `/Kids`, sometimes fabricated -- and on a self-cycling `/Kids`
702
+ # tree with `/Count` missing it raises `SystemStackError`.
703
+ # `Catalog#page_count` is forbidden too, because calling it MUTATES
704
+ # the Catalog: measured, `catalog.value[:Pages]` is a `Reference`
705
+ # before and a resolved `PageTreeNode` after, which leaves the
706
+ # generation guard with nothing to check.
707
+ #
708
+ # THE DECLARED COUNT, which can disagree with the real one without
709
+ # bound: a document with one leaf page declaring `/Count 999`
710
+ # reports 999. Nothing traverses the page tree to check it.
711
+ #
712
+ # `Integer` is a correctness requirement, not taste. Measured,
713
+ # `Models::Inspection` refuses a non-core leaf in `meta` with
714
+ # `Lutaml::Model::ValidationError`, and a Name arrives as a Symbol
715
+ # -- so putting a raw `:Bad` in `meta["pages"]` would not report a
716
+ # bad count, it would raise out of the model. The magnitude check is
717
+ # the publication bound: at most HEADER_SCAN_BYTES decimal digits.
718
+ def count_read(document, node)
719
+ raw = node.value[:Count]
720
+ raw = resolve(document, raw)&.value if raw.is_a?(::Pdfrb::Model::Reference)
721
+ raw if raw.is_a?(::Integer) && !raw.negative? && raw <= MAX_PAGE_COUNT
722
+ end
723
+ end
724
+ include MetadataGate
725
+
726
+ private_constant :MetadataGate
727
+
728
+ # What the timeout block managed to establish. A local carrier and
729
+ # never an ivar: `image.rb:233` builds a handler per call precisely
730
+ # so it holds no per-call state, and the outer rescue has to tell
731
+ # an expiry BEFORE the structure gate from one after it.
732
+ Progress = Struct.new(:version, :node, :page_count, :code, :version_settled)
733
+ private_constant :Progress
734
+
735
+ # One deadline over every potentially unbounded step in the normal
736
+ # operation, including construction of its successful result, never
737
+ # one per stage: five stages each granted five seconds is a
738
+ # twenty-five second worst case wearing a five-second label.
739
+ #
740
+ # An expiry after the VERSION IS SETTLED still returns `ok`. That
741
+ # recovery result is necessarily built after the clock has fired, but
742
+ # only after its page count is cleared; the remaining published
743
+ # strings are capped at HEADER_SCAN_BYTES, so the recovery has
744
+ # bounded input size.
745
+ #
746
+ # The expiry is caught OUTSIDE the block, and the default form is
747
+ # not a style choice. Measured on Ruby 3.4.8: `Timeout.timeout(n)`
748
+ # raises an internal `Timeout::ExitException` inside the block --
749
+ # not a `StandardError` -- and translates it to `Timeout::Error`
750
+ # only outside, so a stage-local rescue catches nothing. The
751
+ # explicit `Timeout.timeout(n, Timeout::Error)` form is worse: it
752
+ # makes a real `Timeout::Error` fly inside the block, where the
753
+ # inner rescue swallows it and the outer one sees nothing.
754
+ #
755
+ # `require "pdfrb"` sits in this method rather than at the top of
756
+ # the file because `registry.rb` requires every handler eagerly.
757
+ # Measured, best of 9 on a monotonic clock: bare `ruby -e ""` is
758
+ # 33.1 ms and `ruby -e 'require "pdfrb"'` is 76.8 ms, so a
759
+ # top-level require would put ~43 ms on every `claricle version`
760
+ # and every `--help`, for a delegate most invocations never touch.
761
+ #
762
+ # It sits OUTSIDE the Timeout block, and that is not cosmetic.
763
+ # Measured: with the require inside, a deadline expiring during it
764
+ # left `Pdfrb` undefined and the next line raised a bare
765
+ # `NameError` -- which is not on any rescue list here, so it
766
+ # escaped `inspection` instead of reporting `"failed"`. The
767
+ # deadline exists to bound reading an untrusted FILE; loading our
768
+ # own dependency is fixed work that no input controls.
769
+ #
770
+ # `version_settled` is the flag, and it is the VERSION rather than
771
+ # the structure gate's node. The reported version is the numeric
772
+ # maximum of the header and the Catalog's `/Version`, so a clock
773
+ # that fires while the Catalog is being read leaves only half the
774
+ # answer. The node was true by then, so the old flag reported "ok"
775
+ # and published the header version -- measured, "1.4" for a file
776
+ # whose Catalog says 1.7. A wrong version called a good read is
777
+ # worse than a "failed", so anything before the version is settled
778
+ # is now `pdf.timeout`.
779
+ #
780
+ # Only the count read and the result construction happen after the
781
+ # flag is set, and both are recoverable: the count is simply
782
+ # omitted.
783
+ def inspection(image)
784
+ progress = Progress.new
785
+ require "pdfrb"
786
+ result = bounded(progress) { run_stages(image, progress) }
787
+ if progress.code
788
+ return failed_inspection(image, code: progress.code,
789
+ message: MESSAGES.fetch(progress.code))
790
+ end
791
+
792
+ result || readable(image, progress)
793
+ end
794
+
795
+ private
796
+
797
+ # The clock, and what an expiry is allowed to conclude. Returns nil
798
+ # on an expiry, so the caller falls back to the recovery result.
799
+ #
800
+ # `||=`, because a code already set names a cause the run actually
801
+ # reached. A structure gate that refused sets STRUCTURE_CODE with
802
+ # the version still unsettled, and a deadline expiring during the
803
+ # unwind after it would otherwise relabel that refusal
804
+ # `pdf.timeout` -- one code per cause, reporting the wrong one.
805
+ def bounded(progress, &)
806
+ Timeout.timeout(DEADLINE_SECONDS, &)
807
+ rescue Timeout::Error
808
+ progress.code ||= TIMEOUT_CODE unless progress.version_settled
809
+ progress.page_count = nil
810
+ end
811
+
812
+ # Every read happens inside `with_path`. The handler never calls
813
+ # `image.content` itself: `with_path` calls it once for a
814
+ # content-born image, to write the tempfile pdfrb needs, and that
815
+ # returns the string the image already retains rather than making a
816
+ # second copy. A path-born inspection materialises nothing.
817
+ def run_stages(image, progress)
818
+ image.with_path do |path|
819
+ progress.version = VersionGate.version(path)
820
+ unless progress.version
821
+ progress.code = HEADER_CODE
822
+ next
823
+ end
824
+
825
+ open_document(path, progress)
826
+ end
827
+ readable(image, progress) unless progress.code
828
+ end
829
+
830
+ module CrDocumentOpening
831
+ private
832
+
833
+ # The BLOCK form, always. Measured on a 1 MB file: the non-block
834
+ # branch is `File.binread(path)` into a `StringIO` held for the
835
+ # document's life, whose `string` is exactly the file; the block
836
+ # form's `doc.io` is a `File` handle that does not respond to
837
+ # `string` at all.
838
+ #
839
+ # `Errno::ENOENT` is deliberately NOT rescued. pdfrb opens the file
840
+ # itself and `with_path` re-yields a path-born image's path without
841
+ # holding the handle, so a file deleted between the header read and
842
+ # this call raises it -- and reporting "failed" would claim the PDF
843
+ # is unreadable when it is simply gone.
844
+ #
845
+ # `opened` narrows this rescue to the OPEN itself. The block form is
846
+ # required (see above), so the rescue would otherwise also cover the
847
+ # stages inside the block, where this handler's own code runs: a bug
848
+ # there was reported as `pdf.unreadable` instead of crashing. Once
849
+ # pdfrb has yielded, every delegate call inside is guarded on its
850
+ # own, so anything still escaping is ours and is re-raised.
851
+ def open_document(path, progress)
852
+ open_native_document(path, progress)
853
+ return unless progress.code == STRUCTURE_CODE && cr_terminated_header?(path)
854
+
855
+ progress.code = nil
856
+ progress.node = nil
857
+ open_cr_document(path, progress)
858
+ end
859
+
860
+ def open_native_document(path, progress)
861
+ opened = false
862
+ ::Pdfrb::Document.open(path) do |document|
863
+ opened = true
864
+ read_document(document, progress)
865
+ end
866
+ rescue *parse_failures, Errno::EINVAL
867
+ raise if opened
868
+
869
+ progress.code = OPEN_CODE
870
+ end
871
+
872
+ # A fallback rather than the default path: ordinary documents retain
873
+ # pdfrb's own block-form open, while a document whose header proves it
874
+ # uses CR gets a second parse with only line reads adapted. The File
875
+ # remains the backing store, so neither the PDF nor its streams are
876
+ # materialised or rewritten.
877
+ def open_cr_document(path, progress)
878
+ opened = false
879
+ File.open(path, "rb") do |file|
880
+ document = ::Pdfrb::Document.new(io: UniversalLineReader.new(file))
881
+ opened = true
882
+ read_document(document, progress)
883
+ end
884
+ rescue *parse_failures, Errno::EINVAL
885
+ raise if opened
886
+
887
+ progress.code = OPEN_CODE
888
+ end
889
+
890
+ def cr_terminated_header?(path)
891
+ prefix = VersionGate.read(path)
892
+ cr = prefix.index("\r")
893
+ lf = prefix.index("\n")
894
+ cr && (!lf || cr < lf)
895
+ end
896
+ end
897
+ private_constant :CrDocumentOpening
898
+ include CrDocumentOpening
899
+
900
+ # The stages that run once pdfrb has handed the document over. A
901
+ # method of its own so the rescue above covers the open and nothing
902
+ # else.
903
+ def read_document(document, progress)
904
+ catalog, progress.node = structure_gate(document)
905
+ unless progress.node
906
+ progress.code = STRUCTURE_CODE
907
+ return
908
+ end
909
+
910
+ read_optional_fields(document, catalog, progress)
911
+ end
912
+
913
+ # Everything the gate did NOT have to prove. Each delegate read is
914
+ # guarded on its own, so a Catalog that will not give up its
915
+ # `/Version` still reports the header's, and a `/Count` that raises
916
+ # still leaves the version intact.
917
+ #
918
+ # The flag is set BETWEEN the two reads. Everything above it decides
919
+ # the published version, so a deadline there must report "failed";
920
+ # everything below it only adds the count, which the recovery drops.
921
+ def read_optional_fields(document, catalog, progress)
922
+ progress.version = highest_version(progress.version,
923
+ catalog_version(document, catalog))
924
+ progress.version_settled = true
925
+ progress.page_count = count_read(document, progress.node)
926
+ end
927
+
928
+ # The NUMERIC MAXIMUM, and every word of that is load-bearing.
929
+ #
930
+ # Not "prefer the Catalog": a real file can declare a LOWER
931
+ # `/Version` than its header, and 1.7 is then the true version --
932
+ # poppler agrees. Not a string compare: `"1.10" > "1.9"` is FALSE.
933
+ # Not a float: `1.10 == 1.1`, so 1.10 and 1.1 collide. Comparing
934
+ # integer pairs split on the dot is the only one of the three that
935
+ # gets both directions right.
936
+ def highest_version(header, catalog)
937
+ return header unless catalog
938
+
939
+ [header, catalog].max_by { |version| version.split(".").map(&:to_i) }
940
+ end
941
+
942
+ # `width`, `height`, `dpi` and `color_space` stay nil, and a nil
943
+ # attribute is simply absent from the serialised output.
944
+ def readable(image, progress)
945
+ Models::Inspection.new(
946
+ format: image.format.to_s, parse_status: "ok", meta: metadata(progress)
947
+ )
948
+ end
949
+
950
+ # `count_read` has already validated it down to a non-negative
951
+ # Integer or nil, so this is a page count and not a raw value. Zero
952
+ # is truthy in Ruby, so a document declaring `/Count 0` reports its
953
+ # zero rather than dropping the key.
954
+ def metadata(progress)
955
+ meta = { "version" => progress.version }
956
+ meta["pages"] = progress.page_count if progress.page_count
957
+ meta
958
+ end
959
+ end
960
+ end
961
+ end