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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../detector"
4
+
5
+ module Claricle
6
+ module PostscriptFidelity
7
+ # The root viewBox, which postsvg reads as [llx lly urx ury] instead of
8
+ # [x y width height]: only an origin of 0 0 is read correctly.
9
+ module ViewBox
10
+ module_function
11
+
12
+ # [x, y, width, height], or nil unless four numbers.
13
+ def parse(text)
14
+ parts = text.to_s.strip.split(/[\s,]+/).map { |part| Float(part, exception: false) }
15
+ parts if parts.length == 4 && parts.all?
16
+ end
17
+
18
+ def root(svg) = parse(Detector.read_root(svg)&.last&.fetch("viewBox", nil))
19
+
20
+ # The height of the viewport `orient` flips about, or nil when unknown.
21
+ def viewport_sum(svg)
22
+ attributes = Detector.read_root(svg)&.last or return
23
+ return height_at_origin(attributes["viewBox"]) if attributes["viewBox"]
24
+
25
+ height = PostscriptFidelity::LENGTH.match(attributes["height"].to_s)
26
+ Float(height[1]) if height
27
+ end
28
+
29
+ # [width, height, scale, dx, dy] mapping the viewBox into the width/height
30
+ # viewport (preserveAspectRatio's default, xMidYMid meet), or nil when
31
+ # the viewBox already is the viewport.
32
+ def fit(svg)
33
+ attributes = Detector.read_root(svg)&.last or return
34
+ box = parse(attributes["viewBox"])
35
+ return unless box && box[2].positive? && box[3].positive?
36
+
37
+ width, height = viewport(attributes, box)
38
+ place(width, height, box) unless [width, height] == box[2, 2]
39
+ end
40
+
41
+ def place(width, height, box)
42
+ scale = [width / box[2], height / box[3]].min
43
+ [width, height, scale, *[width - (scale * box[2]), height - (scale * box[3])].map { |slack| slack / 2 }]
44
+ end
45
+
46
+ # A missing width or height follows the viewBox's aspect ratio.
47
+ def viewport(attributes, box)
48
+ width, height = %w[width height].map { |name| length(attributes[name]) }
49
+ width ||= height ? height * box[2] / box[3] : box[2]
50
+ [width, height || (width * box[3] / box[2])]
51
+ end
52
+
53
+ def length(text)
54
+ match = PostscriptFidelity::LENGTH.match(text.to_s)
55
+ Float(match[1]) if match
56
+ end
57
+ private_class_method :place, :viewport, :length
58
+
59
+ # The origin the delegate was NOT shown: `rebase_origin?` zeroed it, so `orient` must shift by it.
60
+ def rebased_origin(original, fixed)
61
+ before = root(original)
62
+ after = root(fixed)
63
+ after && after[0, 2] == [0.0, 0.0] && before ? before[0, 2] : [0.0, 0.0]
64
+ end
65
+
66
+ # Rewrites a non-zero origin to 0 0; true when it changed.
67
+ def rebase_origin?(element)
68
+ box = parse(element.attributes["viewBox"])
69
+ return false if box.nil? || box[0, 2] == [0.0, 0.0]
70
+
71
+ element.attributes["viewBox"] = "0 0 #{box[2]} #{box[3]}"
72
+ true
73
+ end
74
+
75
+ def height_at_origin(text)
76
+ x, y, _, height = parse(text)
77
+ height if x&.zero? && y&.zero?
78
+ end
79
+ private_class_method :height_at_origin
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+ require_relative "detector"
5
+ require_relative "postscript_fidelity/view_box"
6
+ require_relative "postscript_fidelity/verdict"
7
+
8
+ module Claricle
9
+ # Closes seven silent losses in postsvg-0.3.0's SVG -> PS/EPS writer, so the
10
+ # `:lossless` verdict for the shapes Lossiness proves kept is true.
11
+ # `repair` rewrites the SVG before the writer sees it; `orient` fixes the
12
+ # PostScript it returns. Both pass their input through untouched when they
13
+ # cannot read it, leaving the writer's own error to surface.
14
+ #
15
+ # color.rb:60 /\argb\(/ is BEL, not \A: rgb() never parses, paint is dropped
16
+ # attribute_parser.rb:25 number() takes bare "-?\d+(\.\d+)?" only: "10px", ".5", "+5" read as 0
17
+ # line_handler.rb:12 a <line> is always stroked; SVG's default stroke is none
18
+ # (no code) user space is y-down; PostScript is y-up and nothing flips it.
19
+ # `translate` + `scale`, not `concat`: Postsvg.to_svg ignores concat.
20
+ # svg/parser.rb:27-33 viewBox is read as [llx lly urx ury], so a non-zero origin is
21
+ # misplaced: `repair` rewrites it to origin 0 0, `orient` shifts by it.
22
+ # (no code) width/height are ignored when a viewBox is present: `orient` fits the
23
+ # viewBox into them (xMidYMid meet) and sizes the page to them.
24
+ # (no code) the flip mirrors `show` text: each is counter-flipped about its origin.
25
+ # (`<image>` is emitted as a bare `image` operator with no data: nothing to flip.)
26
+ module PostscriptFidelity
27
+ SVG_NAMESPACE = "http://www.w3.org/2000/svg"
28
+ LENGTH_ATTRIBUTES = %w[width height x y x1 y1 x2 y2].freeze
29
+ PAINT_ATTRIBUTES = %w[fill stroke].freeze
30
+ LENGTH = /\A[ \t\r\n]*([+-]?(?:\d+\.\d+|\.\d+|\d+))(?:px)?[ \t\r\n]*\z/i
31
+ RGB = /\A[ \t\r\n]*rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)[ \t\r\n]*\z/
32
+ STROKE_INHERITING = %w[stroke style class].freeze
33
+ BBOX = /^%%BoundingBox: (\S+) (\S+) (\S+) (\S+)$/
34
+ HINT = /px|rgb\(|<line|[+.]\d|viewBox/i
35
+ SHOW = /^(\S+) (\S+) moveto\n(\((?:\\.|[^\\)])*\) show)$/
36
+
37
+ POSTSCRIPT_TARGETS = %i[eps ps].freeze
38
+
39
+ module_function
40
+
41
+ # Yields the SVG to hand the delegate and returns what it produced,
42
+ # corrected. Other targets pass straight through.
43
+ def convert(svg, target)
44
+ return yield(svg) unless POSTSCRIPT_TARGETS.include?(target)
45
+
46
+ fixed = repair(svg)
47
+ orient(yield(fixed), fixed, ViewBox.rebased_origin(svg, fixed))
48
+ end
49
+
50
+ def repair(svg)
51
+ return svg unless svg.match?(HINT)
52
+
53
+ doc = REXML::Document.new(svg)
54
+ return svg unless doc.root && doc.encoding == "UTF-8"
55
+
56
+ changed = svg_elements(doc.root).map { |element| repaired?(element, doc) }
57
+ changed << ViewBox.rebase_origin?(doc.root)
58
+ changed.any? ? doc.to_s : svg
59
+ rescue REXML::ParseException, RuntimeError, EncodingError, ArgumentError
60
+ svg
61
+ end
62
+
63
+ def orient(postscript, svg, origin = [0.0, 0.0])
64
+ match = BBOX.match(postscript)
65
+ return postscript unless match && postscript.include?("%%EndComments\n")
66
+
67
+ box = match.captures.map { |text| Float(text, exception: false) }
68
+ return postscript unless box.all?
69
+
70
+ fit = ViewBox.fit(svg)
71
+ return upright(scaled(postscript, fit, origin)) if fit
72
+
73
+ upright(reflect(postscript, box, ViewBox.viewport_sum(svg) || (box[1] + box[3]), origin))
74
+ end
75
+
76
+ # width/height differ from the viewBox: postsvg sized the page to the viewBox and applied no scale.
77
+ def scaled(postscript, fit, origin)
78
+ width, height, scale, dx, dy = fit
79
+ shift = origin_shift(origin)
80
+ steps = "#{number(dx)} #{number(dy)} translate\n#{number(scale)} #{number(scale)} scale\n"
81
+ postscript.sub(BBOX) { "%%BoundingBox: 0 0 #{number(width)} #{number(height)}" }
82
+ .sub("%%EndComments\n") { "%%EndComments\n0 #{number(height)} translate\n1 -1 scale\n#{steps}#{shift}" }
83
+ end
84
+
85
+ def origin_shift(origin)
86
+ origin.all?(&:zero?) ? "" : "#{origin.map { |value| number(-value) }.join(" ")} translate\n"
87
+ end
88
+
89
+ def reflect(postscript, box, sum, origin)
90
+ llx, lly, urx, ury = box
91
+ bbox = [llx, sum - ury, urx, sum - lly].map { |value| number(value) }.join(" ")
92
+ shift = origin_shift(origin)
93
+ postscript.sub(BBOX) { "%%BoundingBox: #{bbox}" }
94
+ .sub("%%EndComments\n") { "%%EndComments\n0 #{number(sum)} translate\n1 -1 scale\n#{shift}" }
95
+ end
96
+
97
+ # The page flip mirrors glyphs; flip each `show` back about its own origin.
98
+ def upright(postscript)
99
+ postscript.gsub(SHOW) do
100
+ x, y, text = Regexp.last_match.captures
101
+ "gsave\n#{x} #{y} translate\n1 -1 scale\n0 0 moveto\n#{text}\ngrestore"
102
+ end
103
+ end
104
+
105
+ def svg_elements(root)
106
+ found = [root]
107
+ root.each_recursive { |el| found << el }
108
+ found.select { |el| el.namespace == SVG_NAMESPACE }
109
+ end
110
+
111
+ def repaired?(element, doc)
112
+ return removed_unstroked_line?(element, doc) if element.name == "line"
113
+
114
+ edits = element.attributes.map { |name, value| [name, rewrite(name, value)] }
115
+ edits.reject! { |name, value| value.nil? || element.attributes[name] == value }
116
+ edits.each { |name, value| element.attributes[name] = value }
117
+ edits.any?
118
+ end
119
+
120
+ def rewrite(name, value)
121
+ if LENGTH_ATTRIBUTES.include?(name) && (match = LENGTH.match(value))
122
+ number(Float(match[1]))
123
+ elsif PAINT_ATTRIBUTES.include?(name) && (match = RGB.match(value))
124
+ match.captures.map { |channel| format("%02x", [channel.to_i, 255].min) }.join.prepend("#")
125
+ end
126
+ end
127
+
128
+ # Only a line whose stroke is certainly none: its own `stroke` says so, or
129
+ # nothing from it, its ancestors or a stylesheet can supply one.
130
+ def removed_unstroked_line?(line, doc)
131
+ return false if line.attributes["style"] || line.attributes["class"]
132
+ return false unless line.attributes["stroke"] == "none" || stroke_free?(line, doc)
133
+
134
+ line.remove
135
+ true
136
+ end
137
+
138
+ def stroke_free?(line, doc)
139
+ return false if line.attributes["stroke"] || svg_elements(doc.root).any? { |el| el.name == "style" }
140
+
141
+ ancestors = []
142
+ node = line.parent
143
+ while node.is_a?(REXML::Element)
144
+ ancestors << node
145
+ node = node.parent
146
+ end
147
+ ancestors.none? { |el| STROKE_INHERITING.any? { |name| el.attributes[name] } }
148
+ end
149
+
150
+ def number(value)
151
+ text = format("%.12f", value).sub(/\.?0+\z/, "")
152
+ text == "-0" ? "0" : text
153
+ end
154
+
155
+ private_class_method :svg_elements, :repaired?, :rewrite, :removed_unstroked_line?, :stroke_free?, :number,
156
+ :reflect, :upright, :scaled, :origin_shift
157
+ end
158
+
159
+ private_constant :PostscriptFidelity
160
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requires live here, next to the derivation that needs them, so there is
4
+ # no load order for the entry point to get wrong. Every file in handlers/
5
+ # is loaded, so a new format is one new file there and nothing else.
6
+ require_relative "errors"
7
+ require_relative "handlers/base"
8
+ Dir[File.join(__dir__, "handlers", "*.rb")].each { |file| require file }
9
+
10
+ module Claricle
11
+ module Registry
12
+ # Every handler class loaded above, in name order so nothing depends on
13
+ # the order the filesystem listed the files in.
14
+ HANDLER_CLASSES = Handlers.const_get(:Base).subclasses.sort_by(&:to_s).freeze
15
+
16
+ class << self
17
+ def handler_for(format)
18
+ HANDLERS.fetch(format) { raise UnsupportedFormat, format }
19
+ end
20
+
21
+ def formats
22
+ HANDLERS.keys.sort
23
+ end
24
+
25
+ # What one format's handler can actually do -- the `formats`
26
+ # command builds its row from this. Derived from the handler, so a
27
+ # row cannot advertise an operation it has not implemented.
28
+ def capabilities_for(format)
29
+ handler_for(format).capabilities
30
+ end
31
+
32
+ # The profile names one format accepts. Sorted, so the order a
33
+ # handler happens to declare them in never leaks into a message.
34
+ def profiles_for(format)
35
+ handler_for(format).supported_profiles.sort
36
+ end
37
+
38
+ # Every profile name ANY registered format accepts. This is what
39
+ # lets a batch reject a typo before it opens a single file --
40
+ # whether the name fits the format in hand is a per-file question
41
+ # and is asked again there.
42
+ def profiles
43
+ HANDLERS.values.flat_map(&:supported_profiles).uniq.sort
44
+ end
45
+
46
+ # The user-facing level names for a profile, or nil when that
47
+ # profile has no separate level selector.
48
+ def levels_for_profile(profile)
49
+ owner = HANDLERS.values.uniq.find { |handler| handler.supported_profiles.include?(profile) }
50
+ owner&.levels_for(profile)
51
+ end
52
+
53
+ # What one format's handler can convert to -- the `formats` command's
54
+ # `convert_to` column builds from this, the same way `capabilities_for`
55
+ # builds the operations column.
56
+ #
57
+ # `- [format]` matters only for a handler owning more than one format
58
+ # (Handlers::Postscript, :eps and :ps): its `convert_to` declares the
59
+ # union both can reach, since a class-level declaration cannot vary
60
+ # per image, so the declared list itself still names the format asked
61
+ # about as one of its own targets. Every single-format handler's own
62
+ # format was never in its declared list to begin with, so this is a
63
+ # no-op for them.
64
+ #
65
+ # Targets another handler declared it can produce from this format
66
+ # (`convert_from`) are included, so adding a format makes it a target
67
+ # of every source it accepts without editing any of them.
68
+ def convert_targets_for(format)
69
+ (handler_for(format).convert_targets + inbound_targets(format)).uniq - [format]
70
+ end
71
+
72
+ # The handler to convert a `from` image to `to`, when `to` is a format
73
+ # whose own handler declared it accepts `from` and `from`'s handler
74
+ # does not already list `to`. nil means "ask the source's handler".
75
+ def inbound_handler(from:, to:)
76
+ return if HANDLERS[from]&.convert_targets&.include?(to)
77
+
78
+ owner = HANDLERS[to]
79
+ owner if owner&.convert_sources&.include?(from)
80
+ end
81
+
82
+ # The format a handler's declared detector recognises in these leading
83
+ # bytes, or nil. Reached by the detector only after its built-in
84
+ # probes have all declined.
85
+ def detect(header)
86
+ HANDLER_CLASSES.each do |handler|
87
+ format = handler.detect_format(header)
88
+ return format if format
89
+ end
90
+ nil
91
+ end
92
+
93
+ private
94
+
95
+ def inbound_targets(format)
96
+ HANDLERS.values.uniq.select { |handler| handler.convert_sources.include?(format) }
97
+ .flat_map(&:supported_formats)
98
+ end
99
+
100
+ # Two classes claiming a format is a configuration defect, not a
101
+ # last-one-wins: the design has one immutable owner per format.
102
+ def build(handler_classes)
103
+ handler_classes.each_with_object({}) do |handler, map|
104
+ handler.supported_formats.each do |format|
105
+ if map.key?(format)
106
+ raise Error, "duplicate handler for #{format.inspect}: " \
107
+ "#{map[format]} and #{handler}"
108
+ end
109
+
110
+ map[format] = handler
111
+ end
112
+ end.freeze
113
+ end
114
+ end
115
+
116
+ HANDLERS = send(:build, HANDLER_CLASSES)
117
+
118
+ private_constant :HANDLER_CLASSES, :HANDLERS
119
+ end
120
+
121
+ # Here rather than in the entry point, for the same reason the requires
122
+ # above are: requiring this file on its own is a supported path, and it
123
+ # used to leave Registry public until claricle.rb happened to run.
124
+ private_constant :Registry
125
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Claricle
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end