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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kind"
4
+
5
+ module Claricle
6
+ module Handlers
7
+ module PdfArlington
8
+ # Chooses the Arlington table a value is checked against.
9
+ module Definitions
10
+ LINK_GROUP = /\A\[([\w,-]+)\]\z/
11
+
12
+ module_function
13
+
14
+ # The one candidate table for this value, or nil. With several
15
+ # candidates (`[PageTreeNode,PageObject]`) the value's own /Type
16
+ # must single one out; otherwise no claim is made.
17
+ def pick(names, dict)
18
+ candidates = names.filter_map { |name| load(name) }
19
+ return candidates.first if candidates.one?
20
+
21
+ matching = candidates.select { |candidate| types_of(candidate).include?(dict&.fetch(:Type, nil).to_s) }
22
+ matching.first if matching.one?
23
+ end
24
+
25
+ def types_of(definition)
26
+ definition.field_for("Type")&.possible_value_list || []
27
+ end
28
+
29
+ def load(name)
30
+ ::Pdfrb::Arlington::Loader.object_definition(name)
31
+ rescue ::Pdfrb::Error
32
+ nil
33
+ end
34
+
35
+ # One link group per type in the field's type list (`array;stream`
36
+ # pairs with `[A];[B]`): the group of the type the value really is.
37
+ def link_names(field, kind)
38
+ match = LINK_GROUP.match(link_group(field, kind).to_s)
39
+ match && match[1].split(",")
40
+ end
41
+
42
+ def link_group(field, kind)
43
+ types = field.types_raw.split(";").map(&:strip)
44
+ groups = field.link.to_s.split(";")
45
+ return groups.first if types.one?
46
+ return unless groups.size == types.size
47
+
48
+ index = types.index { |type| Kind::ACCEPTS[type]&.include?(kind) }
49
+ groups[index] if index
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claricle
4
+ module Handlers
5
+ module PdfArlington
6
+ # Maps a Ruby value read from pdfrb onto the Arlington type names it
7
+ # satisfies. A kind of nil means "not one pdfrb type this table knows",
8
+ # and the caller then makes no claim about the value.
9
+ module Kind
10
+ ACCEPTS = {
11
+ "integer" => %i[integer], "bitmask" => %i[integer], "number" => %i[integer number],
12
+ "boolean" => %i[boolean], "name" => %i[name], "null" => %i[null], "stream" => %i[stream],
13
+ "string" => %i[string], "string-ascii" => %i[string], "string-byte" => %i[string],
14
+ "string-text" => %i[string], "date" => %i[string],
15
+ "dictionary" => %i[dictionary], "name-tree" => %i[dictionary],
16
+ "number-tree" => %i[dictionary],
17
+ "array" => %i[array], "rectangle" => %i[array], "matrix" => %i[array]
18
+ }.freeze
19
+
20
+ CLASS_KINDS = {
21
+ ::Hash => :dictionary, ::Array => :array, ::Symbol => :name, ::Integer => :integer,
22
+ ::Float => :number, ::String => :string, ::NilClass => :null,
23
+ ::TrueClass => :boolean, ::FalseClass => :boolean
24
+ }.freeze
25
+
26
+ CONTAINERS = %i[dictionary stream array].freeze
27
+
28
+ module_function
29
+
30
+ # A stream keeps its wrapper (the dictionary is only part of it); any
31
+ # other pdfrb object is reduced to the plain value it holds.
32
+ def plain(object)
33
+ return object if stream?(object)
34
+
35
+ object.respond_to?(:value) ? object.value : object
36
+ end
37
+
38
+ def stream?(object)
39
+ ::Pdfrb::Model::Cos::Stream === object # rubocop:disable Style/CaseEquality
40
+ end
41
+
42
+ def of(value)
43
+ return :stream if stream?(value)
44
+
45
+ CLASS_KINDS.find { |klass, _| value.is_a?(klass) }&.last
46
+ end
47
+
48
+ # The Arlington type names of a field, or nil when any of them is a
49
+ # predicate (`fn:SinceVersion(1.3,dictionary)`) this walker does not
50
+ # evaluate -- a partly understood type list is not checked.
51
+ def types(field)
52
+ names = field.types_raw.split(";").map(&:strip)
53
+ names if names.any? && names.all? { |name| ACCEPTS.key?(name) }
54
+ end
55
+
56
+ def allows?(names, kind)
57
+ names.any? { |name| ACCEPTS.fetch(name).include?(kind) }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claricle
4
+ module Handlers
5
+ module PdfArlington
6
+ # Runs a field's `fn:IsRequired(...)` predicate through pdfrb's
7
+ # evaluator. pdfrb 0.7.49 does not parse every predicate in its own
8
+ # tables (measured: 78 of 192 Required predicates raise SyntaxError)
9
+ # and lacks some functions, so anything it cannot parse or run is
10
+ # "not known to be required" -- never an issue.
11
+ class PredicateGate
12
+ def initialize(version)
13
+ @version = version
14
+ @parsed = {}
15
+ end
16
+
17
+ def required?(source, dict)
18
+ ast = @parsed.fetch(source) { @parsed[source] = parse(source) }
19
+ ast ? evaluate(ast, dict) == true : false
20
+ end
21
+
22
+ private
23
+
24
+ def parse(source)
25
+ ::Pdfrb::Arlington::Predicate::Parser.parse(source)
26
+ rescue ::Pdfrb::Error
27
+ nil
28
+ end
29
+
30
+ def evaluate(ast, dict)
31
+ context = ::Pdfrb::Arlington::Predicate::Context.new(current: dict, version: @version)
32
+ ::Pdfrb::Arlington::Predicate::Evaluator.call(ast, context)
33
+ rescue ::Pdfrb::Error, NoMethodError, TypeError, ArgumentError
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/issue"
4
+ require_relative "../../models/location"
5
+ require_relative "kind"
6
+ require_relative "predicate_gate"
7
+
8
+ module Claricle
9
+ module Handlers
10
+ module PdfArlington
11
+ # What Arlington says about ONE dictionary or ONE value, as Issues.
12
+ # Knows nothing about traversal.
13
+ class Rules
14
+ REQUIRED_CODE = "PDF_ARLINGTON_REQUIRED_KEY"
15
+ TYPE_CODE = "PDF_ARLINGTON_TYPE"
16
+ VALUE_CODE = "PDF_ARLINGTON_VALUE"
17
+
18
+ # `[Pages]` or `[1.0,1.1]`: plain tokens only, so a predicate
19
+ # (`[fn:Eval(...)]`) or a wildcard is never read as a literal.
20
+ PLAIN_VALUES = /\A\[([^\[\]()@*;]+)\]\z/
21
+
22
+ def initialize(version)
23
+ @version = version
24
+ @gate = PredicateGate.new(version)
25
+ end
26
+
27
+ # `inherited` answers "does an ancestor supply this key", for the
28
+ # fields Arlington marks inheritable (a Page's /Resources, /MediaBox).
29
+ def dictionary_issues(definition, dict, path, inherited)
30
+ definition.fields.filter_map do |field|
31
+ next unless missing_required?(field, dict, inherited)
32
+
33
+ issue(REQUIRED_CODE, "#{definition.name} is missing required key /#{field.key}", path)
34
+ end
35
+ end
36
+
37
+ def value_issues(field, kind, value, path)
38
+ return [] if kind.nil?
39
+
40
+ [type_issue(field, kind, path), name_issue(field, kind, value, path)].compact
41
+ end
42
+
43
+ private
44
+
45
+ def missing_required?(field, dict, inherited)
46
+ return false if field.key == "*" || !dict[field.key.to_sym].nil? || !applies?(field)
47
+ return false if field.inheritable? && inherited.call(field.key)
48
+
49
+ required_now?(field, dict)
50
+ end
51
+
52
+ def required_now?(field, dict)
53
+ field.required? || (field.required_predicate? && @gate.required?(field.required_literal, dict))
54
+ end
55
+
56
+ # A key added after the document's version, or by an extension, is
57
+ # not required of it.
58
+ def applies?(field)
59
+ field.since_version.extensions.empty? && (field.since_version <=> @version) <= 0
60
+ end
61
+
62
+ def type_issue(field, kind, path)
63
+ names = Kind.types(field)
64
+ return if names.nil? || kind == :null || Kind.allows?(names, kind)
65
+
66
+ issue(TYPE_CODE, "#{path} is a #{kind}; Arlington allows #{names.join(" or ")}", path)
67
+ end
68
+
69
+ def name_issue(field, kind, value, path)
70
+ return unless kind == :name && field.types_raw == "name"
71
+
72
+ match = PLAIN_VALUES.match(field.possible_values.to_s)
73
+ allowed = match && match[1].split(",").map(&:strip)
74
+ return if allowed.nil? || allowed.include?(value.to_s)
75
+
76
+ issue(VALUE_CODE, "#{path} is /#{value}; Arlington allows #{allowed.join(", ")}", path)
77
+ end
78
+
79
+ def issue(code, message, path)
80
+ Models::Issue.new(severity: "error", code: code, message: message,
81
+ location: Models::Location.new(node_path: path))
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/issue"
4
+ require_relative "definitions"
5
+ require_relative "kind"
6
+ require_relative "rules"
7
+
8
+ module Claricle
9
+ module Handlers
10
+ module PdfArlington
11
+ # Walks a parsed document breadth-first from the Catalog, following
12
+ # every link Arlington's tables name, and collects what `Rules` finds.
13
+ #
14
+ # Bounded three ways: an object reached through a reference is
15
+ # visited once (cycles end), the walk is a worklist rather than
16
+ # recursion (depth costs no stack), and it stops after MAX_NODES
17
+ # values with a warning that says it stopped.
18
+ class Walker
19
+ MAX_NODES = 50_000
20
+ LIMIT_CODE = "PDF_ARLINGTON_LIMIT"
21
+ MAX_ANCESTORS = 64
22
+
23
+ Item = Data.define(:value, :field, :names, :path)
24
+
25
+ def initialize(document, version)
26
+ @document = document
27
+ @rules = Rules.new(version)
28
+ @issues = []
29
+ @seen = {}
30
+ end
31
+
32
+ def issues
33
+ queue = [Item.new(@document.catalog, nil, %w[Catalog], "Catalog")]
34
+ count = 0
35
+ until queue.empty?
36
+ return limit_reached if (count += 1) > MAX_NODES || count + queue.size > MAX_NODES * 2
37
+
38
+ visit(queue.shift, queue)
39
+ end
40
+ @issues
41
+ end
42
+
43
+ private
44
+
45
+ def visit(item, queue)
46
+ value = Kind.plain(resolve(item.value))
47
+ kind = Kind.of(value)
48
+ @issues.concat(@rules.value_issues(item.field, kind, value, item.path)) if item.field
49
+ return unless Kind::CONTAINERS.include?(kind) && first_visit?(item.value)
50
+
51
+ definition = definition_for(item, value, kind)
52
+ descend(definition, value, kind, item.path, queue) if definition
53
+ end
54
+
55
+ def definition_for(item, value, kind)
56
+ names = item.names || Definitions.link_names(item.field, kind)
57
+ names && Definitions.pick(names, kind == :array ? nil : dictionary_of(value))
58
+ end
59
+
60
+ def descend(definition, value, kind, path, queue)
61
+ dict = kind == :array ? nil : dictionary_of(value)
62
+ check(definition, dict, path) if dict
63
+ entries(dict || value).each do |key, child, child_path|
64
+ field = definition.field_for(key.to_s) || definition.field_for("*")
65
+ queue << Item.new(child, field, nil, "#{path}#{child_path}") if field
66
+ end
67
+ end
68
+
69
+ def check(definition, dict, path)
70
+ @issues.concat(@rules.dictionary_issues(definition, dict, path, ->(key) { inherited?(dict, key) }))
71
+ end
72
+
73
+ def entries(container)
74
+ if container.is_a?(::Hash)
75
+ container.map { |key, child| [key, child, "/#{key}"] }
76
+ else
77
+ container.each_with_index.map { |child, index| [index, child, "[#{index}]"] }
78
+ end
79
+ end
80
+
81
+ def dictionary_of(value)
82
+ Kind.stream?(value) ? value.value : value
83
+ end
84
+
85
+ def first_visit?(raw)
86
+ return true unless raw.is_a?(::Pdfrb::Model::Reference)
87
+
88
+ @seen.key?(raw.oid) ? false : (@seen[raw.oid] = true)
89
+ end
90
+
91
+ # Walks /Parent upward, never revisiting an object and never more
92
+ # than MAX_ANCESTORS steps, so a looping page tree ends.
93
+ def inherited?(dict, key)
94
+ seen = {}
95
+ current = dict
96
+ MAX_ANCESTORS.times do
97
+ current = parent_of(current, seen)
98
+ return false unless current
99
+ return true unless current[key.to_sym].nil?
100
+ end
101
+ false
102
+ end
103
+
104
+ def parent_of(dict, seen)
105
+ parent = dict[:Parent]
106
+ return unless parent.is_a?(::Pdfrb::Model::Reference) && !seen.key?(parent.oid)
107
+
108
+ seen[parent.oid] = true
109
+ resolved = Kind.plain(resolve(parent))
110
+ resolved if resolved.is_a?(::Hash)
111
+ end
112
+
113
+ # nil when pdfrb cannot produce the object. Everything pdfrb
114
+ # reports by raising on a malformed file is "no object here"; the
115
+ # structural check owns reporting the file as broken.
116
+ def resolve(value)
117
+ @document.object(value)
118
+ rescue ::Pdfrb::Error, NoMethodError, TypeError, ArgumentError
119
+ nil
120
+ end
121
+
122
+ def limit_reached
123
+ @issues << Models::Issue.new(
124
+ severity: "warning", code: LIMIT_CODE,
125
+ message: "Arlington check stopped after #{MAX_NODES} values; the rest was not checked"
126
+ )
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pdf_arlington/walker"
4
+
5
+ module Claricle
6
+ module Handlers
7
+ # Arlington-model conformance for a parsed pdfrb document: required
8
+ # keys, value types and enumerated names, checked from the Catalog
9
+ # down. pdfrb ships Arlington's tables, predicate evaluator and
10
+ # loader but no document runner (measured on 0.7.49), so the walk is
11
+ # ours.
12
+ module PdfArlington
13
+ module_function
14
+
15
+ # `document` is a `Pdfrb::Document`. Returns an Array of
16
+ # `Models::Issue`; empty when the document has no Catalog (the
17
+ # structural check reports that).
18
+ def issues(document)
19
+ return [] unless catalog?(document)
20
+
21
+ Walker.new(document, version_of(document)).issues
22
+ end
23
+
24
+ # A Catalog that cannot be read is the structural check's finding.
25
+ def catalog?(document)
26
+ Kind.plain(document.catalog).is_a?(::Hash)
27
+ rescue ::Pdfrb::Error, NoMethodError, SystemStackError
28
+ false
29
+ end
30
+
31
+ def version_of(document)
32
+ ::Pdfrb::Arlington::PdfVersion.new(document.version.to_s)
33
+ rescue ::Pdfrb::Error, ArgumentError
34
+ ::Pdfrb::Arlington::PdfVersion.new("1.0")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/issue"
4
+ require_relative "../models/location"
5
+
6
+ module Claricle
7
+ module Handlers
8
+ # Dispatches pdfrb's named conformance families and maps their typed
9
+ # violations into Claricle's common issue model.
10
+ class PdfProfiles
11
+ PROFILE_LEVELS = {
12
+ pdf_a: %i[a1b a1a a2b a2a a3b a3a a4].freeze,
13
+ pdf_ua: nil,
14
+ pdf_x: %i[x1a x3 x4 x6].freeze,
15
+ pdf_vt: %i[vt1 vt2].freeze,
16
+ pades: %i[b-b b-t b-lt b-lta].freeze,
17
+ ltv: nil,
18
+ pdf_2_af: nil,
19
+ tagged_pdf: nil
20
+ }.freeze
21
+ VALIDATORS = {
22
+ pdf_a: :PdfA,
23
+ pdf_ua: :PdfUA,
24
+ pdf_x: :PdfX,
25
+ pdf_vt: :PdfVT,
26
+ pades: :Pades,
27
+ ltv: :Ltv,
28
+ pdf_2_af: :Pdf2AF,
29
+ tagged_pdf: :TaggedPdf
30
+ }.freeze
31
+ PADES_LEVELS = {
32
+ "b-b": :"B-B", "b-t": :"B-T",
33
+ "b-lt": :"B-LT", "b-lta": :"B-LTA"
34
+ }.freeze
35
+ private_constant :PROFILE_LEVELS, :VALIDATORS, :PADES_LEVELS
36
+
37
+ def self.profiles = PROFILE_LEVELS.keys
38
+
39
+ def self.levels_for(profile) = PROFILE_LEVELS.fetch(profile)
40
+
41
+ def self.issues(document, profile, level)
42
+ validator = ::Pdfrb::Conformance.const_get(VALIDATORS.fetch(profile))
43
+ arguments = level ? { level: delegate_level(profile, level) } : {}
44
+ validator.validate(document, **arguments).violations.map { |violation| issue(violation) }
45
+ end
46
+
47
+ def self.delegate_level(profile, level)
48
+ profile == :pades ? PADES_LEVELS.fetch(level) : level
49
+ end
50
+ private_class_method :delegate_level
51
+
52
+ def self.issue(violation)
53
+ location = violation.object && Models::Location.new(node_path: violation.object.to_s)
54
+ Models::Issue.new(
55
+ severity: violation.severity.to_s,
56
+ code: violation.rule_id.to_s,
57
+ message: violation.message,
58
+ location: location
59
+ )
60
+ end
61
+ private_class_method :issue
62
+ end
63
+
64
+ private_constant :PdfProfiles
65
+ end
66
+ end