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.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -1
- data/LICENSE +25 -0
- data/README.adoc +439 -157
- data/TODO.plan/00-overview.md +502 -0
- data/TODO.plan/01-core.md +296 -0
- data/TODO.plan/02-inspect.md +163 -0
- data/TODO.plan/03-conform.md +208 -0
- data/TODO.plan/04-convert.md +216 -0
- data/exe/claricle +1 -1
- data/lib/claricle/batch.rb +240 -0
- data/lib/claricle/cli.rb +514 -40
- data/lib/claricle/conformance_options.rb +49 -0
- data/lib/claricle/conversion_engine.rb +21 -0
- data/lib/claricle/convert.rb +21 -0
- data/lib/claricle/detector.rb +696 -0
- data/lib/claricle/emf_header.rb +33 -0
- data/lib/claricle/errors.rb +73 -0
- data/lib/claricle/fault.rb +69 -0
- data/lib/claricle/handlers/base.rb +169 -0
- data/lib/claricle/handlers/extension_points.rb +124 -0
- data/lib/claricle/handlers/metafile.rb +1011 -0
- data/lib/claricle/handlers/pdf.rb +961 -0
- data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
- data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
- data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
- data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
- data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
- data/lib/claricle/handlers/pdf_arlington.rb +38 -0
- data/lib/claricle/handlers/pdf_profiles.rb +66 -0
- data/lib/claricle/handlers/png.rb +1107 -0
- data/lib/claricle/handlers/postscript.rb +1357 -0
- data/lib/claricle/handlers/svg.rb +787 -0
- data/lib/claricle/image.rb +272 -0
- data/lib/claricle/lossiness/levels.rb +13 -0
- data/lib/claricle/lossiness.rb +864 -0
- data/lib/claricle/models/base.rb +460 -0
- data/lib/claricle/models/batch_item.rb +127 -0
- data/lib/claricle/models/conversion.rb +167 -0
- data/lib/claricle/models/format_capability.rb +40 -0
- data/lib/claricle/models/free_form_hash.rb +77 -0
- data/lib/claricle/models/inspection.rb +188 -0
- data/lib/claricle/models/issue.rb +23 -0
- data/lib/claricle/models/location.rb +51 -0
- data/lib/claricle/models/report.rb +54 -0
- data/lib/claricle/models/uncoerced_integer.rb +38 -0
- data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
- data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
- data/lib/claricle/postscript_fidelity.rb +160 -0
- data/lib/claricle/registry.rb +125 -0
- data/lib/claricle/version.rb +1 -1
- data/lib/claricle/writer.rb +468 -0
- data/lib/claricle.rb +244 -7
- metadata +201 -19
- data/claricle.gemspec +0 -42
- 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
|