canon 0.3.12 → 0.3.14
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/html_comparator.rb +13 -6
- data/lib/canon/comparison/markup_comparator.rb +1 -1
- data/lib/canon/comparison/match_options.rb +15 -0
- data/lib/canon/comparison/whitespace_sensitivity.rb +43 -17
- data/lib/canon/comparison/xml_comparator/child_comparison.rb +4 -15
- data/lib/canon/comparison.rb +10 -0
- data/lib/canon/html/data_model.rb +10 -7
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/data_model.rb +48 -21
- data/lib/canon/xml/element_matcher.rb +67 -29
- data/lib/canon/xml/node.rb +15 -3
- data/lib/canon/xml/nodes/element_node.rb +23 -11
- data/lib/canon/xml/nodes/root_node.rb +0 -4
- data/lib/canon/xml/sax_builder.rb +22 -7
- data/lib/canon/xml/tree_builder.rb +76 -28
- data/lib/canon/xml/whitespace_policy.rb +12 -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: d9954ac6056439abc3c5b211ecf17b9af2391ad0b809eb3c239fef55cbdc723a
|
|
4
|
+
data.tar.gz: dbe215e817f160c840769b5bbd0969fe83d315d2b4429cec125de43312ec821a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1552e6f9a7735903f10b8f73902c6896cfab41b61da29b9bf725c6d5a1132483566eec58eeeb5ce2a59714a5e228ba8cb2979fa79f853cf5bd361f5ffcd849f
|
|
7
|
+
data.tar.gz: c2b93d2248033a3680164769ab57163f44935314a0929155fa98e2a488f1ea278bff8dba5bbdc38b19d313db13e9ddc6ffeb8b56cdf7537d1ba5c08098ec9bb2
|
|
@@ -44,6 +44,16 @@ module Canon
|
|
|
44
44
|
# @return [Boolean, Array] true if equivalent, or array of diffs if
|
|
45
45
|
# verbose
|
|
46
46
|
def equivalent?(html1, html2, opts = {}, child_opts = {})
|
|
47
|
+
# FAST PATH: identical inputs never differ under any profile —
|
|
48
|
+
# byte-identical HTML parses to identical DOMs. Comparison's
|
|
49
|
+
# entry point already catches this; it is repeated here for
|
|
50
|
+
# callers invoking the comparator directly. Verbose callers
|
|
51
|
+
# need full metadata, so they take the whole pipeline.
|
|
52
|
+
if !opts[:verbose] && (html1.equal?(html2) ||
|
|
53
|
+
(html1.is_a?(String) && html2.is_a?(String) && html1 == html2))
|
|
54
|
+
return true
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
opts = DEFAULT_OPTS.merge(opts)
|
|
48
58
|
|
|
49
59
|
# Capture original HTML strings for display.
|
|
@@ -94,10 +104,6 @@ module Canon
|
|
|
94
104
|
# Create child_opts with resolved options
|
|
95
105
|
child_opts = opts.merge(child_opts)
|
|
96
106
|
|
|
97
|
-
# Serialize preprocessed nodes for diff display (avoid re-preprocessing)
|
|
98
|
-
preprocessed_str1 = serialize_for_display(node1)
|
|
99
|
-
preprocessed_str2 = serialize_for_display(node2)
|
|
100
|
-
|
|
101
107
|
differences = []
|
|
102
108
|
diff_children = opts[:diff_children] || false
|
|
103
109
|
|
|
@@ -124,10 +130,11 @@ module Canon
|
|
|
124
130
|
if opts[:verbose]
|
|
125
131
|
ComparisonResult.new(
|
|
126
132
|
differences: differences,
|
|
127
|
-
preprocessed_strings: [
|
|
133
|
+
preprocessed_strings: [serialize_for_display(node1),
|
|
134
|
+
serialize_for_display(node2)],
|
|
128
135
|
original_strings: [original_str1, original_str2],
|
|
129
136
|
format: :html,
|
|
130
|
-
html_version:
|
|
137
|
+
html_version: html_version,
|
|
131
138
|
match_options: match_opts_hash,
|
|
132
139
|
algorithm: :dom,
|
|
133
140
|
parse_errors_expected: Comparison.parse_errors_for(node1),
|
|
@@ -93,7 +93,7 @@ module Canon
|
|
|
93
93
|
# Use preserve_whitespace_elements / strip_whitespace_elements to control.
|
|
94
94
|
# Blacklist (strip) > preserve > collapse > format defaults.
|
|
95
95
|
return false unless text_node?(node) && node.parent
|
|
96
|
-
return false unless MatchOptions.
|
|
96
|
+
return false unless MatchOptions.whitespace_only?(node_text(node))
|
|
97
97
|
|
|
98
98
|
# NBSP (U+00A0) is never insignificant whitespace —
|
|
99
99
|
# it always renders as a visible non-breaking space.
|
|
@@ -93,6 +93,21 @@ module Canon
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# Normalize text preserving Unicode whitespace type distinctions.
|
|
96
|
+
# Fast form of `normalize_text(text).empty?`, called per text
|
|
97
|
+
# node by node_excluded?. Pure-ASCII whitespace (the common
|
|
98
|
+
# case — pretty-print indentation) matches a plain class with
|
|
99
|
+
# no intermediate strings and no \p{} property (Opal's JS
|
|
100
|
+
# regexes do not honor \p{Space}); anything else falls back to
|
|
101
|
+
# normalize_text's exact semantics. NUL is included because
|
|
102
|
+
# String#strip strips nulls too.
|
|
103
|
+
ASCII_WHITESPACE_ONLY = /\A[ \t\r\n\v\f\x00]*\z/
|
|
104
|
+
|
|
105
|
+
def whitespace_only?(text)
|
|
106
|
+
text = text.to_s
|
|
107
|
+
text.empty? || text.match?(ASCII_WHITESPACE_ONLY) ||
|
|
108
|
+
normalize_text(text).empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
96
111
|
def normalize_text_preserving_type(text)
|
|
97
112
|
return "" if text.nil?
|
|
98
113
|
|
|
@@ -24,6 +24,19 @@ module Canon
|
|
|
24
24
|
time tt u var wbr
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
+
# Precomputed symbol forms of the default lists: format_default_*
|
|
28
|
+
# used to map(&:to_sym) per call — one array per classified node.
|
|
29
|
+
HTML_PRESERVE_SYMBOLS = HTML_PRESERVE_ELEMENTS.map(&:to_sym).freeze
|
|
30
|
+
HTML_COLLAPSE_SYMBOLS = HTML_COLLAPSE_ELEMENTS.map(&:to_sym).freeze
|
|
31
|
+
|
|
32
|
+
# The resolved element sets depend only on match_opts, which is
|
|
33
|
+
# resolved once per comparison — but classification runs per node,
|
|
34
|
+
# so the sets are memoized per match_opts object (identity-keyed;
|
|
35
|
+
# side-flag merges produce distinct objects and their own entries).
|
|
36
|
+
# Bounded: stale entries of finished comparisons cost a few
|
|
37
|
+
# hundred bytes until the next clear.
|
|
38
|
+
RESOLVED_SETS_LIMIT = 1024
|
|
39
|
+
|
|
27
40
|
class << self
|
|
28
41
|
# Classify the whitespace behaviour for an element using ancestor walk.
|
|
29
42
|
def classify_element(element, match_opts)
|
|
@@ -107,7 +120,7 @@ module Canon
|
|
|
107
120
|
format = match_opts[:format] || :xml
|
|
108
121
|
case format
|
|
109
122
|
when :html, :html4, :html5
|
|
110
|
-
|
|
123
|
+
HTML_PRESERVE_SYMBOLS
|
|
111
124
|
else
|
|
112
125
|
[].freeze
|
|
113
126
|
end
|
|
@@ -117,7 +130,7 @@ module Canon
|
|
|
117
130
|
format = match_opts[:format] || :xml
|
|
118
131
|
case format
|
|
119
132
|
when :html, :html4, :html5
|
|
120
|
-
|
|
133
|
+
HTML_COLLAPSE_SYMBOLS
|
|
121
134
|
else
|
|
122
135
|
[].freeze
|
|
123
136
|
end
|
|
@@ -184,30 +197,43 @@ module Canon
|
|
|
184
197
|
end
|
|
185
198
|
|
|
186
199
|
def resolved_preserve_elements_set(match_opts)
|
|
187
|
-
|
|
200
|
+
entry = resolved_sets_entry(match_opts)
|
|
201
|
+
entry[0] ||= begin
|
|
202
|
+
set = Set.new(format_default_preserve_elements(match_opts).map(&:to_s))
|
|
188
203
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
204
|
+
if match_opts[:preserve_whitespace_elements]
|
|
205
|
+
set |= match_opts[:preserve_whitespace_elements].map(&:to_s)
|
|
206
|
+
end
|
|
192
207
|
|
|
193
|
-
|
|
194
|
-
|
|
208
|
+
strip_set = resolved_strip_elements_set(match_opts)
|
|
209
|
+
set.reject { |e| strip_set.include?(e) }.to_set
|
|
210
|
+
end
|
|
195
211
|
end
|
|
196
212
|
|
|
197
213
|
def resolved_collapse_elements_set(match_opts)
|
|
198
|
-
|
|
214
|
+
entry = resolved_sets_entry(match_opts)
|
|
215
|
+
entry[1] ||= begin
|
|
216
|
+
set = Set.new(format_default_collapse_elements(match_opts).map(&:to_s))
|
|
199
217
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
218
|
+
if match_opts[:collapse_whitespace_elements]
|
|
219
|
+
set |= match_opts[:collapse_whitespace_elements].map(&:to_s)
|
|
220
|
+
end
|
|
203
221
|
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
strip_set = resolved_strip_elements_set(match_opts)
|
|
223
|
+
set.reject { |e| strip_set.include?(e) }.to_set
|
|
224
|
+
end
|
|
206
225
|
end
|
|
207
226
|
|
|
208
227
|
def resolved_strip_elements_set(match_opts)
|
|
209
|
-
|
|
210
|
-
Set.new((
|
|
228
|
+
entry = resolved_sets_entry(match_opts)
|
|
229
|
+
entry[2] ||= Set.new((match_opts[:strip_whitespace_elements] || [])
|
|
230
|
+
.map(&:to_s))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def resolved_sets_entry(match_opts)
|
|
234
|
+
cache = (@resolved_sets_cache ||= {}.compare_by_identity)
|
|
235
|
+
cache.clear if cache.size >= RESOLVED_SETS_LIMIT
|
|
236
|
+
cache[match_opts] ||= [nil, nil, nil]
|
|
211
237
|
end
|
|
212
238
|
|
|
213
239
|
def walk_ancestor_classification(element, preserve_set, collapse_set,
|
|
@@ -271,7 +297,7 @@ module Canon
|
|
|
271
297
|
name = node_name(element)
|
|
272
298
|
return false unless name
|
|
273
299
|
|
|
274
|
-
list.
|
|
300
|
+
list.any? { |e| e.to_s == name }
|
|
275
301
|
end
|
|
276
302
|
|
|
277
303
|
def text_node_parent?(node)
|
|
@@ -75,21 +75,10 @@ module Canon
|
|
|
75
75
|
# Use ElementMatcher for semantic comparison
|
|
76
76
|
def use_element_matcher_comparison(children1, children2, parent_node, comparator,
|
|
77
77
|
opts, child_opts, diff_children, differences)
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
temp_root2 = Canon::Xml::Nodes::RootNode.new
|
|
83
|
-
temp_root2.children = children2.dup
|
|
84
|
-
|
|
85
|
-
matcher = Canon::Xml::ElementMatcher.new
|
|
86
|
-
matches = matcher.match_trees(temp_root1, temp_root2)
|
|
87
|
-
|
|
88
|
-
# Filter matches to only include direct children
|
|
89
|
-
matches = matches.select do |m|
|
|
90
|
-
(m.elem1.nil? || children1.include?(m.elem1)) &&
|
|
91
|
-
(m.elem2.nil? || children2.include?(m.elem2))
|
|
92
|
-
end
|
|
78
|
+
# Single-level matching: the comparator itself descends via
|
|
79
|
+
# compare_nodes below, so nested matches would be discarded.
|
|
80
|
+
matches = Canon::Xml::ElementMatcher.new
|
|
81
|
+
.match_children_only(children1, children2)
|
|
93
82
|
|
|
94
83
|
# If no matches and children exist, they're all different
|
|
95
84
|
if matches.empty? && (!children1.empty? || children2.empty?)
|
data/lib/canon/comparison.rb
CHANGED
|
@@ -230,6 +230,16 @@ module Canon
|
|
|
230
230
|
# - :verbose - Return detailed diff array (default: false)
|
|
231
231
|
# @return [Boolean, Array] true if equivalent, or array of diffs if verbose
|
|
232
232
|
def equivalent?(obj1, obj2, opts = {})
|
|
233
|
+
# FAST PATH: identical inputs never differ under any profile —
|
|
234
|
+
# byte-identical strings parse to identical structures, and one
|
|
235
|
+
# object parsed twice yields the same tree. Verbose callers need
|
|
236
|
+
# full metadata (differences list, statistics), so they take the
|
|
237
|
+
# whole pipeline.
|
|
238
|
+
if !opts[:verbose] && (obj1.equal?(obj2) ||
|
|
239
|
+
(obj1.is_a?(String) && obj2.is_a?(String) && obj1 == obj2))
|
|
240
|
+
return true
|
|
241
|
+
end
|
|
242
|
+
|
|
233
243
|
# Normalize: match: { semantic_diff: true } → diff_algorithm: :semantic
|
|
234
244
|
if opts.dig(:match, :semantic_diff) || opts.dig(:match, :semantic_tree)
|
|
235
245
|
opts = opts.merge(diff_algorithm: :semantic)
|
|
@@ -107,17 +107,20 @@ module Canon
|
|
|
107
107
|
inherited_namespaces,
|
|
108
108
|
node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
|
|
109
109
|
)
|
|
110
|
+
# HTML attributes are namespace-free; xmlns declarations are
|
|
111
|
+
# not reported as attributes. Flat stride-4 array
|
|
112
|
+
# (TreeBuilder#element contract).
|
|
113
|
+
flat_attributes = []
|
|
114
|
+
node.attribute_nodes.each do |attr|
|
|
115
|
+
next if attr.name.start_with?("xmlns")
|
|
116
|
+
|
|
117
|
+
flat_attributes << attr.name << attr.value << nil << nil
|
|
118
|
+
end
|
|
110
119
|
element = builder.element(
|
|
111
120
|
name: node.name,
|
|
112
121
|
prefix: node.namespace&.prefix,
|
|
113
122
|
namespace_uri: node.namespace&.href,
|
|
114
|
-
|
|
115
|
-
# not reported as attributes.
|
|
116
|
-
attributes: node.attribute_nodes.filter_map do |attr|
|
|
117
|
-
next if attr.name.start_with?("xmlns")
|
|
118
|
-
|
|
119
|
-
[attr.name, attr.value, nil, nil]
|
|
120
|
-
end,
|
|
123
|
+
attributes: flat_attributes,
|
|
121
124
|
namespace_scope: scope,
|
|
122
125
|
)
|
|
123
126
|
node.children.each do |child|
|
data/lib/canon/version.rb
CHANGED
data/lib/canon/xml/data_model.rb
CHANGED
|
@@ -80,8 +80,14 @@ module Canon
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def self.extract_xml_encoding(xml_string)
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
# A valid UTF-8 string matches directly (the regex is
|
|
84
|
+
# ASCII-only); anything else still needs the BINARY view so a
|
|
85
|
+
# broken byte sequence cannot raise mid-probe. Skipping the
|
|
86
|
+
# dup avoids a full-document copy on every parse.
|
|
87
|
+
unless xml_string.encoding.name == "UTF-8" && xml_string.valid_encoding?
|
|
88
|
+
xml_string = xml_string.dup.force_encoding("BINARY")
|
|
89
|
+
end
|
|
90
|
+
if xml_string =~ /\A\s*<\?xml[^>]*\bencoding\s*=\s*["']([^"']+)["'][^>]*\?>/i
|
|
85
91
|
return Regexp.last_match(1)
|
|
86
92
|
end
|
|
87
93
|
|
|
@@ -165,13 +171,16 @@ inherited_namespaces: nil)
|
|
|
165
171
|
inherited_namespaces,
|
|
166
172
|
node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
|
|
167
173
|
)
|
|
174
|
+
flat_attributes = []
|
|
175
|
+
node.attribute_nodes.each do |attr|
|
|
176
|
+
flat_attributes << attr.name << attr.value <<
|
|
177
|
+
attr.namespace&.href << attr.namespace&.prefix
|
|
178
|
+
end
|
|
168
179
|
element = TreeBuilder::DEFAULT.element(
|
|
169
180
|
name: node.name,
|
|
170
181
|
prefix: node.namespace&.prefix,
|
|
171
182
|
namespace_uri: node.namespace&.href,
|
|
172
|
-
attributes:
|
|
173
|
-
[attr.name, attr.value, attr.namespace&.href, attr.namespace&.prefix]
|
|
174
|
-
end,
|
|
183
|
+
attributes: flat_attributes,
|
|
175
184
|
namespace_scope: scope,
|
|
176
185
|
)
|
|
177
186
|
node.children.each do |child|
|
|
@@ -246,13 +255,18 @@ inherited_namespaces: nil)
|
|
|
246
255
|
|
|
247
256
|
def self.build_moxml_subtree_from_records(moxml_element,
|
|
248
257
|
preserve_whitespace: false)
|
|
249
|
-
|
|
258
|
+
# Parallel depth/node stacks: one [depth, node] pair per record
|
|
259
|
+
# cost two Arrays per node; depths are Integers (immediate
|
|
260
|
+
# values), so two parallel arrays allocate nothing per record.
|
|
261
|
+
frame_depths = []
|
|
262
|
+
frame_nodes = []
|
|
250
263
|
own_namespaces = {}.compare_by_identity
|
|
251
264
|
|
|
252
265
|
# materialize_fields: the zero-allocation hot path (moxml#143).
|
|
253
266
|
# Flat reused buffers — attributes stride 4, namespaces stride
|
|
254
267
|
# 2 — valid only inside the block, so the element builder
|
|
255
|
-
#
|
|
268
|
+
# consumes them synchronously before the next record reuses
|
|
269
|
+
# the buffer.
|
|
256
270
|
moxml_element.materialize_fields do |kind, qname, prefix, namespace_uri, namespaces, attributes, text, depth|
|
|
257
271
|
# The record contract is root-subtree-only since moxml 0.5.11
|
|
258
272
|
# (moxml#140). Older 0.5.x releases — still allowed by canon's
|
|
@@ -280,7 +294,7 @@ preserve_whitespace: false)
|
|
|
280
294
|
when :element
|
|
281
295
|
build_moxml_element_from_fields(
|
|
282
296
|
qname, prefix, namespace_uri, namespaces, attributes,
|
|
283
|
-
depth,
|
|
297
|
+
depth, frame_depths, frame_nodes, own_namespaces
|
|
284
298
|
)
|
|
285
299
|
when :text, :cdata
|
|
286
300
|
content = text.to_s
|
|
@@ -294,34 +308,44 @@ preserve_whitespace: false)
|
|
|
294
308
|
TreeBuilder::DEFAULT.processing_instruction(qname, text || "")
|
|
295
309
|
end
|
|
296
310
|
|
|
297
|
-
|
|
311
|
+
if node
|
|
312
|
+
frame_depths << depth
|
|
313
|
+
frame_nodes << node
|
|
314
|
+
end
|
|
298
315
|
end
|
|
299
316
|
|
|
300
|
-
|
|
301
|
-
return nil unless top
|
|
317
|
+
return nil if frame_nodes.empty?
|
|
302
318
|
|
|
303
|
-
|
|
304
|
-
top
|
|
319
|
+
top = frame_nodes.last
|
|
320
|
+
assign_moxml_namespace_scopes(top, nil, own_namespaces)
|
|
321
|
+
top
|
|
305
322
|
end
|
|
306
323
|
|
|
307
324
|
def self.build_moxml_element_from_fields(qname, prefix, namespace_uri,
|
|
308
325
|
namespaces, attributes, depth,
|
|
309
|
-
|
|
326
|
+
frame_depths, frame_nodes,
|
|
327
|
+
own_namespaces)
|
|
328
|
+
# The flat attribute buffer is consumed synchronously — the
|
|
329
|
+
# next record reuses it (no each_slice copy out of the block).
|
|
310
330
|
element = TreeBuilder::DEFAULT.element(
|
|
311
331
|
name: qname,
|
|
312
332
|
prefix: prefix,
|
|
313
333
|
namespace_uri: namespace_uri,
|
|
314
|
-
attributes: attributes
|
|
334
|
+
attributes: attributes,
|
|
315
335
|
)
|
|
316
336
|
|
|
317
337
|
# Adopt completed children: they pop in reverse order; reversing
|
|
318
338
|
# once is O(n) (unshift per child would be O(n^2) on wide trees).
|
|
319
339
|
children = []
|
|
320
|
-
|
|
340
|
+
while frame_depths.any? && frame_depths.last > depth
|
|
341
|
+
frame_depths.pop
|
|
342
|
+
children << frame_nodes.pop
|
|
343
|
+
end
|
|
321
344
|
children.reverse_each { |child| element.add_child(child) }
|
|
322
345
|
|
|
323
|
-
# Copy the declarations out of the reused buffer
|
|
324
|
-
|
|
346
|
+
# Copy the declarations out of the reused buffer (most
|
|
347
|
+
# elements declare none — stash nothing for those).
|
|
348
|
+
own_namespaces[element] = namespaces.each_slice(2).to_a unless namespaces.empty?
|
|
325
349
|
element
|
|
326
350
|
end
|
|
327
351
|
|
|
@@ -345,13 +369,16 @@ inherited_namespaces: nil)
|
|
|
345
369
|
inherited_namespaces,
|
|
346
370
|
node.namespace_definitions.map { |ns| [ns.prefix, ns.uri] },
|
|
347
371
|
)
|
|
372
|
+
flat_attributes = []
|
|
373
|
+
node.attributes.each do |attr|
|
|
374
|
+
flat_attributes << attr.name << attr.value <<
|
|
375
|
+
attr.namespace&.uri << attr.namespace&.prefix
|
|
376
|
+
end
|
|
348
377
|
element = TreeBuilder::DEFAULT.element(
|
|
349
378
|
name: node.name,
|
|
350
379
|
prefix: node.namespace&.prefix,
|
|
351
380
|
namespace_uri: node.namespace&.uri,
|
|
352
|
-
attributes:
|
|
353
|
-
[attr.name, attr.value, attr.namespace&.uri, attr.namespace&.prefix]
|
|
354
|
-
end,
|
|
381
|
+
attributes: flat_attributes,
|
|
355
382
|
namespace_scope: scope,
|
|
356
383
|
)
|
|
357
384
|
node.children.each do |child|
|
|
@@ -130,6 +130,21 @@ module Canon
|
|
|
130
130
|
@matches
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# Match one level of children only — no descent into matched
|
|
134
|
+
# pairs. The comparator walks descendants itself (compare_nodes →
|
|
135
|
+
# ChildComparison per level), so the nested matches match_trees
|
|
136
|
+
# would produce below this level are discarded by its direct-
|
|
137
|
+
# children filter; matching them is wasted work.
|
|
138
|
+
#
|
|
139
|
+
# @param children1 [Array<Canon::Xml::Node>] First parent's children
|
|
140
|
+
# @param children2 [Array<Canon::Xml::Node>] Second parent's children
|
|
141
|
+
# @return [Array<MatchResult>] Match results for these children only
|
|
142
|
+
def match_children_only(children1, children2)
|
|
143
|
+
@matches = []
|
|
144
|
+
match_level(children1, children2, [], recursive: false)
|
|
145
|
+
@matches
|
|
146
|
+
end
|
|
147
|
+
|
|
133
148
|
private
|
|
134
149
|
|
|
135
150
|
# Match children recursively
|
|
@@ -137,10 +152,25 @@ module Canon
|
|
|
137
152
|
# FAST PATH: Same array object means all children match
|
|
138
153
|
return if children1.equal?(children2)
|
|
139
154
|
|
|
155
|
+
match_level(children1, children2, path, recursive: true)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# One matching level: identity attributes, then name+namespace
|
|
159
|
+
# position, then class attribute, then deleted/inserted recording.
|
|
160
|
+
# With +recursive:+ true, matched pairs descend immediately after
|
|
161
|
+
# being recorded (match_trees' ordering contract).
|
|
162
|
+
def match_level(children1, children2, path, recursive:)
|
|
140
163
|
# Filter to only element nodes
|
|
141
164
|
elems1 = children1.select { |n| n.node_type == :element }
|
|
142
165
|
elems2 = children2.select { |n| n.node_type == :element }
|
|
143
166
|
|
|
167
|
+
# Positions by identity: elems.index(elem) was an O(n) scan per
|
|
168
|
+
# recorded match — O(n²) per level on element-heavy parents.
|
|
169
|
+
positions1 = {}
|
|
170
|
+
elems1.each_with_index { |e, i| positions1[e] = i }
|
|
171
|
+
positions2 = {}
|
|
172
|
+
elems2.each_with_index { |e, i| positions2[e] = i }
|
|
173
|
+
|
|
144
174
|
# Build identity maps for quick lookup
|
|
145
175
|
map1 = build_identity_map(elems1)
|
|
146
176
|
map2 = build_identity_map(elems2)
|
|
@@ -160,24 +190,22 @@ module Canon
|
|
|
160
190
|
path + [elem1.name]
|
|
161
191
|
end
|
|
162
192
|
|
|
163
|
-
# Track positions
|
|
164
|
-
pos1 = elems1.index(elem1)
|
|
165
|
-
pos2 = elems2.index(elem2)
|
|
166
|
-
|
|
167
193
|
@matches << MatchResult.new(
|
|
168
194
|
status: :matched,
|
|
169
195
|
elem1: elem1,
|
|
170
196
|
elem2: elem2,
|
|
171
197
|
path: elem_path_with_ns,
|
|
172
|
-
pos1:
|
|
173
|
-
pos2:
|
|
198
|
+
pos1: positions1[elem1],
|
|
199
|
+
pos2: positions2[elem2],
|
|
174
200
|
)
|
|
175
201
|
|
|
176
202
|
matched1.add(elem1)
|
|
177
203
|
matched2.add(elem2)
|
|
178
204
|
|
|
179
205
|
# Recursively match children
|
|
180
|
-
|
|
206
|
+
if recursive
|
|
207
|
+
match_children(elem1.children, elem2.children, elem_path_with_ns)
|
|
208
|
+
end
|
|
181
209
|
end
|
|
182
210
|
end
|
|
183
211
|
|
|
@@ -185,14 +213,15 @@ module Canon
|
|
|
185
213
|
unmatched1 = elems1.reject { |e| matched1.include?(e) }
|
|
186
214
|
unmatched2 = elems2.reject { |e| matched2.include?(e) }
|
|
187
215
|
|
|
188
|
-
match_by_position(unmatched1, unmatched2, path, matched1, matched2
|
|
216
|
+
match_by_position(unmatched1, unmatched2, path, matched1, matched2,
|
|
217
|
+
recursive: recursive)
|
|
189
218
|
|
|
190
219
|
# Fallback: match remaining elements by class attribute
|
|
191
220
|
# This handles insertions that shift positions
|
|
192
221
|
still_unmatched1 = elems1.reject { |e| matched1.include?(e) }
|
|
193
222
|
still_unmatched2 = elems2.reject { |e| matched2.include?(e) }
|
|
194
223
|
match_by_class(still_unmatched1, still_unmatched2, path, matched1,
|
|
195
|
-
matched2)
|
|
224
|
+
matched2, recursive: recursive)
|
|
196
225
|
|
|
197
226
|
# Record unmatched as deleted/inserted
|
|
198
227
|
unmatched1.each do |elem1|
|
|
@@ -203,14 +232,13 @@ module Canon
|
|
|
203
232
|
else
|
|
204
233
|
path + [elem1.name]
|
|
205
234
|
end
|
|
206
|
-
pos1 = elems1.index(elem1)
|
|
207
235
|
|
|
208
236
|
@matches << MatchResult.new(
|
|
209
237
|
status: :deleted,
|
|
210
238
|
elem1: elem1,
|
|
211
239
|
elem2: nil,
|
|
212
240
|
path: elem_path_with_ns,
|
|
213
|
-
pos1:
|
|
241
|
+
pos1: positions1[elem1],
|
|
214
242
|
pos2: nil,
|
|
215
243
|
)
|
|
216
244
|
end
|
|
@@ -223,7 +251,6 @@ module Canon
|
|
|
223
251
|
else
|
|
224
252
|
path + [elem2.name]
|
|
225
253
|
end
|
|
226
|
-
pos2 = elems2.index(elem2)
|
|
227
254
|
|
|
228
255
|
@matches << MatchResult.new(
|
|
229
256
|
status: :inserted,
|
|
@@ -231,17 +258,25 @@ module Canon
|
|
|
231
258
|
elem2: elem2,
|
|
232
259
|
path: elem_path_with_ns,
|
|
233
260
|
pos1: nil,
|
|
234
|
-
pos2:
|
|
261
|
+
pos2: positions2[elem2],
|
|
235
262
|
)
|
|
236
263
|
end
|
|
237
264
|
end
|
|
238
265
|
|
|
239
266
|
# Match remaining elements by name and position
|
|
240
|
-
def match_by_position(elems1, elems2, path, matched1, matched2
|
|
267
|
+
def match_by_position(elems1, elems2, path, matched1, matched2,
|
|
268
|
+
recursive:)
|
|
241
269
|
# Group by element name AND namespace_uri
|
|
242
270
|
by_identity1 = elems1.group_by { |e| [e.name, e.namespace_uri] }
|
|
243
271
|
by_identity2 = elems2.group_by { |e| [e.name, e.namespace_uri] }
|
|
244
272
|
|
|
273
|
+
# Positions within these (already unmatched-filtered) lists —
|
|
274
|
+
# elems.index(elem) was an O(n) scan per recorded match
|
|
275
|
+
subset_positions1 = {}
|
|
276
|
+
elems1.each_with_index { |e, i| subset_positions1[e] = i }
|
|
277
|
+
subset_positions2 = {}
|
|
278
|
+
elems2.each_with_index { |e, i| subset_positions2[e] = i }
|
|
279
|
+
|
|
245
280
|
# For each name+namespace combination, match by position
|
|
246
281
|
by_identity1.each do |identity, list1|
|
|
247
282
|
next unless by_identity2.key?(identity)
|
|
@@ -264,34 +299,39 @@ module Canon
|
|
|
264
299
|
path + [name]
|
|
265
300
|
end
|
|
266
301
|
|
|
267
|
-
# Track positions in original element lists
|
|
268
|
-
pos1 = elems1.index(elem1)
|
|
269
|
-
pos2 = elems2.index(elem2)
|
|
270
|
-
|
|
271
302
|
@matches << MatchResult.new(
|
|
272
303
|
status: :matched,
|
|
273
304
|
elem1: elem1,
|
|
274
305
|
elem2: elem2,
|
|
275
306
|
path: elem_path_with_ns,
|
|
276
|
-
pos1:
|
|
277
|
-
pos2:
|
|
307
|
+
pos1: subset_positions1[elem1],
|
|
308
|
+
pos2: subset_positions2[elem2],
|
|
278
309
|
)
|
|
279
310
|
matched1.add(elem1)
|
|
280
311
|
matched2.add(elem2)
|
|
281
312
|
|
|
282
313
|
# Recursively match children
|
|
283
|
-
|
|
314
|
+
if recursive
|
|
315
|
+
match_children(elem1.children, elem2.children, elem_path_with_ns)
|
|
316
|
+
end
|
|
284
317
|
end
|
|
285
318
|
end
|
|
286
319
|
end
|
|
287
320
|
|
|
288
321
|
# Match remaining elements by class attribute (position-independent)
|
|
289
322
|
# This handles cases where insertions shift positions but elements have class-based identity
|
|
290
|
-
def match_by_class(elems1, elems2, path, matched1, matched2
|
|
323
|
+
def match_by_class(elems1, elems2, path, matched1, matched2,
|
|
324
|
+
recursive:)
|
|
291
325
|
# Build class maps for elements that have class attributes
|
|
292
326
|
class_map1 = build_class_map(elems1)
|
|
293
327
|
class_map2 = build_class_map(elems2)
|
|
294
328
|
|
|
329
|
+
# Positions within these (still-unmatched-filtered) lists
|
|
330
|
+
subset_positions1 = {}
|
|
331
|
+
elems1.each_with_index { |e, i| subset_positions1[e] = i }
|
|
332
|
+
subset_positions2 = {}
|
|
333
|
+
elems2.each_with_index { |e, i| subset_positions2[e] = i }
|
|
334
|
+
|
|
295
335
|
# Match by class attribute
|
|
296
336
|
class_map1.each do |class_value, elem1|
|
|
297
337
|
next if matched1.include?(elem1)
|
|
@@ -311,23 +351,21 @@ module Canon
|
|
|
311
351
|
path + [elem1.name]
|
|
312
352
|
end
|
|
313
353
|
|
|
314
|
-
# Track positions in original element lists
|
|
315
|
-
pos1 = elems1.index(elem1)
|
|
316
|
-
pos2 = elems2.index(elem2)
|
|
317
|
-
|
|
318
354
|
@matches << MatchResult.new(
|
|
319
355
|
status: :matched,
|
|
320
356
|
elem1: elem1,
|
|
321
357
|
elem2: elem2,
|
|
322
358
|
path: elem_path_with_ns,
|
|
323
|
-
pos1:
|
|
324
|
-
pos2:
|
|
359
|
+
pos1: subset_positions1[elem1],
|
|
360
|
+
pos2: subset_positions2[elem2],
|
|
325
361
|
)
|
|
326
362
|
matched1.add(elem1)
|
|
327
363
|
matched2.add(elem2)
|
|
328
364
|
|
|
329
365
|
# Recursively match children
|
|
330
|
-
|
|
366
|
+
if recursive
|
|
367
|
+
match_children(elem1.children, elem2.children, elem_path_with_ns)
|
|
368
|
+
end
|
|
331
369
|
end
|
|
332
370
|
end
|
|
333
371
|
|
data/lib/canon/xml/node.rb
CHANGED
|
@@ -4,17 +4,29 @@ module Canon
|
|
|
4
4
|
module Xml
|
|
5
5
|
# Base class for all XPath data model nodes
|
|
6
6
|
class Node
|
|
7
|
-
attr_reader :parent
|
|
7
|
+
attr_reader :parent
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@parent = nil
|
|
11
|
-
@children =
|
|
11
|
+
@children = nil
|
|
12
12
|
@in_node_set = true
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Leaf nodes (text, attributes, namespaces, comments, PIs) never
|
|
16
|
+
# gain children; allocating the array eagerly cost one Array per
|
|
17
|
+
# node — the single largest retained-allocation source in a built
|
|
18
|
+
# tree — so it materializes on first add or read.
|
|
19
|
+
def children
|
|
20
|
+
@children ||= []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def children=(new_children)
|
|
24
|
+
@children = new_children
|
|
25
|
+
end
|
|
26
|
+
|
|
15
27
|
def add_child(child)
|
|
16
28
|
child.parent = self
|
|
17
|
-
|
|
29
|
+
children << child
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
def in_node_set?
|
|
@@ -3,18 +3,32 @@
|
|
|
3
3
|
module Canon
|
|
4
4
|
module Xml
|
|
5
5
|
module Nodes
|
|
6
|
-
# Element node in the XPath data model
|
|
7
6
|
class ElementNode < Node
|
|
8
|
-
attr_reader :name, :namespace_uri, :prefix
|
|
9
|
-
:attribute_nodes
|
|
7
|
+
attr_reader :name, :namespace_uri, :prefix
|
|
10
8
|
|
|
11
9
|
def initialize(name:, namespace_uri: nil, prefix: nil)
|
|
12
10
|
super()
|
|
13
11
|
@name = name
|
|
14
12
|
@namespace_uri = namespace_uri
|
|
15
13
|
@prefix = prefix
|
|
16
|
-
@namespace_nodes =
|
|
17
|
-
@attribute_nodes =
|
|
14
|
+
@namespace_nodes = nil
|
|
15
|
+
@attribute_nodes = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Lazy: most elements carry no attributes, and inherited
|
|
19
|
+
# namespace nodes arrive as one shared frozen array (see
|
|
20
|
+
# TreeBuilder#attach_namespace_scope) — eager per-element arrays
|
|
21
|
+
# were the largest retained-allocation source in a built tree.
|
|
22
|
+
def namespace_nodes
|
|
23
|
+
@namespace_nodes ||= []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def namespace_nodes=(nodes)
|
|
27
|
+
@namespace_nodes = nodes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attribute_nodes
|
|
31
|
+
@attribute_nodes ||= []
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
def node_type
|
|
@@ -27,24 +41,22 @@ module Canon
|
|
|
27
41
|
|
|
28
42
|
def add_namespace(namespace_node)
|
|
29
43
|
namespace_node.parent = self
|
|
30
|
-
|
|
44
|
+
namespace_nodes << namespace_node
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
def add_attribute(attribute_node)
|
|
34
48
|
attribute_node.parent = self
|
|
35
|
-
|
|
49
|
+
attribute_nodes << attribute_node
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
# Get namespace nodes in sorted order (lexicographically by local name)
|
|
39
53
|
def sorted_namespace_nodes
|
|
40
|
-
|
|
41
|
-
ns.local_name.to_s
|
|
42
|
-
end
|
|
54
|
+
namespace_nodes.sort_by(&:local_name)
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
# Get attribute nodes in sorted order (by namespace URI then local name)
|
|
46
58
|
def sorted_attribute_nodes
|
|
47
|
-
|
|
59
|
+
attribute_nodes.sort_by do |attr|
|
|
48
60
|
[attr.namespace_uri.to_s, attr.local_name]
|
|
49
61
|
end
|
|
50
62
|
end
|
|
@@ -129,20 +129,31 @@ strip_doctype: false)
|
|
|
129
129
|
end
|
|
130
130
|
|
|
131
131
|
# Push new namespace scope with declarations (own shadows
|
|
132
|
-
# inherited — the same merge the TreeBuilder scope kernel
|
|
133
|
-
|
|
132
|
+
# inherited — the same merge the TreeBuilder scope kernel
|
|
133
|
+
# applies). Elements that declare nothing push the inherited
|
|
134
|
+
# scope object itself, so scopes — and their cached
|
|
135
|
+
# namespace-node arrays in TreeBuilder — are shared down runs
|
|
136
|
+
# of undeclaring elements instead of re-merged per element.
|
|
137
|
+
inherited_scope = @namespace_stack.last
|
|
138
|
+
new_scope = ns_hash.empty? ? inherited_scope : inherited_scope.merge(ns_hash)
|
|
134
139
|
@namespace_stack.push(new_scope)
|
|
135
140
|
|
|
141
|
+
# Flat stride-4 attribute array (TreeBuilder#element contract):
|
|
142
|
+
# one array per element instead of one sub-array per attribute.
|
|
143
|
+
flat_attributes = []
|
|
144
|
+
regular_attrs.each do |attr_name, attr_value|
|
|
145
|
+
attr_prefix, attr_local = parse_qname(attr_name)
|
|
146
|
+
attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil
|
|
147
|
+
flat_attributes << attr_local <<
|
|
148
|
+
decode_character_references(attr_value || "") << attr_ns_uri << attr_prefix
|
|
149
|
+
end
|
|
150
|
+
|
|
136
151
|
element = TreeBuilder::DEFAULT.element(
|
|
137
152
|
name: local_name,
|
|
138
153
|
prefix: prefix,
|
|
139
154
|
namespace_uri: new_scope[prefix.to_s],
|
|
140
155
|
namespace_scope: new_scope,
|
|
141
|
-
attributes:
|
|
142
|
-
attr_prefix, attr_local = parse_qname(attr_name)
|
|
143
|
-
attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil
|
|
144
|
-
[attr_local, decode_character_references(attr_value || ""), attr_ns_uri, attr_prefix]
|
|
145
|
-
end,
|
|
156
|
+
attributes: flat_attributes,
|
|
146
157
|
)
|
|
147
158
|
|
|
148
159
|
parent.add_child(element)
|
|
@@ -319,6 +330,10 @@ strip_doctype: false)
|
|
|
319
330
|
# @param value [String] String potentially containing character references
|
|
320
331
|
# @return [String] String with character references decoded
|
|
321
332
|
def decode_character_references(value)
|
|
333
|
+
# Fast path: no reference opener means nothing to decode —
|
|
334
|
+
# skip the gsub and its string copy on every plain text chunk.
|
|
335
|
+
return value unless value.include?("&")
|
|
336
|
+
|
|
322
337
|
value.gsub(/&#(x?[0-9a-fA-F]+);/) do |match|
|
|
323
338
|
code_str = Regexp.last_match(1)
|
|
324
339
|
code_point = if code_str.start_with?("x")
|
|
@@ -23,56 +23,104 @@ module Canon
|
|
|
23
23
|
XML_NAMESPACE_PREFIX = "xml"
|
|
24
24
|
XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"
|
|
25
25
|
|
|
26
|
+
# Repetitive short strings — element/attribute names, prefixes,
|
|
27
|
+
# namespace URIs — are interned so one frozen instance is shared
|
|
28
|
+
# across every tree canon builds: a thousand `<p>` elements hold
|
|
29
|
+
# one "p". Values (text content, attribute values) are unique and
|
|
30
|
+
# are never interned. Bounded; cleared when full.
|
|
31
|
+
INTERN_LIMIT = 8192
|
|
32
|
+
|
|
33
|
+
def intern(string)
|
|
34
|
+
return string if string.nil?
|
|
35
|
+
|
|
36
|
+
cache = (@string_intern_cache ||= {})
|
|
37
|
+
cache.clear if cache.size >= INTERN_LIMIT
|
|
38
|
+
cache.fetch(string) { cache[string] = string.freeze }
|
|
39
|
+
end
|
|
40
|
+
|
|
26
41
|
# In-scope namespace bindings: the element's own declarations
|
|
27
42
|
# shadow inherited ones; xml is prebound at the base. Declaration
|
|
28
43
|
# pairs are [prefix-or-nil, uri]; nil and "" both mean the default
|
|
29
|
-
# namespace.
|
|
44
|
+
# namespace. Elements with no declarations return the inherited
|
|
45
|
+
# scope unchanged — most elements — so equal scopes share one
|
|
46
|
+
# object (and one namespace-node array, see attach). The xml
|
|
47
|
+
# binding is materialized at the root even for undeclaring
|
|
48
|
+
# elements: it is in scope on every element per the XPath data
|
|
49
|
+
# model (and the SAX feed's initial stack carries it too).
|
|
30
50
|
def merge_namespace_scope(inherited, declaration_pairs)
|
|
51
|
+
return inherited if declaration_pairs.empty? && inherited
|
|
52
|
+
|
|
31
53
|
scope = inherited ? inherited.dup : { XML_NAMESPACE_PREFIX => XML_NAMESPACE_URI }
|
|
32
54
|
declaration_pairs.each do |prefix, uri|
|
|
33
55
|
scope[prefix || ""] = uri
|
|
34
56
|
end
|
|
35
|
-
scope
|
|
57
|
+
scope.freeze
|
|
36
58
|
end
|
|
37
59
|
|
|
38
|
-
# Attach an in-scope scope to an element as namespace nodes.
|
|
60
|
+
# Attach an in-scope scope to an element as namespace nodes. The
|
|
61
|
+
# node array is cached per scope object: elements that introduced
|
|
62
|
+
# no declarations share their parent's array instead of
|
|
63
|
+
# re-materializing one NamespaceNode per prefix per element.
|
|
64
|
+
# NamespaceNode#parent is never read, so shared nodes carry the
|
|
65
|
+
# first creator as parent by convention. The cache is identity
|
|
66
|
+
# keyed and bounded — stale scopes of dead trees cost a few
|
|
67
|
+
# hundred bytes until the next clear.
|
|
39
68
|
def attach_namespace_scope(element, scope)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
69
|
+
cache = (@namespace_node_cache ||= {}.compare_by_identity)
|
|
70
|
+
cache.clear if cache.size >= 4096
|
|
71
|
+
nodes = cache[scope]
|
|
72
|
+
if nodes.nil?
|
|
73
|
+
nodes = scope.map do |prefix, uri|
|
|
74
|
+
Nodes::NamespaceNode.new(prefix: intern(prefix), uri: intern(uri))
|
|
75
|
+
end.freeze
|
|
76
|
+
cache[scope] = nodes
|
|
45
77
|
end
|
|
78
|
+
element.namespace_nodes = nodes
|
|
46
79
|
end
|
|
47
80
|
|
|
48
|
-
# Build an element. `attributes`
|
|
49
|
-
#
|
|
50
|
-
#
|
|
81
|
+
# Build an element. `attributes` is a FLAT stride-4 array —
|
|
82
|
+
# [name, value, namespace_uri, prefix, name, value, ...] — so the
|
|
83
|
+
# moxml records feed hands its reused buffer straight through
|
|
84
|
+
# (read synchronously below) and the tree feeds build one flat
|
|
85
|
+
# array instead of one sub-array per attribute. `namespace_scope`
|
|
86
|
+
# is a merged scope (or nil for no namespace nodes).
|
|
51
87
|
def element(name:, prefix: nil, namespace_uri: nil,
|
|
52
88
|
attributes: NO_ATTRIBUTES, namespace_scope: nil)
|
|
53
89
|
element = Nodes::ElementNode.new(
|
|
54
|
-
name: name,
|
|
55
|
-
namespace_uri: namespace_uri,
|
|
56
|
-
prefix: prefix,
|
|
90
|
+
name: intern(name),
|
|
91
|
+
namespace_uri: intern(namespace_uri),
|
|
92
|
+
prefix: intern(prefix),
|
|
57
93
|
)
|
|
58
94
|
attach_namespace_scope(element, namespace_scope) if namespace_scope
|
|
59
95
|
|
|
60
96
|
# Duplicate (name, namespace) pairs are invalid XML — first
|
|
61
|
-
# occurrence wins.
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
attributes.
|
|
65
|
-
|
|
66
|
-
|
|
97
|
+
# occurrence wins. Flat index arithmetic keeps the scan
|
|
98
|
+
# allocation-free.
|
|
99
|
+
base = 0
|
|
100
|
+
limit = attributes.size
|
|
101
|
+
while base < limit
|
|
102
|
+
attr_name = attributes[base]
|
|
103
|
+
attr_namespace_uri = attributes[base + 2]
|
|
104
|
+
duplicate = false
|
|
105
|
+
prior = 0
|
|
106
|
+
while prior < base
|
|
107
|
+
if attributes[prior] == attr_name &&
|
|
108
|
+
attributes[prior + 2] == attr_namespace_uri
|
|
109
|
+
duplicate = true
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
prior += 4
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
unless duplicate
|
|
116
|
+
element.add_attribute(Nodes::AttributeNode.new(
|
|
117
|
+
name: intern(attr_name),
|
|
118
|
+
value: attributes[base + 1],
|
|
119
|
+
namespace_uri: intern(attr_namespace_uri),
|
|
120
|
+
prefix: intern(attributes[base + 3]),
|
|
121
|
+
))
|
|
67
122
|
end
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
element.add_attribute(Nodes::AttributeNode.new(
|
|
71
|
-
name: attr_name,
|
|
72
|
-
value: value,
|
|
73
|
-
namespace_uri: attr_namespace_uri,
|
|
74
|
-
prefix: attr_prefix,
|
|
75
|
-
))
|
|
123
|
+
base += 4
|
|
76
124
|
end
|
|
77
125
|
|
|
78
126
|
element
|
|
@@ -14,6 +14,13 @@ module Canon
|
|
|
14
14
|
# text; engines that report it (libxml2 does not, libleptris 1.9.38+
|
|
15
15
|
# does) stay byte-compatible through here.
|
|
16
16
|
module WhitespacePolicy
|
|
17
|
+
# Zero-allocation forms of the `content.strip.empty?` /
|
|
18
|
+
# `content.gsub(...).empty?` checks these policies used to run
|
|
19
|
+
# per text node. STRIP_ONLY is exactly String#strip's set
|
|
20
|
+
# (ASCII whitespace plus null); SAX drops space/tab/CR/LF runs.
|
|
21
|
+
STRIP_ONLY = /\A[\0\t\n\v\f\r ]*\z/
|
|
22
|
+
SAX_DROPPED = /\A[ \t\r\n]*\z/
|
|
23
|
+
|
|
17
24
|
module_function
|
|
18
25
|
|
|
19
26
|
# DOM conversion rule: whitespace-only text is dropped unless
|
|
@@ -27,11 +34,11 @@ element_parent: true)
|
|
|
27
34
|
# The XPath data model has no root text children — drop
|
|
28
35
|
# whitespace-only document-level text even when preserving
|
|
29
36
|
# (engines that report it stay byte-compatible with libxml2).
|
|
30
|
-
return false if !element_parent && content.
|
|
37
|
+
return false if !element_parent && content.match?(STRIP_ONLY)
|
|
31
38
|
|
|
32
39
|
return true if preserve_whitespace
|
|
33
40
|
|
|
34
|
-
!content.
|
|
41
|
+
!content.match?(STRIP_ONLY)
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# SAX rule: same shape, plus CR-bearing content is always kept
|
|
@@ -39,12 +46,12 @@ element_parent: true)
|
|
|
39
46
|
# whitespace (space, tab, CR, LF) are dropped when not preserving.
|
|
40
47
|
def keep_sax_text?(content, preserve_whitespace:,
|
|
41
48
|
element_parent: true)
|
|
42
|
-
return false if !element_parent && content.
|
|
49
|
+
return false if !element_parent && content.match?(STRIP_ONLY)
|
|
43
50
|
|
|
44
51
|
return true if preserve_whitespace
|
|
45
52
|
return true if content.include?("\r")
|
|
46
53
|
|
|
47
|
-
!content.
|
|
54
|
+
!content.match?(SAX_DROPPED)
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
# HTML conversion rule: whitespace-only text is dropped except in
|
|
@@ -55,7 +62,7 @@ element_parent: true)
|
|
|
55
62
|
HTML_WHITESPACE_SENSITIVE_TAGS = %w[pre code textarea script style].freeze
|
|
56
63
|
|
|
57
64
|
def keep_html_text?(content, parent_name:, inline_significant: false)
|
|
58
|
-
return true unless content.
|
|
65
|
+
return true unless content.match?(STRIP_ONLY)
|
|
59
66
|
return true if content.include?(" ")
|
|
60
67
|
|
|
61
68
|
parent_name = parent_name.to_s.downcase
|
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.14
|
|
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-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|