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,1011 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ require_relative "base"
6
+ require_relative "../conversion_engine"
7
+ require_relative "../models/location"
8
+ require_relative "../models/report"
9
+ require_relative "../models/inspection"
10
+ require_relative "../models/issue"
11
+ require_relative "../models/conversion"
12
+ require_relative "../lossiness"
13
+
14
+ module Claricle
15
+ module Handlers
16
+ # Record framing: where a record starts and stops, and which one ends the
17
+ # stream.
18
+ #
19
+ # Beside `EmfPlus` rather than inside it: `EmfPlus` is about what an EMF+
20
+ # carrier holds -- which record type carries it, the signature, and how
21
+ # much payload to count -- while this is the vocabulary that walking the
22
+ # records consults. `EmfStructure` walks the same vocabulary to a
23
+ # different end, so a rule here changes both walks, which is why it is
24
+ # stated once and in neither of them.
25
+ module EmfRecords
26
+ # EMR_EOF closes the stream.
27
+ EOF_RECORD = 14
28
+ # EMR_EOF is Type, Size, nPalEntries, offPalEntries and SizeLast,
29
+ # so a shorter type-14 record is not an end of stream at all. The
30
+ # delegate records such a record as Raw and keeps going, and a
31
+ # carrier after it is real: measured, with nSize 8, 12 or 16 the
32
+ # gem still finds 12 bytes of EMF+ where stopping finds none.
33
+ EOF_RECORD_BYTES = 20
34
+ RECORD_HEADER_BYTES = 8
35
+ ALIGNMENT = 4
36
+ # EMR_COMMENT, and its own header: Type, Size and DataSize. Both walks
37
+ # read a comment's DataSize, so its layout lives here.
38
+ COMMENT_RECORD = 70
39
+ COMMENT_HEADER_BYTES = 12
40
+
41
+ # A record must declare a size that is aligned, at least a header
42
+ # long, and inside what is left. Anything else means the framing is
43
+ # broken and neither walk can continue: `EmfPlus` stops totalling, and
44
+ # the structural pre-pass reports `emf.record_framing` at this offset.
45
+ # The minimum also guarantees forward progress: a record declaring zero
46
+ # would otherwise pin the walk on one offset forever.
47
+ def self.record_at(content, offset)
48
+ return nil if content.bytesize - offset < RECORD_HEADER_BYTES
49
+
50
+ type, size = content.byteslice(offset, RECORD_HEADER_BYTES).unpack("VV")
51
+ return nil if size < RECORD_HEADER_BYTES || !(size % ALIGNMENT).zero?
52
+ return nil if size > content.bytesize - offset
53
+
54
+ [type, size]
55
+ end
56
+
57
+ # 20 bytes is the MINIMUM, not the size -- a writer may pad an EMR_EOF,
58
+ # and a bigger one still closes the stream.
59
+ def self.eof?(type, size)
60
+ type == EOF_RECORD && size >= EOF_RECORD_BYTES
61
+ end
62
+ end
63
+
64
+ # Finds EMF+ by walking the record framing, because the delegate
65
+ # cannot answer the question. emf 0.1.0 does not parse EMF+ at all --
66
+ # its EMF+ parser is unimplemented, and its full pass only recognises
67
+ # the marker and concatenates the bytes, which is close to what this
68
+ # does. It gets the question wrong three ways. It raises NoMethodError
69
+ # on a comment whose cbData overruns its own record, because the
70
+ # extraction step checks cbData >= 4 without checking four bytes are
71
+ # there. It fails outright on the valid 92- and 96-byte described
72
+ # headers, reporting no EMF+ for files that may carry it. And it
73
+ # counts a record's alignment padding as payload: measured, it reports
74
+ # 4 bytes for `padded_comment.emf`, whose cbData leaves 1.
75
+ #
76
+ # The handler stays a wrapper -- emf still owns every substantive
77
+ # header field. Only the marker hunt is local, and only because
78
+ # upstream's path for it is broken.
79
+ module EmfPlus
80
+ # EMR_COMMENT is the only record type EMF+ travels in. Its declared
81
+ # payload begins after the comment's twelve-byte header, and the
82
+ # signature occupies the first four bytes of it.
83
+ SIGNATURE = "EMF+".b.freeze
84
+
85
+ # A distinct marker rather than a zero advance. Overloading zero
86
+ # would make a malformed record declaring no size look like a clean
87
+ # end of stream, which retires the guard that catches it.
88
+ END_OF_STREAM = :end_of_stream
89
+
90
+ # Bytes of EMF+ payload, or **nil when the stream was over the byte
91
+ # limit and nothing was examined at all**.
92
+ #
93
+ # That is a different answer from zero and an earlier version
94
+ # conflated them. Zero means the walk ran to completion and found
95
+ # none -- including when framing broke partway, since what it read
96
+ # before the break is still established. Nil means the walk never
97
+ # ran, so it cannot say what the stream holds and must not report
98
+ # absence on the strength of bytes nobody read. A stream one byte
99
+ # over the limit can carry `EMF+` in its very first record;
100
+ # answering zero there would be exactly that mistake.
101
+ #
102
+ # The byte limit matches the delegate's own:
103
+ # `Emf::Emr::Parser::MAX_INPUT_BYTES` is the same 200 MiB and
104
+ # refuses on the same `>`. What it buys is that the cost stops
105
+ # growing past it.
106
+ #
107
+ # The iteration budget handed to `walk` is derived, not chosen. It
108
+ # exists only so a defect in `EmfRecords.record_at` cannot spin the
109
+ # walk on one offset forever: it refuses a record declaring fewer
110
+ # than its eight-byte header, so every step advances at least that
111
+ # far, and the content is at most `limit` bytes -- so
112
+ # `limit / EmfRecords::RECORD_HEADER_BYTES` steps always outlast the
113
+ # bytes and the budget can never bind first.
114
+ #
115
+ # It used to be a flat 500,000 and that WAS a ceiling a real file
116
+ # could hit. EMR_SETMETARGN is a valid parameterless 8-byte record
117
+ # and EMR_HEADER's record count is an unsigned 32-bit field, so an
118
+ # internally consistent stream of 500,000 of them is ordinary EMF
119
+ # and only 4.0 MiB long. Measured: it reported no EMF+ verdict at
120
+ # all, and 499,999 of them reported `emf_plus_present: false`.
121
+ #
122
+ # The byte limit is therefore the only thing bounding CPU, and what
123
+ # that costs is worth writing down. Measured on a stream of
124
+ # minimum-size records: 3.8 MiB walks in 0.58s and 20.0 MiB in
125
+ # 3.57s, so the 200 MiB boundary is around 36 seconds. The flat
126
+ # budget held that near 0.6s -- and refused ordinary files to do
127
+ # it. Answering a 4 MiB file correctly is worth the worst case.
128
+ def self.size(content, declared, limit)
129
+ return nil if content.bytesize > limit
130
+
131
+ walk(content, declared, limit / EmfRecords::RECORD_HEADER_BYTES)
132
+ end
133
+
134
+ # Broken framing stops the walk and keeps what came before it.
135
+ # Discarding a validated total was a false negative: measured, a
136
+ # stream whose final EMR_EOF declares nSize 0 still carries two
137
+ # intact carriers, and the delegate reports their 40 bytes while
138
+ # an all-or-nothing rule reported none. Every record counted here
139
+ # had its own framing checked before it was counted, so a later
140
+ # break says nothing about the ones already read.
141
+ #
142
+ # The line after the loop is the budget running out. With the budget
143
+ # derived from the byte limit it cannot be reached at all: offset
144
+ # starts at `declared`, every step adds at least
145
+ # `EmfRecords::RECORD_HEADER_BYTES`, and `budget` is the byte limit
146
+ # divided by that, so the end of the content always arrives first.
147
+ # It stays written for a budget that CAN bind, which is why it asks
148
+ # where the offset landed -- a last budgeted step that finished the
149
+ # stream walked it completely and keeps its total, while one that
150
+ # stopped short answers nil, because the remainder it never reached
151
+ # might carry EMF+ and a partial total would be silent about that.
152
+ def self.walk(content, offset, budget)
153
+ total = 0
154
+ budget.times do
155
+ return total if offset >= content.bytesize
156
+
157
+ advance = step_forward(content, offset, total)
158
+ return total unless advance
159
+
160
+ total, offset = advance
161
+ end
162
+ offset < content.bytesize ? nil : total
163
+ end
164
+
165
+ # A record consumed and folded into the running total, as
166
+ # `[total, offset]`, or nil at broken framing or EMR_EOF -- the
167
+ # two ways a step is not a record to keep walking past.
168
+ def self.step_forward(content, offset, total)
169
+ step = step_at(content, offset)
170
+ return nil if step.nil? || step == END_OF_STREAM
171
+
172
+ [total + step.first, offset + step.last]
173
+ end
174
+
175
+ # One record: `[payload, advance]`, `END_OF_STREAM` at EMR_EOF, or
176
+ # nil when the framing is broken.
177
+ def self.step_at(content, offset)
178
+ type, size = EmfRecords.record_at(content, offset)
179
+ return nil unless size
180
+ return END_OF_STREAM if EmfRecords.eof?(type, size)
181
+
182
+ [payload_of(content, offset, type, size), size]
183
+ end
184
+
185
+ # Only the bytes the comment declares are inspected, and only the
186
+ # declared count is added -- a record's padding is not payload.
187
+ #
188
+ # A comment whose `cbData` overruns its own record contributes
189
+ # nothing and does NOT stop the walk. MS-EMF keeps the whole-record
190
+ # `Size` and the inner `DataSize` separate, and the outer size was
191
+ # already validated as aligned and in bounds -- so it still proves
192
+ # where the next record begins, whatever the inner field claims.
193
+ # Measured: stopping there reported no EMF+ for a stream whose
194
+ # SECOND carrier was intact and which the delegate reads as 12
195
+ # bytes. That is the same mistake as discarding a validated total
196
+ # on a later break, one level down.
197
+ #
198
+ # The record minimum is load-bearing, not belt-and-braces: an
199
+ # 8-byte comment record standing LAST in the stream has no bytes
200
+ # after its header, so `unpack1` gives nil and the bound below
201
+ # raises NoMethodError.
202
+ #
203
+ # The `cbData` minimum is a CORRECTNESS guard. It was written down
204
+ # as a fast path and that was wrong: the signature is sliced from
205
+ # the RECORD, not from the declared run, so a comment declaring
206
+ # fewer than four bytes still gets four read out of it, matches,
207
+ # and the subtraction below goes NEGATIVE. Measured on a 16-byte
208
+ # comment declaring `cbData` 1 in front of an `EMF+` marker: with
209
+ # the line removed the handler reported `emf_plus_present` true
210
+ # and `emf_plus_bytes` -3.
211
+ #
212
+ # It also has to sit ahead of the slice, because reaching the
213
+ # comparison costs two strings for a record that cannot match: the
214
+ # four-byte slice and its `.b` copy. Measured over 20 MiB of
215
+ # 12-byte comments declaring `cbData` 0 -- the densest a stream can
216
+ # be, 1,747,626 records -- moving the check below the slice added
217
+ # 3,495,252 objects, exactly two per record.
218
+ #
219
+ # Only the signature is sliced, never the declared payload, and a
220
+ # comment may declare nearly the whole stream. `byteslice` COPIES
221
+ # at that size: measured, taking 180 MiB out of a 200 MiB string
222
+ # allocated 188,743,721 bytes and grew malloc by the same again.
223
+ # Four bytes costs 40. Slicing the declared run instead would
224
+ # therefore copy the carrier, not point into it.
225
+ def self.payload_of(content, offset, type, size)
226
+ return 0 unless type == EmfRecords::COMMENT_RECORD
227
+ return 0 if size < EmfRecords::COMMENT_HEADER_BYTES
228
+
229
+ declared = content.byteslice(offset + EmfRecords::RECORD_HEADER_BYTES, 4)
230
+ .unpack1("V")
231
+ return 0 if declared > size - EmfRecords::COMMENT_HEADER_BYTES
232
+ return 0 if declared < SIGNATURE.bytesize
233
+
234
+ signature = content.byteslice(offset + EmfRecords::COMMENT_HEADER_BYTES, SIGNATURE.bytesize)
235
+ return 0 unless signature.b == SIGNATURE
236
+
237
+ declared - SIGNATURE.bytesize
238
+ end
239
+
240
+ private_class_method :walk, :step_at, :step_forward, :payload_of
241
+ end
242
+
243
+ # Structural pre-pass over the raw record stream, needing nothing from
244
+ # the delegate. It reports how the stream ends -- framing breaks, no
245
+ # EMR_EOF, bytes after it, an EMR_EOF whose SizeLast disagrees with its
246
+ # Size -- and any comment whose DataSize overruns its own record. Bytes
247
+ # are never read beyond what `EmfRecords.record_at` slices and the
248
+ # DWORD fields named below; the caller owns the read and its bound.
249
+ module EmfStructure
250
+ # EMR_EOF's final field, SizeLast. MS-EMF requires it to equal the
251
+ # record's Size.
252
+ SIZE_LAST_BYTES = 4
253
+
254
+ # `declared` is the caller's contract, not this method's to police: it
255
+ # arrives from `declared_size`, which yields an Integer, 4-aligned and
256
+ # at least MINIMUM_HEADER. Non-negative is the property this walk rests
257
+ # on, and it is worth being exact about what a negative would cost,
258
+ # because it does not fail one way. Measured on `valid.emf`: -1 raises
259
+ # NoMethodError, -16 raises Lutaml::Model::ValidationError from a
260
+ # negative `byte_offset`, and only -88 produces the quiet nonsense of a
261
+ # `byteslice` counting from the end. Two of those three escape a method
262
+ # whose contract is to RETURN issues. That is a reason to keep the
263
+ # contract, not to add a check for an input the handler cannot deliver.
264
+ #
265
+ # `declared` is NOT guaranteed to be inside the bytes on hand.
266
+ # `declared_size` bounds it by `available`, which is the read's own
267
+ # length under the scan limit but the file's stat above it -- so a stat
268
+ # reading high admits a declaration the content cannot hold, the
269
+ # residual written down at `declared_size` itself. No guard is needed
270
+ # for that either: the loop condition is false from the first step and
271
+ # the answer is `[missing_eof]`.
272
+ #
273
+ # nil means "not examined": over the byte limit nothing was read, and
274
+ # `[]` would be the stronger claim that the stream ends properly. The
275
+ # guard is deliberately the same idiom as `EmfPlus.size`'s -- same `>`,
276
+ # same figure, same reason.
277
+ #
278
+ # The walk's bound is the loop condition, so `record_at` is never
279
+ # called at `offset == content.bytesize`: a stream ending exactly on a
280
+ # record boundary is a missing EOF, not broken framing.
281
+ #
282
+ # No iteration budget, unlike `EmfPlus.walk`. That budget exists to stop
283
+ # a defect pinning the walk on one offset forever; here `EmfRecords.record_at`
284
+ # already refuses a record declaring fewer than its eight-byte header, so
285
+ # every step advances at least eight bytes and the byte bound is
286
+ # sufficient on its own.
287
+ #
288
+ # A comment overrun does not stop the walk. The outer Size was already
289
+ # checked, so it still says where the next record begins -- the same
290
+ # reason `EmfPlus.payload_of` carries on past one.
291
+ def self.issues(content, declared, limit)
292
+ return nil if content.bytesize > limit
293
+
294
+ walk(content, declared, [])
295
+ end
296
+
297
+ def self.walk(content, offset, found)
298
+ while offset < content.bytesize
299
+ type, size = EmfRecords.record_at(content, offset)
300
+ return found << framing(offset) if size.nil?
301
+ return found + ending(content, offset, size) if EmfRecords.eof?(type, size)
302
+
303
+ found << overrun(offset, size) if overrun?(content, offset, type, size)
304
+ offset += size
305
+ end
306
+ found << missing_eof
307
+ end
308
+
309
+ def self.body_record_count(content, offset)
310
+ count = 0
311
+ while offset < content.bytesize
312
+ type, size = EmfRecords.record_at(content, offset)
313
+ return nil if size.nil?
314
+
315
+ count += 1
316
+ return count if EmfRecords.eof?(type, size)
317
+
318
+ offset += size
319
+ end
320
+ nil
321
+ end
322
+
323
+ def self.ending(content, offset, size)
324
+ at = offset + size - SIZE_LAST_BYTES
325
+ last = content.byteslice(at, SIZE_LAST_BYTES).unpack1("V")
326
+ found = last == size ? [] : [size_last(at, last, size)]
327
+ found + trailing(content, offset + size)
328
+ end
329
+
330
+ # A comment's DataSize has to fit in what follows its three-field
331
+ # header. An 8-byte comment has no DataSize field at all.
332
+ def self.overrun?(content, offset, type, size)
333
+ return false unless type == EmfRecords::COMMENT_RECORD
334
+ return true if size < EmfRecords::COMMENT_HEADER_BYTES
335
+
336
+ content.byteslice(offset + EmfRecords::RECORD_HEADER_BYTES, 4).unpack1("V") >
337
+ size - EmfRecords::COMMENT_HEADER_BYTES
338
+ end
339
+
340
+ def self.overrun(at, size)
341
+ issue("emf.comment_overrun", "the EMR_COMMENT at byte #{at} declares more data than its record holds",
342
+ Models::Location.new(byte_offset: at, byte_length: size))
343
+ end
344
+
345
+ def self.size_last(at, last, size)
346
+ issue("emf.eof_size_mismatch", "EMR_EOF declares size #{size} but its SizeLast field says #{last}",
347
+ Models::Location.new(byte_offset: at, byte_length: SIZE_LAST_BYTES))
348
+ end
349
+
350
+ def self.trailing(content, at)
351
+ return [] if at >= content.bytesize
352
+
353
+ count = content.bytesize - at
354
+ noun = count == 1 ? "byte follows" : "bytes follow"
355
+ [issue("emf.trailing_bytes", "#{count} #{noun} the EMR_EOF record",
356
+ Models::Location.new(byte_offset: at, byte_length: count))]
357
+ end
358
+
359
+ # `record_at` refuses for four different reasons and returns a bare nil
360
+ # carrying none of them, so the message names the offset and no reason.
361
+ def self.framing(at)
362
+ issue("emf.record_framing", "EMF record framing breaks at byte #{at}",
363
+ Models::Location.new(byte_offset: at))
364
+ end
365
+
366
+ def self.missing_eof
367
+ issue("emf.missing_eof", "EMF record stream ends without an EMR_EOF record", nil)
368
+ end
369
+
370
+ def self.issue(code, message, location)
371
+ Models::Issue.new(severity: "error", code: code, message: message,
372
+ location: location)
373
+ end
374
+
375
+ private_class_method :walk, :ending, :overrun?, :overrun, :size_last, :trailing, :framing,
376
+ :missing_eof, :issue
377
+ end
378
+
379
+ private_constant :EmfRecords, :EmfPlus, :EmfStructure
380
+
381
+ # The one issue `inspection` can report. Beside the conformance codes
382
+ # rather than inside `Metafile`, so every code this file can emit reads
383
+ # in one place.
384
+ HEADER_ISSUE_CODE = "emf.header_unreadable"
385
+ HEADER_ISSUE_MESSAGE = "EMF header could not be read"
386
+
387
+ private_constant :HEADER_ISSUE_CODE, :HEADER_ISSUE_MESSAGE
388
+
389
+ # Builds the `Report` a conform operation returns. A sibling of the
390
+ # metadata reading below and nothing more: the two share a handler
391
+ # and no state.
392
+ class ConformanceMapper
393
+ # `Emf.parse` raises three families on truncation, at different cut
394
+ # points, all measured on this repo's own fixtures:
395
+ #
396
+ # Emf::FormatError truncated_44.emf, declared_100_have_99.emf
397
+ # IOError nsize_87.emf, truncated_117.emf
398
+ # EOFError described_92.emf, nsize_44.emf
399
+ #
400
+ # `EOFError < IOError`, so listing IOError covers both -- but only
401
+ # because this rescues by CLASS MATCHING. A guard comparing
402
+ # `e.class == IOError` would silently miss every EOFError.
403
+ #
404
+ # NoMethodError is the one the plan card does not mention, and it
405
+ # is not hypothetical: `bad_comment.emf`, already in this repo,
406
+ # reaches it, and a 3000-document fuzz hit it 66 times. The gem
407
+ # trusts a comment record's declared `cb_data` without checking the
408
+ # bytes it actually parsed, so `getbyte` returns nil and the shift
409
+ # raises. Left off the allowlist, a real malformed file exits 4 --
410
+ # an internal error -- instead of 1.
411
+ MALFORMED = [::Emf::FormatError, ::IOError, ::NoMethodError].freeze
412
+
413
+ # Its own copies, not `Metafile`'s: those are `private_constant` on a
414
+ # class this module sits beside, and reaching for them would couple
415
+ # the two in the one direction that is hard to unpick later.
416
+ MINIMUM_HEADER = 88
417
+ SCAN_LIMIT = 200 * 1024 * 1024
418
+ # The header's Bytes field: MS-EMF defines it as the size of the whole
419
+ # metafile.
420
+ BYTES_OFFSET = 48
421
+ RECORDS_OFFSET = 52
422
+
423
+ # The structural walk is the authority on whether the file is broken;
424
+ # the gem is a second opinion that may not arrive. A gem exception
425
+ # cannot tell the two apart -- `described_92.emf` is legal and raises
426
+ # EOFError, `truncated_117.emf` is damaged and raises IOError -- so
427
+ # only reading the record stream separates them:
428
+ #
429
+ # valid [] described_92 []
430
+ # after_eof [trailing_bytes] truncated_117 [record_framing]
431
+ # misaligned_record [record_framing] bad_comment [comment_overrun]
432
+ def self.report(image)
433
+ issues = image.with_source { |source| issues_for(bounded(source)) }
434
+
435
+ Models::Report.new(
436
+ source_path: image.path, format: image.format.to_s,
437
+ validator_version: ::Emf::VERSION, issues: issues
438
+ )
439
+ end
440
+
441
+ # One byte past the limit is enough to show the stream is over it, so
442
+ # nothing bigger is read from a file. Content already in memory is
443
+ # used as it is. `IO#read` answers nil on an empty stream.
444
+ def self.bounded(source)
445
+ return source unless source.respond_to?(:read)
446
+
447
+ source.read(SCAN_LIMIT + 1) || "".b
448
+ end
449
+
450
+ # Structural findings first, then whatever the delegate managed. Do not
451
+ # trust the gem's `ok?` alone: a stream cut at a record boundary parses
452
+ # clean. A nil from the walk means "not examined" -- content over the
453
+ # limit -- which is a weaker claim than `[]` and must not read as clean.
454
+ def self.issues_for(content)
455
+ declared = header_size(content)
456
+ return [header_framing] unless declared
457
+
458
+ walked = EmfStructure.issues(content, declared, SCAN_LIMIT)
459
+ return [unexamined] if walked.nil?
460
+
461
+ structural = byte_count_issues(content) + walked
462
+ structural + delegate_issues(content, structural, declared)
463
+ end
464
+
465
+ # EMR_HEADER is a record, framed by the same rule as every other --
466
+ # aligned, at least a record header long, inside the stream -- and its
467
+ # fixed fields need 88 bytes. So a 109- or 110-byte header fails here.
468
+ def self.header_size(content)
469
+ _type, size = EmfRecords.record_at(content, 0)
470
+ size if size && size >= MINIMUM_HEADER
471
+ end
472
+
473
+ def self.header_framing
474
+ issue("emf.record_framing", "the EMR_HEADER record declares a size no header can have", offset: 0)
475
+ end
476
+
477
+ # The record walk still has to run when this agrees: `after_eof.emf`
478
+ # declares 392 and is 392, because the header was rewritten to cover
479
+ # the bytes after EMR_EOF.
480
+ def self.byte_count_issues(content)
481
+ declared = content.byteslice(BYTES_OFFSET, 4).unpack1("V")
482
+ return [] if declared == content.bytesize
483
+
484
+ [issue("emf.byte_count_mismatch",
485
+ "the header declares #{declared} bytes and the file holds #{content.bytesize}")]
486
+ end
487
+
488
+ # A gem failure is evidence about the VALIDATOR, not about the file.
489
+ # Where the walk found the stream intact, the honest answer is that
490
+ # this file was not fully judged -- a WARNING, so `Report#valid`
491
+ # derives `:suspicious` instead of claiming `:yes` or `:no`. Where the
492
+ # walk already found real damage, the gem's failure adds nothing and
493
+ # is dropped rather than restated in weaker words.
494
+ def self.delegate_issues(content, structural, declared)
495
+ metafile = parsed(content)
496
+ return fallback_issues(content, structural, declared, metafile) if metafile.is_a?(::Exception)
497
+
498
+ metafile.errors.map { |error| parse_issue(error) } + [count_issue(metafile)].compact
499
+ end
500
+
501
+ def self.parsed(content)
502
+ ::Emf.parse(content)
503
+ rescue *MALFORMED => e
504
+ e
505
+ end
506
+
507
+ def self.fallback_issues(content, structural, declared, error)
508
+ base = structural.empty? ? [unverified(error)] : []
509
+ base + [independent_count_issue(content, declared)].compact
510
+ end
511
+
512
+ def self.independent_count_issue(content, declared)
513
+ expected = content.byteslice(RECORDS_OFFSET, 4)&.unpack1("V")
514
+ actual = EmfStructure.body_record_count(content, declared)
515
+ return if expected.nil? || actual.nil? || expected == actual + 1
516
+
517
+ issue("emf.record_count_mismatch",
518
+ "the header declares #{expected} records and #{actual + 1} were read")
519
+ end
520
+
521
+ def self.unexamined
522
+ Models::Issue.new(
523
+ severity: "warning", code: "emf.not_examined",
524
+ message: "the file is larger than the scan limit and was not examined"
525
+ )
526
+ end
527
+
528
+ def self.unverified(error)
529
+ Models::Issue.new(
530
+ severity: "warning", code: "emf.unverified",
531
+ message: "the record stream is intact but the validator could not read it: #{error.class}"
532
+ )
533
+ end
534
+
535
+ def self.parse_issue(error)
536
+ issue("emf.parse_error", error.message.to_s, offset: error.offset)
537
+ end
538
+
539
+ # The count reads `records.size + 1` because the header counts
540
+ # ITSELF: EMR_HEADER is type 1 in the same record-type space as
541
+ # every body record, while `Metafile#records` holds only what
542
+ # follows it.
543
+ def self.count_issue(metafile)
544
+ declared = metafile.header.n_records
545
+ return if declared.nil? || declared == metafile.records.size + 1
546
+
547
+ issue(
548
+ "emf.record_count_mismatch",
549
+ "the header declares #{declared} records and #{metafile.records.size + 1} were read"
550
+ )
551
+ end
552
+
553
+ def self.issue(code, message, offset: nil)
554
+ Models::Issue.new(
555
+ severity: "error", code: code, message: message,
556
+ location: offset.nil? ? nil : Models::Location.new(byte_offset: offset)
557
+ )
558
+ end
559
+
560
+ private_class_method :bounded, :issues_for, :header_size, :header_framing,
561
+ :byte_count_issues, :delegate_issues, :unexamined, :unverified,
562
+ :parsed, :fallback_issues, :independent_count_issue,
563
+ :parse_issue, :count_issue, :issue
564
+ end
565
+
566
+ private_constant :ConformanceMapper
567
+
568
+ # The conversion path's own two pieces, kept out of Metafile's body
569
+ # for the same reason EmfPlus is: this class already carries the
570
+ # whole header-parse contract, and conversion is a second, unrelated
571
+ # concern layered on top of it. `module_function` rather than a class
572
+ # with instance methods -- there is no state to hold between calls.
573
+ module MetafileConvert
574
+ # Symbol -> the vectory method it dispatches to. A lookup table
575
+ # rather than `public_send(:"to_#{to}")`: `to` is already checked
576
+ # against `convert_targets` before this runs, so nothing
577
+ # attacker-controlled reaches `public_send` either way, but a
578
+ # closed table is what a third target (a future `:png` once
579
+ # vectory gains one) would have to be added to, rather than
580
+ # trusting every future target's symbol to spell a real
581
+ # `Vectory::Emf` method.
582
+ TARGET_METHODS = { svg: :to_svg, eps: :to_eps, ps: :to_ps }.freeze
583
+
584
+ # Matches Metafile's own `SCAN_LIMIT` (below) and the underlying
585
+ # `emf` gem's own `Emf::Emr::Parser::MAX_INPUT_BYTES` -- kept as a
586
+ # separate constant, not a reference to `Metafile::SCAN_LIMIT`,
587
+ # because that one is `private_constant` and declared later in this
588
+ # file. Checked before the delegate ever sees the content: the
589
+ # gem's own limit runs on `bytesize` *after* the whole file is
590
+ # already a Ruby String, so refusing here is what actually stops
591
+ # the parse/render cost from growing on an oversized input --
592
+ # measured, a 364-byte corrupted EMF was still climbing past 1.2GB
593
+ # RSS with no cap at all.
594
+ MAX_CONVERT_BYTES = 200 * 1024 * 1024
595
+
596
+ # NOT an exhaustive list of what the delegate chain can raise --
597
+ # measured otherwise. Originally listed as four explicit classes,
598
+ # matching the house convention (`PARSE_FAILURES` below,
599
+ # postscript.rb's `Postscript::ParseError` rescue, png.rb's
600
+ # `PngConform::Error` rescue). A fuzz of the real fixture found
601
+ # three real, reachable exceptions outside that list: `IndexError`
602
+ # from `emfsvg`'s object table on a corrupted CreatePen record,
603
+ # `IOError`/`EOFError` from `bindata` on a truncated stream, and a
604
+ # `NameError` that is a genuine bug in `postsvg` 0.3.0's own
605
+ # autoload (`Postsvg::Model::UnknownOperator` is declared in the
606
+ # singular `operator.rb`, defined in the plural `operators.rb`),
607
+ # reachable through any EMF whose rendered SVG embeds a raster
608
+ # image. None of the three is a defect in THIS file, and this
609
+ # method's body calls nothing but the delegate chain, so there is
610
+ # no local bug a wider rescue would hide. Logged as a deviation
611
+ # from the house convention above -- see
612
+ # `deviations.md` under this task's entry.
613
+ module_function
614
+
615
+ def run(image, to:, convert_targets:)
616
+ raise UnsupportedFormat.new(image.format, :convert, target: to) unless convert_targets.include?(to)
617
+
618
+ content = bounded_content(image)
619
+ ConversionEngine.load!
620
+ converted = convert_content(content, to)
621
+ build(image, to, content, converted)
622
+ end
623
+
624
+ # Bounds the READ itself rather than trusting a stat taken on a
625
+ # separate filesystem call -- the same reasoning as `Metafile`'s own
626
+ # `header_prefix` below, and for the same failure mode: `#bytesize`
627
+ # and `#content` are two different filesystem operations for a
628
+ # path-born image, so checking a stat first and only THEN calling
629
+ # `#content` still materialises the whole, oversized file in memory
630
+ # before the check can refuse it. Reading MAX_CONVERT_BYTES + 1 and
631
+ # getting that many back is the proof the stream is over the limit
632
+ # whatever a stat would have said; getting fewer proves these bytes
633
+ # ARE the whole stream -- which, unlike inspection's own header-only
634
+ # prefix, is also the entire content a within-limit conversion needs.
635
+ def bounded_content(image)
636
+ content = image.with_source { |source| bounded_read(source, MAX_CONVERT_BYTES + 1) }
637
+ return content if content.bytesize <= MAX_CONVERT_BYTES
638
+
639
+ raise ConversionError, "emf image exceeds the #{MAX_CONVERT_BYTES}-byte convert limit"
640
+ end
641
+
642
+ # Duplicated from `Metafile`'s own private `#bounded_read` rather
643
+ # than shared across the module boundary: both are three-line pure
644
+ # functions with no state, and `Metafile`'s copy is a `private`
645
+ # instance method, reachable only through an instance this
646
+ # `module_function` module has no reason to hold.
647
+ def bounded_read(source, length)
648
+ return source.read(length) || "".b if source.respond_to?(:read)
649
+
650
+ source.byteslice(0, length)
651
+ end
652
+
653
+ # Scoped to only the delegate calls, not `build` below -- `build`'s
654
+ # own `Lossiness.classify`/`Models::Conversion.new` are this file's
655
+ # local logic, not the delegate chain `rescue StandardError` exists
656
+ # to cover, so a bug inside `build` surfaces as itself rather than
657
+ # being relabelled a conversion failure.
658
+ def convert_content(content, to)
659
+ ::Vectory::Emf.from_content(content).public_send(TARGET_METHODS.fetch(to))
660
+ rescue StandardError => e
661
+ raise ConversionError, "#{e.class}: #{e.message}"
662
+ end
663
+
664
+ # `output_path` is deliberately absent here -- the caller
665
+ # (`Claricle.convert_one`) does not know the real written path
666
+ # until after `Writer#write` runs, and `Models::Base` seals (and
667
+ # therefore freezes) every instance at construction, so it cannot
668
+ # be set on this instance afterward. The caller builds its own
669
+ # final `Models::Conversion` once the path is known, copying every
670
+ # field asserted here.
671
+ #
672
+ # Takes `content` rather than re-reading `image.content` -- the
673
+ # bytes `run` already read via `#bounded_content` are already the
674
+ # right ones (bounded, and the full stream for anything under the
675
+ # limit), and calling `image.content` here would silently re-read
676
+ # the whole file a second time for a path-born image, unbounded.
677
+ def build(image, to, content, converted)
678
+ Models::Conversion.new(
679
+ source_path: image.path,
680
+ source_format: image.format.to_s,
681
+ target_format: to.to_s,
682
+ lossiness: Lossiness.classify(source_format: image.format, target_format: to, source: content),
683
+ content: converted.content
684
+ )
685
+ end
686
+ end
687
+
688
+ private_constant :MetafileConvert
689
+
690
+ # Reads an EMF header. WMF is deliberately absent: the released emf
691
+ # parser reports "WMF parser not yet implemented" (D14), so nothing
692
+ # registers `:wmf` and it reaches exit 3 through the registry.
693
+ # The header is parsed by the delegate; the EMF+ marker is found by
694
+ # walking the record framing here. The delegate is not asked for the
695
+ # second answer because it cannot give it -- see `EmfPlus.size`.
696
+ #
697
+ # `image.bytesize` is a `File.size` stat, and it decides ONE thing:
698
+ # whether to bother reading past the fixed header. Every other
699
+ # decision is made from the bytes `header_prefix` actually returned,
700
+ # because the stat and the read are separate filesystem calls and
701
+ # the file is free to change between them. A stat reading high only
702
+ # makes this under-report, which is the safe direction.
703
+ #
704
+ # Conformance lives on `ConformanceMapper` above. No lazy require:
705
+ # `emf` is the detector's one EAGER delegate (D5).
706
+ class Metafile < Base
707
+ def conformance_report(image) = ConformanceMapper.report(image)
708
+
709
+ formats :emf
710
+ # png and pdf have no vectory class (04-convert.md); :emf itself is
711
+ # not a target -- Base's same-format guard in `Claricle.convert_one`
712
+ # already refuses that before any handler is reached, and
713
+ # `Vectory::Emf` has no `#to_emf` to route it to regardless.
714
+ convert_to :svg, :eps, :ps
715
+
716
+ # EMR_HEADER's declared size, a 4-byte little-endian value at a
717
+ # fixed offset. Below the minimum there is no header to read.
718
+ SIZE_OFFSET = 4
719
+ MINIMUM_HEADER = 88
720
+ ALIGNMENT = 4
721
+ MILLIMETRES_PER_INCH = 25.4
722
+
723
+ # Every way this delegate fails on input rather than on a defect.
724
+ # `Emf::Error` alone, because the delegate is only ever handed a
725
+ # normalised 88-byte header now: it can no longer be sent reading
726
+ # past the end, which is what raised `EOFError` and `IOError` when
727
+ # this took the declared prefix. Fuzzed over 8704 corrupted
728
+ # headers -- every failure was an `Emf::Error`, none reached an
729
+ # IO arm. An IOError raised AFTER the parse still propagates, and
730
+ # a spec pins that.
731
+ PARSE_FAILURES = [::Emf::Error].freeze
732
+
733
+ # The delegate refuses whole streams above this, so the walk does
734
+ # not go further than the parser it replaces would have. It bounds
735
+ # the READ as well: nothing beyond one byte past it is ever
736
+ # materialised, whatever the stat said the file was.
737
+ #
738
+ # It does not bound the HEADER. Applying it there turned a
739
+ # fully framed 209 MB EMF with a perfectly readable 100x50 header
740
+ # into `failed`, breaking the header-first contract this handler
741
+ # exists to keep -- the delegate itself parses that header and
742
+ # refuses only the full stream.
743
+ SCAN_LIMIT = 200 * 1024 * 1024
744
+
745
+ private_constant :SIZE_OFFSET, :MINIMUM_HEADER, :ALIGNMENT, :MILLIMETRES_PER_INCH, :PARSE_FAILURES, :SCAN_LIMIT
746
+
747
+ def inspection(image)
748
+ bytesize = image.bytesize
749
+ oversized = bytesize > SCAN_LIMIT
750
+ prefix = header_prefix(image, oversized)
751
+ declared = declared_size(prefix, oversized ? bytesize : prefix.bytesize)
752
+ return unreadable(image) unless declared
753
+
754
+ header = read_header(prefix)
755
+ return unreadable(image) unless header
756
+
757
+ readable(image, header, emf_plus_bytes(prefix, oversized, declared))
758
+ end
759
+
760
+ # The target guard, the delegate call and the `Models::Conversion`
761
+ # it builds all live in `MetafileConvert` above -- see its `build`
762
+ # for why `output_path` is absent from the result.
763
+ def convert(image, to:) = MetafileConvert.run(image, to: to, convert_targets: self.class.convert_targets)
764
+
765
+ private
766
+
767
+ # Every byte the rest of the inspection is allowed to rest on, and
768
+ # never more than SCAN_LIMIT + 1 of them.
769
+ #
770
+ # Over the stat's limit that is the fixed header alone: the walk is
771
+ # skipped anyway, so nothing past the header is worth reading.
772
+ # Under it, one byte more than the walk can handle. Getting that
773
+ # many back proves the stream is over the limit whatever the stat
774
+ # said; getting fewer proves these bytes ARE the whole stream. So
775
+ # both answers come from the read rather than from a stat taken
776
+ # before it, and memory is bounded either way.
777
+ def header_prefix(image, oversized)
778
+ length = oversized ? MINIMUM_HEADER : SCAN_LIMIT + 1
779
+ image.with_source { |source| bounded_read(source, length) }
780
+ end
781
+
782
+ # Takes an IO or a String, because `with_source` hands over
783
+ # whichever the image is made of: an open file for a path-born
784
+ # image, the bytes already in hand for a content-born one.
785
+ #
786
+ # Bounding a String costs nothing when there is nothing to cut.
787
+ # Measured on 20 MB: a `byteslice` covering the whole string SHARES
788
+ # it -- 40 bytes of object, no copy -- and only a partial one
789
+ # copies, which here is the 88-byte header alone. So the String arm
790
+ # needs no shortcut for the content-born image that is already
791
+ # inside the bound.
792
+ #
793
+ # `IO#read` answers nil at end of stream for a non-zero length --
794
+ # measured on an empty file -- and an empty stream still has to
795
+ # reach `declared_size` as a String.
796
+ def bounded_read(source, length)
797
+ return source.read(length) || "".b if source.respond_to?(:read)
798
+
799
+ source.byteslice(0, length)
800
+ end
801
+
802
+ # Over the scan limit `prefix` is only the fixed header, so there
803
+ # is no walk to ask for. Under it the question is still
804
+ # `EmfPlus.size`'s to refuse: a prefix that came back at
805
+ # SCAN_LIMIT + 1 is a stream over the limit however the stat read,
806
+ # and that call's own bytesize check is what catches it.
807
+ def emf_plus_bytes(prefix, oversized, declared)
808
+ oversized ? nil : EmfPlus.size(prefix, declared, SCAN_LIMIT)
809
+ end
810
+
811
+ # The delegate is handed the fixed 88-byte header with `nSize`
812
+ # normalised to 88, rather than the declared prefix. Any `nSize`
813
+ # above 88 makes emf 0.1.0 read the Win95 optional fields --
814
+ # cbPixelFormat, offPixelFormat and bOpenGL, twelve more bytes --
815
+ # and above 100 szlMicrometers as well, eight more. So it needs a
816
+ # full 100 bytes once `nSize` passes 88, and 108 once it passes
817
+ # 100, which leaves exactly three aligned sizes with no layout it
818
+ # can map onto: 92, 96 and 104 each raise EOFError. Swept 88 to
819
+ # 356, on a stream long enough for every one.
820
+ #
821
+ # Those three are the ones that matter. A standards-valid header
822
+ # carrying a short description lands on them, because MS-EMF places
823
+ # the description directly after the fixed 88 bytes. Measured: a
824
+ # 92-byte header fails on the declared prefix AND on the full
825
+ # stream, so re-slicing cannot rescue it.
826
+ #
827
+ # Every field reported here lives inside those 88 bytes and `nSize` is
828
+ # not one of them, so normalising it changes nothing that is read --
829
+ # verified to agree with the delegate figure-for-figure on the files
830
+ # it parses natively. The real `nSize` is still validated, by
831
+ # `declared_size`, before this runs.
832
+ def read_header(prefix)
833
+ ::Emf.parse(fixed_header(prefix)).header
834
+ rescue *PARSE_FAILURES
835
+ nil
836
+ end
837
+
838
+ # `header_prefix` guarantees BINARY -- an open file in "rb" mode,
839
+ # the image's own binary content, or `"".b` -- which matters here
840
+ # because `String#[]=` indexes by CHARACTER, and a UTF-16LE tag
841
+ # would make this raise on a file the delegate reads correctly.
842
+ # The caller's string is never written to. `byteslice` returns a
843
+ # NEW String either way -- measured, a full-cover one SHARES the
844
+ # buffer at 40 bytes of object while a partial one copies -- and a
845
+ # shared buffer unshares on the first write, so the `[]=` below
846
+ # lands on this copy alone. Measured: mutating a full-cover slice
847
+ # of `"ABCDEFGH"` left the source unchanged.
848
+ def fixed_header(prefix)
849
+ header = prefix.byteslice(0, MINIMUM_HEADER)
850
+ header[SIZE_OFFSET, 4] = [MINIMUM_HEADER].pack("V")
851
+ header
852
+ end
853
+
854
+ # The declared header size, or nil when the stream does not declare a
855
+ # usable one. EMF control-record sizes are multiples of four: without
856
+ # that check an nSize of 109 parses as a header -- the delegate
857
+ # reports ok? true and the full baseline metadata -- while the record
858
+ # stream is framed from an impossible offset. This is record framing,
859
+ # not the nBytes/nRecords/nHandles conformance that item 03 owns.
860
+ #
861
+ # `available` is how many bytes the stream is KNOWN to hold, and the
862
+ # caller supplies it differently on each branch. Under the scan
863
+ # limit it is `prefix.bytesize`, because there the prefix IS the
864
+ # read: either the whole stream or SCAN_LIMIT + 1 bytes of it, and
865
+ # both are lengths the stream really has. Over the limit the prefix
866
+ # is deliberately only the fixed 88 bytes while a legal header may
867
+ # declare 108, so `prefix.bytesize` would reject perfectly good
868
+ # large files; there the stat is the only figure there is. The
869
+ # residual is that a stat reading high over a short file can still
870
+ # admit a declaration the file cannot hold.
871
+ #
872
+ # Measuring against the stat on BOTH branches was the defect:
873
+ # measured, a stat of 200 over the 99-byte `declared_100_have_99`
874
+ # reported `ok` for a header declaring 100, and the walk then began
875
+ # at offset 100 -- past the real end -- and called that a completed
876
+ # scan.
877
+ #
878
+ # The length guard has to be on `prefix`, not on `available`.
879
+ # `byteslice(4, 4)` answers nil for a prefix under four bytes and a
880
+ # short String for one under eight, so an empty or 1-3 byte read
881
+ # raised `NoMethodError` before any nil `unpack1` could be checked
882
+ # for -- measured, by stubbing a stat over the limit while the file
883
+ # underneath is 0, 1 or 3 bytes long. At MINIMUM_HEADER and up the
884
+ # field is always four bytes, so `unpack1` always gives an Integer.
885
+ def declared_size(prefix, available)
886
+ return nil if prefix.bytesize < MINIMUM_HEADER
887
+
888
+ declared = prefix.byteslice(SIZE_OFFSET, 4).unpack1("V")
889
+ return nil if declared < MINIMUM_HEADER
890
+ return nil if declared > available || !(declared % ALIGNMENT).zero?
891
+
892
+ declared
893
+ end
894
+
895
+ def readable(image, header, emf_plus_bytes)
896
+ Models::Inspection.new(
897
+ format: image.format.to_s,
898
+ # `Rect` gives plain Integers, so this is `to_f` on an Integer;
899
+ # `Integer()` first only so a delegate that starts wrapping them
900
+ # fails loudly here rather than serialising as a string.
901
+ width: Integer(header.bounds.width).to_f,
902
+ height: Integer(header.bounds.height).to_f,
903
+ dpi: dpi(header),
904
+ meta: metadata(header, emf_plus_bytes),
905
+ parse_status: "ok"
906
+ )
907
+ end
908
+
909
+ def unreadable(image)
910
+ Models::Inspection.new(
911
+ format: image.format.to_s,
912
+ parse_status: "failed",
913
+ issues: [Models::Issue.new(severity: "error", code: HEADER_ISSUE_CODE,
914
+ message: HEADER_ISSUE_MESSAGE)]
915
+ )
916
+ end
917
+
918
+ def dpi(header)
919
+ pixels = size_of(header.device_pixels)
920
+ millimetres = size_of(header.device_mm)
921
+ return nil unless measurable?(pixels) && measurable?(millimetres)
922
+ return nil unless resolution_uniform?(pixels, millimetres)
923
+
924
+ (pixels["width"] / (millimetres["width"] / MILLIMETRES_PER_INCH)).round(2)
925
+ end
926
+
927
+ # Positive, not merely non-zero, and on BOTH pairs.
928
+ #
929
+ # MS-EMF's SIZEL holds signed 32-bit fields, and the delegate hands
930
+ # them back signed -- `Size#cx` is a `BinData::Int32le`. A negative
931
+ # one is not a measurement, and the arithmetic below divides it
932
+ # happily: measured on `valid.emf`, device_mm -26 x -13 reported
933
+ # -97.69 dpi, and negating the pixels as well reported a confident
934
+ # 97.69 out of two nonsense values cancelling.
935
+ #
936
+ # Zero is here for two different reasons. Zero millimetres divides
937
+ # by zero and the answer is Infinity, which `to_json` refuses
938
+ # outright. Zero pixels divides fine and reported a dpi of 0.0,
939
+ # which reads as a measured resolution rather than a missing one.
940
+ def measurable?(size)
941
+ size["width"].positive? && size["height"].positive?
942
+ end
943
+
944
+ # A cross product, in integers: Float equality would be fragile,
945
+ # and comparing the AXES rather than the ratios would be wrong.
946
+ # Our own baseline is 100x50 pixels over 26x13 millimetres --
947
+ # unequal on both axes, with both ratios at 97.69.
948
+ def resolution_uniform?(pixels, millimetres)
949
+ pixels["width"] * millimetres["height"] == pixels["height"] * millimetres["width"]
950
+ end
951
+
952
+ # Coerced because SOME of these are BinData wrappers, which behave
953
+ # like Integers and serialize as JSON strings. Measured across every
954
+ # fixture: `Size#cx`/`#cy`, `n_records` and `n_handles` are
955
+ # `BinData::Int32le`/`Uint32le`/`Uint16le` and render as `"100"`;
956
+ # `Rect#width`/`#height` are already plain Integers, so `Integer()`
957
+ # is a no-op there.
958
+ #
959
+ # It stays uniform rather than applied selectively: `size_of` serves
960
+ # both `Rect` and `Size`, and one of them needs it. Splitting the
961
+ # coercion by shape would make the two callers disagree about a
962
+ # value the delegate is free to change the class of.
963
+ def metadata(header, emf_plus_bytes)
964
+ {
965
+ "frame" => size_of(header.frame),
966
+ "device_pixels" => size_of(header.device_pixels),
967
+ "device_mm" => size_of(header.device_mm),
968
+ "n_records" => Integer(header.n_records),
969
+ "n_handles" => Integer(header.n_handles)
970
+ }.merge(emf_plus(emf_plus_bytes))
971
+ end
972
+
973
+ def size_of(value)
974
+ width = value.respond_to?(:width) ? value.width : value.cx
975
+ height = value.respond_to?(:height) ? value.height : value.cy
976
+ { "width" => Integer(width), "height" => Integer(height) }
977
+ end
978
+
979
+ # Presence and size only, because JSON has nowhere to put packed
980
+ # binary. It fails LOUDLY on most payloads and quietly on the rest,
981
+ # and the quiet one is worse. Measured on `emf_plus.emf`'s 40
982
+ # bytes: `to_json` raises `JSON::GeneratorError` under BINARY,
983
+ # UTF-8 and UTF-16LE. Measured on `padded_comment.emf`'s four,
984
+ # where every byte happens to be valid text: it serialises as an
985
+ # "X" followed by three escaped NULs, which reads like a value and
986
+ # is really a byte dump -- and under UTF-16LE those same four
987
+ # bytes come out as TWO characters, half of them silently gone.
988
+ # A count avoids all of it.
989
+ #
990
+ # Three outcomes, not two. A stream the walk never examined carries
991
+ # NO EMF+ keys at all -- silence is the only honest report for a
992
+ # question that was not asked, and `false` would be a claim about
993
+ # bytes nobody read.
994
+ #
995
+ # Presence follows the count, so a carrier declaring `cbData` 4 --
996
+ # the marker and nothing behind it -- reports absent rather than
997
+ # present-with-nothing. Deliberate: that comment is not valid EMF+,
998
+ # since the first carrier has to hold an EmfPlusHeader record, and
999
+ # the delegate offers no better answer to copy. Measured on a
1000
+ # marker-only comment: unpadded it returns nil for `emf_plus`,
1001
+ # which is exactly how it says absent, and padded it returns the
1002
+ # padding -- four zero bytes that are not EMF+ at all.
1003
+ def emf_plus(bytes)
1004
+ return {} if bytes.nil?
1005
+ return { "emf_plus_present" => false } if bytes.zero?
1006
+
1007
+ { "emf_plus_present" => true, "emf_plus_bytes" => bytes }
1008
+ end
1009
+ end
1010
+ end
1011
+ end