canon 0.3.20 → 0.3.22
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/lib/canon/comparison/node_inspector.rb +4 -1
- data/lib/canon/formatters/html_formatter_base.rb +39 -11
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/character_encoder.rb +12 -2
- data/lib/canon/xml/namespace_handler.rb +24 -5
- data/lib/canon/xml/nodes/element_node.rb +16 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a369d56aae65493ad94c6352f01a15915eb0af020cde2c98868b7d51a8b0658
|
|
4
|
+
data.tar.gz: f667f5bf42ed4f1e581ee32951d561653187c06b74b6b996f9bcd1bb9fb0f073
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6741dac3a19b779fbbd9fe49edaa7f3d6c71497fe4a1cc6339eb65f0d749bcfe9e0800323cb321f4cfd35a756e0ebfab5f8740f8c3aa5e895b70d7c71adc3a84
|
|
7
|
+
data.tar.gz: 8a360875b2c5597c9e87e81eb0a8037979efa6b8166fe18155a68efc432626d4efebc1aa8ad82ea66ec398681aa1d03f83d35aa1c8fe0684e74a616f0ee96913
|
|
@@ -68,8 +68,11 @@ module Canon
|
|
|
68
68
|
def self.whitespace_only_text?(node)
|
|
69
69
|
return false unless text_node?(node)
|
|
70
70
|
|
|
71
|
+
# This runs per child pair in the realignment walk — the
|
|
72
|
+
# zero-allocation form of `text.strip.empty?` (exactly
|
|
73
|
+
# String#strip's character set; see WhitespacePolicy).
|
|
71
74
|
text = text_content(node)
|
|
72
|
-
!text.empty? && text.
|
|
75
|
+
!text.empty? && text.match?(Canon::Xml::WhitespacePolicy::STRIP_ONLY)
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
# --- Noise classification ---
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "nokogiri" unless RUBY_ENGINE == "opal"
|
|
4
|
+
require "set"
|
|
4
5
|
|
|
5
6
|
module Canon
|
|
6
7
|
module Formatters
|
|
@@ -55,6 +56,14 @@ module Canon
|
|
|
55
56
|
WHITESPACE_SENSITIVE_ELEMENTS = %w[
|
|
56
57
|
pre code textarea script style
|
|
57
58
|
].freeze
|
|
59
|
+
# Set form for the per-sibling hot lookup; Nokogiri lowercases
|
|
60
|
+
# HTML element names, so the downcase fallback only runs for
|
|
61
|
+
# unusual (already-mixed-case) input.
|
|
62
|
+
BLOCK_ELEMENT_SET = BLOCK_ELEMENTS.to_set
|
|
63
|
+
# Compiled once — building the alternation and compiling the
|
|
64
|
+
# regex per format call cost more than the gsub it drives.
|
|
65
|
+
BLOCK_SPACING_PATTERN =
|
|
66
|
+
Regexp.new("(</(?:#{BLOCK_ELEMENTS.join('|')})>)(<(?:#{BLOCK_ELEMENTS.join('|')})[\s>])").freeze
|
|
58
67
|
# Format HTML using canonical form
|
|
59
68
|
# @param html [String] HTML document to canonicalize
|
|
60
69
|
# @return [String] Canonical form of HTML
|
|
@@ -100,6 +109,15 @@ module Canon
|
|
|
100
109
|
next unless node.element?
|
|
101
110
|
next if node.attributes.empty?
|
|
102
111
|
|
|
112
|
+
names = node.attributes.keys
|
|
113
|
+
# Already-sorted un-namespaced is the common case — removing
|
|
114
|
+
# and re-adding every attribute is expensive, so check first.
|
|
115
|
+
# Namespaced attributes must take the slow path: the
|
|
116
|
+
# remove/re-add below flattens their prefix, and skipping
|
|
117
|
+
# would change the canonical output.
|
|
118
|
+
next if names.each_cons(2).all? { |a, b| (a <=> b) <= 0 } &&
|
|
119
|
+
node.attributes.each_value.all? { |a| a.namespace.nil? }
|
|
120
|
+
|
|
103
121
|
sorted_attrs = node.attributes.sort_by { |name, _| name }
|
|
104
122
|
node.attributes.each_key { |name| node.remove_attribute(name) }
|
|
105
123
|
sorted_attrs.each { |name, attr| node[name] = attr.value }
|
|
@@ -122,7 +140,7 @@ module Canon
|
|
|
122
140
|
end
|
|
123
141
|
|
|
124
142
|
# Handle whitespace-only text nodes
|
|
125
|
-
if node.text.
|
|
143
|
+
if node.text.match?(Canon::Xml::WhitespacePolicy::STRIP_ONLY) && node.parent&.element?
|
|
126
144
|
# Check if this text node is between block-level elements
|
|
127
145
|
prev_sibling = node.previous_sibling
|
|
128
146
|
next_sibling = node.next_sibling
|
|
@@ -138,14 +156,17 @@ module Canon
|
|
|
138
156
|
else
|
|
139
157
|
# Collapse multiple whitespace characters into single spaces
|
|
140
158
|
# but preserve leading/trailing single spaces for inline content
|
|
141
|
-
|
|
159
|
+
text = node.text
|
|
160
|
+
normalized = text.gsub(/\s+/, " ")
|
|
142
161
|
# Only strip if the entire parent chain suggests it's appropriate
|
|
143
162
|
# (e.g., at document boundaries)
|
|
144
163
|
if node.parent&.name == "body" &&
|
|
145
164
|
(node.previous_sibling.nil? || node.next_sibling.nil?)
|
|
146
165
|
normalized = normalized.strip
|
|
147
166
|
end
|
|
148
|
-
node.content
|
|
167
|
+
# node.content= re-parses the string — skip it when nothing
|
|
168
|
+
# changed (text with no collapsible whitespace).
|
|
169
|
+
node.content = normalized unless normalized == text
|
|
149
170
|
end
|
|
150
171
|
end
|
|
151
172
|
end
|
|
@@ -154,19 +175,20 @@ module Canon
|
|
|
154
175
|
# @param html [String] Serialized HTML string
|
|
155
176
|
# @return [String] HTML with proper spacing between block elements
|
|
156
177
|
def self.ensure_block_element_spacing(html)
|
|
157
|
-
# Build regex pattern for block element tags
|
|
158
|
-
block_tags = BLOCK_ELEMENTS.join("|")
|
|
159
|
-
|
|
160
178
|
# Add space between closing and opening block element tags
|
|
161
|
-
#
|
|
162
|
-
html.gsub(
|
|
179
|
+
# (pattern compiled once — see BLOCK_SPACING_PATTERN)
|
|
180
|
+
html.gsub(BLOCK_SPACING_PATTERN, '\1 \2')
|
|
163
181
|
end
|
|
164
182
|
|
|
165
183
|
# Check if a node is a block-level element
|
|
166
184
|
# @param node [Nokogiri::XML::Node, nil] Node to check
|
|
167
185
|
# @return [Boolean] true if node is a block element
|
|
168
186
|
def self.block_element?(node)
|
|
169
|
-
node&.element?
|
|
187
|
+
return false unless node&.element?
|
|
188
|
+
|
|
189
|
+
name = node.name
|
|
190
|
+
BLOCK_ELEMENT_SET.include?(name) ||
|
|
191
|
+
BLOCK_ELEMENT_SET.include?(name.downcase)
|
|
170
192
|
end
|
|
171
193
|
|
|
172
194
|
# Check if a node is a whitespace-sensitive element
|
|
@@ -178,8 +200,14 @@ module Canon
|
|
|
178
200
|
# Check if this element or any ancestor is whitespace-sensitive
|
|
179
201
|
current = node
|
|
180
202
|
while current
|
|
181
|
-
if current.element?
|
|
182
|
-
|
|
203
|
+
if current.element?
|
|
204
|
+
name = current.name
|
|
205
|
+
# Nokogiri lowercases HTML names — the downcase fallback
|
|
206
|
+
# only allocates for unusual mixed-case input.
|
|
207
|
+
if WHITESPACE_SENSITIVE_ELEMENTS.include?(name) ||
|
|
208
|
+
WHITESPACE_SENSITIVE_ELEMENTS.include?(name.downcase)
|
|
209
|
+
return true
|
|
210
|
+
end
|
|
183
211
|
end
|
|
184
212
|
# Stop at document root - documents don't have parents
|
|
185
213
|
break if current.is_a?(Nokogiri::XML::Document) || current.is_a?(Nokogiri::HTML5::Document)
|
data/lib/canon/version.rb
CHANGED
|
@@ -5,10 +5,18 @@ module Canon
|
|
|
5
5
|
# Character encoder for C14N 1.1
|
|
6
6
|
# Handles UTF-8 encoding and character reference encoding per spec
|
|
7
7
|
class CharacterEncoder
|
|
8
|
+
# Most text and attribute values contain nothing to escape —
|
|
9
|
+
# the zero-allocation guard keeps those from paying the gsub
|
|
10
|
+
# copy on every render.
|
|
11
|
+
TEXT_ESCAPABLE = /[&<>\r]/
|
|
12
|
+
ATTRIBUTE_ESCAPABLE = /[&<"\t\n\r]/
|
|
13
|
+
|
|
8
14
|
# Encode text node content
|
|
9
15
|
# Replace: & → &, < → <, > → >, #xD → 
|
|
10
16
|
def encode_text(text)
|
|
11
|
-
text.
|
|
17
|
+
return text unless text.match?(TEXT_ESCAPABLE)
|
|
18
|
+
|
|
19
|
+
text.gsub(TEXT_ESCAPABLE) do |char|
|
|
12
20
|
case char
|
|
13
21
|
when "&" then "&"
|
|
14
22
|
when "<" then "<"
|
|
@@ -22,7 +30,9 @@ module Canon
|
|
|
22
30
|
# Replace: & → &, < → <, " → ",
|
|
23
31
|
# #x9 → 	, #xA → 
, #xD → 
|
|
24
32
|
def encode_attribute(value)
|
|
25
|
-
value.
|
|
33
|
+
return value unless value.match?(ATTRIBUTE_ESCAPABLE)
|
|
34
|
+
|
|
35
|
+
value.gsub(ATTRIBUTE_ESCAPABLE) do |char|
|
|
26
36
|
case char
|
|
27
37
|
when "&" then "&"
|
|
28
38
|
when "<" then "<"
|
|
@@ -14,7 +14,7 @@ module Canon
|
|
|
14
14
|
def process_namespaces(element, output, parent_element = nil)
|
|
15
15
|
return unless element.in_node_set?
|
|
16
16
|
|
|
17
|
-
namespaces = element
|
|
17
|
+
namespaces = renderable_namespaces(element)
|
|
18
18
|
|
|
19
19
|
# Check if we need to emit xmlns="" for empty default namespace
|
|
20
20
|
if should_emit_empty_default_namespace?(element, namespaces,
|
|
@@ -61,26 +61,45 @@ module Canon
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Check if a namespace node should be skipped
|
|
64
|
-
def should_skip_namespace?(ns,
|
|
64
|
+
def should_skip_namespace?(ns, element, parent_element)
|
|
65
65
|
# Skip xml namespace with standard URI
|
|
66
66
|
return true if ns.xml_namespace?
|
|
67
67
|
|
|
68
68
|
# Skip if an ancestor already declared this namespace
|
|
69
|
-
return true if namespace_declared_by_ancestor?(ns,
|
|
69
|
+
return true if namespace_declared_by_ancestor?(ns, element,
|
|
70
|
+
parent_element)
|
|
70
71
|
|
|
71
72
|
false
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
# Check if a namespace is already declared by an ancestor
|
|
75
|
-
def namespace_declared_by_ancestor?(ns, parent_element)
|
|
76
|
+
def namespace_declared_by_ancestor?(ns, element, parent_element)
|
|
76
77
|
return false unless parent_element
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
parent_nodes = parent_element.namespace_nodes
|
|
80
|
+
# Elements that declared nothing share their parent's scope
|
|
81
|
+
# object (TreeBuilder scope sharing), so identical arrays mean
|
|
82
|
+
# identical bindings — every namespace of this element is
|
|
83
|
+
# already declared above and will be skipped without a scan.
|
|
84
|
+
return true if parent_nodes.equal?(element.namespace_nodes)
|
|
85
|
+
|
|
86
|
+
parent_ns = parent_nodes.find do |parent_ns|
|
|
79
87
|
parent_ns.prefix == ns.prefix && parent_ns.in_node_set?
|
|
80
88
|
end
|
|
81
89
|
|
|
82
90
|
parent_ns && parent_ns.uri == ns.uri
|
|
83
91
|
end
|
|
92
|
+
|
|
93
|
+
# Sorted, in-node-set namespaces for one element. Undeclaring
|
|
94
|
+
# elements share one frozen array object, and node-set marking is
|
|
95
|
+
# fixed before processing runs, so the rendered form is cached
|
|
96
|
+
# per array identity for the lifetime of this handler (one
|
|
97
|
+
# canonicalization run).
|
|
98
|
+
def renderable_namespaces(element)
|
|
99
|
+
cache = (@renderable_namespaces ||= {}.compare_by_identity)
|
|
100
|
+
nodes = element.namespace_nodes
|
|
101
|
+
cache[nodes] ||= nodes.sort_by(&:local_name).select(&:in_node_set?)
|
|
102
|
+
end
|
|
84
103
|
end
|
|
85
104
|
end
|
|
86
105
|
end
|
|
@@ -53,15 +53,26 @@ module Canon
|
|
|
53
53
|
(@attribute_nodes ||= []) << attribute_node
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Get namespace nodes in sorted order (lexicographically by local name)
|
|
56
|
+
# Get namespace nodes in sorted order (lexicographically by local name).
|
|
57
57
|
def sorted_namespace_nodes
|
|
58
|
-
namespace_nodes
|
|
58
|
+
nodes = namespace_nodes
|
|
59
|
+
return nodes if nodes.empty?
|
|
60
|
+
|
|
61
|
+
nodes.sort_by(&:local_name)
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
# Get attribute nodes in sorted order (by namespace URI then local
|
|
64
|
+
# Get attribute nodes in sorted order (by namespace URI then local
|
|
65
|
+
# name). A comparator block instead of sort_by keys allocates no
|
|
66
|
+
# per-attribute key arrays; (uri, name) pairs are unique per
|
|
67
|
+
# element (duplicates are resolved at build), so the order is
|
|
68
|
+
# identical.
|
|
62
69
|
def sorted_attribute_nodes
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
attrs = attribute_nodes
|
|
71
|
+
return attrs if attrs.empty?
|
|
72
|
+
|
|
73
|
+
attrs.sort do |a, b|
|
|
74
|
+
(a.namespace_uri.to_s <=> b.namespace_uri.to_s).nonzero? ||
|
|
75
|
+
(a.local_name <=> b.local_name)
|
|
65
76
|
end
|
|
66
77
|
end
|
|
67
78
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: canon
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.22
|
|
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-09-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|