claricle 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
@@ -0,0 +1,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