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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 581a483d81eba304f20fefc4e14777efced86bd7b829d1181209bee00c3ac7df
4
- data.tar.gz: ea8929a3686f268e5c99376a0aded4999714b298fe7a558c2ac713929fe3d6ac
3
+ metadata.gz: 4a369d56aae65493ad94c6352f01a15915eb0af020cde2c98868b7d51a8b0658
4
+ data.tar.gz: f667f5bf42ed4f1e581ee32951d561653187c06b74b6b996f9bcd1bb9fb0f073
5
5
  SHA512:
6
- metadata.gz: 19d37bd2c6fec3949791787ca2a64e24afce11588bf17d335efa81fba225d945d77e578a2eb8dae0b8df48904f0f824830f43018cd78830448e05da127498f99
7
- data.tar.gz: d3c3f990c6a133cb9cada5dbfae4bb752f524c972f3b75253c24c71226e7ad86138467559e533611b20beb95520f4441bfb80fafd4648bacf5b63e0717a69e70
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.strip.empty?
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.strip.empty? && node.parent&.element?
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
- normalized = node.text.gsub(/\s+/, " ")
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 = normalized
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
- # Match: ><opening_block_tag or </closing_block_tag><opening_block_tag
162
- html.gsub(/(<\/(?:#{block_tags})>)(<(?:#{block_tags})[\s>])/, '\1 \2')
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? && BLOCK_ELEMENTS.include?(node.name.downcase)
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? && WHITESPACE_SENSITIVE_ELEMENTS.include?(current.name.downcase)
182
- return true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.20"
4
+ VERSION = "0.3.22"
5
5
  end
@@ -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: & → &amp;, < → &lt;, > → &gt;, #xD → &#xD;
10
16
  def encode_text(text)
11
- text.gsub(/[&<>\r]/) do |char|
17
+ return text unless text.match?(TEXT_ESCAPABLE)
18
+
19
+ text.gsub(TEXT_ESCAPABLE) do |char|
12
20
  case char
13
21
  when "&" then "&amp;"
14
22
  when "<" then "&lt;"
@@ -22,7 +30,9 @@ module Canon
22
30
  # Replace: & → &amp;, < → &lt;, " → &quot;,
23
31
  # #x9 → &#x9;, #xA → &#xA;, #xD → &#xD;
24
32
  def encode_attribute(value)
25
- value.gsub(/[&<"\t\n\r]/) do |char|
33
+ return value unless value.match?(ATTRIBUTE_ESCAPABLE)
34
+
35
+ value.gsub(ATTRIBUTE_ESCAPABLE) do |char|
26
36
  case char
27
37
  when "&" then "&amp;"
28
38
  when "<" then "&lt;"
@@ -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.sorted_namespace_nodes.select(&:in_node_set?)
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, _element, parent_element)
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, parent_element)
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
- parent_ns = parent_element.namespace_nodes.find do |parent_ns|
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.sort_by(&:local_name)
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 name)
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
- attribute_nodes.sort_by do |attr|
64
- [attr.namespace_uri.to_s, attr.local_name]
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.20
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-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs