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,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