vectory 0.13.1 → 0.13.3

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: 5f48a547c1520b19d88d77ad41c2e2967da41896163f49ef39492f6f6e73b1af
4
- data.tar.gz: cb36e0382b283f7ad50816a5ce5ff5b6a80736a44f8a062db57dd8ce836385f2
3
+ metadata.gz: 6ee735ec4ea1feac5c918c1255839ae19c9e3468d5f8d9b8137d386ba960eb4f
4
+ data.tar.gz: a81a2bc24cf8fdcf5804fd1642099aeb4a05e592c52cbe34265ee99d4d2b77e6
5
5
  SHA512:
6
- metadata.gz: 11759d94c1a88218d39f5e4561a661c1c0bee4f371d9c369571d95c34e141dadfb308646c890fc08ddef5a5484fe381e5303b07fc153985b0989fd249e85d8c8
7
- data.tar.gz: 1a0151957b311956558f7935e548e92d2678d283238eb66445ec7203916b3fba3753f93140affb5145b732df6f8cd3b75746a2985f9801875a2bf4688f021ad3
6
+ metadata.gz: ad9305a8070f277be8cfa9f60e2237e46937995490a3c031df57f115fc3151f07fac9c3fd1e74254b9266fe0dda4fbae864eddc32fac68cd6999307cc2f0142f
7
+ data.tar.gz: cc73b481d8e550a4d05a010945c3051fa6d8fe91b40689a777c3247b25c1c5be1abe0e4b500fc78012f99a51ef2614347908949638512726705e60cbcc5f4df6
@@ -9,7 +9,7 @@ on:
9
9
  pull_request:
10
10
 
11
11
  permissions:
12
- contents: write
12
+ contents: read
13
13
  packages: read
14
14
 
15
15
  jobs:
@@ -20,5 +20,29 @@ jobs:
20
20
  submodules: true
21
21
  choco-cache: true
22
22
  private-fonts: false
23
+ cascade: false
23
24
  secrets:
24
25
  pat_token: ${{ secrets.CLARICLE_CI_PAT_TOKEN }}
26
+
27
+ tests-passed:
28
+ needs: rake
29
+ runs-on: ubuntu-latest
30
+ permissions:
31
+ contents: write
32
+ steps:
33
+ - name: Tests passed
34
+ uses: peter-evans/repository-dispatch@v3
35
+ with:
36
+ token: ${{ secrets.CLARICLE_CI_PAT_TOKEN || github.token }}
37
+ repository: ${{ github.repository }}
38
+ event-type: tests-passed
39
+ client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "type": "tests-passed"}'
40
+
41
+ - if: startsWith(github.ref, 'refs/tags/v')
42
+ name: Repository ready for release
43
+ uses: peter-evans/repository-dispatch@v3
44
+ with:
45
+ token: ${{ secrets.CLARICLE_CI_PAT_TOKEN || github.token }}
46
+ repository: ${{ github.repository }}
47
+ event-type: do-release
48
+ client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "type": "do-release"}'
data/README.adoc CHANGED
@@ -410,6 +410,39 @@ gem bump --version minor --tag --push
410
410
  ----
411
411
 
412
412
 
413
+ == Serialized SVG transforms
414
+
415
+ Vectory exposes immutable transformations of serialized XML strings. Class
416
+ selectors are parsed with Crass (`~> 1.0`, `>= 1.0.7`); class lists and selectors
417
+ use the same positional `_inject_<index>` suffix as ID disambiguation.
418
+ `SvgClassSuffixer` is the canonical Vectory class-suffix implementation.
419
+
420
+ [source,ruby]
421
+ ----
422
+ cleanup = Vectory::SvgTransform.disambiguate(index: 0, duplicate_ids: collisions) >>
423
+ Vectory::SvgTransform.suffix_classes(index: 0) >>
424
+ Vectory::SvgTransform.bind_namespaces(bind: { "xlink" => "http://www.w3.org/1999/xlink" })
425
+ result = cleanup.call(svg_xml)
426
+ ----
427
+
428
+ The executable profiles are `graft` (`G`), `svg_cleanup` (`D -> S -> N`),
429
+ and `graft_then_svg_cleanup` (`G -> D -> S -> N`). Other sequences are rejected;
430
+ construction prefixes cannot execute until complete. Cleanup accepts a selected
431
+ SVG root, while a graft can accept a complete host document. Reapplying a profile
432
+ with the same captured inputs is XML-equivalent. Step options are deeply frozen
433
+ snapshots, so later changes to a caller's collision map cannot change a pipeline.
434
+
435
+ Namespace binding preserves expanded element and attribute names; it is separate
436
+ from the legacy `SvgDocument#namespace` ID/link cleanup. See the API reference for
437
+ safe binding, unbinding and URI-to-prefix remapping.
438
+
439
+ For multiple embedded SVGs, graft the complete mapped document once, then build a
440
+ fresh cleanup pipeline for each SVG from the current collision map. Merge each
441
+ result's IDs before constructing the next pipeline. Standoc #1298 adopts ID
442
+ disambiguation while retaining its inline Crass helpers; adopting
443
+ `SvgClassSuffixer` is a separate downstream task after a containing release.
444
+ SVG-to-PNG rendering is a separate projection feature.
445
+
413
446
  == Contributing
414
447
 
415
448
  Bug reports and pull requests are welcome on GitHub at:
data/docs/Gemfile CHANGED
@@ -3,6 +3,9 @@ source "https://rubygems.org"
3
3
  # Jekyll plugins
4
4
  gem "jekyll", "~> 3.0"
5
5
  gem "just-the-docs", "~> 0.7"
6
+ # Jekyll 3 dependencies require these gems explicitly on recent Ruby versions.
7
+ gem "base64", "~> 0.2"
8
+ gem "bigdecimal", ">= 3.1", "< 5"
6
9
 
7
10
  # Markdown and AsciiDoc processing
8
11
  gem "asciidoctor", "~> 2.0"
data/docs/lychee.toml CHANGED
@@ -6,20 +6,11 @@ exclude = [
6
6
  'http://localhost:*',
7
7
  'http://127.0.0.1:*',
8
8
  'file:///*',
9
- ]
10
-
11
- # Exclude mailto: links
12
- exclude = [
9
+ # Exclude mailto: links
13
10
  'mailto:*',
14
- ]
15
-
16
- # Exclude version-specific URLs that may break
17
- exclude = [
11
+ # Exclude version-specific URLs that may break
18
12
  'https://github.com/*/releases/tag/*', # GitHub release tags
19
13
  'https://rubygems.org/gems/*/versions/*', # RubyGem version history
20
- ]
21
-
22
- # Allow certain redirects
23
- exclude = [
14
+ # Example domains used in docs
24
15
  'https://example.com/*', # Example domains used in docs
25
16
  ]
@@ -430,6 +430,90 @@ fig1_ISO_17301-1_2016 → fig1_ISO_17301-1_2016_000000000
430
430
  * **ID suffix** (`id_suffix` parameter): Derived from document/container identity. Optional.
431
431
  * **Index suffix** (`index_suffix` parameter): Zero-padded position (0, 1, 2... → _000000000, _000000001). Automatically applied.
432
432
 
433
+ == Serialized SVG transforms
434
+
435
+ All methods consume XML strings and return a new XML string. The input DOM is
436
+ never shared with the result. These content transforms do not render PNG images.
437
+
438
+ * `SvgClassSuffixer.rewrite(content, suffix)` updates XML class lists and CSS class
439
+ selectors using Crass, including escaped identifiers and nested selector rules.
440
+ Reapplying the same suffix is idempotent.
441
+ * `SvgNamespaceBinding.rewrite(content, bind: {}, unbind: [], remap: {})` binds
442
+ prefix-to-URI declarations, unbinds unused prefixes, and remaps URIs to target
443
+ prefixes. An empty or nil prefix means the default namespace. Expanded names
444
+ are preserved; default namespaces never qualify unprefixed attributes.
445
+ Conflicting bindings, used unbinds and identity-changing default remaps raise
446
+ `ArgumentError`. Malformed, rootless or non-SVG input also raises `ArgumentError`.
447
+ * `SvgSubtreeGraft.replace_root_content(host_content, processed_content)` preserves
448
+ the host document/root metadata and replaces only the ordered root children,
449
+ including text, comments, CDATA and processing instructions. Invalid input
450
+ raises `Vectory::ParsingError`, identifying `host_content` or `processed_content`.
451
+ Grafts decode each input's XML encoding and return UTF-8, updating an existing
452
+ encoding declaration while preserving document content and envelope order.
453
+
454
+ `SvgTransform` factories return named immutable steps:
455
+
456
+ * `graft_into(host_content:)` validates and captures the host at construction.
457
+ * `disambiguate(index:, duplicate_ids:)` delegates to `SvgIdDisambiguator` with a
458
+ deeply copied collision map and `_inject_<index>` suffix.
459
+ * `suffix_classes(index:)` uses that same suffix for classes.
460
+ * `bind_namespaces(bind: {}, unbind: [], remap: {})` uses the namespace contract above.
461
+
462
+ Compose steps with `>>`. `SvgTransformPipeline#steps` exposes stable names and
463
+ deeply frozen option snapshots; `#profile` identifies the executable profile.
464
+ `#call(content)` returns serialized XML. Exactly three profiles can execute:
465
+
466
+ [cols="1,1,2",options="header"]
467
+ |===
468
+ |Profile |Steps |Scope
469
+ |`graft` |`G` |A complete rooted processed document grafted into the captured host.
470
+ |`svg_cleanup` |`D -> S -> N` |Exactly one selected SVG root, including its nested SVGs.
471
+ |`graft_then_svg_cleanup` |`G -> D -> S -> N` |A captured host whose root is SVG.
472
+ |===
473
+
474
+ Appending an invalid sequence raises `ArgumentError` containing
475
+ `invalid step sequence`. Calling an incomplete prefix raises `ArgumentError`
476
+ containing `incomplete profile`. A wrong input scope raises `ArgumentError`
477
+ containing `execution-scope mismatch`. Parse errors stop execution and propagate.
478
+
479
+ === Standoc migration boundary
480
+
481
+ Map and graft the complete prepared host once, then process the selected SVGs in
482
+ document order. Preserve the caller's sequential collision map; do not use the
483
+ batch `SvgIdDisambiguator.duplicate_ids` helper for this compatibility flow.
484
+
485
+ [source,ruby]
486
+ ----
487
+ mapped = Vectory::SvgMapping.new(original_host).call.to_xml
488
+ grafted = Vectory::SvgTransform.graft_into(host_content: original_host).call(mapped)
489
+ document = Leptris::XML.parse(grafted)
490
+ collisions = {}
491
+ document.xpath("//*[local-name()='svg' and not(ancestor::*[local-name()='svg'])]").each_with_index do |svg, index|
492
+ # Make inherited namespace bindings explicit before serializing this subtree.
493
+ declared_prefixes = svg.namespace_definitions.map(&:prefix)
494
+ svg.namespaces.each do |name, uri|
495
+ prefix = name == "xmlns" ? nil : name.delete_prefix("xmlns:")
496
+ next if declared_prefixes.include?(prefix)
497
+
498
+ svg.add_namespace_definition(prefix, uri)
499
+ end
500
+ pipeline = Vectory::SvgTransform.disambiguate(index: index, duplicate_ids: collisions) >>
501
+ Vectory::SvgTransform.suffix_classes(index: index) >>
502
+ Vectory::SvgTransform.bind_namespaces(bind: { "xlink" => "http://www.w3.org/1999/xlink" })
503
+ cleaned = pipeline.call(svg.to_xml)
504
+ Leptris::XML.parse(cleaned).xpath("//*[@id]").each { |node| collisions[node["id"]] = true }
505
+ svg.replace(cleaned)
506
+ end
507
+ ----
508
+
509
+ Each pipeline retains the collision map as it existed at construction. The first
510
+ repeated root ID remains unchanged; the later one receives its positional suffix.
511
+ The selected SVG must carry its in-scope namespace declarations across the string
512
+ boundary; a declaration inherited from the host is otherwise lost by `to_xml`.
513
+ Standoc #1298 consumes `SvgIdDisambiguator` and retains its current Crass helpers.
514
+ `SvgClassSuffixer` is the canonical Vectory implementation; later adoption and
515
+ removal of the inline helpers require a separate task after a containing release.
516
+
433
517
  == See Also
434
518
 
435
519
  * link:../guides/error-handling/[Error Handling Guide] - Common errors and solutions
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crass"
4
+ require "leptris"
5
+
6
+ module Vectory
7
+ # Suffixes SVG class lists and CSS class selectors without normalizing CSS.
8
+ class SvgClassSuffixer
9
+ RULE_AT_RULES = %w[media supports layer container document -moz-document
10
+ scope starting-style keyframes -webkit-keyframes].freeze
11
+ SELECTOR_FUNCTIONS = %w[is where not has nth-child nth-last-child host
12
+ host-context slotted].freeze
13
+ private_constant :RULE_AT_RULES, :SELECTOR_FUNCTIONS
14
+
15
+ class << self
16
+ # @param content [String] SVG XML, or a host document containing SVGs
17
+ # @param suffix [String] a CSS identifier suffix, e.g. "_inject_0"
18
+ # @return [String] rewritten XML
19
+ def rewrite(content, suffix)
20
+ doc = parse(content)
21
+ doc.xpath("//*[local-name()='svg']").each do |svg|
22
+ ([svg] + svg.xpath(".//*")).each do |element|
23
+ rewrite_element(element, suffix)
24
+ end
25
+ end
26
+ doc.to_xml
27
+ end
28
+
29
+ private
30
+
31
+ def parse(content)
32
+ doc = Leptris::XML.parse(content)
33
+ # Recent Leptris versions recover malformed XML by default.
34
+ raise Leptris::XML::ParseError, doc.errors.first.message if doc.respond_to?(:errors) && !doc.errors.empty?
35
+
36
+ doc
37
+ end
38
+
39
+ def rewrite_element(element, suffix)
40
+ rewrite_classes(element, suffix)
41
+ return unless element.name == "style"
42
+
43
+ rewrite_style(element, suffix)
44
+ end
45
+
46
+ def rewrite_style(element, suffix)
47
+ # Parse the complete CSS stream, even when an XML comment or CDATA
48
+ # boundary splits a token. Apply insertions to the original text nodes
49
+ # so comments, processing instructions and CDATA remain in place.
50
+ nodes = element.xpath(".//text()")
51
+ changes = css_insertions(nodes.map(&:text).join, suffix)
52
+ offset = 0
53
+ nodes.each do |node|
54
+ text = node.text
55
+ rewrite_style_node(node, text, offset, changes)
56
+ offset += text.length
57
+ end
58
+ end
59
+
60
+ def rewrite_style_node(node, text, offset, changes)
61
+ rewritten = text.dup
62
+ changes.each do |position, insertion|
63
+ next unless position > offset && position <= offset + text.length
64
+
65
+ rewritten.insert(position - offset, insertion)
66
+ end
67
+ node.content = rewritten unless rewritten == text
68
+ end
69
+
70
+ def rewrite_classes(element, suffix)
71
+ # Keep attribute reads and writes on Leptris's native attribute layer.
72
+ attribute = element.attribute_nodes.find { |node| node.name == "class" }
73
+ if attribute
74
+ attribute.value = attribute.value.gsub(/[^\t\n\f\r ]+/) do |name|
75
+ name.end_with?(suffix) ? name : "#{name}#{suffix}"
76
+ end
77
+ end
78
+ end
79
+
80
+ def css_insertions(css, suffix)
81
+ changes = {}
82
+ rules = Crass::Parser.new(css, preserve_comments: true).consume_rules(top_level: true)
83
+ rewrite_rules(rules, suffix, changes)
84
+ # Crass tolerates malformed CSS but stringify may repair or drop it.
85
+ # Token positions use CSS-normalized newlines; map them to the source.
86
+ offsets = source_offsets(css)
87
+ changes.sort.reverse.map { |position, insertion| [offsets[position], insertion] }
88
+ end
89
+
90
+ def source_offsets(css)
91
+ offsets = [0]
92
+ css.scan(/\r\n|./m) { |character| offsets << (offsets.last + character.length) }
93
+ offsets
94
+ end
95
+
96
+ def rewrite_rules(rules, suffix, changes)
97
+ rules.each do |rule|
98
+ case rule[:node]
99
+ when :qualified_rule
100
+ rewrite_selector(rule[:prelude], suffix, changes)
101
+ rewrite_nested_rules(rule[:block], suffix, changes)
102
+ when :at_rule
103
+ rewrite_at_rule(rule, suffix, changes)
104
+ end
105
+ end
106
+ end
107
+
108
+ def rewrite_nested_rules(tokens, suffix, changes)
109
+ # Crass's declaration parser predates CSS nesting and discards nested
110
+ # rules. Walk its component values, keeping declaration values opaque.
111
+ parser = Crass::Parser.new(tokens)
112
+ prelude = []
113
+ while (token = parser.tokens.consume)
114
+ case token[:node]
115
+ when :semicolon
116
+ prelude.clear
117
+ when :at_keyword
118
+ rewrite_nested_at_rule(parser, prelude, suffix, changes)
119
+ when :simple_block
120
+ rewrite_nested_block(token, prelude, suffix, changes)
121
+ else
122
+ prelude << token
123
+ end
124
+ end
125
+ end
126
+
127
+ def rewrite_nested_at_rule(parser, prelude, suffix, changes)
128
+ if prelude.all? { |token| trivia?(token) }
129
+ parser.tokens.reconsume
130
+ rewrite_at_rule(parser.consume_at_rule, suffix, changes)
131
+ prelude.clear
132
+ else
133
+ prelude << parser.tokens.current
134
+ end
135
+ end
136
+
137
+ def rewrite_nested_block(token, prelude, suffix, changes)
138
+ if token[:start] == "{" && !custom_property?(prelude)
139
+ rewrite_selector(prelude, suffix, changes)
140
+ rewrite_nested_rules(token[:value], suffix, changes)
141
+ prelude.clear
142
+ else
143
+ prelude << token
144
+ end
145
+ end
146
+
147
+ def trivia?(token)
148
+ [:comment, :whitespace].include?(token[:node])
149
+ end
150
+
151
+ def custom_property?(tokens)
152
+ significant = tokens.reject { |token| trivia?(token) }
153
+ significant.first&.dig(:node) == :ident && significant.first[:value].start_with?("--") &&
154
+ significant[1]&.dig(:node) == :colon
155
+ end
156
+
157
+ def rewrite_at_rule(rule, suffix, changes)
158
+ return unless rule[:block] && RULE_AT_RULES.include?(rule[:name].downcase)
159
+
160
+ rewrite_scope(rule[:prelude], suffix, changes) if rule[:name].downcase == "scope"
161
+ rewrite_nested_rules(rule[:block], suffix, changes)
162
+ end
163
+
164
+ def rewrite_scope(tokens, suffix, changes)
165
+ tokens.each do |token|
166
+ next unless token[:node] == :simple_block && token[:start] == "("
167
+
168
+ rewrite_selector(token[:value], suffix, changes)
169
+ end
170
+ end
171
+
172
+ def rewrite_selector(tokens, suffix, changes)
173
+ previous = nil
174
+ tokens.each do |token|
175
+ next if token[:node] == :comment
176
+
177
+ if class_identifier?(token, previous)
178
+ suffix_identifier(token, suffix, changes)
179
+ elsif selector_function?(token)
180
+ rewrite_selector(token[:value], suffix, changes)
181
+ end
182
+ previous = token
183
+ end
184
+ end
185
+
186
+ def class_identifier?(token, previous)
187
+ token[:node] == :ident && previous&.dig(:node) == :delim && previous[:value] == "."
188
+ end
189
+
190
+ def selector_function?(token)
191
+ token[:node] == :function && SELECTOR_FUNCTIONS.include?(token[:name].downcase)
192
+ end
193
+
194
+ def suffix_identifier(token, suffix, changes)
195
+ return if token[:value].end_with?(suffix)
196
+
197
+ # A trailing short hex escape can absorb the first suffix character.
198
+ # Add its optional terminator only when concatenation changes meaning.
199
+ combined = Crass::Tokenizer.tokenize(token[:raw] + suffix).first
200
+ insertion = combined[:value] == token[:value] + suffix ? suffix : " #{suffix}"
201
+ changes[token[:pos] + token[:raw].length] = insertion
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,138 @@
1
+ require "leptris"
2
+
3
+ module Vectory
4
+ # Disambiguates element ids across multiple SVG documents embedded in one
5
+ # output (the multi-svg collision problem): ids that appear in more than
6
+ # one SVG get a per-document suffix, and every reference form is rewritten
7
+ # to match — `url(#id)` in presentation attributes and `<style>` blocks,
8
+ # `href`/`xlink:href` fragment references, and the `id` attributes
9
+ # themselves.
10
+ #
11
+ # This is the string-in/string-out counterpart of SvgMapping's document
12
+ # processing: content in, processed content out, same as SvgMapping's
13
+ # "serialize on entry" contract. Ported from metanorma-standoc's
14
+ # svg_uniqueids cleanup (lib/metanorma/cleanup/image.rb), where it was
15
+ # written against Nokogiri inline.
16
+ #
17
+ # Usage — N embedded SVGs, disambiguate by position:
18
+ #
19
+ # dupes = Vectory::SvgIdDisambiguator.duplicate_ids(svgs)
20
+ # svgs.map.with_index { |svg, i| Vectory::SvgIdDisambiguator.rewrite(svg, "_inject_#{i}", dupes) }
21
+ #
22
+ class SvgIdDisambiguator
23
+ # SVG elements whose ids are referenced through presentation attributes
24
+ # and CSS rather than href: the tag maps to the attributes that carry
25
+ # the reference (nil = the tag name itself is the CSS property).
26
+ IRI_TAG_PROPERTIES_MAP = {
27
+ "clipPath" => ["clip-path"],
28
+ "color-profile" => nil,
29
+ "cursor" => nil,
30
+ "filter" => nil,
31
+ "linearGradient" => ["fill", "stroke"],
32
+ "marker" => ["marker", "marker-end", "marker-mid", "marker-start"],
33
+ "mask" => nil,
34
+ "pattern" => ["fill", "stroke"],
35
+ "radialGradient" => ["fill", "stroke"],
36
+ }.freeze
37
+
38
+ URL_RE = %r{url\("?#([a-zA-Z][\w:.-]*)"?\)}.freeze
39
+ private_constant :URL_RE
40
+
41
+ class << self
42
+ # Ids that appear more than once across the given SVG contents.
43
+ #
44
+ # @param contents [Array<String>] SVG document contents
45
+ # @return [Hash{String => Integer}] id => occurrence count (count >= 2)
46
+ def duplicate_ids(contents)
47
+ contents.flat_map { |c| id_values(Leptris::XML.parse(c)) }
48
+ .group_by(&:itself).transform_values(&:count)
49
+ .delete_if { |_, v| v < 2 }
50
+ end
51
+
52
+ # Suffix every id in +duplicate_ids+ with +suffix+ inside +content+,
53
+ # rewriting all reference forms to match. Ids absent from the map are
54
+ # left untouched.
55
+ #
56
+ # @param content [String] SVG document content
57
+ # @param suffix [String] the full disambiguator, separator included
58
+ # (e.g. "_inject_0")
59
+ # @param duplicate_ids [Hash{String => Integer}] from .duplicate_ids
60
+ # @return [String] the rewritten content
61
+ def rewrite(content, suffix, duplicate_ids)
62
+ doc = Leptris::XML.parse(content)
63
+ svg_roots(doc).each { |svg| rewrite_svg(svg, suffix, duplicate_ids) }
64
+ doc.to_xml
65
+ end
66
+
67
+ private
68
+
69
+ def svg_roots(doc)
70
+ root = doc.root
71
+ if root&.name == "svg"
72
+ [root]
73
+ else
74
+ # SVGs embedded in a host document
75
+ doc.xpath("//*[local-name()='svg']")
76
+ end
77
+ end
78
+
79
+ def id_values(doc)
80
+ doc.xpath("//*").filter_map { |e| e["id"] if e["id"] }
81
+ end
82
+
83
+ def rewrite_svg(svg, suffix, ids)
84
+ id_elems = svg.xpath(".//*[@id]")
85
+ id_elems = [svg] + id_elems if svg["id"]
86
+ iri_properties = iri_properties(id_elems)
87
+ ([svg] + svg.xpath("//*")).each { |e| rewrite_element(e, iri_properties, suffix, ids) }
88
+ end
89
+
90
+ def rewrite_element(elem, iri_properties, suffix, ids)
91
+ if elem.name == "style"
92
+ elem.children = rewrite_urls(elem.text, suffix, ids)
93
+ return
94
+ end
95
+
96
+ # All attribute reads AND writes go through attribute_nodes: mixing
97
+ # in the elem[...] accessors after an id write desyncs the native
98
+ # attribute layer (leptris-ruby#526).
99
+ nodes = elem.attribute_nodes || []
100
+ return if nodes.empty?
101
+
102
+ iri_properties.each do |p|
103
+ node = nodes.find { |a| a.name == p } or next
104
+ node.value = rewrite_urls(node.value, suffix, ids)
105
+ end
106
+ nodes.each do |a|
107
+ case a.name
108
+ when "id"
109
+ a.value = "#{a.value}#{suffix}" if ids[a.value]
110
+ when "xlink:href", "href"
111
+ v = a.value.to_s.strip
112
+ a.value = "#{a.value}#{suffix}" if v.start_with?("#") && ids[v.sub(/\A#/, "")]
113
+ end
114
+ end
115
+ end
116
+
117
+ # The CSS-referenceable properties present among the id-bearing
118
+ # elements, plus "style" when any exist (standoc svg_iri_properties).
119
+ def iri_properties(id_elems)
120
+ iri_tag_names = id_elems.filter_map do |e|
121
+ e.name if IRI_TAG_PROPERTIES_MAP.key?(e.name)
122
+ end.uniq
123
+ iri_properties = iri_tag_names.flat_map do |t|
124
+ IRI_TAG_PROPERTIES_MAP[t] || [t]
125
+ end.uniq
126
+ return [] if iri_properties.empty?
127
+
128
+ iri_properties + ["style"]
129
+ end
130
+
131
+ def rewrite_urls(text, suffix, ids)
132
+ text.gsub(URL_RE) do |x|
133
+ ids[$1] ? "url(##{$1}#{suffix})" : x
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "leptris"
4
+
5
+ module Vectory
6
+ # Declaration changes on one serialized SVG. Remaps use namespace URIs,
7
+ # rather than lexical source prefixes, so shadows and repeats are unambiguous.
8
+ # Existing aliases remain unless explicitly requested in +unbind+.
9
+ class SvgNamespaceBinding
10
+ def self.rewrite(content, bind: {}, unbind: [], remap: {})
11
+ new(content, bind, unbind, remap).rewrite
12
+ end
13
+
14
+ def initialize(content, bind, unbind, remap)
15
+ unless bind.is_a?(Hash) && unbind.is_a?(Array) && remap.is_a?(Hash)
16
+ raise ArgumentError, "invalid namespace request: expected maps and an array"
17
+ end
18
+
19
+ normalize_options(bind, unbind, remap)
20
+ @document = parse(content)
21
+ @elements = @document.xpath("//*")
22
+ validate_names
23
+ end
24
+
25
+ def rewrite
26
+ targets = requested_targets
27
+ targets.each { |prefix, uri| preflight_target(prefix, uri) }
28
+ preflight_unbind
29
+ targets.each { |prefix, uri| bind_target(prefix, uri) }
30
+ @elements.each { |element| remap_element(element) }
31
+ unbind_namespaces
32
+ @document.to_xml
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_options(bind, unbind, remap)
38
+ @bind = normalize_bindings(bind)
39
+ @unbind = unbind.map { |prefix| normalize_prefix(prefix) }.uniq
40
+ @remap = remap.to_h { |uri, prefix| [validate_uri(uri), normalize_prefix(prefix)] }
41
+ end
42
+
43
+ def normalize_bindings(bind)
44
+ bindings = {}
45
+ bind.each do |prefix, uri|
46
+ prefix = normalize_prefix(prefix)
47
+ uri = validate_uri(uri)
48
+ add_target(bindings, prefix, uri)
49
+ end
50
+ bindings
51
+ end
52
+
53
+ def requested_targets
54
+ targets = @bind.dup
55
+ @remap.each { |uri, prefix| add_target(targets, prefix, uri) }
56
+ targets
57
+ end
58
+
59
+ def add_target(targets, prefix, uri)
60
+ if targets.key?(prefix) && targets[prefix] != uri
61
+ raise ArgumentError, "namespace conflict: incompatible requested bindings"
62
+ end
63
+
64
+ targets[prefix] = uri
65
+ end
66
+
67
+ def bind_target(prefix, uri)
68
+ @document.root.add_namespace_definition(prefix, uri) unless
69
+ @document.root.namespaces[declaration_name(prefix)] == uri
70
+ end
71
+
72
+ def unbind_element(element)
73
+ element.namespace_definitions.each do |namespace|
74
+ element.remove_namespace_definition(namespace.prefix) if @unbind.include?(namespace.prefix)
75
+ end
76
+ end
77
+
78
+ def unbind_namespaces
79
+ # Descendants still need their inherited source bindings during remapping.
80
+ @elements.each { |element| unbind_element(element) }
81
+ end
82
+
83
+ def normalize_prefix(prefix)
84
+ return nil if prefix.nil? || prefix == ""
85
+ if !prefix.is_a?(String) || !/\A[A-Za-z_][A-Za-z0-9_.-]*\z/.match?(prefix) ||
86
+ prefix.downcase.start_with?("xml")
87
+ raise ArgumentError, "invalid namespace request: invalid or reserved prefix"
88
+ end
89
+
90
+ prefix
91
+ end
92
+
93
+ def validate_uri(uri)
94
+ unless uri.is_a?(String) && !uri.empty? && !/\s/.match?(uri) &&
95
+ !["http://www.w3.org/XML/1998/namespace", "http://www.w3.org/2000/xmlns/"].include?(uri)
96
+ raise ArgumentError, "invalid namespace request: expected nonempty namespace URI"
97
+ end
98
+
99
+ uri
100
+ end
101
+
102
+ def parse(content)
103
+ raise ArgumentError, "invalid SVG: expected serialized string" unless content.is_a?(String)
104
+
105
+ document = Leptris::XML.parse(content, strict: true)
106
+ unless document.root&.name == "svg"
107
+ raise ArgumentError, "invalid SVG: expected SVG root"
108
+ end
109
+
110
+ document
111
+ rescue Leptris::XML::Error => e
112
+ raise ArgumentError, "invalid SVG: #{e.message}"
113
+ end
114
+
115
+ def declaration_name(prefix)
116
+ prefix ? "xmlns:#{prefix}" : "xmlns"
117
+ end
118
+
119
+ def validate_names
120
+ @elements.each do |element|
121
+ if element.name.include?(":") && !element.namespace&.href
122
+ raise ArgumentError, "invalid SVG: unbound element prefix"
123
+ end
124
+
125
+ validate_attributes(element)
126
+ end
127
+ end
128
+
129
+ def validate_attributes(element)
130
+ element.attribute_nodes.each do |attr|
131
+ if attr.name.include?(":") && !attr.namespace_uri
132
+ raise ArgumentError, "invalid SVG: unbound attribute prefix"
133
+ end
134
+ end
135
+ end
136
+
137
+ def preflight_target(prefix, uri)
138
+ preflight_declarations(prefix, uri)
139
+ preflight_default(uri) unless prefix
140
+ end
141
+
142
+ def preflight_declarations(prefix, uri)
143
+ @elements.each do |element|
144
+ element.namespace_definitions.each do |namespace|
145
+ if namespace.prefix == prefix && namespace.href != uri
146
+ raise ArgumentError, "namespace conflict: target prefix already bound"
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ def preflight_default(uri)
153
+ if @elements.any? { |element| !element.namespace&.href }
154
+ raise ArgumentError, "default namespace: would qualify unqualified elements"
155
+ end
156
+ return unless remapped_to_default?(uri)
157
+ return unless attributes_use_uri?(uri)
158
+
159
+ raise ArgumentError, "default namespace: attributes require an explicit prefix"
160
+ end
161
+
162
+ def remapped_to_default?(uri)
163
+ @remap[uri].nil? && @remap.key?(uri)
164
+ end
165
+
166
+ def attributes_use_uri?(uri)
167
+ @elements.any? { |element| element.attribute_nodes.any? { |attr| attr.namespace_uri == uri } }
168
+ end
169
+
170
+ def preflight_unbind
171
+ @unbind.each do |prefix|
172
+ if @bind.key?(prefix) || @remap.value?(prefix)
173
+ raise ArgumentError, "namespace conflict: target also requested for unbind"
174
+ end
175
+
176
+ preflight_prefix_usage(prefix)
177
+ end
178
+ end
179
+
180
+ def preflight_prefix_usage(prefix)
181
+ @elements.each do |element|
182
+ used = element_uses_prefix?(element, prefix) || attributes_use_prefix?(element, prefix)
183
+ raise ArgumentError, "namespace in use: cannot remove requested prefix" if used
184
+ end
185
+ end
186
+
187
+ def element_uses_prefix?(element, prefix)
188
+ namespace = element.namespace
189
+ namespace && namespace.prefix == prefix && !@remap.key?(namespace.href)
190
+ end
191
+
192
+ def attributes_use_prefix?(element, prefix)
193
+ element.attribute_nodes.any? do |attr|
194
+ attr.namespace_uri && attr.name.split(":")[0...-1].join(":") == prefix &&
195
+ !@remap.key?(attr.namespace_uri)
196
+ end
197
+ end
198
+
199
+ def remap_element(element)
200
+ # Snapshot before changing names: native namespace resolution is lexical.
201
+ namespace = element.namespace
202
+ attributes = attribute_snapshot(element)
203
+ if namespace && @remap.key?(namespace.href)
204
+ element.name = qualified_name(@remap[namespace.href], element.name)
205
+ end
206
+ remap_attributes(element, attributes)
207
+ end
208
+
209
+ def attribute_snapshot(element)
210
+ element.attribute_nodes.map do |attr|
211
+ [attr, attr.namespace_uri, attr.name.split(":").last, attr.value]
212
+ end
213
+ end
214
+
215
+ def remap_attributes(element, attributes)
216
+ attributes.each do |attr, uri, local, value|
217
+ next unless @remap.key?(uri)
218
+
219
+ attr.remove
220
+ element[qualified_name(@remap[uri], local)] = value
221
+ end
222
+ end
223
+
224
+ def qualified_name(prefix, local)
225
+ prefix ? "#{prefix}:#{local}" : local
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "leptris"
4
+
5
+ module Vectory
6
+ # Replaces serialized root content while retaining the host document envelope.
7
+ class SvgSubtreeGraft
8
+ ENCODING_SIGNATURES = {
9
+ "\x00\x00\xFE\xFF".b => "UTF-32", "\xFF\xFE\x00\x00".b => "UTF-32",
10
+ "\xFE\xFF".b => "UTF-16", "\xFF\xFE".b => "UTF-16",
11
+ "\x00\x00\x00<".b => "UTF-32BE", "<\x00\x00\x00".b => "UTF-32LE",
12
+ "\x00<\x00?".b => "UTF-16BE", "<\x00?\x00".b => "UTF-16LE"
13
+ }.freeze
14
+ private_constant :ENCODING_SIGNATURES
15
+
16
+ class << self
17
+ # @return [String] the complete host document with processed root children
18
+ def replace_root_content(host_content, processed_content)
19
+ host_content = utf8_content(host_content, "host_content")
20
+ processed_content = utf8_content(processed_content, "processed_content")
21
+ host = parse_document(host_content, "host_content")
22
+ processed = parse_document(processed_content, "processed_content")
23
+ # Native node copying/adoption can drop interleaved namespaced attributes.
24
+ # Reparse serialized children instead of crossing that copy boundary.
25
+ children = processed.root.children.map { |node| serialize_child(node, host.root.namespaces) }
26
+ host.root.children = children
27
+ serialize_host(host, host_content)
28
+ end
29
+
30
+ # @api private
31
+ # Leptris consumes UTF-8 bytes without decoding XML encoding declarations.
32
+ # Normalize a working copy; captured pipeline options retain the original.
33
+ def utf8_content(content, label)
34
+ encoding = source_encoding(content)
35
+ utf8 = content.dup.force_encoding(encoding).encode(Encoding::UTF_8)
36
+ return utf8 if Encoding.find(encoding) == Encoding::UTF_8
37
+
38
+ utf8.sub(/\A(<\?xml[ \t\r\n][^?]*\bencoding\s*=\s*)(["'])[^"']+\2/) do
39
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2)}UTF-8#{Regexp.last_match(2)}"
40
+ end
41
+ rescue EncodingError, ArgumentError => e
42
+ raise ParsingError, "invalid #{label} XML: #{e.message}"
43
+ end
44
+
45
+ private
46
+
47
+ def source_encoding(content)
48
+ bytes = content.b
49
+ signature = ENCODING_SIGNATURES.find { |prefix, _| bytes.start_with?(prefix) }
50
+ return signature.last if signature
51
+
52
+ bytes[/\A(?:\xEF\xBB\xBF)?<\?xml[ \t\r\n][^?]*\bencoding\s*=\s*(["'])([^"']+)\1/n, 2] || "UTF-8"
53
+ end
54
+
55
+ def serialize_host(host, content)
56
+ # The document serializer moves DOCTYPE before leading comments/PIs.
57
+ # Keep the original envelope and serialize only the retained host root.
58
+ prefix = content.byteslice(0, host.root.byte_offset)
59
+ suffix = content[/(?:[ \t\r\n]|<!--(?:(?!-->).)*-->|<\?(?:(?!\?>).)*\?>)*\z/m]
60
+ "#{prefix}#{host.root.to_xml}#{suffix}"
61
+ end
62
+
63
+ def parse_document(content, label)
64
+ document = Leptris::XML.parse(content, strict: true)
65
+ raise ParsingError, "#{label} has no document root" unless document.root
66
+
67
+ document
68
+ rescue Leptris::XML::ParseError => e
69
+ # Strict parsing also rejects valid rootless XML. Appending a root
70
+ # validates that envelope without allowing repaired malformed input.
71
+ begin
72
+ Leptris::XML.parse("#{content}<vectory-root-check/>", strict: true)
73
+ rescue Leptris::XML::ParseError
74
+ raise ParsingError, "invalid #{label} XML: #{e.message}"
75
+ end
76
+ raise ParsingError, "#{label} has no document root"
77
+ end
78
+
79
+ def serialize_child(node, host_namespaces)
80
+ seal_namespaces(node, host_namespaces) if node.element?
81
+ node.to_xml
82
+ end
83
+
84
+ def seal_namespaces(node, host_namespaces)
85
+ # Retain inherited bindings only where the host cannot supply them.
86
+ namespaces = { "xmlns" => "" }.merge(node.namespaces)
87
+ declared = node.namespace_definitions.map(&:prefix)
88
+ namespaces.each do |name, uri|
89
+ prefix = name == "xmlns" ? nil : name.delete_prefix("xmlns:")
90
+ next if declared.include?(prefix) || host_namespaces[name] == uri
91
+
92
+ node.add_namespace_definition(prefix, uri)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "leptris"
4
+
5
+ module Vectory
6
+ # An immutable, named operation at a serialized XML boundary.
7
+ class SvgTransform
8
+ attr_reader :name, :options
9
+
10
+ class << self
11
+ def graft_into(host_content:)
12
+ host = host_content.dup.freeze
13
+ root = Leptris::XML.parse(SvgSubtreeGraft.utf8_content(host, "host_content"), strict: true).root
14
+ raise ParsingError, "host_content has no document root" unless root
15
+
16
+ new(:graft_into, { host_content: host, svg_host: root.name == "svg" })
17
+ rescue Leptris::XML::SyntaxError, Leptris::XML::ParseError => e
18
+ raise ParsingError, "invalid host_content XML: #{e.message}"
19
+ end
20
+
21
+ def disambiguate(index:, duplicate_ids:)
22
+ new(:disambiguate, { index: index, duplicate_ids: duplicate_ids })
23
+ end
24
+
25
+ def suffix_classes(index:)
26
+ new(:suffix_classes, { index: index })
27
+ end
28
+
29
+ def bind_namespaces(bind: {}, unbind: [], remap: {})
30
+ new(:bind_namespaces, { bind: bind, unbind: unbind, remap: remap })
31
+ end
32
+
33
+ private
34
+
35
+ def snapshot(value)
36
+ case value
37
+ when Hash
38
+ value.to_h { |key, item| [snapshot(key), snapshot(item)] }.freeze
39
+ when Array
40
+ value.map { |item| snapshot(item) }.freeze
41
+ else
42
+ value.frozen? ? value : value.dup.freeze
43
+ end
44
+ end
45
+ end
46
+
47
+ def initialize(name, options)
48
+ @name = name
49
+ @options = self.class.send(:snapshot, options)
50
+ freeze
51
+ end
52
+ private_class_method :new
53
+
54
+ def >>(other)
55
+ SvgTransformPipeline.new([self]) >> other
56
+ end
57
+
58
+ def call(content)
59
+ SvgTransformPipeline.new([self]).call(content)
60
+ end
61
+
62
+ # Pipeline execution owns ordering and scope checks.
63
+ def apply(content)
64
+ case name
65
+ when :graft_into
66
+ SvgSubtreeGraft.replace_root_content(options.fetch(:host_content), content)
67
+ when :disambiguate
68
+ SvgIdDisambiguator.rewrite(content, suffix, options.fetch(:duplicate_ids))
69
+ when :suffix_classes
70
+ SvgClassSuffixer.rewrite(content, suffix)
71
+ when :bind_namespaces
72
+ SvgNamespaceBinding.rewrite(content, **options)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def suffix
79
+ "_inject_#{options.fetch(:index)}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "leptris"
4
+
5
+ module Vectory
6
+ # Only the supported whole-document and selected-SVG profiles are executable.
7
+ class SvgTransformPipeline
8
+ PROFILES = {
9
+ graft: %i[graft_into],
10
+ svg_cleanup: %i[disambiguate suffix_classes bind_namespaces],
11
+ graft_then_svg_cleanup: %i[graft_into disambiguate suffix_classes bind_namespaces],
12
+ }.transform_values(&:freeze).freeze
13
+
14
+ attr_reader :steps
15
+
16
+ def initialize(steps)
17
+ unless steps.all?(SvgTransform)
18
+ raise ArgumentError, "invalid step sequence: expected SVG transforms"
19
+ end
20
+
21
+ @steps = steps.dup.freeze
22
+ validate_sequence
23
+ freeze
24
+ end
25
+
26
+ def >>(other)
27
+ additions = other.is_a?(SvgTransformPipeline) ? other.steps : [other]
28
+ self.class.new(steps + additions)
29
+ end
30
+
31
+ def profile
32
+ PROFILES.key(steps.map(&:name))
33
+ end
34
+
35
+ def call(content)
36
+ raise ArgumentError, "incomplete profile: #{steps.map(&:name).join(' -> ')}" unless profile
37
+
38
+ validate_scope(content)
39
+ output = steps.reduce(content) { |xml, step| step.apply(xml) }
40
+ profile == :graft_then_svg_cleanup ? finalize_graft(output) : output
41
+ end
42
+
43
+ private
44
+
45
+ def finalize_graft(output)
46
+ # Cleanup serializers may reorder the envelope. Keep the cleaned root,
47
+ # including its new attributes and bindings, inside the captured envelope.
48
+ host_content = SvgSubtreeGraft.utf8_content(steps.first.options.fetch(:host_content), "host_content")
49
+ host = Leptris::XML.parse(host_content, strict: true)
50
+ prefix = host_content.byteslice(0, host.root.byte_offset)
51
+ tail = host_content[/(?:[ \t\r\n]|<!--(?:(?!-->).)*-->|<\?(?:(?!\?>).)*\?>)*\z/m]
52
+ "#{prefix}#{final_graft_root(output).to_xml}#{tail}"
53
+ end
54
+
55
+ def final_graft_root(output)
56
+ root = Leptris::XML.parse(output, strict: true).root
57
+ # Bindings sealed onto grafted children may now be supplied by the root.
58
+ root.xpath(".//*").each { |element| remove_redundant_namespaces(element) }
59
+ root
60
+ end
61
+
62
+ def remove_redundant_namespaces(element)
63
+ inherited = element.parent.namespaces
64
+ element.namespace_definitions.each do |namespace|
65
+ name = namespace.prefix ? "xmlns:#{namespace.prefix}" : "xmlns"
66
+ element.remove_namespace_definition(namespace.prefix) if inherited[name] == namespace.href
67
+ end
68
+ end
69
+
70
+ def validate_sequence
71
+ names = steps.map(&:name)
72
+ unless !names.empty? && PROFILES.values.any? { |profile| profile.take(names.length) == names }
73
+ raise ArgumentError, "invalid step sequence: #{names.join(' -> ')}"
74
+ end
75
+ end
76
+
77
+ def validate_scope(content)
78
+ case profile
79
+ when :svg_cleanup
80
+ validate_svg_root(content)
81
+ when :graft_then_svg_cleanup
82
+ return if steps.first.options.fetch(:svg_host)
83
+
84
+ raise ArgumentError, "execution-scope mismatch: graft_then_svg_cleanup requires an SVG host root"
85
+ end
86
+ rescue Leptris::XML::SyntaxError, Leptris::XML::ParseError => e
87
+ raise ParsingError, "invalid content XML: #{e.message}"
88
+ end
89
+
90
+ def validate_svg_root(content)
91
+ root = Leptris::XML.parse(content, strict: true).root
92
+ return if root&.name == "svg"
93
+
94
+ raise ArgumentError, "execution-scope mismatch: svg_cleanup requires an SVG root"
95
+ end
96
+ end
97
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vectory
4
- VERSION = "0.13.1"
4
+ VERSION = "0.13.3"
5
5
  end
data/lib/vectory.rb CHANGED
@@ -15,13 +15,11 @@ module Vectory
15
15
  class NotWrittenToDiskError < Error; end
16
16
 
17
17
  class ParsingError < Error; end
18
- end
19
18
 
20
- require_relative "vectory/errors"
21
- require_relative "vectory/version"
19
+ require_relative "vectory/errors"
20
+ require_relative "vectory/version"
22
21
 
23
- # Lazy load: all other internal Vectory dependencies via autoload
24
- module Vectory
22
+ # Lazy load: all other internal Vectory dependencies via autoload
25
23
  # Core utilities
26
24
  autoload :Utils, "vectory/utils"
27
25
 
@@ -37,11 +35,15 @@ module Vectory
37
35
  autoload :Svg, "vectory/svg"
38
36
  autoload :SvgMapping, "vectory/svg_mapping"
39
37
  autoload :SvgDocument, "vectory/svg_document"
38
+ autoload :SvgIdDisambiguator, "vectory/svg_id_disambiguator"
39
+ autoload :SvgClassSuffixer, "vectory/svg_class_suffixer"
40
+ autoload :SvgNamespaceBinding, "vectory/svg_namespace_binding"
41
+ autoload :SvgSubtreeGraft, "vectory/svg_subtree_graft"
42
+ autoload :SvgTransform, "vectory/svg_transform"
43
+ autoload :SvgTransformPipeline, "vectory/svg_transform_pipeline"
40
44
  autoload :FileMagic, "vectory/file_magic"
41
- end
42
45
 
43
- # Define additional module methods
44
- module Vectory
46
+ # Define additional module methods
45
47
  def self.ui
46
48
  @ui ||= Logger.new($stdout).tap do |logger|
47
49
  logger.level = ENV["VECTORY_LOG"] || Logger::WARN
data/vectory.gemspec CHANGED
@@ -16,21 +16,22 @@ Gem::Specification.new do |spec|
16
16
 
17
17
  spec.homepage = "https://github.com/metanorma/vectory"
18
18
  spec.license = "BSD-2-Clause"
19
+ spec.metadata["rubygems_mfa_required"] = "true"
19
20
 
20
21
  spec.bindir = "exe"
21
22
  spec.executables = ["vectory"]
22
23
  spec.require_paths = ["lib"]
23
24
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
24
- f.match(%r{^(bin|spec)/})
25
+ f.match(%r{^(bin|spec|TODO\.[^/]+)/})
25
26
  end
26
- spec.test_files = `git ls-files -- {spec}/*`.split("\n")
27
27
  spec.required_ruby_version = ">= 3.1.0"
28
28
 
29
29
  spec.add_dependency "base64"
30
+ spec.add_dependency "crass", "~> 1.0", ">= 1.0.7"
30
31
  spec.add_dependency "emfsvg", "~> 0.1", ">= 0.1.2"
31
32
  spec.add_dependency "image_size", ">= 3.2.0"
33
+ spec.add_dependency "leptris", "~> 1.9.399"
32
34
  spec.add_dependency "marcel", "~> 1.0"
33
- spec.add_dependency "leptris", ">= 1.9.361.1"
34
35
  spec.add_dependency "postsvg", "~> 0.3"
35
36
  spec.add_dependency "thor", "~> 1.0"
36
37
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vectory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,6 +24,26 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: crass
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 1.0.7
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '1.0'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.0.7
27
47
  - !ruby/object:Gem::Dependency
28
48
  name: emfsvg
29
49
  requirement: !ruby/object:Gem::Requirement
@@ -59,33 +79,33 @@ dependencies:
59
79
  - !ruby/object:Gem::Version
60
80
  version: 3.2.0
61
81
  - !ruby/object:Gem::Dependency
62
- name: marcel
82
+ name: leptris
63
83
  requirement: !ruby/object:Gem::Requirement
64
84
  requirements:
65
85
  - - "~>"
66
86
  - !ruby/object:Gem::Version
67
- version: '1.0'
87
+ version: 1.9.399
68
88
  type: :runtime
69
89
  prerelease: false
70
90
  version_requirements: !ruby/object:Gem::Requirement
71
91
  requirements:
72
92
  - - "~>"
73
93
  - !ruby/object:Gem::Version
74
- version: '1.0'
94
+ version: 1.9.399
75
95
  - !ruby/object:Gem::Dependency
76
- name: leptris
96
+ name: marcel
77
97
  requirement: !ruby/object:Gem::Requirement
78
98
  requirements:
79
- - - ">="
99
+ - - "~>"
80
100
  - !ruby/object:Gem::Version
81
- version: 1.9.361.1
101
+ version: '1.0'
82
102
  type: :runtime
83
103
  prerelease: false
84
104
  version_requirements: !ruby/object:Gem::Requirement
85
105
  requirements:
86
- - - ">="
106
+ - - "~>"
87
107
  - !ruby/object:Gem::Version
88
- version: 1.9.361.1
108
+ version: '1.0'
89
109
  - !ruby/object:Gem::Dependency
90
110
  name: postsvg
91
111
  requirement: !ruby/object:Gem::Requirement
@@ -167,8 +187,14 @@ files:
167
187
  - lib/vectory/image_resize.rb
168
188
  - lib/vectory/ps.rb
169
189
  - lib/vectory/svg.rb
190
+ - lib/vectory/svg_class_suffixer.rb
170
191
  - lib/vectory/svg_document.rb
192
+ - lib/vectory/svg_id_disambiguator.rb
171
193
  - lib/vectory/svg_mapping.rb
194
+ - lib/vectory/svg_namespace_binding.rb
195
+ - lib/vectory/svg_subtree_graft.rb
196
+ - lib/vectory/svg_transform.rb
197
+ - lib/vectory/svg_transform_pipeline.rb
172
198
  - lib/vectory/utils.rb
173
199
  - lib/vectory/vector.rb
174
200
  - lib/vectory/version.rb
@@ -178,7 +204,8 @@ files:
178
204
  homepage: https://github.com/metanorma/vectory
179
205
  licenses:
180
206
  - BSD-2-Clause
181
- metadata: {}
207
+ metadata:
208
+ rubygems_mfa_required: 'true'
182
209
  post_install_message:
183
210
  rdoc_options: []
184
211
  require_paths: