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,1357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "../conversion_engine"
|
|
7
|
+
require_relative "../detector"
|
|
8
|
+
require_relative "../models/inspection"
|
|
9
|
+
require_relative "../models/issue"
|
|
10
|
+
require_relative "../models/conversion"
|
|
11
|
+
require_relative "../lossiness"
|
|
12
|
+
|
|
13
|
+
module Claricle
|
|
14
|
+
module Handlers
|
|
15
|
+
# DSC's own number syntax, anchored -- the second vocabulary
|
|
16
|
+
# `Dsc` consults, beside `DscKeywords`. Both are what the DSC
|
|
17
|
+
# spec says a token may look like; neither decides where a header
|
|
18
|
+
# starts or stops.
|
|
19
|
+
module DscNumbers
|
|
20
|
+
# `Postscript.parse` is far looser than this: its
|
|
21
|
+
# box grammar accepts `e`, `.`, `+`, `1e` and `1..2`, then `to_f`
|
|
22
|
+
# turns them into finite numbers -- so `%%HiResBoundingBox: e e e e`
|
|
23
|
+
# arrives as four zeroes and beats a valid coarse box, reporting a
|
|
24
|
+
# 0x0 page as a fact. Nothing in the delegate's output distinguishes
|
|
25
|
+
# that from a genuine `0 0 0 0`.
|
|
26
|
+
#
|
|
27
|
+
# This is NOT redundant with Ruby's `Float()`, which was my own
|
|
28
|
+
# earlier claim. `Float("0x10")` is 16.0, and with
|
|
29
|
+
# `%%BoundingBox: 0x10 0 116 50` followed by a second declaration of
|
|
30
|
+
# `16 0 116 50`, the delegate reports the second and `Float` agrees
|
|
31
|
+
# with it -- so a looser grammar publishes a box built from a hex
|
|
32
|
+
# literal the file never legally declared.
|
|
33
|
+
#
|
|
34
|
+
# **The two box comments have different grammars.**
|
|
35
|
+
# `%%BoundingBox` takes four INTEGERS; only `%%HiResBoundingBox`
|
|
36
|
+
# takes reals. Applying real syntax to both published
|
|
37
|
+
# `0.5 0 100.5 50` and `1e2 0 2e2 50` as coarse boxes.
|
|
38
|
+
REAL = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?/
|
|
39
|
+
INTEGER = /[+-]?\d+/
|
|
40
|
+
|
|
41
|
+
def self.operands(number)
|
|
42
|
+
/\A[ \t]*(#{number})[ \t]+(#{number})[ \t]+(#{number})[ \t]+(#{number})[ \t]*\z/
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
GRAMMARS = { "BoundingBox" => operands(INTEGER),
|
|
46
|
+
"HiResBoundingBox" => operands(REAL) }.freeze
|
|
47
|
+
|
|
48
|
+
# `%%LanguageLevel` takes an UNSIGNED integer. Ruby's own
|
|
49
|
+
# conversion is wider -- `Integer("2_0", 10)` is 20 and
|
|
50
|
+
# `Integer("+2", 10)` is 2 -- so a file declaring `2_0` and then
|
|
51
|
+
# `20` had the SECOND value published under the first one's
|
|
52
|
+
# authority. That is the wrong `hex_first_box` records for boxes,
|
|
53
|
+
# reached through a different conversion.
|
|
54
|
+
UNSIGNED = /\A\d+\z/
|
|
55
|
+
|
|
56
|
+
# The declared text as an unsigned DSC integer, or nil.
|
|
57
|
+
def self.unsigned(text)
|
|
58
|
+
return nil unless UNSIGNED.match?(text)
|
|
59
|
+
|
|
60
|
+
Integer(text, 10)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The DSC structuring comments that belong to some other part of
|
|
65
|
+
# the document, enumerated with the punctuation each one requires.
|
|
66
|
+
#
|
|
67
|
+
# Beside `Dsc` rather than inside it: `Dsc` is about FRAMING -- where
|
|
68
|
+
# a header starts and stops, and what its lines declare -- while this
|
|
69
|
+
# is the vocabulary that framing consults. The list grows when DSC
|
|
70
|
+
# does; the framing rules do not.
|
|
71
|
+
module DscKeywords
|
|
72
|
+
# None of these is a header comment, so each one ends the header.
|
|
73
|
+
#
|
|
74
|
+
# **A keyword's punctuation is part of the keyword.** DSC defines
|
|
75
|
+
# each structuring comment with its arity, and the colon belongs to
|
|
76
|
+
# the ones that take operands. A generic "colon, whitespace or end
|
|
77
|
+
# of line" delimiter behind a `Begin`/`End`/`Include` STEM read
|
|
78
|
+
# four spellings DSC does not define as though they were the
|
|
79
|
+
# keywords they resemble -- `%%BeginFuture:`, `%%Trailer: fake`,
|
|
80
|
+
# a bare `%%Page` and `%%PageTrailer: fake` each ended the header
|
|
81
|
+
# and discarded a valid box and title behind it. A comment DSC does
|
|
82
|
+
# not define is one a reader must IGNORE, which is what leaving it
|
|
83
|
+
# unmatched here does.
|
|
84
|
+
#
|
|
85
|
+
# So the names are enumerated, split by arity, rather than matched
|
|
86
|
+
# by stem. Nothing standard is lost: every structural comment the
|
|
87
|
+
# old stems reached is named below.
|
|
88
|
+
#
|
|
89
|
+
# ARGUMENT keywords take operands, so the colon is required.
|
|
90
|
+
# `%%Page:` opens the body -- and `%%Pages:` is a genuine header
|
|
91
|
+
# comment, which is why `Page` may not simply prefix-match.
|
|
92
|
+
# `%%BeginDocument:` is followed by a CHILD's header,
|
|
93
|
+
# `%%BeginData:` declares the lines after it opaque, so a
|
|
94
|
+
# `%%BoundingBox` inside one is bytes that happen to look like a
|
|
95
|
+
# comment. Every `%%Include<X>:` form names a resource and cannot
|
|
96
|
+
# appear in a header at all.
|
|
97
|
+
#
|
|
98
|
+
# BARE keywords take none, so a colon after one makes it a
|
|
99
|
+
# different, unknown comment. `%%Trailer`, `%%PageTrailer` and
|
|
100
|
+
# `%%EOF` open the trailer, a page's own trailer, and end the
|
|
101
|
+
# document. `%%EndProlog` and `%%EndSetup` are the closers
|
|
102
|
+
# reachable while the header is still being read, since DSC
|
|
103
|
+
# allows either alone for an empty section.
|
|
104
|
+
#
|
|
105
|
+
# `%%EndComments` is deliberately absent from both lists. Bare, it
|
|
106
|
+
# is the sentinel and `HeaderScanner#classify` tests for it first;
|
|
107
|
+
# with a colon it is neither that sentinel nor any other DSC comment,
|
|
108
|
+
# and matching it as a section closer ended the header on it.
|
|
109
|
+
ARGUMENT_KEYWORDS = %w[
|
|
110
|
+
Page
|
|
111
|
+
BeginDocument BeginBinary BeginData BeginFeature BeginFile
|
|
112
|
+
BeginFont BeginProcSet BeginResource BeginPreview
|
|
113
|
+
BeginExitServer BeginEmulation BeginPaperSize BeginObject
|
|
114
|
+
BeginCustomColor BeginProcessColor
|
|
115
|
+
IncludeDocument IncludeFeature IncludeFile IncludeFont
|
|
116
|
+
IncludeProcSet IncludeResource
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
119
|
+
BARE_KEYWORDS = %w[
|
|
120
|
+
BeginProlog EndProlog BeginSetup EndSetup
|
|
121
|
+
BeginPageSetup EndPageSetup BeginDefaults EndDefaults
|
|
122
|
+
EndDocument EndBinary EndData EndFeature EndFile EndFont
|
|
123
|
+
EndProcSet EndResource EndPreview EndExitServer EndEmulation
|
|
124
|
+
EndPaperSize EndObject EndCustomColor EndProcessColor
|
|
125
|
+
PageTrailer Trailer EOF
|
|
126
|
+
].freeze
|
|
127
|
+
|
|
128
|
+
# A bare keyword ends at whitespace or at the end of its line, and
|
|
129
|
+
# at nothing else -- which is why `%%Page-Bogus` and
|
|
130
|
+
# `%%EndComments-Bogus` stay ordinary comments. `\b` got this
|
|
131
|
+
# wrong: it treats the `-` as a boundary and dropped the whole
|
|
132
|
+
# header behind such a line.
|
|
133
|
+
BARE_END = /(?=[\t ]|\r|\n|\z)/
|
|
134
|
+
|
|
135
|
+
# Query constructs are the forward-extensible `%%?Begin...` and
|
|
136
|
+
# `%%?End...` families. The `%%?` prefix alone is not structural:
|
|
137
|
+
# an unknown comment such as `%%?FutureHeader` is ignored like any
|
|
138
|
+
# other unrecognised DSC comment.
|
|
139
|
+
QUERY_PREFIXES = %w[?Begin ?End].freeze
|
|
140
|
+
|
|
141
|
+
ARGUMENT_PART = /\A%%(?:#{Regexp.union(ARGUMENT_KEYWORDS)}):/
|
|
142
|
+
BARE_PART = /\A%%(?:#{Regexp.union(BARE_KEYWORDS)})#{BARE_END}/
|
|
143
|
+
QUERY_PART = /\A%%(?:#{Regexp.union(QUERY_PREFIXES)})/
|
|
144
|
+
|
|
145
|
+
OTHER_PART = Regexp.union(
|
|
146
|
+
ARGUMENT_PART,
|
|
147
|
+
BARE_PART,
|
|
148
|
+
QUERY_PART
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# A partial line has no trustworthy end-of-string delimiter. These
|
|
152
|
+
# forms are decisive before its terminator arrives: argument comments
|
|
153
|
+
# once their colon is present, bare comments once real whitespace is
|
|
154
|
+
# present, and either query-family prefix.
|
|
155
|
+
PARTIAL_OTHER_PART = Regexp.union(
|
|
156
|
+
ARGUMENT_PART,
|
|
157
|
+
/\A%%(?:#{Regexp.union(BARE_KEYWORDS)})[\t \r\n]/,
|
|
158
|
+
QUERY_PART
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Enough bytes to decide every structural prefix above. Deriving it
|
|
162
|
+
# from the vocabulary keeps a future longer keyword from silently
|
|
163
|
+
# making the partial-line scan stop before its colon or whitespace.
|
|
164
|
+
PARTIAL_PREFIX_BYTES = (
|
|
165
|
+
ARGUMENT_KEYWORDS.map { |keyword| "%%#{keyword}:" } +
|
|
166
|
+
BARE_KEYWORDS.map { |keyword| "%%#{keyword} " } +
|
|
167
|
+
QUERY_PREFIXES.map { |prefix| "%%#{prefix}" }
|
|
168
|
+
).map(&:bytesize).max
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cursor state for the DSC header reader. Keeping it outside `Dsc`
|
|
172
|
+
# separates the mutable stream walk from the framing predicates and
|
|
173
|
+
# declaration lookups that remain module functions there.
|
|
174
|
+
class HeaderScanner
|
|
175
|
+
def initialize(limit:)
|
|
176
|
+
@bytes = +"".b
|
|
177
|
+
@line_start = 0
|
|
178
|
+
@search_from = 0
|
|
179
|
+
@header_end = nil
|
|
180
|
+
@limit = limit
|
|
181
|
+
@truncated = false
|
|
182
|
+
@cr_grace_used = false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def append(chunk)
|
|
186
|
+
@bytes << chunk
|
|
187
|
+
scan(final: false)
|
|
188
|
+
self
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def finish
|
|
192
|
+
scan(final: true)
|
|
193
|
+
@header_end = @line_start if @header_end.nil?
|
|
194
|
+
self
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def done?
|
|
198
|
+
!@header_end.nil?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def header
|
|
202
|
+
@bytes.byteslice(0, @header_end) if done?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# True once the byte ceiling, rather than %%EndComments, a
|
|
206
|
+
# disqualifying line, or genuine end of input, is what stopped the
|
|
207
|
+
# scan. Distinct from every other way `done?` becomes true: those
|
|
208
|
+
# all mean the WHOLE header was seen, so `Dsc.settled?`/`disputed?`/
|
|
209
|
+
# `continued?` are sound over it. A truncated read has seen only a
|
|
210
|
+
# PREFIX -- a `%%+` continuation or a conflicting repeat of the same
|
|
211
|
+
# comment could sit just past the cut, and those checks have no way
|
|
212
|
+
# to know that. Callers must not treat a truncated header as safe to
|
|
213
|
+
# extract fields from.
|
|
214
|
+
def truncated?
|
|
215
|
+
@truncated
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def scan(final:)
|
|
221
|
+
scan_complete_lines(final)
|
|
222
|
+
return if done?
|
|
223
|
+
|
|
224
|
+
final ? scan_final_line : scan_partial_line
|
|
225
|
+
return if done?
|
|
226
|
+
|
|
227
|
+
# Only after giving the pending partial line a chance to end the
|
|
228
|
+
# header decisively on its own -- `scan_partial_line` recognises a
|
|
229
|
+
# disqualifying prefix (a body line that plainly is not a comment)
|
|
230
|
+
# without waiting for its terminator, and that must win over the
|
|
231
|
+
# ceiling. Checking the limit first truncated a header that had
|
|
232
|
+
# already, correctly, ended: a well-formed comment block followed
|
|
233
|
+
# immediately by a newline-free body line (no CR/LF yet) crossed
|
|
234
|
+
# the byte ceiling mid-body-line before `scan_partial_line` ever
|
|
235
|
+
# ran, and was wrongly reported as truncated.
|
|
236
|
+
#
|
|
237
|
+
# And never while a CR is pending, for exactly ONE probe -- but no
|
|
238
|
+
# more than that. `pending_cr?` below already buffers a trailing CR
|
|
239
|
+
# rather than classifying it, specifically so a CRLF split across
|
|
240
|
+
# two reads is not misread as a CR-terminated line followed by a
|
|
241
|
+
# bogus empty LF-only line. Truncating in that same window hits the
|
|
242
|
+
# identical ambiguity from the other side: a `%%EndComments\r`
|
|
243
|
+
# landing exactly at the ceiling is decisively the sentinel whether
|
|
244
|
+
# or not a `\n` follows, but was truncated before ever being
|
|
245
|
+
# classified, because it was one byte short of what
|
|
246
|
+
# `scan_complete_lines` needs to commit to a line ending.
|
|
247
|
+
#
|
|
248
|
+
# The grace is spent, not renewable: a source built so every probe
|
|
249
|
+
# boundary lands on a CR -- an ordinary comment line sized to
|
|
250
|
+
# divide the probe length evenly -- kept re-arming this exemption
|
|
251
|
+
# on every single append, so a source many times the limit read in
|
|
252
|
+
# full with `truncated?` false. `@cr_grace_used` caps the cost of
|
|
253
|
+
# resolving the ambiguity to exactly one extra probe, ever, the
|
|
254
|
+
# same tolerance already accepted elsewhere in this design; a
|
|
255
|
+
# second pending CR after the grace is spent truncates like any
|
|
256
|
+
# other still-ambiguous state.
|
|
257
|
+
return unless !final && over_limit?
|
|
258
|
+
return grant_cr_grace! if cr_grace_available?
|
|
259
|
+
|
|
260
|
+
truncate!
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def cr_grace_available?
|
|
264
|
+
pending_cr_at_end? && !@cr_grace_used
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def grant_cr_grace!
|
|
268
|
+
@cr_grace_used = true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Every byte scanned counts toward the bound, not just bytes
|
|
272
|
+
# classified into complete lines -- a single line that never
|
|
273
|
+
# terminates (no CR, no LF) must be capped too, and it never
|
|
274
|
+
# advances @line_start.
|
|
275
|
+
def over_limit?
|
|
276
|
+
@bytes.bytesize >= @limit
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# True exactly when `scan_complete_lines` deferred classifying a
|
|
280
|
+
# trailing CR via `wait_on_cr` -- the one state where one more byte,
|
|
281
|
+
# whatever it is, is guaranteed to make the pending line decisive.
|
|
282
|
+
# Bounded the same way the rest of this scan is: at most one more
|
|
283
|
+
# probe is read before the deferral resolves either way.
|
|
284
|
+
def pending_cr_at_end?
|
|
285
|
+
@search_from < @bytes.bytesize && @bytes.getbyte(@search_from) == 13
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Stops the scan at the last complete line read, the same cut point
|
|
289
|
+
# #finish falls back to at genuine end of input -- but this is NOT
|
|
290
|
+
# the same situation. Genuine EOF means there is nothing more to
|
|
291
|
+
# read, so what was read IS the whole header. Hitting the byte
|
|
292
|
+
# ceiling means there might be more: a `%%+` continuation or a
|
|
293
|
+
# conflicting repeat of a comment could be one byte past the cut,
|
|
294
|
+
# unseen. `#truncated?` flags this so callers can refuse to publish
|
|
295
|
+
# fields whose validity depends on having seen the whole header.
|
|
296
|
+
def truncate!
|
|
297
|
+
@header_end = @line_start
|
|
298
|
+
@truncated = true
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def scan_complete_lines(final)
|
|
302
|
+
while (ending = @bytes.index(Dsc::LINE_BREAK, @search_from))
|
|
303
|
+
return wait_on_cr(ending) if pending_cr?(ending, final)
|
|
304
|
+
|
|
305
|
+
classify(line_end(ending))
|
|
306
|
+
return if done?
|
|
307
|
+
end
|
|
308
|
+
@search_from = @bytes.bytesize
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def pending_cr?(ending, final)
|
|
312
|
+
!final && @bytes.getbyte(ending) == 13 && ending + 1 == @bytes.bytesize
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def wait_on_cr(ending)
|
|
316
|
+
@search_from = ending
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def line_end(ending)
|
|
320
|
+
crlf = @bytes.getbyte(ending) == 13 && @bytes.getbyte(ending + 1) == 10
|
|
321
|
+
ending + (crlf ? 2 : 1)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def classify(line_end)
|
|
325
|
+
line = @bytes.byteslice(@line_start, line_end - @line_start)
|
|
326
|
+
if Dsc::HEADER_END.match?(line)
|
|
327
|
+
@header_end = line_end
|
|
328
|
+
elsif @line_start.positive? && !Dsc.header_line?(line)
|
|
329
|
+
@header_end = @line_start
|
|
330
|
+
else
|
|
331
|
+
@line_start = line_end
|
|
332
|
+
@search_from = line_end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def scan_final_line
|
|
337
|
+
classify(@bytes.bytesize) if @line_start < @bytes.bytesize
|
|
338
|
+
@header_end = @line_start if @header_end.nil?
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def scan_partial_line
|
|
342
|
+
return unless @line_start.positive? && @line_start < @bytes.bytesize
|
|
343
|
+
|
|
344
|
+
prefix = @bytes.byteslice(@line_start, DscKeywords::PARTIAL_PREFIX_BYTES)
|
|
345
|
+
@header_end = @line_start if Dsc.partial_boundary?(prefix)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Finds the PostScript section of a source and feeds only that section to
|
|
350
|
+
# the stateful scanner, one bounded probe at a time. Binary-preview EPS
|
|
351
|
+
# files are reframed to the offset and length declared by their wrapper.
|
|
352
|
+
module DscHeader
|
|
353
|
+
# `[header, truncated?]`, or bare `nil` when `raw` does not carry a
|
|
354
|
+
# PostScript section at all. `truncated?` is only meaningful when
|
|
355
|
+
# `header` is present, and callers must refuse to extract fields
|
|
356
|
+
# from a truncated header -- see `HeaderScanner#truncated?`.
|
|
357
|
+
def self.signed_header(raw, signature, probe_bytes, scan_limit)
|
|
358
|
+
if raw.respond_to?(:read)
|
|
359
|
+
io_header(raw, signature, probe_bytes, scan_limit)
|
|
360
|
+
else
|
|
361
|
+
string_header(raw, signature, probe_bytes, scan_limit)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.io_header(io, signature, probe_bytes, scan_limit)
|
|
366
|
+
first = io.read(probe_bytes) || "".b
|
|
367
|
+
return scan_io(io, first, probe_bytes, scan_limit) if signed?(first, signature)
|
|
368
|
+
|
|
369
|
+
range = EpsBinary.postscript_range(first, io.size)
|
|
370
|
+
return nil unless range
|
|
371
|
+
|
|
372
|
+
offset, length = range
|
|
373
|
+
io.seek(offset)
|
|
374
|
+
first = io.read([probe_bytes, length].min) || "".b
|
|
375
|
+
return nil unless signed?(first, signature)
|
|
376
|
+
|
|
377
|
+
scan_io(io, first, probe_bytes, scan_limit, length - first.bytesize)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def self.scan_io(io, first, probe_bytes, scan_limit, remaining = nil)
|
|
381
|
+
scanner = HeaderScanner.new(limit: scan_limit).append(first)
|
|
382
|
+
until scanner.done?
|
|
383
|
+
read = read_io_chunk(io, probe_bytes, remaining)
|
|
384
|
+
break unless read
|
|
385
|
+
|
|
386
|
+
chunk, remaining = read
|
|
387
|
+
scanner.append(chunk)
|
|
388
|
+
end
|
|
389
|
+
scanner.finish unless scanner.done?
|
|
390
|
+
[scanner.header, scanner.truncated?]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def self.read_io_chunk(io, probe_bytes, remaining)
|
|
394
|
+
return nil if remaining&.zero?
|
|
395
|
+
|
|
396
|
+
amount = [probe_bytes, remaining || probe_bytes].min
|
|
397
|
+
bytes = io.read(amount)
|
|
398
|
+
return nil if bytes.nil? || bytes.empty?
|
|
399
|
+
|
|
400
|
+
[bytes, remaining && (remaining - bytes.bytesize)]
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def self.string_header(raw, signature, probe_bytes, scan_limit)
|
|
404
|
+
return scan_string(raw, 0, raw.bytesize, probe_bytes, scan_limit) if signed?(raw, signature)
|
|
405
|
+
|
|
406
|
+
range = EpsBinary.postscript_range(raw, raw.bytesize)
|
|
407
|
+
return nil unless range
|
|
408
|
+
|
|
409
|
+
offset, length = range
|
|
410
|
+
return nil if length < signature.bytesize
|
|
411
|
+
return nil unless signed?(raw.byteslice(offset, signature.bytesize), signature)
|
|
412
|
+
|
|
413
|
+
scan_string(raw, offset, length, probe_bytes, scan_limit)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def self.scan_string(raw, offset, length, probe_bytes, scan_limit)
|
|
417
|
+
scanner = HeaderScanner.new(limit: scan_limit)
|
|
418
|
+
limit = offset + length
|
|
419
|
+
until scanner.done? || offset >= limit
|
|
420
|
+
amount = [probe_bytes, limit - offset].min
|
|
421
|
+
scanner.append(raw.byteslice(offset, amount))
|
|
422
|
+
offset += amount
|
|
423
|
+
end
|
|
424
|
+
scanner.finish unless scanner.done?
|
|
425
|
+
[scanner.header, scanner.truncated?]
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def self.signed?(bytes, signature)
|
|
429
|
+
bytes.byteslice(0, signature.bytesize) == signature
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# DSC framing: where the header ends, and what a box comment declares.
|
|
434
|
+
#
|
|
435
|
+
# Extracted because it is the part that reads the file's own bytes
|
|
436
|
+
# rather than the delegate's answers, and because `Handlers::Metafile`
|
|
437
|
+
# sets the precedent with `EmfPlus` -- the byte-level reader lives
|
|
438
|
+
# beside the handler, not inside it.
|
|
439
|
+
module Dsc
|
|
440
|
+
# One line, ending in CR, LF, CRLF or the end of input -- DSC's
|
|
441
|
+
# three line boundaries, not Ruby's one.
|
|
442
|
+
LINE = /[^\r\n]*(?:\r\n|\r|\n|\z)/
|
|
443
|
+
# The sentinel is the WHOLE line. `%%EndComments-Bogus` is an
|
|
444
|
+
# ordinary comment, and treating it as the terminator silently
|
|
445
|
+
# suppressed every header comment after it.
|
|
446
|
+
HEADER_END = /\A%%EndComments[ \t]*\r?\n?\z/
|
|
447
|
+
|
|
448
|
+
# `%X` continues the header when `X` is PRINTABLE, which includes
|
|
449
|
+
# vendor comments like `%GBDNodeName:` from Adobe's own DSC
|
|
450
|
+
# examples. `[!-~]`, not `[^ \t\r\n]`: the latter also accepts NUL,
|
|
451
|
+
# ESC, VT, FF, DEL and arbitrary high bytes, none of which DSC
|
|
452
|
+
# admits as a comment character. The implicit TERMINATORS are the
|
|
453
|
+
# whitespace forms -- `% `, `%\t` and a lone `%`.
|
|
454
|
+
#
|
|
455
|
+
# This predicate has been wrong three times, in three directions:
|
|
456
|
+
# every `%` line continuing (a body box leaked in), only `%%` and
|
|
457
|
+
# `%!` continuing (a valid header dropped at its first vendor
|
|
458
|
+
# comment), and any non-whitespace byte continuing (control bytes
|
|
459
|
+
# treated as comments).
|
|
460
|
+
HEADER_LINE = /\A%[!-~]/
|
|
461
|
+
|
|
462
|
+
# DSC's own padding, and its own line terminators. Nothing wider:
|
|
463
|
+
# `String#strip` also removes NUL, VT and FF, none of which DSC
|
|
464
|
+
# admits as whitespace, so `%%BoundingBox: 0 0 100 50\0` reached
|
|
465
|
+
# the anchored grammar with the NUL already gone and published a
|
|
466
|
+
# box from a line the file never legally declared.
|
|
467
|
+
PADDING = /\A[ \t]+/
|
|
468
|
+
TERMINATOR = /[ \t]*(?:\r\n|\r|\n)?\z/
|
|
469
|
+
|
|
470
|
+
# A `%%+` line continues the comment above it. 0.2.0 does not
|
|
471
|
+
# reconstruct the logical value -- it puts the continuation in
|
|
472
|
+
# `custom` and leaves the first fragment in the field -- so
|
|
473
|
+
# publishing that fragment reports a truncated value as a complete
|
|
474
|
+
# one. The field is omitted instead.
|
|
475
|
+
CONTINUATION = /\A%%\+/
|
|
476
|
+
LINE_BREAK = /[\r\n]/
|
|
477
|
+
|
|
478
|
+
def self.header_line?(line)
|
|
479
|
+
HEADER_LINE.match?(line) && !DscKeywords::OTHER_PART.match?(line)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# A partial body's first line may never terminate, so wait only while
|
|
483
|
+
# its prefix can still become a header comment or an exact boundary.
|
|
484
|
+
def self.partial_boundary?(prefix)
|
|
485
|
+
return true unless prefix.start_with?("%")
|
|
486
|
+
return false if prefix.bytesize == 1
|
|
487
|
+
return true unless HEADER_LINE.match?(prefix)
|
|
488
|
+
|
|
489
|
+
DscKeywords::PARTIAL_OTHER_PART.match?(prefix)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# The header's own lines, on DSC's three boundaries. Every lookup
|
|
493
|
+
# below goes through it, so no two of them can disagree about
|
|
494
|
+
# which line is the first declaration.
|
|
495
|
+
def self.lines(source)
|
|
496
|
+
source.scan(LINE)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# What marks a line as declaring `name`. DSC makes the colon part
|
|
500
|
+
# of the keyword, so it is part of the prefix. Stated once:
|
|
501
|
+
# `continued?`, `disputed?` and `first_text` must not disagree
|
|
502
|
+
# about which line is the first declaration, and they did while
|
|
503
|
+
# two of them carried their own copy of this test.
|
|
504
|
+
def self.declares?(line, name)
|
|
505
|
+
line.start_with?("%%#{name}:")
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# The trimmed text of EVERY `%%<name>:` line, in order.
|
|
509
|
+
def self.declarations(header, name)
|
|
510
|
+
header.filter_map do |line|
|
|
511
|
+
trim(line.delete_prefix("%%#{name}:")) if declares?(line, name)
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Whether the header declares `name` more than once, disagreeing
|
|
516
|
+
# with itself.
|
|
517
|
+
#
|
|
518
|
+
# DSC gives precedence to the first occurrence, so a later one is
|
|
519
|
+
# ignored -- but a file that declares the same comment twice with
|
|
520
|
+
# different text has not said one thing, and this handler declines
|
|
521
|
+
# to pick between them.
|
|
522
|
+
#
|
|
523
|
+
# This used to be inferred rather than checked: 0.2.0 is last-wins,
|
|
524
|
+
# so a repeated comment left the delegate holding a value that
|
|
525
|
+
# disagreed with the first declaration, and the field fell to the
|
|
526
|
+
# equality check in `box` and `authoritative`. That inference was
|
|
527
|
+
# wrong in two directions. It only held for the repeats 0.2.0
|
|
528
|
+
# RECOGNISES -- a later `%%BoundingBox: (atend)` is not one, so the
|
|
529
|
+
# delegate kept the earlier value and agreed with itself -- and
|
|
530
|
+
# making it cost the delegate every repeated line, which is the
|
|
531
|
+
# quadratic `filter` exists to stop paying.
|
|
532
|
+
def self.disputed?(header, name)
|
|
533
|
+
declarations(header, name).uniq.size > 1
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Whether the header states `name` once and states it whole, which
|
|
537
|
+
# is what every published field needs of it: not continued by a
|
|
538
|
+
# `%%+`, since 0.2.0 leaves only the first fragment behind, and not
|
|
539
|
+
# repeated with text that disagrees with itself.
|
|
540
|
+
#
|
|
541
|
+
# One predicate rather than the two guards repeated at both call
|
|
542
|
+
# sites: a box and a scalar field ask the same question of the
|
|
543
|
+
# header, and asking it in two places invited them to drift.
|
|
544
|
+
# Scanned once and shared: `continued?` and `disputed?` ask two
|
|
545
|
+
# questions of the same header, and splitting the scan doubled it
|
|
546
|
+
# for no answer either one needed alone.
|
|
547
|
+
def self.settled?(source, name)
|
|
548
|
+
header = lines(source)
|
|
549
|
+
!continued?(header, name) && !disputed?(header, name)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Whether the first `%%<name>:` line is continued by a `%%+`.
|
|
553
|
+
def self.continued?(header, name)
|
|
554
|
+
index = header.index { |line| declares?(line, name) }
|
|
555
|
+
return false unless index
|
|
556
|
+
|
|
557
|
+
CONTINUATION.match?(header[index + 1].to_s)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# The text of the FIRST `%%<name>:` line, or nil. DSC gives
|
|
561
|
+
# precedence to the first occurrence of a header comment; 0.2.0
|
|
562
|
+
# keeps the last, so every published field is checked against this,
|
|
563
|
+
# not just the boxes.
|
|
564
|
+
#
|
|
565
|
+
# Found by line, not by Ruby's `^`, which starts a line only after
|
|
566
|
+
# LF. In a file mixing CR and LF, `^` walked straight past a
|
|
567
|
+
# CR-delimited first declaration onto the next one -- where it
|
|
568
|
+
# agreed with the delegate's last-wins value and published a box
|
|
569
|
+
# that was not the file's first.
|
|
570
|
+
def self.first_text(source, name)
|
|
571
|
+
declarations(lines(source), name).first
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def self.trim(text)
|
|
575
|
+
text.sub(PADDING, "").sub(TERMINATOR, "")
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# `source`, reduced to at most one line per thing the delegate is
|
|
579
|
+
# asked about: the FIRST `%!` line, the FIRST `%%<name>:` line for
|
|
580
|
+
# each name in `names`, and the `%%EndComments` sentinel.
|
|
581
|
+
#
|
|
582
|
+
# **The count is the bound, not the name.** 0.2.0 merges every
|
|
583
|
+
# comment it does not recognise into a growing `custom` hash and
|
|
584
|
+
# duplicates that hash on each merge, so its cost is quadratic in
|
|
585
|
+
# the number of comments it fails to read. Keeping every occurrence
|
|
586
|
+
# of a whitelisted name left that cost fully reachable -- the name
|
|
587
|
+
# was bounded, the count was not, and whether a line landed in
|
|
588
|
+
# `custom` turned on its VALUE. Measured at 16,000 comments:
|
|
589
|
+
# `%%BoundingBox: (atend)` 1.823s (DSC-valid, deferring the box to
|
|
590
|
+
# the trailer, which 0.2.0 does not recognise), a junk
|
|
591
|
+
# `%%HiResBoundingBox` 1.775s, a junk `%%LanguageLevel` 1.117s, and
|
|
592
|
+
# repeated `%!` lines 340 KB of delegate input.
|
|
593
|
+
#
|
|
594
|
+
# Keeping only the first occurrence closes all of them at once, and
|
|
595
|
+
# closes the shapes nobody has thought of yet, because this filter
|
|
596
|
+
# never looks at a value -- only at how many times a name has been
|
|
597
|
+
# seen. What reaches the delegate is at most `names.size + 2`
|
|
598
|
+
# lines, whatever the header contains.
|
|
599
|
+
#
|
|
600
|
+
# Dropping the later occurrences is what DSC says to do anyway: it
|
|
601
|
+
# gives precedence to the FIRST occurrence of a header comment, and
|
|
602
|
+
# `first_text` and `declaration` already read only that one. The
|
|
603
|
+
# repeated declarations still have a say in whether a field is
|
|
604
|
+
# published, but `disputed?` asks them here, from the file's own
|
|
605
|
+
# bytes, instead of making the delegate parse them to find out.
|
|
606
|
+
#
|
|
607
|
+
# `%%+` is dropped unconditionally, never kept even right behind a
|
|
608
|
+
# comment that IS kept -- measured, 0.2.0 does not attach a
|
|
609
|
+
# continuation to whichever comment preceded it at all: every
|
|
610
|
+
# `%%+` line lands in `custom` regardless of context, the same
|
|
611
|
+
# bucket an unrecognised comment does. A field this handler reads
|
|
612
|
+
# keeps only its first fragment either way (0.2.0 never
|
|
613
|
+
# reconstructs the continued value), so a kept field's own
|
|
614
|
+
# continuations are as unread as an unrecognised comment's.
|
|
615
|
+
def self.filter(source, names)
|
|
616
|
+
keyword = /\A%%(#{Regexp.union(names)}):/
|
|
617
|
+
seen = {}
|
|
618
|
+
lines(source).each_with_object(+"") do |line, kept|
|
|
619
|
+
key = kept_key(line, keyword)
|
|
620
|
+
next if key.nil? || seen.key?(key)
|
|
621
|
+
|
|
622
|
+
seen[key] = true
|
|
623
|
+
kept << delegate_line(line)
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# postscript 0.2.0 recognises LF and CRLF but not DSC's third line
|
|
628
|
+
# ending, a bare CR. Normalise only that terminator in the small,
|
|
629
|
+
# filtered copy sent to the delegate; the original bytes still
|
|
630
|
+
# govern declaration order and validation everywhere else.
|
|
631
|
+
def self.delegate_line(line)
|
|
632
|
+
return line unless line.end_with?("\r")
|
|
633
|
+
|
|
634
|
+
"#{line.delete_suffix("\r")}\n"
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# What `line` would occupy in the filtered header, or nil for a
|
|
638
|
+
# line the delegate is never asked about. Two lines sharing a key
|
|
639
|
+
# are the same declaration repeated, and only the first is kept.
|
|
640
|
+
def self.kept_key(line, keyword)
|
|
641
|
+
return nil if CONTINUATION.match?(line)
|
|
642
|
+
return :version if line.start_with?("%!")
|
|
643
|
+
return :sentinel if HEADER_END.match?(line)
|
|
644
|
+
|
|
645
|
+
matched = keyword.match(line)
|
|
646
|
+
matched && matched[1]
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# The four operands of that line, each well-formed in the grammar
|
|
650
|
+
# its own comment takes -- integers for `%%BoundingBox`, reals for
|
|
651
|
+
# `%%HiResBoundingBox`.
|
|
652
|
+
def self.declaration(source, name)
|
|
653
|
+
text = first_text(source, name)
|
|
654
|
+
return nil unless text
|
|
655
|
+
|
|
656
|
+
parsed_declaration(text, name)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def self.parsed_declaration(text, name)
|
|
660
|
+
operands = DscNumbers::GRAMMARS.fetch(name).match(text)
|
|
661
|
+
return nil unless operands
|
|
662
|
+
|
|
663
|
+
operands.captures.map { |value| Float(value, exception: false) }
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# A bounded-memory reader for DSC's CR, LF and CRLF-delimited lines.
|
|
668
|
+
# It also exposes exact byte/line skips for DSC data sections, whose
|
|
669
|
+
# payload may contain arbitrary bytes that resemble structuring comments.
|
|
670
|
+
class DscStream
|
|
671
|
+
# DSC comments top out at 255 characters. Extra room tolerates a
|
|
672
|
+
# non-DSC body line without letting one unterminated line grow with
|
|
673
|
+
# the file.
|
|
674
|
+
MAX_LINE_BYTES = 1024
|
|
675
|
+
|
|
676
|
+
def initialize(source, offset:, length:, chunk_bytes:)
|
|
677
|
+
@source = source
|
|
678
|
+
@cursor = offset
|
|
679
|
+
@remaining = length
|
|
680
|
+
@chunk_bytes = chunk_bytes
|
|
681
|
+
@buffer = +"".b
|
|
682
|
+
@discarding = false
|
|
683
|
+
@source.seek(offset) if @source.respond_to?(:read)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def next_line
|
|
687
|
+
loop do
|
|
688
|
+
ending = @buffer.index(Dsc::LINE_BREAK)
|
|
689
|
+
return extract_line(ending) if ending
|
|
690
|
+
return final_line unless fill?
|
|
691
|
+
|
|
692
|
+
discard_long_line if @buffer.bytesize > MAX_LINE_BYTES
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def skip_bytes(count)
|
|
697
|
+
buffered = [count, @buffer.bytesize].min
|
|
698
|
+
@buffer.slice!(0, buffered)
|
|
699
|
+
skip_source(count - buffered)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def skip_lines(count)
|
|
703
|
+
count.times { break unless next_line }
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
private
|
|
707
|
+
|
|
708
|
+
def extract_line(ending)
|
|
709
|
+
fill? if pending_cr?(ending)
|
|
710
|
+
line = @buffer.slice!(0, line_end(ending))
|
|
711
|
+
return line unless @discarding
|
|
712
|
+
|
|
713
|
+
@discarding = false
|
|
714
|
+
+"".b
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def fill?
|
|
718
|
+
return false if @remaining.zero?
|
|
719
|
+
|
|
720
|
+
bytes = source_bytes
|
|
721
|
+
if bytes.nil? || bytes.empty?
|
|
722
|
+
@remaining = 0
|
|
723
|
+
return false
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
@cursor += bytes.bytesize
|
|
727
|
+
@remaining -= bytes.bytesize
|
|
728
|
+
@buffer << bytes
|
|
729
|
+
true
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def source_bytes
|
|
733
|
+
amount = [@chunk_bytes, @remaining].min
|
|
734
|
+
return @source.read(amount) if @source.respond_to?(:read)
|
|
735
|
+
|
|
736
|
+
@source.byteslice(@cursor, amount)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def pending_cr?(ending)
|
|
740
|
+
@buffer.getbyte(ending) == 13 && ending + 1 == @buffer.bytesize && @remaining.positive?
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def line_end(ending)
|
|
744
|
+
crlf = @buffer.getbyte(ending) == 13 && @buffer.getbyte(ending + 1) == 10
|
|
745
|
+
ending + (crlf ? 2 : 1)
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def final_line
|
|
749
|
+
if @discarding
|
|
750
|
+
@buffer.clear
|
|
751
|
+
@discarding = false
|
|
752
|
+
return +"".b
|
|
753
|
+
end
|
|
754
|
+
return nil if @buffer.empty?
|
|
755
|
+
|
|
756
|
+
@buffer.slice!(0, @buffer.bytesize)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def discard_long_line
|
|
760
|
+
@buffer.clear
|
|
761
|
+
@discarding = true
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def skip_source(count)
|
|
765
|
+
amount = [count, @remaining].min
|
|
766
|
+
if @source.respond_to?(:read)
|
|
767
|
+
@source.seek(amount, IO::SEEK_CUR)
|
|
768
|
+
else
|
|
769
|
+
@cursor += amount
|
|
770
|
+
end
|
|
771
|
+
@remaining -= amount
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Resolves box comments deferred with `(atend)` from the outer
|
|
776
|
+
# document trailer. Nested documents and declared data payloads are
|
|
777
|
+
# opaque, so comment-shaped bytes inside them never become metadata.
|
|
778
|
+
module DscTrailer
|
|
779
|
+
TRAILER = /\A%%Trailer[ \t]*(?:\r\n|\r|\n|\z)/
|
|
780
|
+
EOF_COMMENT = /\A%%EOF[ \t]*(?:\r\n|\r|\n|\z)/
|
|
781
|
+
BEGIN_DOCUMENT = /\A%%BeginDocument:/
|
|
782
|
+
END_DOCUMENT = /\A%%EndDocument[ \t]*(?:\r\n|\r|\n|\z)/
|
|
783
|
+
BEGIN_BINARY = /\A%%BeginBinary:[ \t]*(\d+)[ \t]*(?:\r\n|\r|\n|\z)/
|
|
784
|
+
BEGIN_DATA = /\A%%BeginData:[ \t]*(\d+)(?:[ \t]+(?:Binary|ASCII|Hex))?[ \t]+(Bytes|Lines)[ \t]*(?:\r\n|\r|\n|\z)/
|
|
785
|
+
|
|
786
|
+
class Scanner
|
|
787
|
+
def initialize(stream)
|
|
788
|
+
@stream = stream
|
|
789
|
+
@declarations = Hash.new { |hash, key| hash[key] = [] }
|
|
790
|
+
@depth = 0
|
|
791
|
+
@trailer = false
|
|
792
|
+
@done = false
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def scan
|
|
796
|
+
until @done
|
|
797
|
+
line = @stream.next_line
|
|
798
|
+
break unless line
|
|
799
|
+
|
|
800
|
+
consume(line)
|
|
801
|
+
end
|
|
802
|
+
@declarations
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
private
|
|
806
|
+
|
|
807
|
+
def consume(line)
|
|
808
|
+
return if skip_payload?(line)
|
|
809
|
+
return @depth += 1 if BEGIN_DOCUMENT.match?(line)
|
|
810
|
+
return @depth -= 1 if END_DOCUMENT.match?(line) && @depth.positive?
|
|
811
|
+
return if @depth.positive?
|
|
812
|
+
|
|
813
|
+
consume_outer(line)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def consume_outer(line)
|
|
817
|
+
@trailer = true if TRAILER.match?(line)
|
|
818
|
+
@done = @trailer && EOF_COMMENT.match?(line)
|
|
819
|
+
collect(line) if @trailer && !@done
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def skip_payload?(line)
|
|
823
|
+
if (match = BEGIN_BINARY.match(line))
|
|
824
|
+
@stream.skip_bytes(Integer(match[1], 10))
|
|
825
|
+
elsif (match = BEGIN_DATA.match(line))
|
|
826
|
+
skip_data(match)
|
|
827
|
+
else
|
|
828
|
+
return false
|
|
829
|
+
end
|
|
830
|
+
true
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def skip_data(match)
|
|
834
|
+
count = Integer(match[1], 10)
|
|
835
|
+
return @stream.skip_bytes(count) if match[2] == "Bytes"
|
|
836
|
+
|
|
837
|
+
@stream.skip_lines(count)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def collect(line)
|
|
841
|
+
%w[BoundingBox HiResBoundingBox].each do |name|
|
|
842
|
+
next unless Dsc.declares?(line, name)
|
|
843
|
+
|
|
844
|
+
@declarations[name] << Dsc.trim(line.delete_prefix("%%#{name}:"))
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
private_constant :Scanner
|
|
850
|
+
|
|
851
|
+
def self.boxes(raw, signature, chunk_bytes)
|
|
852
|
+
range = postscript_range(raw, signature)
|
|
853
|
+
return {} unless range
|
|
854
|
+
|
|
855
|
+
stream = DscStream.new(raw, offset: range.first, length: range.last, chunk_bytes: chunk_bytes)
|
|
856
|
+
declarations = Scanner.new(stream).scan
|
|
857
|
+
declarations.to_h do |name, values|
|
|
858
|
+
unique = values.uniq
|
|
859
|
+
[name, unique.one? ? Dsc.parsed_declaration(unique.first, name) : nil]
|
|
860
|
+
end.compact
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def self.postscript_range(raw, signature)
|
|
864
|
+
size = raw.respond_to?(:read) ? raw.size : raw.bytesize
|
|
865
|
+
first = if raw.respond_to?(:read)
|
|
866
|
+
raw.seek(0)
|
|
867
|
+
raw.read([30, size].min) || "".b
|
|
868
|
+
else
|
|
869
|
+
raw.byteslice(0, [30, size].min)
|
|
870
|
+
end
|
|
871
|
+
return [0, size] if first.start_with?(signature)
|
|
872
|
+
|
|
873
|
+
EpsBinary.postscript_range(first, size)
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Coordinates the extra reads and box validation needed by inspection,
|
|
878
|
+
# leaving the handler responsible for building the public result.
|
|
879
|
+
module PostscriptInspection
|
|
880
|
+
def self.source(image, signature, probe_bytes, scan_limit, box_names)
|
|
881
|
+
settings = { signature:, probe_bytes:, box_names: }
|
|
882
|
+
image.with_source do |raw|
|
|
883
|
+
header = DscHeader.signed_header(raw, signature, probe_bytes, scan_limit)
|
|
884
|
+
next [nil, nil, {}] unless header
|
|
885
|
+
|
|
886
|
+
bytes, truncated = header
|
|
887
|
+
[bytes, truncated, trailer_boxes(raw, bytes, truncated, settings)]
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def self.filtered(source, field_comments)
|
|
892
|
+
Dsc.filter(source, field_comments)
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def self.boxes(header, source, trailer_boxes, box_comments)
|
|
896
|
+
box_comments.to_h do |key, name|
|
|
897
|
+
[key, box(header, source, key, name, trailer_boxes[name])]
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def self.trailer_boxes(raw, source, truncated, settings)
|
|
902
|
+
return {} if truncated || !deferred?(source, settings[:box_names])
|
|
903
|
+
|
|
904
|
+
DscTrailer.boxes(raw, settings[:signature], settings[:probe_bytes])
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def self.deferred?(source, names)
|
|
908
|
+
names.any? { |name| Dsc.first_text(source, name) == "(atend)" }
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def self.box(header, source, key, name, trailer_box)
|
|
912
|
+
return deferred_box(source, name, trailer_box) if Dsc.first_text(source, name) == "(atend)"
|
|
913
|
+
|
|
914
|
+
direct_box(header, source, key, name)
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def self.deferred_box(source, name, trailer_box)
|
|
918
|
+
return nil unless Dsc.settled?(source, name)
|
|
919
|
+
|
|
920
|
+
trailer_box
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def self.direct_box(header, source, key, name)
|
|
924
|
+
declared = header.public_send(key)
|
|
925
|
+
return nil unless declared.is_a?(Array) && Dsc.settled?(source, name)
|
|
926
|
+
return nil unless Dsc.declaration(source, name) == declared
|
|
927
|
+
|
|
928
|
+
declared
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
private_constant :Dsc, :DscHeader, :DscKeywords, :DscNumbers, :DscStream, :DscTrailer,
|
|
933
|
+
:HeaderScanner, :PostscriptInspection
|
|
934
|
+
|
|
935
|
+
# The conversion path's own pieces, kept out of Postscript's body for
|
|
936
|
+
# the same reason Handlers::Metafile's own MetafileConvert is: that
|
|
937
|
+
# class already carries the whole DSC-header-parse contract, and
|
|
938
|
+
# conversion is a second, unrelated concern layered on top of it --
|
|
939
|
+
# extracted to stay inside Metrics/ClassLength without an exemption,
|
|
940
|
+
# the same route lossiness.rb's Scanner/AttributeRules split takes.
|
|
941
|
+
# `module_function` rather than a class with instance methods -- there
|
|
942
|
+
# is no state to hold between calls, matching MetafileConvert.
|
|
943
|
+
module PostscriptConvert
|
|
944
|
+
# Symbol -> the vectory class name that owns that SOURCE format. The
|
|
945
|
+
# class itself is resolved only after ConversionEngine loads vectory.
|
|
946
|
+
DELEGATE_CLASS_NAMES = { eps: "Vectory::Eps", ps: "Vectory::Ps" }.freeze
|
|
947
|
+
|
|
948
|
+
# Symbol -> the vectory method it dispatches to, matching
|
|
949
|
+
# Handlers::Svg's own CONVERT_TARGET_METHODS convention for the same
|
|
950
|
+
# job, extended to every target both delegate classes expose.
|
|
951
|
+
TARGET_METHODS = { svg: :to_svg, emf: :to_emf, eps: :to_eps, ps: :to_ps }.freeze
|
|
952
|
+
|
|
953
|
+
# Matches Handlers::Svg's own MAX_CONVERT_BYTES: bound the READ
|
|
954
|
+
# itself, not the bytesize checked after the fact.
|
|
955
|
+
MAX_CONVERT_BYTES = 200 * 1024 * 1024
|
|
956
|
+
|
|
957
|
+
# One deadline around the input-controlled delegate work. A 372-byte
|
|
958
|
+
# program containing a stray closing string delimiter otherwise keeps
|
|
959
|
+
# postsvg's parser running indefinitely.
|
|
960
|
+
DEADLINE_SECONDS = 5
|
|
961
|
+
private_constant :DEADLINE_SECONDS
|
|
962
|
+
|
|
963
|
+
module_function
|
|
964
|
+
|
|
965
|
+
# `convert_targets` is the class-level declared union both :eps and
|
|
966
|
+
# :ps can reach (a class-level declaration cannot vary per image), so
|
|
967
|
+
# a same-format request (:eps -> :eps, :ps -> :ps) passes that check
|
|
968
|
+
# and must be refused here explicitly, the same UnsupportedFormat a
|
|
969
|
+
# genuinely undeclared target raises. Neither Vectory::Eps nor
|
|
970
|
+
# Vectory::Ps defines a same-format method to fall through to;
|
|
971
|
+
# refusing first keeps that a clean invocation error rather than a
|
|
972
|
+
# NoMethodError laundered through ConversionError.
|
|
973
|
+
def run(image, to:, convert_targets:)
|
|
974
|
+
unless convert_targets.include?(to) && to != image.format
|
|
975
|
+
raise UnsupportedFormat.new(image.format, :convert, target: to)
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
content = postscript_section(bounded_content(image))
|
|
979
|
+
ConversionEngine.load!
|
|
980
|
+
converted = bounded_conversion(image.format, content, to)
|
|
981
|
+
build(image, to, content, converted)
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Near-verbatim copy of Handlers::Svg's own `bounded_content` --
|
|
985
|
+
# bounds the READ itself rather than trusting a stat taken on a
|
|
986
|
+
# separate filesystem call, for the same reason.
|
|
987
|
+
def bounded_content(image)
|
|
988
|
+
content = image.with_source { |source| bounded_read(source, MAX_CONVERT_BYTES + 1) }
|
|
989
|
+
return content if content.bytesize <= MAX_CONVERT_BYTES
|
|
990
|
+
|
|
991
|
+
raise ConversionError, "postscript image exceeds the #{MAX_CONVERT_BYTES}-byte convert limit"
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Near-verbatim copy of Handlers::Svg's own `bounded_read` -- kept as
|
|
995
|
+
# its own small copy rather than a shared Handlers::Base helper, the
|
|
996
|
+
# same reasoning svg.rb's own copy states.
|
|
997
|
+
def bounded_read(source, length)
|
|
998
|
+
return source.read(length) || "".b if source.respond_to?(:read)
|
|
999
|
+
|
|
1000
|
+
source.byteslice(0, length)
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
# Loading the conversion stack is fixed work and stays outside this
|
|
1004
|
+
# deadline. Only the delegate receives attacker-controlled bytes.
|
|
1005
|
+
# Timeout's default exception becomes Timeout::Error outside the block,
|
|
1006
|
+
# so convert_content's StandardError rescue cannot mislabel the expiry.
|
|
1007
|
+
def bounded_conversion(source_format, content, to)
|
|
1008
|
+
Timeout.timeout(DEADLINE_SECONDS) { convert_content(source_format, content, to) }
|
|
1009
|
+
rescue Timeout::Error
|
|
1010
|
+
raise ConversionError,
|
|
1011
|
+
"postscript conversion did not finish within #{DEADLINE_SECONDS} seconds"
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# Scoped to only the delegate call, not `build` below -- mirrors
|
|
1015
|
+
# Handlers::Svg's own `convert_content` deviation from the house
|
|
1016
|
+
# exception-list convention, for the same reason: the delegate chain
|
|
1017
|
+
# here is a third-party render pipeline (vectory/postsvg) whose
|
|
1018
|
+
# exact raised classes are not this file's to enumerate.
|
|
1019
|
+
def convert_content(source_format, content, to)
|
|
1020
|
+
delegate = ::Object.const_get(DELEGATE_CLASS_NAMES.fetch(source_format))
|
|
1021
|
+
delegate.from_content(content).public_send(TARGET_METHODS.fetch(to))
|
|
1022
|
+
rescue StandardError => e
|
|
1023
|
+
raise ConversionError, "#{e.class}: #{e.message}"
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
# A DOS/Windows binary-preview EPS carries the actual PostScript
|
|
1027
|
+
# program at a declared offset/length inside a larger container
|
|
1028
|
+
# (DscHeader's own `string_header` reframes the same way). Pass only
|
|
1029
|
+
# that range to the delegate -- never the whole container, whose
|
|
1030
|
+
# preview and any trailing bytes are not PostScript. Check `wrapped?`
|
|
1031
|
+
# and `postscript_range` separately: `postscript_range`'s own nil
|
|
1032
|
+
# means both "unwrapped" (pass content through) and "wrapped with an
|
|
1033
|
+
# invalid range", and those must not collapse into one behaviour --
|
|
1034
|
+
# an invalid range is refused, never treated as unwrapped.
|
|
1035
|
+
def postscript_section(content)
|
|
1036
|
+
return content unless EpsBinary.wrapped?(content)
|
|
1037
|
+
|
|
1038
|
+
range = EpsBinary.postscript_range(content, content.bytesize)
|
|
1039
|
+
raise ConversionError, "eps binary wrapper declares a postscript range outside the file" unless range
|
|
1040
|
+
|
|
1041
|
+
content.byteslice(*range)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# `output_path` is deliberately absent here -- mirrors
|
|
1045
|
+
# Handlers::Svg's own `build_conversion` for the same reason: the
|
|
1046
|
+
# caller (Claricle.convert_one) does not know the real written path
|
|
1047
|
+
# until after Writer#write runs.
|
|
1048
|
+
#
|
|
1049
|
+
# `Lossiness.classify` always answers "unknown" here -- it returns
|
|
1050
|
+
# early unless `source_format == :svg` (lossiness.rb), and this
|
|
1051
|
+
# handler's source is always :eps or :ps. Called anyway rather than
|
|
1052
|
+
# hardcoding the string, so a future change to that guard is not
|
|
1053
|
+
# silently bypassed by a handler that stopped asking.
|
|
1054
|
+
def build(image, to, content, converted)
|
|
1055
|
+
Models::Conversion.new(
|
|
1056
|
+
source_path: image.path,
|
|
1057
|
+
source_format: image.format.to_s,
|
|
1058
|
+
target_format: to.to_s,
|
|
1059
|
+
lossiness: Lossiness.classify(source_format: image.format, target_format: to, source: content),
|
|
1060
|
+
content: converted.content
|
|
1061
|
+
)
|
|
1062
|
+
end
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
private_constant :PostscriptConvert
|
|
1066
|
+
|
|
1067
|
+
# Reports a PostScript program's DSC header: dimensions from the
|
|
1068
|
+
# bounding box, and the comments that 0.2.0 actually populates.
|
|
1069
|
+
#
|
|
1070
|
+
# `:eps` and `:ps` share this handler and differ only in what the
|
|
1071
|
+
# detector called them. `image.format` supplies the reported format,
|
|
1072
|
+
# so one class reports both correctly.
|
|
1073
|
+
class Postscript < Base
|
|
1074
|
+
formats :eps, :ps
|
|
1075
|
+
# The full cross-format matrix -- one class owns both :eps and :ps, so
|
|
1076
|
+
# this declares every OTHER format either can reach; PostscriptConvert.run
|
|
1077
|
+
# refuses same-format explicitly, and Registry#convert_targets_for
|
|
1078
|
+
# subtracts the image's own format before this reaches `claricle formats`.
|
|
1079
|
+
convert_to :svg, :emf, :eps, :ps
|
|
1080
|
+
|
|
1081
|
+
# The detector's own PostScript-section signature, not a second copy
|
|
1082
|
+
# of it. A plain file carries it at byte zero; a DOS EPS carries it
|
|
1083
|
+
# at the offset shared `EpsBinary` framing declares.
|
|
1084
|
+
#
|
|
1085
|
+
# `Postscript.parse` succeeds on "not postscript at all", so the
|
|
1086
|
+
# delegate not raising says nothing (D17) -- exactly as it said
|
|
1087
|
+
# nothing for PNG's chunk reader. Anchored, not `include?` and not
|
|
1088
|
+
# `lstrip`: a leading space or newline means the file does not
|
|
1089
|
+
# start with a header.
|
|
1090
|
+
SIGNATURE = Detector::POSTSCRIPT_SIGNATURE
|
|
1091
|
+
|
|
1092
|
+
# The `meta` key each box comment supplies.
|
|
1093
|
+
BOX_COMMENTS = { "bounding_box" => "BoundingBox", "hires_bounding_box" => "HiResBoundingBox" }.freeze
|
|
1094
|
+
|
|
1095
|
+
ISSUE_CODE = "postscript.header_unreadable"
|
|
1096
|
+
ISSUE_MESSAGE = "PostScript header could not be read"
|
|
1097
|
+
|
|
1098
|
+
# The delegate's own answer for the fields this handler reads, so
|
|
1099
|
+
# the set is not restated: the two box names, plus the four scalar
|
|
1100
|
+
# ones `metadata` populates.
|
|
1101
|
+
#
|
|
1102
|
+
# 0.2.0 merges every comment it does NOT recognise into a growing
|
|
1103
|
+
# `custom` hash and duplicates the whole hash on each merge --
|
|
1104
|
+
# measured, a 629 KB header of 32,000 unique `%%For:` comments (a
|
|
1105
|
+
# real DSC General Header Comment, not malformed input) took over
|
|
1106
|
+
# 20 seconds to parse. Nothing here ever reads `custom`.
|
|
1107
|
+
#
|
|
1108
|
+
# Naming the six is only half of it: `Dsc.filter` also keeps just
|
|
1109
|
+
# the FIRST line for each, so the delegate sees a bounded number of
|
|
1110
|
+
# lines rather than a bounded set of names. Six names with no count
|
|
1111
|
+
# behind them left the same quadratic fully reachable through a
|
|
1112
|
+
# repeated whitelisted comment.
|
|
1113
|
+
FIELD_COMMENTS = (BOX_COMMENTS.values + %w[Title Creator CreationDate LanguageLevel]).freeze
|
|
1114
|
+
|
|
1115
|
+
# The most a single probe reads before checking whether the header
|
|
1116
|
+
# ended inside it. Generous for the common case -- a handful of
|
|
1117
|
+
# scalar comments and a box are a few hundred bytes -- and not a
|
|
1118
|
+
# ceiling: the scanner reads past it when a real header runs
|
|
1119
|
+
# longer, exactly the 629 KB case `FIELD_COMMENTS` exists for.
|
|
1120
|
+
HEADER_PROBE_BYTES = 8192
|
|
1121
|
+
|
|
1122
|
+
# The most this handler will ever read while looking for
|
|
1123
|
+
# %%EndComments. DSC sets no ceiling on header length, so a run of
|
|
1124
|
+
# ordinary-looking comment lines that never declares %%EndComments --
|
|
1125
|
+
# every line matching HEADER_LINE, none matching DscKeywords::OTHER_PART
|
|
1126
|
+
# -- read to EOF before this bound existed.
|
|
1127
|
+
#
|
|
1128
|
+
# Unlike Handlers::Metafile's SCAN_LIMIT, which deliberately does NOT
|
|
1129
|
+
# bound its header (a separate, fixed-size read that stays readable
|
|
1130
|
+
# however large the surrounding stream is), this bounds the header
|
|
1131
|
+
# itself, because here the scan IS the header -- there is no separate
|
|
1132
|
+
# fixed-size read to fall back on.
|
|
1133
|
+
#
|
|
1134
|
+
# 8 MiB: about 13x the largest header this file's own comments cite --
|
|
1135
|
+
# a real 629 KB header of 32,000 %%For: comments, see the
|
|
1136
|
+
# quadratic-parse fix above. Measured: an unbounded scan of a
|
|
1137
|
+
# never-ending header cost 1.71s at 8 MB and climbed to 90.5s by
|
|
1138
|
+
# 80 MB and 144s by 100 MB (4.64 GB peak RSS) -- this keeps the
|
|
1139
|
+
# worst case bounded and fast whatever the file's real size is.
|
|
1140
|
+
#
|
|
1141
|
+
# A header that runs past this is reported `failed`, not `ok` with
|
|
1142
|
+
# whatever was read. That is NOT the same trade-off
|
|
1143
|
+
# `Detector::EpsHeader` makes for its own scan ceiling (treating "ran
|
|
1144
|
+
# past it" the same as "no match" there is safe, because an EPSF
|
|
1145
|
+
# match is a single independent regex hit with nothing else to get
|
|
1146
|
+
# wrong): here, `Dsc.settled?`/`disputed?`/`continued?` decide what a
|
|
1147
|
+
# field means only by looking at the header they are handed, and
|
|
1148
|
+
# they are sound only when that IS the whole header. A truncated
|
|
1149
|
+
# read has seen a PREFIX -- a `%%+` continuation or a conflicting
|
|
1150
|
+
# repeat of the same comment could sit one byte past the cut, unseen
|
|
1151
|
+
# -- so those checks can answer "settled" when the real header is
|
|
1152
|
+
# "continued", and publish a value DSC's own first-occurrence rule
|
|
1153
|
+
# says to suppress. Measured against the real handler: a `%%Title`
|
|
1154
|
+
# ending five bytes before the ceiling, followed by a `%%+`
|
|
1155
|
+
# continuation just past it, reports `title: "First part"` if
|
|
1156
|
+
# truncation is treated as safe to read from -- the full header
|
|
1157
|
+
# correctly suppresses it as continued.
|
|
1158
|
+
HEADER_LIMIT_BYTES = 8 * 1024 * 1024
|
|
1159
|
+
|
|
1160
|
+
private_constant :SIGNATURE, :BOX_COMMENTS, :FIELD_COMMENTS, :HEADER_PROBE_BYTES,
|
|
1161
|
+
:HEADER_LIMIT_BYTES, :ISSUE_CODE, :ISSUE_MESSAGE
|
|
1162
|
+
|
|
1163
|
+
# `image.content` would cost a path-born image a file-sized
|
|
1164
|
+
# allocation it retains for the image's whole lifetime, for a
|
|
1165
|
+
# handler that reads only the header -- measured on a large body,
|
|
1166
|
+
# +file-size RSS and the body kept alive -- so this goes through
|
|
1167
|
+
# `with_source` instead, the same reader `Handlers::Svg` uses for
|
|
1168
|
+
# the same reason.
|
|
1169
|
+
def inspection(image)
|
|
1170
|
+
source, truncated, trailer_boxes = PostscriptInspection.source(
|
|
1171
|
+
image, SIGNATURE, HEADER_PROBE_BYTES, HEADER_LIMIT_BYTES, BOX_COMMENTS.values
|
|
1172
|
+
)
|
|
1173
|
+
# A truncated header has only been seen as a PREFIX -- see
|
|
1174
|
+
# HeaderScanner#truncated? -- so `settled?`/`disputed?`/`continued?`
|
|
1175
|
+
# below cannot be trusted to have seen the whole document.
|
|
1176
|
+
return unreadable(image) unless source && !truncated
|
|
1177
|
+
|
|
1178
|
+
header = read_header(source)
|
|
1179
|
+
return unreadable(image) unless header
|
|
1180
|
+
|
|
1181
|
+
readable(image, header, source, trailer_boxes)
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
# The target guard, the delegate call and the `Models::Conversion`
|
|
1185
|
+
# it builds all live in `PostscriptConvert` above -- see its `run`
|
|
1186
|
+
# for why a same-format request is refused there rather than here,
|
|
1187
|
+
# and `build` for why `output_path` is absent from the result.
|
|
1188
|
+
def convert(image, to:) = PostscriptConvert.run(image, to: to, convert_targets: self.class.convert_targets)
|
|
1189
|
+
|
|
1190
|
+
private
|
|
1191
|
+
|
|
1192
|
+
def read_header(source)
|
|
1193
|
+
require "postscript"
|
|
1194
|
+
|
|
1195
|
+
begin
|
|
1196
|
+
::Postscript.parse(PostscriptInspection.filtered(source, FIELD_COMMENTS)).header
|
|
1197
|
+
rescue ::Postscript::ParseError
|
|
1198
|
+
nil
|
|
1199
|
+
end
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
def readable(image, header, source, trailer_boxes)
|
|
1203
|
+
boxes = PostscriptInspection.boxes(header, source, trailer_boxes, BOX_COMMENTS)
|
|
1204
|
+
spans = usable_spans(boxes)
|
|
1205
|
+
|
|
1206
|
+
Models::Inspection.new(
|
|
1207
|
+
format: image.format.to_s,
|
|
1208
|
+
width: spans&.first,
|
|
1209
|
+
height: spans&.last,
|
|
1210
|
+
meta: metadata(header, boxes, source),
|
|
1211
|
+
parse_status: "ok"
|
|
1212
|
+
)
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
def unreadable(image)
|
|
1216
|
+
Models::Inspection.new(
|
|
1217
|
+
format: image.format.to_s,
|
|
1218
|
+
parse_status: "failed",
|
|
1219
|
+
issues: [Models::Issue.new(severity: "error", code: ISSUE_CODE, message: ISSUE_MESSAGE)]
|
|
1220
|
+
)
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
# First usable candidate: hires, then coarse, then nil. The DSC
|
|
1224
|
+
# defines `%%BoundingBox` as the rounded counterpart of
|
|
1225
|
+
# `%%HiResBoundingBox`, so a file declaring both declares one
|
|
1226
|
+
# rectangle twice. Falling back reports the document's own coarser
|
|
1227
|
+
# statement rather than inventing a number, and reporting nil for a
|
|
1228
|
+
# file carrying a readable `%%BoundingBox` would be less honest.
|
|
1229
|
+
#
|
|
1230
|
+
# "Usable" is what `spans` accepts: both spans finite AND
|
|
1231
|
+
# non-negative. A box that fails on ONE axis is rejected whole --
|
|
1232
|
+
# taking width from the box whose height was just refused would
|
|
1233
|
+
# report a rectangle no one declared.
|
|
1234
|
+
def usable_spans(boxes)
|
|
1235
|
+
[boxes["hires_bounding_box"], boxes["bounding_box"]]
|
|
1236
|
+
.filter_map { |box| spans(box) }
|
|
1237
|
+
.first
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
# `[width, height]`, or nil when either cannot be computed.
|
|
1241
|
+
#
|
|
1242
|
+
# Finiteness on the COMPUTED span, not on the endpoints: in
|
|
1243
|
+
# `-1e308 0 1e308 10` every endpoint is a finite Float while the
|
|
1244
|
+
# difference is Infinity, which `to_json` refuses. An endpoint
|
|
1245
|
+
# check misses exactly that case.
|
|
1246
|
+
#
|
|
1247
|
+
# Both spans or neither. A box overflowing on ONE axis is rejected
|
|
1248
|
+
# whole, since taking the width from a box whose height was just
|
|
1249
|
+
# refused would report a rectangle nobody declared.
|
|
1250
|
+
#
|
|
1251
|
+
# Spans must also be NON-NEGATIVE. DSC orders `%%BoundingBox` as
|
|
1252
|
+
# lower-left then upper-right, so `100 50 0 0` is malformed rather
|
|
1253
|
+
# than merely reversed -- unlike PDF, which permits either diagonal
|
|
1254
|
+
# pair and is normalised with `abs`. Measured, it published
|
|
1255
|
+
# `-100.0 x -50.0`. The raw box still reaches `meta`, since that
|
|
1256
|
+
# reports what the file declared.
|
|
1257
|
+
def spans(box)
|
|
1258
|
+
return nil unless box
|
|
1259
|
+
|
|
1260
|
+
width = box[2] - box[0]
|
|
1261
|
+
height = box[3] - box[1]
|
|
1262
|
+
return nil unless width.finite? && height.finite?
|
|
1263
|
+
|
|
1264
|
+
width.negative? || height.negative? ? nil : [width, height]
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Only the fields 0.2.0 populates reliably. `epsf` is never set,
|
|
1268
|
+
# even for a genuine EPSF header. `page_count` and `pages` are set
|
|
1269
|
+
# only by the two-argument `%%Pages: n m` form, so reporting them
|
|
1270
|
+
# would give a count for some files and nil for others with nothing
|
|
1271
|
+
# in `meta` to tell the cases apart -- and `pages` is the obsolete
|
|
1272
|
+
# page-order argument, not a count. `custom` is the UNRECOGNISED-comment
|
|
1273
|
+
# bucket, keyed by the whole comment text: `%%Pages: 3` lands there
|
|
1274
|
+
# as `{"Pages: 3" => true}` precisely because 0.2.0 cannot read the
|
|
1275
|
+
# one-argument form, so it is a record of what the parser failed at.
|
|
1276
|
+
def metadata(header, boxes, source)
|
|
1277
|
+
{
|
|
1278
|
+
"bounding_box" => carried_box(boxes["bounding_box"]),
|
|
1279
|
+
"hires_bounding_box" => carried_box(boxes["hires_bounding_box"]),
|
|
1280
|
+
"title" => authoritative(header.title, source, "Title"),
|
|
1281
|
+
"creator" => authoritative(header.creator, source, "Creator"),
|
|
1282
|
+
"creation_date" => authoritative(header.creation_date, source, "CreationDate"),
|
|
1283
|
+
"language_level" => authoritative(header.language_level, source, "LanguageLevel")
|
|
1284
|
+
}.compact
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# DSC's first-occurrence rule is not about boxes; it covers every
|
|
1288
|
+
# header comment, so a scalar field is refused on a disagreeing
|
|
1289
|
+
# repeat exactly as a box is, and is checked against the first
|
|
1290
|
+
# declaration's own text the same way a box is checked against its
|
|
1291
|
+
# first operands.
|
|
1292
|
+
def authoritative(value, source, name)
|
|
1293
|
+
return nil if value.nil?
|
|
1294
|
+
return nil unless Dsc.settled?(source, name)
|
|
1295
|
+
|
|
1296
|
+
declared = Dsc.first_text(source, name)
|
|
1297
|
+
return nil unless declared
|
|
1298
|
+
return nil unless agrees?(declared, value)
|
|
1299
|
+
|
|
1300
|
+
value.is_a?(String) ? carried(value) : value
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
# Textual for a string field, semantic for a numeric one.
|
|
1304
|
+
# `%%LanguageLevel: 02` is a valid unsigned integer and 0.2.0 reads
|
|
1305
|
+
# it as 2, but `"02" != "2"`, so a textual comparison suppressed a
|
|
1306
|
+
# correctly-read value.
|
|
1307
|
+
#
|
|
1308
|
+
# The numeric side reads the declared text through DSC's own
|
|
1309
|
+
# unsigned-integer grammar, not Ruby's `Integer`. `Integer` is
|
|
1310
|
+
# wider, and a file declaring `2_0` and then `20` had the second
|
|
1311
|
+
# value published as though it were the first.
|
|
1312
|
+
#
|
|
1313
|
+
# Textual means the delegate's own text, byte for byte. 0.2.0
|
|
1314
|
+
# hands back `%%Title: (Draft)` as `"(Draft)"`, parentheses and
|
|
1315
|
+
# all, and that is what reaches `meta`. Decoding DSC's text syntax
|
|
1316
|
+
# here would report something the parser never saw -- the same
|
|
1317
|
+
# line `cr_only.ps` draws.
|
|
1318
|
+
def agrees?(declared, value)
|
|
1319
|
+
return declared == value if value.is_a?(String)
|
|
1320
|
+
return DscNumbers.unsigned(declared) == value if value.is_a?(Integer)
|
|
1321
|
+
|
|
1322
|
+
false
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
# A box reaches `meta` on its own endpoints being finite, not on
|
|
1326
|
+
# the dimensions being usable: those two fail independently, and a
|
|
1327
|
+
# box whose endpoints are finite is JSON-safe even when its span
|
|
1328
|
+
# overflows.
|
|
1329
|
+
#
|
|
1330
|
+
# No shape check, because the delegate does it. Measured across
|
|
1331
|
+
# three, five, one and zero values, non-numeric and mixed
|
|
1332
|
+
# operands: every malformed `%%BoundingBox` comes back nil, and a
|
|
1333
|
+
# readable one is always exactly four Floats. Guarding a case the
|
|
1334
|
+
# delegate cannot produce would be dead code, and a spec pins the
|
|
1335
|
+
# contract so a delegate that changes its mind is caught.
|
|
1336
|
+
def carried_box(box)
|
|
1337
|
+
return nil unless box
|
|
1338
|
+
|
|
1339
|
+
box.dup if box.all?(&:finite?)
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
# JSON safety, not encoding validity. A path-born string comes from
|
|
1343
|
+
# `File.binread` tagged ASCII-8BIT, where `valid_encoding?` is true
|
|
1344
|
+
# for any bytes at all while `to_json` still raises.
|
|
1345
|
+
#
|
|
1346
|
+
# The value carried is the UTF-8 dup, not the original: json 2.21
|
|
1347
|
+
# serializes a BINARY-tagged UTF-8 string while warning that it
|
|
1348
|
+
# will raise in json 3.0.
|
|
1349
|
+
def carried(value)
|
|
1350
|
+
return nil unless value.is_a?(String)
|
|
1351
|
+
|
|
1352
|
+
candidate = value.dup.force_encoding(Encoding::UTF_8)
|
|
1353
|
+
candidate.valid_encoding? ? candidate : nil
|
|
1354
|
+
end
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
end
|