vectory 0.13.2 → 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 +4 -4
- data/.github/workflows/rake.yml +25 -1
- data/README.adoc +33 -0
- data/docs/Gemfile +3 -0
- data/docs/lychee.toml +3 -12
- data/docs/reference/api.adoc +84 -0
- data/lib/vectory/svg_class_suffixer.rb +205 -0
- data/lib/vectory/svg_namespace_binding.rb +228 -0
- data/lib/vectory/svg_subtree_graft.rb +97 -0
- data/lib/vectory/svg_transform.rb +82 -0
- data/lib/vectory/svg_transform_pipeline.rb +97 -0
- data/lib/vectory/version.rb +1 -1
- data/lib/vectory.rb +9 -8
- data/vectory.gemspec +3 -2
- metadata +37 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ee735ec4ea1feac5c918c1255839ae19c9e3468d5f8d9b8137d386ba960eb4f
|
|
4
|
+
data.tar.gz: a81a2bc24cf8fdcf5804fd1642099aeb4a05e592c52cbe34265ee99d4d2b77e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad9305a8070f277be8cfa9f60e2237e46937995490a3c031df57f115fc3151f07fac9c3fd1e74254b9266fe0dda4fbae864eddc32fac68cd6999307cc2f0142f
|
|
7
|
+
data.tar.gz: cc73b481d8e550a4d05a010945c3051fa6d8fe91b40689a777c3247b25c1c5be1abe0e4b500fc78012f99a51ef2614347908949638512726705e60cbcc5f4df6
|
data/.github/workflows/rake.yml
CHANGED
|
@@ -9,7 +9,7 @@ on:
|
|
|
9
9
|
pull_request:
|
|
10
10
|
|
|
11
11
|
permissions:
|
|
12
|
-
contents:
|
|
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
|
]
|
data/docs/reference/api.adoc
CHANGED
|
@@ -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,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
|
data/lib/vectory/version.rb
CHANGED
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
|
|
|
@@ -38,11 +36,14 @@ module Vectory
|
|
|
38
36
|
autoload :SvgMapping, "vectory/svg_mapping"
|
|
39
37
|
autoload :SvgDocument, "vectory/svg_document"
|
|
40
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"
|
|
41
44
|
autoload :FileMagic, "vectory/file_magic"
|
|
42
|
-
end
|
|
43
45
|
|
|
44
|
-
# Define additional module methods
|
|
45
|
-
module Vectory
|
|
46
|
+
# Define additional module methods
|
|
46
47
|
def self.ui
|
|
47
48
|
@ui ||= Logger.new($stdout).tap do |logger|
|
|
48
49
|
logger.level = ENV["VECTORY_LOG"] || Logger::WARN
|
data/vectory.gemspec
CHANGED
|
@@ -16,6 +16,7 @@ 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"]
|
|
@@ -23,14 +24,14 @@ Gem::Specification.new do |spec|
|
|
|
23
24
|
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
|
24
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.
|
|
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-
|
|
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:
|
|
82
|
+
name: leptris
|
|
63
83
|
requirement: !ruby/object:Gem::Requirement
|
|
64
84
|
requirements:
|
|
65
85
|
- - "~>"
|
|
66
86
|
- !ruby/object:Gem::Version
|
|
67
|
-
version:
|
|
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:
|
|
94
|
+
version: 1.9.399
|
|
75
95
|
- !ruby/object:Gem::Dependency
|
|
76
|
-
name:
|
|
96
|
+
name: marcel
|
|
77
97
|
requirement: !ruby/object:Gem::Requirement
|
|
78
98
|
requirements:
|
|
79
|
-
- - "
|
|
99
|
+
- - "~>"
|
|
80
100
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 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.
|
|
108
|
+
version: '1.0'
|
|
89
109
|
- !ruby/object:Gem::Dependency
|
|
90
110
|
name: postsvg
|
|
91
111
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -167,9 +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
|
|
171
192
|
- lib/vectory/svg_id_disambiguator.rb
|
|
172
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
|
|
173
198
|
- lib/vectory/utils.rb
|
|
174
199
|
- lib/vectory/vector.rb
|
|
175
200
|
- lib/vectory/version.rb
|
|
@@ -179,7 +204,8 @@ files:
|
|
|
179
204
|
homepage: https://github.com/metanorma/vectory
|
|
180
205
|
licenses:
|
|
181
206
|
- BSD-2-Clause
|
|
182
|
-
metadata:
|
|
207
|
+
metadata:
|
|
208
|
+
rubygems_mfa_required: 'true'
|
|
183
209
|
post_install_message:
|
|
184
210
|
rdoc_options: []
|
|
185
211
|
require_paths:
|