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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ require_relative "base"
6
+ # Only the vocabulary, not the classifier: `../lossiness` pulls in the whole
7
+ # `Lossiness` scanner and, through `detector.rb`, REXML -- measured, a caller
8
+ # that only wants this result model to round-trip JSON was paying for that.
9
+ # `lossiness/levels` requires nothing else.
10
+ require_relative "../lossiness/levels"
11
+
12
+ # Nested `module Claricle` / `module Models`, never the compact
13
+ # `module Claricle::Models`. Measured: the compact form leaves `Claricle` out of
14
+ # Module.nesting, so the unqualified `Lossiness` below raises
15
+ # `NameError: uninitialized constant Claricle::Models::Conversion::Lossiness`.
16
+ # Reproduce with:
17
+ # ruby -e 'module A; module B; end; end; module A::B; p Module.nesting; end'
18
+ module Claricle
19
+ module Models
20
+ # Holds bytes lutaml must not try to render.
21
+ #
22
+ # `Models::Base#own_string` runs `JSON.generate` over every String
23
+ # attribute at seal time (base.rb:466-475, reached from freeze_attributes
24
+ # at base.rb:448-458), so a `:string` attribute REFUSES binary content --
25
+ # measured, `ValidationError: content expects a String JSON can render, got
26
+ # "\xFF" from ASCII-8BIT to UTF-8`. Excluding the attribute from the
27
+ # key_value mapping does not help: the check is on the stored String, not
28
+ # on what gets rendered. So the value is stored wrapped, where
29
+ # freeze_attributes does not reach it.
30
+ class BinaryContent < Lutaml::Model::Type::Value
31
+ def self.cast(value, _options = {})
32
+ return value if Lutaml::Model::Utils.uninitialized?(value)
33
+ return nil if value.nil?
34
+ return value if value.instance_of?(self)
35
+
36
+ refuse(value) unless value.instance_of?(::String)
37
+
38
+ wrap(::String.new(value).freeze)
39
+ end
40
+
41
+ # `allocate`, not `new`: `Type::Value#initialize` calls `self.class.cast`
42
+ # (lutaml-model-0.8.22/lib/lutaml/model/type/value.rb:52), so building
43
+ # the wrapper through the constructor recurses to SystemStackError --
44
+ # measured. Reproduce with:
45
+ # ruby -e 'c = Class.new(Lutaml::Model::Type::Value) do
46
+ # def self.cast(v, _o = {}) = new(v)
47
+ # end; c.cast("x")'
48
+ # The String is copied before freezing, so this does not take the
49
+ # caller's own String away from them -- the reason base.rb:445 dups.
50
+ def self.wrap(frozen_bytes)
51
+ allocate.tap do |wrapper|
52
+ wrapper.instance_variable_set(:@value, frozen_bytes)
53
+ wrapper.freeze
54
+ end
55
+ end
56
+ private_class_method :wrap
57
+
58
+ def self.serialize(value)
59
+ value.nil? ? nil : cast(value)
60
+ end
61
+
62
+ def self.refuse(value)
63
+ raise Lutaml::Model::ValidationError,
64
+ [TypeError.new("content expects a String, got #{value.class}")]
65
+ end
66
+ private_class_method :refuse
67
+ end
68
+
69
+ # One conversion's result. `lossiness` is decided per conversion from what
70
+ # the source contains (D23), never from a table of edges.
71
+ class Conversion < Base
72
+ LOSSINESS_LEVELS = Lossiness::LEVELS
73
+
74
+ attribute :source_path, :string
75
+ attribute :source_format, :string, required: true
76
+ attribute :target_format, :string, required: true
77
+ attribute :lossiness, :string, values: LOSSINESS_LEVELS, required: true
78
+ attribute :output_path, :string
79
+ attribute :content, BinaryContent
80
+
81
+ key_value do
82
+ map "source_path", to: :source_path
83
+ map "source_format", to: :source_format
84
+ map "target_format", to: :target_format
85
+ map "lossiness", to: :lossiness
86
+ map "output_path", to: :output_path
87
+ # `content` is deliberately unmapped: the bytes are an in-memory
88
+ # payload, not part of the document. Measured -- an attribute absent
89
+ # from an explicit key_value block is excluded from to_json, to_yaml
90
+ # and to_hash alike, and a "content" key in an incoming document is
91
+ # ignored rather than resurrected.
92
+ #
93
+ # It is also OPTIONAL rather than required, and that is forced: a
94
+ # required attribute excluded from the mapping cannot reload its own
95
+ # output -- measured, `from_json(to_json)` raises
96
+ # `ValidationError: Missing required attribute: content`.
97
+ end
98
+
99
+ # Unwraps the stored BinaryContent. The `*args` form, because lutaml
100
+ # defines this attribute's reader with `define_method` on this class and
101
+ # the generated reader doubles as the builder-block setter, so overriding
102
+ # it with a zero-arity reader would break `content "..."` in a builder
103
+ # block.
104
+ #
105
+ # NOT because every attribute here takes that form. `lossiness`, declared
106
+ # four lines above, is a zero-arity reader -- lutaml's enum path defines
107
+ # it elsewhere. Measured: `instance_method(:lossiness).arity` is 0 against
108
+ # -1 for `content`, and `conversion.lossiness("lossy")` raises
109
+ # ArgumentError. Same shape as Inspection#meta (inspection.rb:160).
110
+ def content(*args)
111
+ unless args.empty?
112
+ self.content = args.first
113
+ return args.first
114
+ end
115
+
116
+ @content&.value
117
+ end
118
+
119
+ # A handler's own `Conversion` never knows its `output_path` -- it is
120
+ # built before the write happens, and `Models::Base` seals (freezes)
121
+ # every instance at construction, so the field cannot be set on this
122
+ # object afterward. The caller (`Claricle.convert_one`) uses this once
123
+ # the real destination is known, rather than re-listing every other
124
+ # field by hand.
125
+ def with_output_path(path)
126
+ self.class.new(source_path: source_path, source_format: source_format,
127
+ target_format: target_format, lossiness: lossiness,
128
+ output_path: path, content: content)
129
+ end
130
+
131
+ private
132
+
133
+ # Runs BEFORE lutaml's own `validate!`, which is the whole point:
134
+ # `validate!` reaches every attribute through `public_send`, so it calls
135
+ # the `content` reader, and on a bad shape the reader is what dies.
136
+ # `validate_types` is too late -- base.rb:288-292 orders `validate!`
137
+ # ahead of it.
138
+ #
139
+ # Every non-String content refuses through `BinaryContent.cast`, EXCEPT
140
+ # an Array: lutaml's collection path never calls `cast` on the Array
141
+ # itself, so it is stored raw. Measured, `content: ["a"]` reached
142
+ # `NoMethodError: undefined method 'value' for an instance of Array`
143
+ # instead of refusing. `[1, 2]` DID refuse, but only because an Integer
144
+ # ELEMENT tripped `cast` -- a guard that holds for the wrong reason, and
145
+ # an Array of Strings walks straight past it.
146
+ #
147
+ # Reads the raw ivar, never the reader, for the same reason.
148
+ def normalize
149
+ raw = instance_variable_get(:@content)
150
+ return if raw.nil? || raw.is_a?(BinaryContent)
151
+ return if Lutaml::Model::Utils.uninitialized?(raw)
152
+
153
+ refuse(:content, "a String", raw.class)
154
+ end
155
+
156
+ # `source_format`/`target_format`/`lossiness` are `required: true` on
157
+ # the attribute itself, so lutaml's own `validate!` (runs before this,
158
+ # per the comment on `normalize` above) already refuses a missing one,
159
+ # wrapped in `Lutaml::Model::ValidationError` and naming the attribute.
160
+ # Do not re-add a hand-rolled nil check here: see the gate record for
161
+ # why `required: true` replaced one (the introspectable schema used to
162
+ # disagree with the runtime refusal).
163
+ end
164
+
165
+ private_constant :BinaryContent
166
+ end
167
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Claricle
6
+ module Models
7
+ # One row of `claricle formats --json`.
8
+ class FormatCapability < Base
9
+ # Lutaml's Boolean accepts lexical aliases such as "false" and 0.
10
+ # Capability rows are structured data, so only JSON booleans belong.
11
+ class StrictBoolean < Lutaml::Model::Type::Boolean
12
+ def self.cast(value, _options = {})
13
+ return value if value.equal?(true) || value.equal?(false)
14
+ return value if value.nil? || Lutaml::Model::Utils.uninitialized?(value)
15
+
16
+ raise Lutaml::Model::ValidationError,
17
+ [TypeError.new("boolean expects true or false")]
18
+ end
19
+ end
20
+
21
+ attribute :format, :string, required: true
22
+ # Not :inspect, which would replace Object#inspect with a boolean.
23
+ attribute :inspectable, StrictBoolean, required: true
24
+ attribute :conform, StrictBoolean, required: true
25
+ attribute :convert, StrictBoolean, required: true
26
+ attribute :convert_to, :string, collection: true
27
+
28
+ key_value do
29
+ map "format", to: :format
30
+ map "inspect", to: :inspectable
31
+ map "conform", to: :conform
32
+ map "convert", to: :convert
33
+ # Without render_empty an empty list is dropped from the row.
34
+ map "convert_to", to: :convert_to, render_empty: true
35
+ end
36
+
37
+ private_constant :StrictBoolean
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Claricle
6
+ module Models
7
+ # A Hash attribute for a plain JSON data graph.
8
+ #
9
+ # lutaml-model's own `:hash` type is shaped for XML, and it treats
10
+ # two key names as structure rather than data. Measured on 0.8.19:
11
+ #
12
+ # {"node" => {"text" => "hi", "lang" => "en"}} -> {"node" => {"lang" => "en"}}
13
+ # {"elements" => {"width" => 1}} -> {"width" => 1}
14
+ # {"text" => "hi"} -> raises NoMethodError
15
+ #
16
+ # The first silently discards a value, the second unwraps a level,
17
+ # and the third is not representable at all. That is correct for
18
+ # markup, where those names carry meaning, and wrong for `meta`,
19
+ # which is whatever a handler read out of a file -- an SVG root
20
+ # legitimately carries `elements` or `text` as an attribute name, and
21
+ # inspecting one crashed the CLI.
22
+ #
23
+ # So this type does no key-name normalisation in either direction.
24
+ # It accepts only an exact core Hash at the outer boundary and copies
25
+ # that container without invoking virtual traversal. `Inspection`
26
+ # owns and validates the complete graph before sealing it.
27
+ class FreeFormHash < Lutaml::Model::Type::Hash
28
+ CORE_INSTANCE = ::Object.instance_method(:instance_of?)
29
+ CLASS_OF = ::Object.instance_method(:class)
30
+ DUPLICATE = ::Object.instance_method(:dup)
31
+ private_constant :CORE_INSTANCE, :CLASS_OF, :DUPLICATE
32
+
33
+ # lutaml's `hash_type?` compares the class EXACTLY, so a subclass
34
+ # is not recognised as a hash and the value arrives wrapped in an
35
+ # instance of this type rather than as a bare Hash.
36
+ def self.cast(value)
37
+ return super if Lutaml::Model::Utils.uninitialized?(value)
38
+ return nil if value.nil?
39
+
40
+ value = value.value if CORE_INSTANCE.bind_call(value, self)
41
+ refuse(value) unless CORE_INSTANCE.bind_call(value, ::Hash)
42
+
43
+ copy(value)
44
+ end
45
+
46
+ # Measured on 0.8.19: the key_value pipeline never reaches this --
47
+ # `cast` runs in both directions and does the whole job. It stays
48
+ # because it is the other half of the Type contract, and what it
49
+ # would otherwise inherit is the XML-shaped `serialize` this class
50
+ # exists to keep away from `meta`.
51
+ def self.serialize(value)
52
+ return nil if value.nil?
53
+
54
+ cast(value)
55
+ end
56
+
57
+ def self.copy(value)
58
+ DUPLICATE.bind_call(value)
59
+ end
60
+ private_class_method :copy
61
+
62
+ def self.refuse(value)
63
+ got = CLASS_OF.bind_call(value)
64
+ raise Lutaml::Model::ValidationError,
65
+ [TypeError.new("meta expects core JSON values under String keys, got #{got}")]
66
+ end
67
+ private_class_method :refuse
68
+ end
69
+
70
+ # A workaround for one measured lutaml-model behaviour, not a type a
71
+ # caller ever names: `meta` is declared with it here, and what comes
72
+ # back out of a model is a plain Hash either way. Private where it is
73
+ # defined, so the guarantee survives this file being required on its
74
+ # own.
75
+ private_constant :FreeFormHash
76
+ end
77
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "base"
6
+ require_relative "free_form_hash"
7
+ require_relative "issue"
8
+
9
+ module Claricle
10
+ module Models
11
+ # Owns Inspection's free-form metadata boundary. Keeping the grammar
12
+ # and canonical copy together makes the graph accepted during
13
+ # validation exactly the frozen graph stored by the model.
14
+ module Metadata
15
+ DUPLICATE = ::Object.instance_method(:dup)
16
+ HASH_BY_IDENTITY = ::Hash.instance_method(:compare_by_identity?)
17
+ HASH_EMPTY = ::Hash.instance_method(:empty?)
18
+ HASH_SHIFT = ::Hash.instance_method(:shift)
19
+ HASH_SIZE = ::Hash.instance_method(:size)
20
+ STRING_ENCODE = ::String.instance_method(:encode)
21
+ META_MAX_DEPTH = ::JSON::State.new.max_nesting - 1
22
+ META_SCALARS = [::Integer, ::Float, ::TrueClass, ::FalseClass, ::NilClass].freeze
23
+ private_constant :DUPLICATE, :HASH_BY_IDENTITY, :HASH_EMPTY,
24
+ :HASH_SHIFT, :HASH_SIZE, :STRING_ENCODE,
25
+ :META_MAX_DEPTH, :META_SCALARS
26
+
27
+ private
28
+
29
+ def unwrap_meta(value)
30
+ core_instance?(value, FreeFormHash) ? value.value : value
31
+ end
32
+
33
+ # Copies iteratively so cycles terminate and over-deep graphs stop
34
+ # at JSON's own nesting boundary. The canonical copy is also the
35
+ # graph later stored, so validation and storage cannot observe
36
+ # different virtual views.
37
+ def canonical_meta(value)
38
+ return if value.nil?
39
+
40
+ copy = copy_meta_graph(value)
41
+ JSON.generate({ "meta" => copy })
42
+ copy
43
+ rescue JSON::JSONError => e
44
+ refuse("meta", "values JSON can render", e.message)
45
+ end
46
+
47
+ def copy_meta_graph(root)
48
+ refuse_meta(root) unless core_instance?(root, ::Hash)
49
+
50
+ copies = {}.compare_by_identity
51
+ copy = new_container(root)
52
+ copies[root] = copy
53
+ pending = [[root, copy, 1]]
54
+ populate_meta_copy(pending, copies) until pending.empty?
55
+ copies.each_value(&:freeze)
56
+ copy
57
+ end
58
+
59
+ def populate_meta_copy(pending, copies)
60
+ source, target, depth = pending.pop
61
+ return populate_hash(source, target, pending, copies, depth) if core_instance?(source, ::Hash)
62
+
63
+ each_array(source) do |item|
64
+ target << copy_meta_value(item, pending, copies, depth + 1)
65
+ end
66
+ end
67
+
68
+ def populate_hash(source, target, pending, copies, depth)
69
+ work = DUPLICATE.bind_call(source)
70
+ until HASH_EMPTY.bind_call(work)
71
+ key, item = HASH_SHIFT.bind_call(work)
72
+ refuse_meta(key) unless core_instance?(key, ::String)
73
+
74
+ target[canonical_meta_key(key)] = copy_meta_value(item, pending, copies, depth + 1)
75
+ end
76
+ refuse_meta(source) unless HASH_SIZE.bind_call(source) == HASH_SIZE.bind_call(target)
77
+ end
78
+
79
+ def canonical_meta_key(key)
80
+ STRING_ENCODE.bind_call(key, Encoding::UTF_8).freeze
81
+ rescue EncodingError => e
82
+ refuse("meta", "UTF-8 String keys", e.message)
83
+ end
84
+
85
+ def copy_meta_value(value, pending, copies, depth)
86
+ return ::String.new(value).freeze if core_instance?(value, ::String)
87
+ return value if META_SCALARS.include?(class_of(value))
88
+ return refuse_meta(value) unless core_instance?(value, ::Hash) || core_instance?(value, ::Array)
89
+
90
+ refuse_meta_depth if depth > META_MAX_DEPTH
91
+ return copies[value] if copies.key?(value)
92
+
93
+ new_container(value).tap do |copy|
94
+ copies[value] = copy
95
+ pending << [value, copy, depth]
96
+ end
97
+ end
98
+
99
+ def new_container(source)
100
+ return [] if core_instance?(source, ::Array)
101
+
102
+ refuse_meta(source) if HASH_BY_IDENTITY.bind_call(source)
103
+ {}
104
+ end
105
+
106
+ def refuse_meta(value)
107
+ got = class_of(value)
108
+ refuse("meta", "core JSON values under String keys", got)
109
+ end
110
+
111
+ def refuse_meta_depth
112
+ refuse("meta", "values JSON can render", "nesting exceeds #{META_MAX_DEPTH}")
113
+ end
114
+ end
115
+
116
+ # Inspection reports whether metadata parsed, and makes no validity
117
+ # claim -- that belongs to conform alone (D17).
118
+ class Inspection < Base
119
+ include Metadata
120
+
121
+ PARSE_STATUSES = %w[ok failed].freeze
122
+
123
+ attribute :format, :string
124
+ attribute :width, :float
125
+ attribute :height, :float
126
+ attribute :dpi, :float
127
+ attribute :color_space, :string
128
+ attribute :meta, FreeFormHash
129
+ attribute :parse_status, :string, values: PARSE_STATUSES, required: true
130
+ attribute :issues, Issue, collection: true
131
+
132
+ key_value do
133
+ map "format", to: :format
134
+ map "width", to: :width
135
+ map "height", to: :height
136
+ map "dpi", to: :dpi
137
+ map "color_space", to: :color_space
138
+ # render_empty, so a handler that deliberately reported an empty
139
+ # `meta` gets `{}` back rather than nil. Without it the key is
140
+ # omitted from JSON and reloads as nil, turning "I looked and
141
+ # there was nothing" into "I did not look".
142
+ map "meta", to: :meta, render_empty: true
143
+ map "parse_status", to: :parse_status
144
+ map "issues", to: :issues, render_empty: true
145
+ end
146
+
147
+ # Deserialization wraps the value in the type instance rather than
148
+ # casting it back: lutaml wraps any Hash whose attribute type is
149
+ # not EXACTLY `Type::Hash`, and `FreeFormHash` is a subclass. A
150
+ # caller asked for a Hash and gets one whichever way the model was
151
+ # built.
152
+ #
153
+ # The ivar directly, not `super`: 0.8.19 defines attribute readers
154
+ # with `define_method` on this very class, so writing one here
155
+ # replaces it and there is no superclass method to reach.
156
+ #
157
+ # `*args` because the generated reader doubles as the builder-form
158
+ # setter -- `Inspection.new { |i| i.meta("a" => 1) }`. A zero-arity
159
+ # reader made `meta` the one attribute that form could not set.
160
+ def meta(*args)
161
+ unless args.empty?
162
+ self.meta = args.first
163
+ return args.first
164
+ end
165
+
166
+ unwrap_meta(@meta)
167
+ end
168
+
169
+ private
170
+
171
+ def normalize
172
+ self.issues = [] if issues.nil?
173
+ end
174
+
175
+ def validate_types
176
+ super
177
+ canonical = canonical_meta(meta)
178
+ @meta = canonical unless object_frozen?(self)
179
+ end
180
+
181
+ def nested_models
182
+ issues
183
+ end
184
+ end
185
+
186
+ private_constant :Metadata
187
+ end
188
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "location"
5
+
6
+ module Claricle
7
+ module Models
8
+ class Issue < Base
9
+ SEVERITIES = %w[error warning info].freeze
10
+
11
+ attribute :severity, :string, values: SEVERITIES, required: true
12
+ attribute :code, :string
13
+ attribute :message, :string, required: true
14
+ attribute :location, Location
15
+
16
+ private
17
+
18
+ def nested_models
19
+ [location].compact
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ require_relative "base"
6
+ require_relative "uncoerced_integer"
7
+
8
+ module Claricle
9
+ module Models
10
+ # Where an issue sits in its source. Every field is nullable, because
11
+ # what a delegate can report varies by format. `byte_offset` and
12
+ # `byte_length` are a zero-based half-open range -- issue #1 asks for a
13
+ # byte range, which a bare offset cannot express.
14
+ class Location < Base
15
+ # Every position this model carries, and none of them can be
16
+ # negative: an offset into the source, the length that runs from
17
+ # it, and a line and a column, which are counts into a document.
18
+ POSITIONS = %i[byte_offset byte_length line column].freeze
19
+ # Only `validate_types` below reads it, and that is private. The two
20
+ # enums on `Inspection` and `Issue` are vocabulary a caller builds a
21
+ # model from; this is the loop counter for a check they never run.
22
+ private_constant :POSITIONS
23
+
24
+ attribute :byte_offset, UncoercedInteger
25
+ attribute :byte_length, UncoercedInteger
26
+ attribute :line, UncoercedInteger
27
+ attribute :column, UncoercedInteger
28
+ attribute :chunk, :string
29
+ attribute :node_path, :string
30
+
31
+ private
32
+
33
+ # Absent is fine -- every field is nullable. Anything present has
34
+ # to BE a non-negative Integer, not merely convert to one: the
35
+ # declared type keeps the caller's value intact precisely so this
36
+ # can tell `-0.5` from the `0` lutaml would have made of it.
37
+ #
38
+ # `inspect`, so `"3"` and `3` do not read as the same rejected
39
+ # value in the message.
40
+ def validate_types
41
+ super
42
+ POSITIONS.each do |name|
43
+ value = public_send(name)
44
+ next if value.nil? || (value.is_a?(::Integer) && !value.negative?)
45
+
46
+ refuse(name, "a non-negative integer", value.inspect)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "issue"
5
+
6
+ module Claricle
7
+ module Models
8
+ class Report < Base
9
+ attribute :source_path, :string
10
+ attribute :format, :string
11
+ attribute :issues, Issue, collection: true
12
+ attribute :profile, :string
13
+ attribute :validator_version, :string
14
+
15
+ key_value do
16
+ map "source_path", to: :source_path
17
+ map "format", to: :format
18
+ # Without render_empty an empty collection is dropped from the
19
+ # document entirely, and a consumer sees no issues key at all.
20
+ map "issues", to: :issues, render_empty: true
21
+ map "profile", to: :profile
22
+ map "validator_version", to: :validator_version
23
+ map "valid", with: { to: :valid_to_doc, from: :valid_from_doc }
24
+ end
25
+
26
+ # Derived on read, never stored, so it cannot go stale -- reloading
27
+ # recomputes it, and the collection is frozen against appends anyway.
28
+ # D8 order: info never downgrades.
29
+ def valid
30
+ return :no if issues.any? { |issue| issue.severity == "error" }
31
+ return :suspicious if issues.any? { |issue| issue.severity == "warning" }
32
+
33
+ :yes
34
+ end
35
+
36
+ def valid_to_doc(model, doc)
37
+ doc["valid"] = model.valid.to_s
38
+ end
39
+
40
+ # Whatever arrived is discarded; `valid` is recomputed from issues.
41
+ def valid_from_doc(_model, _value); end
42
+
43
+ private
44
+
45
+ def normalize
46
+ self.issues = [] if issues.nil?
47
+ end
48
+
49
+ def nested_models
50
+ issues
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Claricle
6
+ module Models
7
+ # An integer attribute that hands validation the value it was given,
8
+ # rather than a coercion of it.
9
+ #
10
+ # lutaml's own `:integer` casts before anything can look at the
11
+ # value, and the cast destroys the evidence a check would need --
12
+ # measured on 0.8.19, at construction and through `from_json` alike:
13
+ # `-0.5` becomes `0`, `1.5` becomes `1`, `true` becomes `1`, `false`
14
+ # becomes `0` and `"3"` becomes `3`. A non-negative check running
15
+ # after that sees only the result, so `{"byte_offset":-0.5}`
16
+ # deserialized, sealed, and rendered `0` for a position the document
17
+ # never carried.
18
+ #
19
+ # So the cast is the identity, and the declaring model's
20
+ # `validate_types` decides. One rule, in one place, with the value the
21
+ # caller actually supplied still in hand to name in the message.
22
+ # Everything a model stores is an Integer or nil by the time the
23
+ # lifecycle seals it, so `Type::Integer.serialize` -- which renders
24
+ # through this same cast -- has nothing left to convert.
25
+ #
26
+ # Its own file rather than Location's, because BatchItem's `exit_code`
27
+ # needs it for the same reason and this codebase requires what it names
28
+ # rather than leaning on a transitive require.
29
+ class UncoercedInteger < Lutaml::Model::Type::Integer
30
+ def self.cast(value, _options = {})
31
+ value
32
+ end
33
+ end
34
+
35
+ # A coercion workaround, not a type a caller ever names.
36
+ private_constant :UncoercedInteger
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+ require_relative "../lossiness"
5
+
6
+ module Claricle
7
+ module PostscriptFidelity
8
+ # The lossiness of an svg -> ps/eps conversion: `:lossless` only holds
9
+ # when `repair` could read the SVG it was meant to fix.
10
+ module Verdict
11
+ module_function
12
+
13
+ def classify(source_format, target, svg)
14
+ lossiness = Lossiness.classify(source_format: source_format, target_format: target, source: svg)
15
+ return lossiness unless lossiness == "lossless" && POSTSCRIPT_TARGETS.include?(target)
16
+
17
+ repairable?(svg) && renderable?(svg) ? lossiness : "unknown"
18
+ end
19
+
20
+ # A viewBox with no area disables rendering, so nothing it draws is right.
21
+ def renderable?(svg)
22
+ text = Detector.read_root(svg)&.last&.fetch("viewBox", nil)
23
+ box = ViewBox.parse(text)
24
+ text.nil? || box.nil? || box.last(2).all?(&:positive?)
25
+ end
26
+
27
+ def repairable?(svg)
28
+ return true unless svg.match?(HINT)
29
+
30
+ doc = REXML::Document.new(svg)
31
+ !doc.root.nil? && doc.encoding == "UTF-8"
32
+ rescue REXML::ParseException, RuntimeError, EncodingError, ArgumentError
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end