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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claricle
|
|
4
|
+
# emfsvg's writer leaves EMR_HEADER.nBytes (u32 at offset 48) at 0, so
|
|
5
|
+
# its output declares a size the file does not have. `seal` writes the
|
|
6
|
+
# real length; bytes that are not an EMF header, or that already declare
|
|
7
|
+
# a size, pass through untouched, as does any target other than :emf.
|
|
8
|
+
# nRecords and nHandles are written correctly by the delegate and are
|
|
9
|
+
# not touched.
|
|
10
|
+
module EmfHeader
|
|
11
|
+
HEADER_TYPE = 1
|
|
12
|
+
N_BYTES_OFFSET = 48
|
|
13
|
+
MIN_HEADER_BYTES = 52
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def seal(target, bytes)
|
|
18
|
+
return bytes unless target == :emf && unsealed?(bytes)
|
|
19
|
+
|
|
20
|
+
sealed = bytes.b
|
|
21
|
+
sealed[N_BYTES_OFFSET, 4] = [sealed.bytesize].pack("V")
|
|
22
|
+
sealed
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unsealed?(bytes)
|
|
26
|
+
bytes.bytesize >= MIN_HEADER_BYTES &&
|
|
27
|
+
bytes.byteslice(0, 4).unpack1("V") == HEADER_TYPE &&
|
|
28
|
+
bytes.byteslice(N_BYTES_OFFSET, 4).unpack1("V").zero?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private_constant :EmfHeader
|
|
33
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claricle
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class UnknownFormat < Error; end
|
|
7
|
+
|
|
8
|
+
# Bad arguments, conflicting flags, a refused destination. Besides
|
|
9
|
+
# Thor::Error and ENOENT, the only *mapped error class* that means exit
|
|
10
|
+
# 2 -- a command can still request 2 explicitly with a Status, or by
|
|
11
|
+
# exiting, since Runner hands a SystemExit's own status back when it is
|
|
12
|
+
# a valid byte.
|
|
13
|
+
class InvocationError < Error; end
|
|
14
|
+
|
|
15
|
+
# A conversion that failed after dispatch. Reaches exit 4 through the
|
|
16
|
+
# ordinary StandardError row; there is no blanket Claricle::Error row.
|
|
17
|
+
class ConversionError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a profile is recognised as a name some format defines, but
|
|
20
|
+
# not by the format of the file in hand. Separate from UnsupportedFormat
|
|
21
|
+
# because the format IS supported -- it is the pairing that is wrong,
|
|
22
|
+
# and a caller fixes the two by different means. Names what the format
|
|
23
|
+
# does accept, because a refusal that does not is a second question.
|
|
24
|
+
class UnsupportedProfile < InvocationError
|
|
25
|
+
def initialize(format, profile, accepted)
|
|
26
|
+
super(build_message(format, profile, accepted))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_message(format, profile, accepted)
|
|
32
|
+
message = "format #{format.inspect} does not define profile #{profile.to_s.inspect}"
|
|
33
|
+
return "#{message}; it defines none" if accepted.empty?
|
|
34
|
+
|
|
35
|
+
"#{message}; it defines #{accepted.map(&:inspect).join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Raised when a format is recognised but nothing handles it, or handles
|
|
40
|
+
# the operation asked for. The class builds its own sentence: callers
|
|
41
|
+
# pass what they were doing, not a phrase.
|
|
42
|
+
class UnsupportedFormat < Error
|
|
43
|
+
# A target nobody can pass, so "not given" and "given as nil" stay
|
|
44
|
+
# different things. `convert(image, to:)` requires the keyword, so a
|
|
45
|
+
# nil arriving here is a caller who forgot their target, not one who
|
|
46
|
+
# has none, and the message says so.
|
|
47
|
+
ABSENT = Object.new.freeze
|
|
48
|
+
private_constant :ABSENT
|
|
49
|
+
|
|
50
|
+
def initialize(format, operation = nil, target: ABSENT)
|
|
51
|
+
super(build_message(format, operation, target))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# No readers and no state: the arguments exist to compose one sentence.
|
|
57
|
+
# A target without an operation reads as nonsense, so it is ignored
|
|
58
|
+
# rather than rendered.
|
|
59
|
+
#
|
|
60
|
+
# Every supplied target is named, however unhelpful it looks. Tested
|
|
61
|
+
# by truthiness the message dropped `false`; tested for nil it
|
|
62
|
+
# dropped `nil`. Both had been supplied, and both times the error
|
|
63
|
+
# named the operation and hid what it was refused for.
|
|
64
|
+
def build_message(format, operation, target)
|
|
65
|
+
message = "format #{format.inspect} is not supported"
|
|
66
|
+
return message unless operation
|
|
67
|
+
|
|
68
|
+
message << " for #{operation}"
|
|
69
|
+
message << " to #{target.inspect}" unless target.equal?(ABSENT)
|
|
70
|
+
message
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Claricle
|
|
6
|
+
# An exception described as data: the status it means, and a message that
|
|
7
|
+
# can be rendered. Both are needed in two places -- the CLI runner turns a
|
|
8
|
+
# fault into a process status, and a batch turns one into an envelope --
|
|
9
|
+
# and a second copy of either rule is a second place for them to disagree.
|
|
10
|
+
#
|
|
11
|
+
# Thor's own errors are deliberately absent. They are the CLI's business,
|
|
12
|
+
# they cannot reach a batch operation, and naming them here would drag
|
|
13
|
+
# `require "thor"` into the library layer.
|
|
14
|
+
module Fault
|
|
15
|
+
CLASS_OF = ::Object.instance_method(:class)
|
|
16
|
+
KIND_OF = ::Object.instance_method(:is_a?)
|
|
17
|
+
MODULE_NAME = ::Module.instance_method(:name)
|
|
18
|
+
MODULE_TO_S = ::Module.instance_method(:to_s)
|
|
19
|
+
private_constant :CLASS_OF, :KIND_OF, :MODULE_NAME, :MODULE_TO_S
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def exit_code(error)
|
|
24
|
+
case error
|
|
25
|
+
when Errno::ENOENT, InvocationError then 2
|
|
26
|
+
when UnknownFormat, UnsupportedFormat then 3
|
|
27
|
+
else 4
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A delegate's message is not guaranteed to be valid UTF-8. Left alone
|
|
32
|
+
# it reaches a String attribute that Models::Base refuses because JSON
|
|
33
|
+
# cannot render it, so reporting the failure would become the failure.
|
|
34
|
+
def message(error)
|
|
35
|
+
value = raw_message(error)
|
|
36
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
37
|
+
rescue EncodingError
|
|
38
|
+
value.b.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Call the core readers directly. An exception is an ordinary Ruby
|
|
42
|
+
# object and can override #class just as it can override #message; the
|
|
43
|
+
# diagnostic path must still name the exception that was actually raised.
|
|
44
|
+
def class_name(error)
|
|
45
|
+
klass = CLASS_OF.bind_call(error)
|
|
46
|
+
MODULE_NAME.bind_call(klass) || MODULE_TO_S.bind_call(klass)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def matches_class?(error, kind)
|
|
50
|
+
KIND_OF.bind_call(error, kind)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Exception subclasses can override #message, including with a non-String
|
|
54
|
+
# result or another exception. Reporting must not replace the original
|
|
55
|
+
# failure with a failure from its diagnostic path. Copy a real String into
|
|
56
|
+
# the core class so an overridden #encode cannot do the same thing later.
|
|
57
|
+
def raw_message(error)
|
|
58
|
+
value = error.message
|
|
59
|
+
return ::String.new(value) if ::String === value # rubocop:disable Style/CaseEquality
|
|
60
|
+
|
|
61
|
+
class_name(error)
|
|
62
|
+
rescue StandardError
|
|
63
|
+
class_name(error)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :raw_message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private_constant :Fault
|
|
69
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../models/inspection"
|
|
5
|
+
require_relative "../models/issue"
|
|
6
|
+
require_relative "extension_points"
|
|
7
|
+
|
|
8
|
+
module Claricle
|
|
9
|
+
module Handlers
|
|
10
|
+
# A handler declares the formats it owns and implements the operations
|
|
11
|
+
# it supports. Anything it does not implement raises, naming what was
|
|
12
|
+
# asked for, so an unfinished handler says so rather than returning nil.
|
|
13
|
+
class Base
|
|
14
|
+
include ExtensionPoints
|
|
15
|
+
|
|
16
|
+
# Each operation, mapped to the method a handler overrides to
|
|
17
|
+
# support it. The names are the vocabulary of the public API; the
|
|
18
|
+
# CLI happens to print them, but does not own them.
|
|
19
|
+
OPERATIONS = {
|
|
20
|
+
inspect: :inspection,
|
|
21
|
+
conform: :conformance_report,
|
|
22
|
+
convert: :convert
|
|
23
|
+
}.freeze
|
|
24
|
+
private_constant :OPERATIONS
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# One declaration per handler, refused on a second call. The
|
|
28
|
+
# registry derives a frozen map at load, so a later redeclaration
|
|
29
|
+
# would leave the two disagreeing about who owns a format --
|
|
30
|
+
# measured: after `Png.formats(:svg)` the registry still answered
|
|
31
|
+
# :png while the handler claimed :svg.
|
|
32
|
+
#
|
|
33
|
+
# What this refuses is a change of mind, not a late first
|
|
34
|
+
# declaration. A class that has declared nothing by the time
|
|
35
|
+
# registry.rb reads the list owns nothing in the map, and
|
|
36
|
+
# declaring afterwards leaves it claiming a format the registry
|
|
37
|
+
# does not route. No handler does that -- the declaration sits in
|
|
38
|
+
# the class body, which has run before the list is read -- so the
|
|
39
|
+
# guarantee here is "no take-backs", not "the two can never
|
|
40
|
+
# disagree".
|
|
41
|
+
def formats(*symbols)
|
|
42
|
+
raise Error, "#{self} already declared formats #{@formats.inspect}" if @formats
|
|
43
|
+
|
|
44
|
+
# Symbols only. The registry keys its map on these and sorts the
|
|
45
|
+
# keys for `Registry.formats`, so one String among them is not a
|
|
46
|
+
# near miss -- measured: after `formats "png", :svg` the sort
|
|
47
|
+
# raises `comparison of String with :svg failed`, and
|
|
48
|
+
# `handler_for(:png)` calls :png unsupported. Refused where the
|
|
49
|
+
# typo is rather than at the first call that trips over it.
|
|
50
|
+
bad = symbols.grep_v(Symbol)
|
|
51
|
+
raise Error, "#{self} declared non-Symbol formats #{bad.inspect}" if bad.any?
|
|
52
|
+
|
|
53
|
+
@formats = symbols.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def supported_formats
|
|
57
|
+
@formats || [].freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The profile names this handler's conform operation accepts, in
|
|
61
|
+
# the order the format's own spec ranks them, so the FIRST is what
|
|
62
|
+
# a plain `conform` runs. Declared rather than derived, unlike
|
|
63
|
+
# `capabilities` below: a profile is a name in a delegate's own
|
|
64
|
+
# vocabulary, and nothing in the handler's method list says which
|
|
65
|
+
# names those are.
|
|
66
|
+
#
|
|
67
|
+
# Refused twice over on the same grounds as `formats` -- no
|
|
68
|
+
# take-backs, and Symbols only, because `Registry.profiles_for`
|
|
69
|
+
# sorts these and one String among them raises on comparison
|
|
70
|
+
# rather than at the typo.
|
|
71
|
+
def profiles(*symbols)
|
|
72
|
+
raise Error, "#{self} already declared profiles #{@profiles.inspect}" if @profiles
|
|
73
|
+
|
|
74
|
+
bad = symbols.grep_v(Symbol)
|
|
75
|
+
raise Error, "#{self} declared non-Symbol profiles #{bad.inspect}" if bad.any?
|
|
76
|
+
|
|
77
|
+
@profiles = symbols.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Empty means "this format has no profiles", which is a real
|
|
81
|
+
# answer and not a gap: PNG conformance is one fixed requirement
|
|
82
|
+
# set, so there is nothing to choose between.
|
|
83
|
+
def supported_profiles
|
|
84
|
+
@profiles || [].freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# nil means this profile takes no separate level. Handlers with
|
|
88
|
+
# level-bearing profiles override this with their accepted names.
|
|
89
|
+
def levels_for(_profile)
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A handler's convert target list, declared the same way `formats`
|
|
94
|
+
# is and for the same reason: two calls would let a handler change
|
|
95
|
+
# its mind about what it converts to, and the registry would have
|
|
96
|
+
# no way to notice. Kept as its own declaration rather than derived
|
|
97
|
+
# from overriding `convert` -- a handler can convert to several
|
|
98
|
+
# targets through one method, and `capabilities` already answers
|
|
99
|
+
# the yes/no question from whether `convert` is overridden at all.
|
|
100
|
+
def convert_to(*symbols)
|
|
101
|
+
raise Error, "#{self} already declared convert targets #{@convert_targets.inspect}" if @convert_targets
|
|
102
|
+
|
|
103
|
+
bad = symbols.grep_v(Symbol)
|
|
104
|
+
raise Error, "#{self} declared non-Symbol convert targets #{bad.inspect}" if bad.any?
|
|
105
|
+
|
|
106
|
+
@convert_targets = symbols.freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def convert_targets
|
|
110
|
+
@convert_targets || [].freeze
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Derived, never declared. A declaration is a second place to say
|
|
114
|
+
# what the code already says, and it can advertise an operation
|
|
115
|
+
# that is still Base's raising stub -- exactly the lie `formats`
|
|
116
|
+
# exists to avoid. Overriding the method is the only way to claim
|
|
117
|
+
# the capability. Item 04's conversion targets stay declared,
|
|
118
|
+
# because a list of targets is data rather than a boolean.
|
|
119
|
+
def capabilities
|
|
120
|
+
OPERATIONS.reject { |_, method| instance_method(method).owner == Base }.keys
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The image's own format, not the first one this handler declares --
|
|
125
|
+
# a handler owning :svg and :svgz would otherwise name :svg however
|
|
126
|
+
# it was reached. The operation named is the user-facing one, so
|
|
127
|
+
# `inspection` reports `inspect` (D2 renamed the method because Ruby
|
|
128
|
+
# owns `inspect`, but the command is still `inspect`).
|
|
129
|
+
def inspection(image)
|
|
130
|
+
raise UnsupportedFormat.new(image.format, :inspect)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def conformance_report(image)
|
|
134
|
+
raise UnsupportedFormat.new(image.format, :conform)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def convert(image, to:)
|
|
138
|
+
raise UnsupportedFormat.new(image.format, :convert, target: to)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# The one shape every handler needs and none of them differ on: a
|
|
144
|
+
# parse that did not get far enough to report anything, carrying the
|
|
145
|
+
# single error that says why. Severity is not a parameter because
|
|
146
|
+
# there is only one answer -- an inspection that failed to parse has
|
|
147
|
+
# nothing to warn about.
|
|
148
|
+
#
|
|
149
|
+
# The image's own format, for the same reason `inspection` names it:
|
|
150
|
+
# a handler owning :svg and :svgz must not report :svg for both.
|
|
151
|
+
#
|
|
152
|
+
# `parse_status: "failed"` makes no validity claim; that belongs to
|
|
153
|
+
# conform alone (D17).
|
|
154
|
+
def failed_inspection(image, code:, message:)
|
|
155
|
+
Models::Inspection.new(
|
|
156
|
+
format: image.format.to_s,
|
|
157
|
+
parse_status: "failed",
|
|
158
|
+
issues: [Models::Issue.new(severity: "error", code: code, message: message)]
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private_constant :Base
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Private where it is defined, so the guarantee survives this file
|
|
167
|
+
# being required on its own rather than through the entry point.
|
|
168
|
+
private_constant :Handlers
|
|
169
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../lossiness"
|
|
5
|
+
require_relative "../models/conversion"
|
|
6
|
+
|
|
7
|
+
module Claricle
|
|
8
|
+
module Handlers
|
|
9
|
+
# What a handler declares so that adding its format is that one class and
|
|
10
|
+
# no edit elsewhere: how the format is recognised, what it can be
|
|
11
|
+
# converted from, and what that conversion loses. Included by Base.
|
|
12
|
+
module ExtensionPoints
|
|
13
|
+
# Bounds the READ of a source being converted INTO a handler's format,
|
|
14
|
+
# the same limit every outbound converter applies.
|
|
15
|
+
MAX_CONVERT_BYTES = 200 * 1024 * 1024
|
|
16
|
+
private_constant :MAX_CONVERT_BYTES
|
|
17
|
+
|
|
18
|
+
def self.included(base) = base.extend(ClassMethods)
|
|
19
|
+
|
|
20
|
+
module ClassMethods
|
|
21
|
+
# How this format is recognised: a block handed the first bytes of
|
|
22
|
+
# the input (a binary String, at most `Detector::HEADER_BYTES`),
|
|
23
|
+
# answering one of this handler's own formats as a Symbol, or nil.
|
|
24
|
+
# The built-in probes run first and this runs only for input none
|
|
25
|
+
# of them claimed, so a new format cannot change what an existing
|
|
26
|
+
# one detects. A Symbol outside `formats` is a defect in the
|
|
27
|
+
# handler and raises where it happens, not later as a routing miss.
|
|
28
|
+
def detect(&probe)
|
|
29
|
+
raise ArgumentError, "detect needs a block" unless probe
|
|
30
|
+
raise Error, "#{self} already declared a detector" if @detector
|
|
31
|
+
|
|
32
|
+
@detector = probe
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def detect_format(header)
|
|
36
|
+
format = @detector&.call(header)
|
|
37
|
+
return format if format.nil? || supported_formats.include?(format)
|
|
38
|
+
|
|
39
|
+
raise Error, "#{self} detected #{format.inspect}, which it did not declare in formats"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Declares that this handler can PRODUCE its formats from the given
|
|
43
|
+
# source formats. The block receives the source bytes and the
|
|
44
|
+
# target format and returns the converted bytes. Declared on the
|
|
45
|
+
# target's handler, so adding a format never edits the source's.
|
|
46
|
+
# A source handler that already lists the target in `convert_to`
|
|
47
|
+
# keeps its own path.
|
|
48
|
+
def convert_from(*sources, &producer)
|
|
49
|
+
raise ArgumentError, "convert_from needs a block" unless producer
|
|
50
|
+
|
|
51
|
+
bad = sources.grep_v(Symbol)
|
|
52
|
+
raise Error, "#{self} declared non-Symbol convert sources #{bad.inspect}" if bad.any?
|
|
53
|
+
|
|
54
|
+
sources.each do |source|
|
|
55
|
+
raise Error, "#{self} already declared a converter from #{source.inspect}" if converters.key?(source)
|
|
56
|
+
|
|
57
|
+
converters[source] = producer
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def converters
|
|
62
|
+
@converters ||= {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def convert_sources
|
|
66
|
+
converters.keys
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The SVG features converting INTO this format loses and keeps, in
|
|
70
|
+
# the vocabulary of `Lossiness`. Optional: without it a conversion
|
|
71
|
+
# into this format is reported `unknown`, never `lossless`.
|
|
72
|
+
def loss_rules(lost:, kept:)
|
|
73
|
+
raise Error, "#{self} already declared loss rules" if @loss_rule
|
|
74
|
+
|
|
75
|
+
@loss_rule = { lost: lost.freeze, kept: kept.freeze }.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :loss_rule
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Converts `image` INTO one of this handler's formats, through the
|
|
82
|
+
# producer declared with `convert_from` for the image's format.
|
|
83
|
+
# Reached from `Image#convert` only when the source's own handler
|
|
84
|
+
# does not list the target.
|
|
85
|
+
def convert_inbound(image, to:)
|
|
86
|
+
producer = self.class.converters.fetch(image.format) do
|
|
87
|
+
raise UnsupportedFormat.new(image.format, :convert, target: to)
|
|
88
|
+
end
|
|
89
|
+
content = bounded_content(image)
|
|
90
|
+
conversion(image, to, content, produce(producer, content, to))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def conversion(image, to, content, converted)
|
|
96
|
+
loss = Lossiness.classify(source_format: image.format, target_format: to,
|
|
97
|
+
source: content, rule: self.class.loss_rule)
|
|
98
|
+
Models::Conversion.new(source_path: image.path, source_format: image.format.to_s,
|
|
99
|
+
target_format: to.to_s, lossiness: loss, content: converted)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def bounded_content(image)
|
|
103
|
+
content = image.with_source { |source| bounded_read(source) }
|
|
104
|
+
return content if content.bytesize <= MAX_CONVERT_BYTES
|
|
105
|
+
|
|
106
|
+
raise ConversionError, "#{image.format} image exceeds the #{MAX_CONVERT_BYTES}-byte convert limit"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bounded_read(source)
|
|
110
|
+
return source.read(MAX_CONVERT_BYTES + 1) || "".b if source.respond_to?(:read)
|
|
111
|
+
|
|
112
|
+
source.byteslice(0, MAX_CONVERT_BYTES + 1)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Scoped to the producer alone: what the producer raises is not ours
|
|
116
|
+
# to enumerate, and the rest of `convert_inbound` is.
|
|
117
|
+
def produce(producer, content, to)
|
|
118
|
+
producer.call(content, to)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
raise ConversionError, "#{e.class}: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|