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