sevgi-derender 0.73.2 → 0.94.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f93d7904c06fa7f3f220a38fa776e1e6cf31e8dbe7c1dba45616dc06f045a47c
4
- data.tar.gz: 959530f54e652a97025fa83ff7a5799f45640772357059b890742faaa83768e4
3
+ metadata.gz: 9f1b7265f14be33da5b43f634ac82059a6d438e723ecc2153a062452ea1610fc
4
+ data.tar.gz: 5c52f4aef783cd3b3c712f19883ebbcab9c28efce3802318fe1b1f55b2a7fd88
5
5
  SHA512:
6
- metadata.gz: e8d3d5908c4c9fb6d729152a59ebbf644434ca5d19ffae80c2e94599f13048e42ef9f00377a982c584a5bd6bffb018c7666de46b5b1051245e9d274505d98513
7
- data.tar.gz: b360cb383d3967c847553288ba376c994c602eff23b539abdec42bebb613c9e35c15ba2f2fe06f782a7ce41ad50aea01979fce62d3b712153e17e1fe9087ad03
6
+ metadata.gz: 98d494547c8854554dc9f791c4abd6ef1e99eb726face62c03b49756a0694be0267d311b103ac35a3797ad52dcc60cacf770c270569babcf5c4a6e9ee1bb0930
7
+ data.tar.gz: 3b127ab458de366d81b7efc0fb8b4ba94d6ce26cf2c9761389e4375aea02a079aaaa0c8447841416e45857674d9daee4494ffb1c28e2bca30b8944ba3addeeb3
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Derender follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Derender is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md CHANGED
@@ -0,0 +1,44 @@
1
+ # Sevgi Derender
2
+
3
+ Converts SVG/XML content back into Sevgi DSL source.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-derender
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/derender"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ source = "<svg><rect width=\"3\" height=\"5\"/></svg>"
21
+ Sevgi::Derender.derender(source)
22
+ ```
23
+
24
+ ## Executable
25
+
26
+ ```sh
27
+ igves drawing.svg
28
+ ```
29
+
30
+ ## Ruby compatibility
31
+
32
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
33
+
34
+ ## Native prerequisites
35
+
36
+ None beyond Ruby and this gem's Ruby dependencies. Nokogiri may use platform packages depending on the target Ruby
37
+ platform.
38
+
39
+ ## Links
40
+
41
+ - Documentation: https://sevgi.roktas.dev
42
+ - API documentation: https://www.rubydoc.info/gems/sevgi-derender
43
+ - Source: https://github.com/roktas/sevgi/tree/main/derender
44
+ - Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
@@ -4,14 +4,23 @@ require "sevgi"
4
4
 
5
5
  module Sevgi
6
6
  module Binaries
7
+ # Implements the `igves` executable that converts SVG files into Sevgi DSL source.
7
8
  module Igves
8
9
  extend self
9
10
 
11
+ # Executable name used in help output.
10
12
  PROGNAME = "igves"
11
13
 
14
+ # Error raised for invalid command-line usage.
12
15
  Error = Class.new(::Sevgi::Error)
13
16
 
17
+ # Parsed command-line options for the `igves` executable.
18
+ # @api private
14
19
  Options = Struct.new(:vomit, :help, :version) do
20
+ # Parses command-line options and removes them from the argv array.
21
+ # @param argv [Array<String>] mutable command-line argument array
22
+ # @return [Sevgi::Binaries::Igves::Options] parsed options
23
+ # @raise [Sevgi::Binaries::Igves::Error] when an option is not recognized
15
24
  def self.parse(argv)
16
25
  new.tap do |options|
17
26
  argv.first.start_with?("-") ? option(argv, options) : break until argv.empty?
@@ -23,6 +32,8 @@ module Sevgi
23
32
 
24
33
  def option(argv, options)
25
34
  case (arg = argv.shift)
35
+ when "-x", "--exception"
36
+ options.vomit = true
26
37
  when "-h", "--help"
27
38
  options.help = true
28
39
  when "-v", "--version"
@@ -36,25 +47,49 @@ module Sevgi
36
47
 
37
48
  private_constant :Options
38
49
 
50
+ # Runs the `igves` command-line interface.
51
+ # @param argv [Array<String>, String, nil] command-line arguments
52
+ # @return [nil]
53
+ # @raise [Sevgi::ArgumentError] when the SVG file cannot be found
54
+ # @raise [Sevgi::PanicError] when generated Ruby source cannot be formatted
55
+ # @raise [StandardError] when `--exception` or `SEVGI_VOMIT` requests raw errors
56
+ # @raise [SystemExit] when command-line usage aborts
39
57
  def call(argv)
40
- return puts(help) if (options = Options.parse(argv = Array(argv))).help
41
- return puts(::Sevgi::VERSION) if options.version
42
-
43
- puts(run(argv.shift, options))
58
+ dispatch(Array(argv))
44
59
  rescue Binaries::Igves::Error => e
45
60
  abort(e.message)
46
61
  end
47
62
 
48
63
  private
49
64
 
65
+ def dispatch(argv)
66
+ options = Options.parse(argv)
67
+ return puts(help) if options.help
68
+ return puts(::Sevgi::VERSION) if options.version
69
+
70
+ print_file(argv.shift, options)
71
+ rescue Binaries::Igves::Error
72
+ raise
73
+ rescue ::StandardError => e
74
+ raise if raw_error?(options)
75
+
76
+ die(e, nil)
77
+ end
78
+
50
79
  def die(error, _file)
51
80
  warn(error.message)
52
81
  warn("")
53
- error.backtrace!.each { warn(" #{it}") }
82
+ backtrace(error).each { warn(" #{it}") }
54
83
 
55
84
  exit(1)
56
85
  end
57
86
 
87
+ def backtrace(error)
88
+ return error.backtrace! if error.respond_to?(:backtrace!)
89
+
90
+ error.backtrace || []
91
+ end
92
+
58
93
  def help
59
94
  <<~HELP
60
95
  Usage: #{PROGNAME} [options...] <SVG file>
@@ -74,6 +109,20 @@ module Sevgi
74
109
 
75
110
  Derender.derender_file(file)
76
111
  end
112
+
113
+ def print_file(file, options)
114
+ puts(run(file, options))
115
+ rescue Binaries::Igves::Error
116
+ raise
117
+ rescue ::StandardError => e
118
+ raise if raw_error?(options)
119
+
120
+ die(e, file)
121
+ end
122
+
123
+ def raw_error?(options)
124
+ options&.vomit || ENV.fetch(ENVVOMIT, nil)
125
+ end
77
126
  end
78
127
  end
79
128
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Sevgi
4
4
  module Derender
5
+ # Converts SVG/XML attribute hashes into Sevgi DSL keyword source.
6
+ # @api private
5
7
  module Attributes
8
+ # Attribute keys rendered before ordinary attributes.
6
9
  ATTRIBUTES_SHOULD_COME_FIRST = %w[
7
10
  id
8
11
  inkscape:label
@@ -14,11 +17,17 @@ module Sevgi
14
17
  xmlns:_
15
18
  ]
16
19
  .freeze
20
+
21
+ # Attribute keys rendered after ordinary attributes.
17
22
  ATTRIBUTES_SHOULD_COME_LAST = %w[
18
23
  style
19
24
  ].freeze
20
25
 
26
+ # Converts an attribute hash into Sevgi DSL keyword source.
27
+ # @param hash [Hash{String, Symbol => Object}] attributes to render
28
+ # @return [String] Ruby keyword or hash source
21
29
  def decompile(hash)
30
+ hash = hash.dup
22
31
  pre, post = {}, {}
23
32
 
24
33
  ATTRIBUTES_SHOULD_COME_FIRST.each { |key| pre[key] = hash.delete(key) if hash.key?(key) }
@@ -29,7 +38,8 @@ module Sevgi
29
38
  key = Css.to_key(key) if key.is_a?(::String)
30
39
 
31
40
  if key == "style"
32
- "{ #{Attributes.decompile(Css.to_h!(value))} }"
41
+ style = Css.to_h!(value)
42
+ style.empty? ? "{}" : "{ #{Attributes.decompile(style)} }"
33
43
  elsif value.is_a?(::String)
34
44
  Css.to_value(value)
35
45
  elsif value.is_a?(::Hash)
@@ -38,7 +48,7 @@ module Sevgi
38
48
  value
39
49
  end => value
40
50
 
41
- key.match?(/\W/) ? "\"#{key}\": #{value}" : "#{key}: #{value}"
51
+ key.match?(/\W/) ? "#{Ruby.literal(key)}: #{value}" : "#{key}: #{value}"
42
52
  end
43
53
  .join(", ")
44
54
  end
@@ -4,46 +4,74 @@ require "nokogiri"
4
4
 
5
5
  module Sevgi
6
6
  module Derender
7
+ # Parsed SVG/XML document wrapper used by the derender pipeline.
7
8
  class Document
8
- @cache = {}
9
-
10
- class << self
11
- attr_reader :cache
12
- end
13
-
9
+ # Loads and parses an SVG/XML file.
10
+ #
11
+ # Each call reads the current file content and returns an isolated parsed document. Caller mutation of one loaded
12
+ # document does not affect later loads of the same path.
13
+ # @param path [String] path to the source file, with or without `.svg` extension
14
+ # @return [Sevgi::Derender::Document] document wrapper
15
+ # @raise [Sevgi::ArgumentError] when the file cannot be found or file content is malformed XML
16
+ # @raise [Errno::EACCES] when the file cannot be read
14
17
  def self.load_file(path)
15
18
  entry = ::File.expand_path(F.qualify(path, "svg"))
16
19
 
17
20
  ArgumentError.("File not found: #{path}") unless ::File.exist?(entry)
18
21
 
19
- new(::File.read(entry)) do
20
- @doc = self.class.cache[entry] ||
21
- begin
22
- self.class.cache[entry] = self.class.parse(::File.read(entry))
23
- end
24
- end
22
+ content = ::File.read(entry)
23
+ new(content)
25
24
  end
26
25
 
27
- def self.parse(content) = Nokogiri::XML(content)
26
+ # Parses SVG/XML content in strict XML mode.
27
+ # @param content [String] SVG/XML source content
28
+ # @return [Nokogiri::XML::Document] parsed XML document
29
+ # @raise [Sevgi::ArgumentError] when content is not well-formed XML
30
+ # @raise [Sevgi::ArgumentError] when content has no root element
31
+ def self.parse(content)
32
+ Nokogiri::XML(content.to_s.lstrip, &:strict).tap do |doc|
33
+ ArgumentError.("XML document has no root element") unless doc.root
34
+ end
28
35
 
36
+ rescue Nokogiri::XML::SyntaxError => e
37
+ raise ArgumentError, "Malformed XML: #{e.message.lines.first.strip}", cause: e
38
+ end
39
+
40
+ # Extracts the XML declaration from SVG/XML content.
41
+ # @param content [String] SVG/XML source content
42
+ # @return [String, nil] XML declaration line, if present
29
43
  def self.declaration(content)
30
- return unless (content = content.lstrip).start_with?("<?xml ")
44
+ return unless (content = content.to_s.lstrip).start_with?("<?xml ")
31
45
 
32
- content.split("\n").first
46
+ content[/\A<\?xml\b.*?\?>/m]
33
47
  end
34
48
 
49
+ # @!attribute [r] doc
50
+ # @return [Nokogiri::XML::Document] parsed XML document
51
+ # @!attribute [r] decl
52
+ # @return [String, nil] XML declaration line, if present
35
53
  attr_reader :doc, :decl
36
54
 
55
+ # Builds a parsed document wrapper from SVG/XML content.
56
+ # @param content [String] SVG/XML source content
57
+ # @yield optional initializer used by {load_file} to install cached parse state
58
+ # @yieldreturn [void]
59
+ # @return [void]
60
+ # @raise [Sevgi::ArgumentError] when content is malformed XML
37
61
  def initialize(content, &block)
38
62
  instance_exec(&block) if block
39
63
 
40
- @doc = self.class.parse(content)
64
+ @doc ||= self.class.parse(content)
41
65
  @decl = self.class.declaration(content)
42
66
  end
43
67
 
68
+ # Converts the root or selected node into a derender node.
69
+ # @param id [String, nil] optional SVG id selecting a node inside the document
70
+ # @return [Sevgi::Derender::Node] selected node in the derender tree
71
+ # @raise [Sevgi::ArgumentError] when the document has no root element or the id is absent
44
72
  def decompile(id = nil)
45
73
  if id
46
- if (found = doc.xpath("//*[@id='#{id}']") || []).empty?
74
+ if (found = doc.xpath("//*[@id=#{xpath_literal(id)}]") || []).empty?
47
75
  ArgumentError.("No such element with id '#{id}' in document")
48
76
  end
49
77
 
@@ -52,15 +80,46 @@ module Sevgi
52
80
  doc.root
53
81
  end => element
54
82
 
55
- Node.new(element, pres)
83
+ ArgumentError.("XML document has no root element") unless element
84
+
85
+ Node.new(element, pres, namespaces: namespace_scope(element))
56
86
  end
57
87
 
88
+ # Returns XML declaration and pre-root nodes preserved for root decompilation.
89
+ # @return [Array<String>] preamble XML lines
58
90
  def pres
59
91
  @pres ||= [].tap do |lines|
60
92
  lines.append(*doc.children.take_while { |node| node != doc.root }.map(&:to_xml))
61
93
  lines.unshift(decl) unless lines.first == decl
62
94
  end
63
95
  end
96
+
97
+ private
98
+
99
+ def namespace_scope(element)
100
+ element == doc.root ? local_namespaces(element) : element.namespaces
101
+ end
102
+
103
+ def local_namespaces(element)
104
+ return {} unless element.respond_to?(:namespace_definitions)
105
+
106
+ element.namespace_definitions.to_h do |namespace|
107
+ [namespace.prefix ? "xmlns:#{namespace.prefix}" : "xmlns", namespace.href]
108
+ end
109
+ end
110
+
111
+ def xpath_literal(value)
112
+ value = value.to_s
113
+
114
+ return "'#{value}'" unless value.include?("'")
115
+ return "\"#{value}\"" unless value.include?("\"")
116
+
117
+ parts = value.split("'", -1).flat_map.with_index do |part, index|
118
+ index.zero? ? [part] : ["'", part]
119
+ end
120
+
121
+ "concat(#{parts.map { |part| part == "'" ? "\"'\"" : "'#{part}'" }.join(", ")})"
122
+ end
64
123
  end
65
124
  end
66
125
  end
@@ -3,7 +3,11 @@
3
3
  module Sevgi
4
4
  module Derender
5
5
  module Elements
6
+ # Default element strategy for ordinary SVG/XML elements.
7
+ # @api private
6
8
  module Any
9
+ # Converts this node into unformatted Sevgi DSL lines.
10
+ # @return [Array<String>] unformatted Ruby source lines
7
11
  def decompile(*)
8
12
  if children.any?
9
13
  children.one? && children.first.node.text? ? Array(leaf(has_attributes: attributes.any?)) : tree
@@ -16,11 +20,11 @@ module Sevgi
16
20
 
17
21
  def leaf(has_content: true, has_attributes: true)
18
22
  attributes = attributes!
19
- [
20
- element,
21
- *("'#{content}', " if has_content),
22
- *(Attributes.decompile(attributes) if has_attributes)
23
- ].join(" ")
23
+ args = []
24
+ args << Ruby.literal(content) if has_content
25
+ args << Attributes.decompile(attributes) if has_attributes && attributes.any?
26
+
27
+ args.empty? ? element : "#{element} #{args.join(", ")}"
24
28
  end
25
29
 
26
30
  def tree
@@ -1,10 +1,13 @@
1
- #!/usr/bin/env ruby
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Sevgi
5
4
  module Derender
6
5
  module Elements
6
+ # Element strategy for SVG style elements.
7
+ # @api private
7
8
  module CSS
9
+ # Converts a style node into unformatted Sevgi DSL lines.
10
+ # @return [Array<String>] unformatted Ruby source lines
8
11
  def decompile(*)
9
12
  return [] unless (lines = css_lines)
10
13
 
@@ -24,7 +27,7 @@ module Sevgi
24
27
  hash
25
28
  .map do |selector, declarations|
26
29
  [
27
- "\"#{selector}\": {",
30
+ "#{Ruby.literal(selector)}: {",
28
31
  *declarations.map { |key, value| "#{Css.to_key_value(key, value)}," },
29
32
  "},"
30
33
  ]
@@ -1,10 +1,13 @@
1
- #!/usr/bin/env ruby
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Sevgi
5
4
  module Derender
6
5
  module Elements
6
+ # Element strategy for comments and ignored XML nodes.
7
+ # @api private
7
8
  module Junk
9
+ # Drops ignored nodes from generated source.
10
+ # @return [Array<String>] empty source lines
8
11
  def decompile(*) = []
9
12
  end
10
13
  end
@@ -3,9 +3,14 @@
3
3
  module Sevgi
4
4
  module Derender
5
5
  module Elements
6
+ # Element strategy for the SVG root element.
7
+ # @api private
6
8
  module Root
7
9
  include Any
8
10
 
11
+ # Converts the SVG root into unformatted Sevgi DSL lines.
12
+ # @param pres [Array<String>] preamble XML lines
13
+ # @return [Array<String>] unformatted Ruby source lines
9
14
  def decompile(pres = [])
10
15
  lines = super
11
16
  return lines unless pres&.any?
@@ -13,15 +18,19 @@ module Sevgi
13
18
  lines.unshift(
14
19
  [
15
20
  "SVG.document preambles: [",
16
- *pres.map { "'#{it}'," },
21
+ *pres.map { "#{Ruby.literal(it)}," },
17
22
  "]",
18
23
  ""
19
24
  ]
20
25
  )
21
26
  end
22
27
 
28
+ # Returns the root DSL word.
29
+ # @return [String]
23
30
  def element = "SVG"
24
31
 
32
+ # Returns root attributes merged with namespace declarations.
33
+ # @return [Hash{String => String}] attributes and namespaces
25
34
  def attributes! = {**attributes, **namespaces}
26
35
  end
27
36
  end
@@ -1,11 +1,14 @@
1
- #!/usr/bin/env ruby
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Sevgi
5
4
  module Derender
6
5
  module Elements
6
+ # Element strategy for text nodes.
7
+ # @api private
7
8
  module Text
8
- def decompile(*) = ["_ #{content}"]
9
+ # Converts a text node into unformatted Sevgi DSL lines.
10
+ # @return [Array<String>] unformatted Ruby source lines
11
+ def decompile(*) = ["_ #{Ruby.literal(content)}"]
9
12
  end
10
13
  end
11
14
  end
@@ -1,5 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module Sevgi
4
+ module Derender
5
+ # Element strategy modules mixed into derender nodes according to XML node type.
6
+ # @api private
7
+ module Elements
8
+ end
9
+ end
10
+ end
11
+
3
12
  require_relative "elements/any"
4
13
  require_relative "elements/css"
5
14
  require_relative "elements/junk"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Derender
5
+ # Builds graphics element trees from derender nodes without evaluating Ruby source.
6
+ # @api private
7
+ class Evaluator
8
+ # Builds an evaluator.
9
+ # @param parent [Sevgi::Graphics::Element] target graphics parent
10
+ # @return [void]
11
+ def initialize(parent) = @parent = parent
12
+
13
+ # Appends a derender node to the target parent.
14
+ # @param node [Sevgi::Derender::Node] derender node
15
+ # @return [Sevgi::Graphics::Element, nil] included element, or nil when the node does not produce graphics output
16
+ def append(node)
17
+ case node.type
18
+ when :CSS
19
+ append_css(node)
20
+ when :Text
21
+ parent.Element(:_, node.content)
22
+ else
23
+ append_element(node)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :parent
30
+
31
+ def append_css(node)
32
+ return unless (hash = Css.to_h(node.node.content))
33
+
34
+ parent.Element(:style, Graphics::Content.css(hash), type: "text/css", **node.attributes)
35
+ end
36
+
37
+ def append_element(node)
38
+ parent.Element(node.name, *content(node), **attributes(node)).tap do |element|
39
+ node.children.each { self.class.new(element).append(it) } unless content(node).any?
40
+ end
41
+ end
42
+
43
+ def attributes(node) = node.attributes!
44
+
45
+ def content(node)
46
+ node.children.one? && node.children.first.node.text? ? [node.content] : []
47
+ end
48
+ end
49
+
50
+ private_constant :Evaluator
51
+ end
52
+ end
@@ -4,37 +4,70 @@ require "sevgi/function"
4
4
 
5
5
  module Sevgi
6
6
  module Derender
7
+ # CSS parser and formatter helpers used while derendering attributes and style nodes.
8
+ # @api private
7
9
  module Css
8
10
  require "css_parser"
9
11
 
12
+ # Parses CSS rules into a selector/declaration hash.
13
+ # @param css_string [String] CSS rule source
14
+ # @return [Hash, nil] parsed CSS declarations grouped by selector
10
15
  def to_h(css_string)
11
16
  parser = ::CssParser::Parser.new
12
17
  parser.load_string!(css_string)
13
18
  parser.to_h["all"]
14
19
  end
15
20
 
16
- def to_h!(style_string) = to_h("* { #{style_string} }")["*"]
21
+ # Parses an inline style declaration string.
22
+ # @param style_string [String] CSS declaration source
23
+ # @return [Hash] parsed declarations for the synthetic universal selector
24
+ def to_h!(style_string)
25
+ hash = to_h("* { #{style_string} }")
17
26
 
27
+ hash ? hash.fetch("*", {}) : {}
28
+ end
29
+
30
+ # Converts a CSS key into a Ruby hash key.
31
+ # @param arg [String] CSS key
32
+ # @return [String] Ruby hash key source
18
33
  def to_key(arg) = arg
19
34
 
20
- def to_key_value(key, value) = "\"#{to_key(key)}\": #{to_value(value)}"
35
+ # Converts a CSS key/value pair into Ruby hash source.
36
+ # @param key [String] CSS declaration key
37
+ # @param value [String] CSS declaration value
38
+ # @return [String] Ruby hash pair source
39
+ def to_key_value(key, value) = "#{Ruby.literal(to_key(key))}: #{to_value(value)}"
21
40
 
22
- def to_value(arg) = (arg.to_f.to_s == arg) || (arg.to_i.to_s == arg) ? arg : "\"#{arg}\""
41
+ # Converts a CSS value into Ruby source.
42
+ # @param arg [String] CSS value
43
+ # @return [String] Ruby literal or numeric source
44
+ def to_value(arg) = (arg.to_f.to_s == arg) || (arg.to_i.to_s == arg) ? arg : arg.inspect
23
45
 
24
46
  extend self
25
47
  end
26
48
 
27
49
  private_constant :Css
28
50
 
51
+ # Ruby source formatting helpers used by the derender pipeline.
52
+ # @api private
29
53
  module Ruby
30
54
  require "rufo"
31
55
 
56
+ # Formats generated Ruby source.
57
+ # @param unformatted_ruby [String] unformatted Ruby source
58
+ # @return [String] formatted Ruby source
59
+ # @raise [Sevgi::PanicError] when source cannot be parsed by the formatter
32
60
  def format(unformatted_ruby)
33
61
  Rufo::Formatter.format(unformatted_ruby)
34
62
  rescue Rufo::SyntaxError
35
- raise unformatted_ruby
63
+ PanicError.(unformatted_ruby)
36
64
  end
37
65
 
66
+ # Converts a value into a Ruby string literal.
67
+ # @param value [Object] value to stringify
68
+ # @return [String] Ruby string literal source
69
+ def literal(value) = value.to_s.inspect
70
+
38
71
  extend self
39
72
  end
40
73
 
@@ -2,17 +2,34 @@
2
2
 
3
3
  module Sevgi
4
4
  module Derender
5
+ # Node in a derender tree produced from an SVG/XML node.
5
6
  class Node
7
+ # @!attribute [r] node
8
+ # @return [Nokogiri::XML::Node] source XML node
9
+ # @!attribute [r] pres
10
+ # @return [Array<String>] preamble XML lines carried by the root node
11
+ # @!attribute [r] type
12
+ # @return [Symbol] dispatch type used by derender element strategies
6
13
  attr_reader :node, :pres, :type
7
14
 
8
- def initialize(node, pres = [])
15
+ # Builds a derender node.
16
+ # @param node [Nokogiri::XML::Node] source XML node
17
+ # @param pres [Array<String>] preamble XML lines carried by the root node
18
+ # @param namespaces [Hash{String => String}, nil] namespace declarations to emit on this node; selected roots use
19
+ # their full inherited namespace scope, while ordinary children use only declarations from their own element
20
+ # @return [void]
21
+ def initialize(node, pres = [], namespaces: nil)
9
22
  @node = node
10
23
  @pres = pres
24
+ @namespaces = namespaces
11
25
  @type = dispatch
12
26
  end
13
27
 
28
+ # Attribute namespace prefix used for Sevgi metadata.
14
29
  META_NAMESPACE = "_:"
15
30
 
31
+ # Returns Sevgi metadata attributes without the metadata namespace prefix.
32
+ # @return [Hash{String => String}] metadata attributes
16
33
  def _
17
34
  @_ ||= attributes
18
35
  .slice(
@@ -23,76 +40,147 @@ module Sevgi
23
40
 
24
41
  alias meta _
25
42
 
26
- def attributes
27
- @attributes ||= node.attribute_nodes.to_h do |attr|
28
- name, value = attr.name, attr.value
43
+ # Returns source XML attributes keyed with namespace prefixes when present.
44
+ # @return [Hash{String => String}] XML attributes
45
+ def attributes = @attributes ||= node.attribute_nodes.to_h { [attribute_key(it), it.value] }
29
46
 
30
- if attr.respond_to?(:namespace) && (namespace = attr.namespace) && (prefix = namespace.prefix)
31
- "#{prefix}:#{name}"
32
- else
33
- name
34
- end => key
35
-
36
- [key, value]
37
- end
38
- end
39
-
40
- def attributes! = attributes
47
+ # Returns source XML attributes and namespace declarations emitted on this node.
48
+ # @return [Hash{String => String}] XML attributes
49
+ def attributes! = {**attributes, **namespaces}
41
50
 
51
+ # Returns non-ignorable child derender nodes.
52
+ # @return [Array<Sevgi::Derender::Node>] child nodes
42
53
  def children
43
- @children ||= node.children.map { |child| self.class.new(child) }.reject do |child|
44
- (child.node.text? and child.node.text.strip.empty?) or child.type == :Junk
45
- end
54
+ @children ||= node
55
+ .children
56
+ .map { |child| self.class.new(child) }
57
+ .reject { |child| ignorable_child?(child) }
46
58
  end
47
59
 
48
- def content = @content ||= node.content.strip
60
+ # Returns node text content.
61
+ #
62
+ # `xml:space="preserve"` keeps content verbatim. Default-space text nodes are stripped for ordinary pretty-printed
63
+ # content, but inline text boundary spaces next to element siblings are kept because they affect rendered SVG text.
64
+ # @return [String]
65
+ def content = @content ||= preserve_space? ? node.content : normalized_content
49
66
 
67
+ # Converts this node into formatted Sevgi DSL Ruby source.
68
+ # @return [String] formatted Sevgi DSL source
69
+ # @raise [Sevgi::PanicError] when generated Ruby source cannot be formatted
50
70
  def derender = Ruby.format(decompile(pres).join("\n"))
51
71
 
72
+ # Returns the Sevgi DSL element name for this node.
73
+ # @return [String]
52
74
  def element = name
53
75
 
76
+ # Evaluates this node under a graphics element.
77
+ # @param element [Sevgi::Graphics::Element] target graphics element
78
+ # @param include_current [Boolean] true to evaluate this node, false to evaluate only children
79
+ # @return [Sevgi::Graphics::Element, Array<Sevgi::Graphics::Element>, nil] included current element, included child
80
+ # elements when include_current is false, or nil when the node does not produce graphics output
54
81
  def evaluate(element, include_current = true)
55
- return element.instance_eval(derender) if include_current
82
+ return Evaluator.new(element).append(self) if include_current
56
83
 
57
- children.each { element.instance_eval(it.derender) }
84
+ evaluate_children(element)
58
85
  end
59
86
 
60
- def evaluate!(element) = evaluate(element, false)
87
+ # Evaluates only this node's children under a graphics element.
88
+ # @param element [Sevgi::Graphics::Element] target graphics element
89
+ # @return [Array<Sevgi::Graphics::Element>] included child elements
90
+ def evaluate_children(element) = children.map { it.evaluate(element) }.compact
61
91
 
92
+ # Finds the first descendant whose attribute matches a value.
93
+ # @param arg [String] attribute value to find
94
+ # @param by [String] attribute name used for lookup
95
+ # @return [Sevgi::Derender::Node, nil] matching node, or nil
62
96
  def find(arg, by: "id")
63
97
  return self if attributes[by] == arg
64
98
 
65
- children&.each do |child|
66
- found = child.find(arg, by:)
67
-
68
- return found if found
69
- end
70
-
71
- nil
99
+ children.lazy.map { it.find(arg, by:) }.find(&:itself)
72
100
  end
73
101
 
102
+ # Returns the source XML node name.
103
+ # @return [String]
74
104
  def name = @name ||= node.name
75
105
 
76
- def namespaces = (@namespaces ||= node.namespaces.to_h { |namespace, uri| [namespace, uri] })
106
+ # Returns source XML namespace declarations emitted on this node.
107
+ # @return [Hash{String => String}] namespace declarations
108
+ def namespaces = (@namespaces ||= local_namespaces)
77
109
 
110
+ # Reports whether this node is the SVG root strategy.
111
+ # @return [Boolean]
78
112
  def root? = type == :Root
79
113
 
80
114
  private
81
115
 
116
+ def attribute_key(attribute) = [attribute.namespace&.prefix, attribute.name].compact.join(":")
117
+
82
118
  def dispatch
83
- if node.text?
119
+ case
120
+ when node.text?
84
121
  :Text
85
- elsif node.comment?
122
+ when node.comment?
86
123
  :Junk
87
- elsif node.name == "style"
124
+ when node.name == "style"
88
125
  :CSS
89
- elsif node.name == "svg"
126
+ when node.name == "svg"
90
127
  :Root
91
128
  else
92
129
  :Any
93
130
  end
94
131
  .tap { extend(Elements.const_get(it)) }
95
132
  end
133
+
134
+ def ignorable_child?(child)
135
+ child.type == :Junk ||
136
+ (child.node.text? && child.node.text.strip.empty? && !child.preserve_space? && !child.inline_text?)
137
+ end
138
+
139
+ protected
140
+
141
+ def preserve_space?
142
+ each_node do |current|
143
+ case xml_space(current)
144
+ when "preserve"
145
+ return true
146
+ when "default"
147
+ return false
148
+ end
149
+ end
150
+
151
+ false
152
+ end
153
+
154
+ def inline_text? = node.text? && !node.content.match?(/[\r\n]/) && node.parent&.children&.any?(&:element?)
155
+
156
+ private
157
+
158
+ def normalized_content = inline_text? ? node.content : node.content.strip
159
+
160
+ def local_namespaces
161
+ return {} unless node.respond_to?(:namespace_definitions)
162
+
163
+ node.namespace_definitions.to_h do |namespace|
164
+ [namespace.prefix ? "xmlns:#{namespace.prefix}" : "xmlns", namespace.href]
165
+ end
166
+ end
167
+
168
+ def each_node
169
+ current = node
170
+
171
+ while current
172
+ yield current
173
+ current = current.respond_to?(:parent) ? current.parent : nil
174
+ end
175
+ end
176
+
177
+ def xml_space(current)
178
+ return unless current.respond_to?(:attribute_nodes)
179
+
180
+ current.attribute_nodes.find { xml_space_attribute?(it) }&.value
181
+ end
182
+
183
+ def xml_space_attribute?(attribute) = attribute.name == "space" && attribute.namespace&.prefix == "xml"
96
184
  end
97
185
  end
98
186
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Sevgi
4
4
  module Derender
5
- VERSION = "0.73.2"
5
+ # Current version of the Sevgi derender gem.
6
+ VERSION = "0.94.0"
6
7
  end
7
8
  end
@@ -5,27 +5,147 @@ require_relative "derender/internal"
5
5
  require_relative "derender/attributes"
6
6
  require_relative "derender/document"
7
7
  require_relative "derender/elements"
8
+ require_relative "derender/evaluator"
8
9
  require_relative "derender/node"
9
10
 
10
11
  require_relative "derender/version"
11
12
 
12
13
  module Sevgi
14
+ # Converts SVG/XML content into Sevgi DSL source or evaluates it into graphics elements.
15
+ #
16
+ # Evaluation APIs treat SVG/XML as data: they build graphics element trees directly and do not execute generated Ruby
17
+ # source. Malformed, rootless, or unmatched input is rejected with {Sevgi::ArgumentError}.
13
18
  module Derender
14
- # Takes SVG (XML content) and returns a Derender::Node object.
19
+ # @!method self.decompile(content, id: nil)
20
+ # Converts SVG/XML content into a derender node.
21
+ # @param content [String] SVG/XML source content
22
+ # @param id [String, nil] optional SVG id selecting a node inside the source
23
+ # @return [Sevgi::Derender::Node] selected node in the derender tree
24
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
25
+ # @!method self.decompile_file(file, id: nil)
26
+ # Converts an SVG/XML file into a derender node.
27
+ # @param file [String] path to the source SVG/XML file
28
+ # @param id [String, nil] optional SVG id selecting a node inside the source
29
+ # @return [Sevgi::Derender::Node] selected node in the derender tree
30
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
31
+ # absent
32
+ # @!method self.derender(content, id: nil)
33
+ # Converts SVG/XML content into Sevgi DSL Ruby source.
34
+ # @param content [String] SVG/XML source content
35
+ # @param id [String, nil] optional SVG id selecting a node inside the source
36
+ # @return [String] formatted Sevgi DSL source
37
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
38
+ # @raise [Sevgi::PanicError] when generated Ruby source cannot be formatted
39
+ # @!method self.derender_file(file, id: nil)
40
+ # Converts an SVG/XML file into Sevgi DSL Ruby source.
41
+ # @param file [String] path to the source SVG/XML file
42
+ # @param id [String, nil] optional SVG id selecting a node inside the source
43
+ # @return [String] formatted Sevgi DSL source
44
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
45
+ # absent
46
+ # @raise [Sevgi::PanicError] when generated Ruby source cannot be formatted
47
+ # @!method self.evaluate(content, element, id: nil)
48
+ # Evaluates SVG/XML content under a graphics element, including the selected node.
49
+ # @param content [String] SVG/XML source content
50
+ # @param element [Sevgi::Graphics::Element] target graphics element
51
+ # @param id [String, nil] optional SVG id selecting a node inside the source
52
+ # @return [Sevgi::Graphics::Element, nil] included selected/root graphics element, or nil when the selected node
53
+ # produces no graphics output
54
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
55
+ # @!method self.evaluate_children(content, element, id: nil)
56
+ # Evaluates only the selected node's children under a graphics element.
57
+ # @param content [String] SVG/XML source content
58
+ # @param element [Sevgi::Graphics::Element] target graphics element
59
+ # @param id [String, nil] optional SVG id selecting a node inside the source
60
+ # @return [Array<Sevgi::Graphics::Element>] included child graphics elements
61
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
62
+ # @!method self.evaluate_file(file, element, id: nil)
63
+ # Evaluates an SVG/XML file under a graphics element, including the selected node.
64
+ # @param file [String] path to the source SVG/XML file
65
+ # @param element [Sevgi::Graphics::Element] target graphics element
66
+ # @param id [String, nil] optional SVG id selecting a node inside the source
67
+ # @return [Sevgi::Graphics::Element, nil] included selected/root graphics element, or nil when the selected node
68
+ # produces no graphics output
69
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
70
+ # absent
71
+ # @!method self.evaluate_file_children(file, element, id: nil)
72
+ # Evaluates only the selected node's children from an SVG/XML file under a graphics element.
73
+ # @param file [String] path to the source SVG/XML file
74
+ # @param element [Sevgi::Graphics::Element] target graphics element
75
+ # @param id [String, nil] optional SVG id selecting a node inside the source
76
+ # @return [Array<Sevgi::Graphics::Element>] included child graphics elements
77
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
78
+ # absent
79
+
80
+ # Converts SVG/XML content into a derender node.
81
+ # @param content [String] SVG/XML source content
82
+ # @param id [String, nil] optional SVG id selecting a node inside the source
83
+ # @return [Sevgi::Derender::Node] selected node in the derender tree
84
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
15
85
  def decompile(content, id: nil) = Document.new(content).decompile(id)
86
+
87
+ # Converts an SVG/XML file into a derender node.
88
+ # @param file [String] path to the source SVG/XML file
89
+ # @param id [String, nil] optional SVG id selecting a node inside the source
90
+ # @return [Sevgi::Derender::Node] selected node in the derender tree
91
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
92
+ # absent
16
93
  def decompile_file(file, id: nil) = Document.load_file(file).decompile(id)
17
94
 
18
- # Takes SVG (XML content) and returns Sevgi DSL string (formatted Ruby content).
95
+ # Converts SVG/XML content into Sevgi DSL Ruby source.
96
+ # @param content [String] SVG/XML source content
97
+ # @param id [String, nil] optional SVG id selecting a node inside the source
98
+ # @return [String] formatted Sevgi DSL source
99
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
100
+ # @raise [Sevgi::PanicError] when generated Ruby source cannot be formatted
19
101
  def derender(content, id: nil) = Document.new(content).decompile(id).derender
102
+
103
+ # Converts an SVG/XML file into Sevgi DSL Ruby source.
104
+ # @param file [String] path to the source SVG/XML file
105
+ # @param id [String, nil] optional SVG id selecting a node inside the source
106
+ # @return [String] formatted Sevgi DSL source
107
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
108
+ # absent
109
+ # @raise [Sevgi::PanicError] when generated Ruby source cannot be formatted
20
110
  def derender_file(file, id: nil) = Document.load_file(file).decompile(id).derender
21
111
 
22
- # Takes SVG (XML content), evaluates it under the given Graphics element and returns the element.
112
+ # Evaluates SVG/XML content under a graphics element, including the selected node.
113
+ # @param content [String] SVG/XML source content
114
+ # @param element [Sevgi::Graphics::Element] target graphics element
115
+ # @param id [String, nil] optional SVG id selecting a node inside the source
116
+ # @return [Sevgi::Graphics::Element, nil] included selected/root graphics element, or nil when the selected node
117
+ # produces no graphics output
118
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
23
119
  def evaluate(content, element, id: nil) = Document.new(content).decompile(id).evaluate(element)
120
+
121
+ # Evaluates only the selected node's children under a graphics element.
122
+ # @param content [String] SVG/XML source content
123
+ # @param element [Sevgi::Graphics::Element] target graphics element
124
+ # @param id [String, nil] optional SVG id selecting a node inside the source
125
+ # @return [Array<Sevgi::Graphics::Element>] included child graphics elements
126
+ # @raise [Sevgi::ArgumentError] when content is malformed or rootless, or when the id is absent
127
+ def evaluate_children(content, element, id: nil) = Document.new(content).decompile(id).evaluate_children(element)
128
+
129
+ # Evaluates an SVG/XML file under a graphics element, including the selected node.
130
+ # @param file [String] path to the source SVG/XML file
131
+ # @param element [Sevgi::Graphics::Element] target graphics element
132
+ # @param id [String, nil] optional SVG id selecting a node inside the source
133
+ # @return [Sevgi::Graphics::Element, nil] included selected/root graphics element, or nil when the selected node
134
+ # produces no graphics output
135
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
136
+ # absent
24
137
  def evaluate_file(file, element, id: nil) = Document.load_file(file).decompile(id).evaluate(element)
25
138
 
26
- # Takes SVG (XML content), evaluates the inner node under the given Graphics element and returns the element.
27
- def evaluate!(content, element, id: nil) = Document.new(content).decompile(id).evaluate!(element)
28
- def evaluate_file!(file, element, id: nil) = Document.load_file(file).decompile(id).evaluate!(element)
139
+ # Evaluates only the selected node's children from an SVG/XML file under a graphics element.
140
+ # @param file [String] path to the source SVG/XML file
141
+ # @param element [Sevgi::Graphics::Element] target graphics element
142
+ # @param id [String, nil] optional SVG id selecting a node inside the source
143
+ # @return [Array<Sevgi::Graphics::Element>] included child graphics elements
144
+ # @raise [Sevgi::ArgumentError] when the file cannot be found, file content is malformed or rootless, or the id is
145
+ # absent
146
+ def evaluate_file_children(file, element, id: nil)
147
+ Document.load_file(file).decompile(id).evaluate_children(element)
148
+ end
29
149
 
30
150
  extend self
31
151
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-derender
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.73.2
4
+ version: 0.94.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.73.2
18
+ version: 0.94.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.73.2
25
+ version: 0.94.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: sevgi-graphics
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 0.73.2
32
+ version: 0.94.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 0.73.2
39
+ version: 0.94.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: css_parser
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -86,9 +86,10 @@ executables:
86
86
  extensions: []
87
87
  extra_rdoc_files: []
88
88
  files:
89
+ - CHANGELOG.md
90
+ - LICENSE
89
91
  - README.md
90
92
  - bin/igves
91
- - lib/sevgi/binaries/igsev.rb
92
93
  - lib/sevgi/binaries/igves.rb
93
94
  - lib/sevgi/derender.rb
94
95
  - lib/sevgi/derender/attributes.rb
@@ -99,6 +100,7 @@ files:
99
100
  - lib/sevgi/derender/elements/junk.rb
100
101
  - lib/sevgi/derender/elements/root.rb
101
102
  - lib/sevgi/derender/elements/text.rb
103
+ - lib/sevgi/derender/evaluator.rb
102
104
  - lib/sevgi/derender/internal.rb
103
105
  - lib/sevgi/derender/node.rb
104
106
  - lib/sevgi/derender/version.rb
@@ -117,7 +119,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
117
119
  requirements:
118
120
  - - ">="
119
121
  - !ruby/object:Gem::Version
120
- version: 3.4.0.pre.preview1
122
+ version: 3.4.0
121
123
  required_rubygems_version: !ruby/object:Gem::Requirement
122
124
  requirements:
123
125
  - - ">="
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sevgi"
4
-
5
- module Sevgi
6
- module Binaries
7
- module Igsev
8
- extend self
9
-
10
- PROGNAME = "igsev"
11
-
12
- Error = Class.new(::Sevgi::Error)
13
-
14
- Options = Struct.new(:require, :vomit, :help, :version) do
15
- def self.parse(argv)
16
- new.tap do |options|
17
- argv.first.start_with?("-") ? option(argv, options) : break until argv.empty?
18
- end
19
- end
20
-
21
- class << self
22
- private
23
-
24
- def option(argv, options)
25
- case (arg = argv.shift)
26
- when "-r", "--require"
27
- options.require = argv.shift
28
- when "-x", "--exception"
29
- options.vomit = true
30
-
31
- when "-h", "--help"
32
- options.help = true
33
- when "-v", "--version"
34
- options.version = true
35
- else
36
- Error.("Not a valid option: #{arg}")
37
- end
38
- end
39
- end
40
- end
41
-
42
- private_constant :Options
43
-
44
- def call(argv)
45
- return puts(help) if (options = Options.parse(argv = Array(argv))).help
46
- return puts(::Sevgi::VERSION) if options.version
47
-
48
- result = run(file = argv.shift, options)
49
-
50
- if result.error?
51
- raise result.error if options.vomit || ENV[ENVVOMIT]
52
-
53
- die(result.error, file)
54
- else
55
- result.recent.Out()
56
- end
57
-
58
- rescue Binaries::Igsev::Error => e
59
- abort(e.message)
60
- end
61
-
62
- private
63
-
64
- def die(error, _file)
65
- warn(error.message)
66
- warn("")
67
- error.backtrace!.each { warn(" #{it}") }
68
-
69
- exit(1)
70
- end
71
-
72
- def help
73
- <<~HELP
74
- Usage: #{PROGNAME} [options...] <SVG file>
75
-
76
- See documentation for detailed help.
77
-
78
- Options:
79
-
80
- -r, --require LIB Require Ruby LIB
81
- -x, --exception Raise exception instead of abort
82
-
83
- -h, --help Show this help
84
- -v, --version Display version
85
- HELP
86
- end
87
-
88
- def run(file, options)
89
- Error.("No SVG file given.") unless file
90
-
91
- sevgi = Derender.derender_file(file)
92
-
93
- ::Sevgi.execute(sevgi, require: options.require)
94
- end
95
- end
96
- end
97
- end