canon 0.3.4 → 0.3.6

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: 53a9382cf5977e8e1b65a847cd11f9f2c61a162c3913a64588e4d48c71cfbf94
4
- data.tar.gz: 851f0227e56cffdd225fce23ec32b78b3b44f43e08137a39572a2db237d94aaf
3
+ metadata.gz: 04012234db38cae77206ba33f856aca1262a0ad0111e7d233d5b814e90c22187
4
+ data.tar.gz: 3700758afcb5619f59ba7e2f896963bc63695d534f60af7d922dfce8c26076ec
5
5
  SHA512:
6
- metadata.gz: 67710ade1d18f99c23e4477540303f45867d145c7f92df59f2f1277cf442c576eca2a3d8562cb92972f56d80dbe111e6104d28353cb7c5091d690490605772ff
7
- data.tar.gz: e8d2783baa8fba878cb3cbb7a602dff3563f23dfb15df95ca5c24c075475d53c8319f2579bf3ce3c533e1989b1c75b91b72913c57c30279278a59de68202aef0
6
+ metadata.gz: c6452b462325e65c3f38f31438656b1cf60790646413314d8e835f6a27f78026bc10de2474065627b26a602962c424fd6858e1bc97ed3bca43e8a009b1779291
7
+ data.tar.gz: 749b78e282b4b2c7d440f9dff4a2361ee8d7742afcdab54b490bf836ae9417afc5f677e2039143020a6e50eceaf12d42950372c7a5f62dfcd335223243962fef
@@ -76,171 +76,74 @@ module Canon
76
76
  Canon::Xml::DataModel.serialize(node)
77
77
  end
78
78
 
79
- # Build XPath data model from Nokogiri document or fragment
79
+ # Build XPath data model from a Nokogiri HTML document or
80
+ # fragment — a TreeBuilder walk like the XML extractors, with the
81
+ # HTML whitespace policy and xmlns-free attributes.
80
82
  def self.build_from_nokogiri(nokogiri_doc)
83
+ builder = Canon::Xml::TreeBuilder::DEFAULT
81
84
  root = Canon::Xml::Nodes::RootNode.new
85
+ skip_types = defined?(Nokogiri) ? [Nokogiri::XML::DTD] : []
82
86
 
83
87
  if nokogiri_doc.is_a?(Nokogiri::XML::Document) && nokogiri_doc.root
84
- # For Documents (HTML4, HTML5): process the root element
85
- root.add_child(build_element_node(nokogiri_doc.root))
86
-
87
- # Process PIs and comments outside doc element
88
- nokogiri_doc.children.each do |child|
89
- next if child == nokogiri_doc.root
90
- next if child.is_a?(Nokogiri::XML::DTD)
91
-
92
- node = build_node_from_nokogiri(child)
93
- root.add_child(node) if node
88
+ root.add_child(walk(builder, nokogiri_doc.root))
89
+ builder.add_document_children(root, nokogiri_doc.children,
90
+ nokogiri_doc.root, skip_types) do |child|
91
+ walk(builder, child)
94
92
  end
95
93
  else
96
- # For DocumentFragments: process all children directly
97
- # Fragments don't have a single .root, they contain multiple top-level nodes
98
- nokogiri_doc.children.each do |child|
99
- next if child.is_a?(Nokogiri::XML::DTD)
100
-
101
- node = build_node_from_nokogiri(child)
102
- root.add_child(node) if node
94
+ builder.add_document_children(root, nokogiri_doc.children,
95
+ nil, skip_types) do |child|
96
+ walk(builder, child)
103
97
  end
104
98
  end
105
99
 
106
100
  root
107
101
  end
108
102
 
109
- # Build node from Nokogiri node
110
- def self.build_node_from_nokogiri(nokogiri_node)
111
- case nokogiri_node
103
+ def self.walk(builder, node, inherited_namespaces: nil)
104
+ case node
112
105
  when Nokogiri::XML::Element
113
- build_element_node(nokogiri_node)
114
- when Nokogiri::XML::Text
115
- build_text_node(nokogiri_node)
116
- when Nokogiri::XML::Comment
117
- build_comment_node(nokogiri_node)
118
- when Nokogiri::XML::ProcessingInstruction
119
- build_pi_node(nokogiri_node)
120
- end
121
- end
122
-
123
- # Build element node from Nokogiri element
124
- def self.build_element_node(nokogiri_element)
125
- element = Canon::Xml::Nodes::ElementNode.new(
126
- name: nokogiri_element.name,
127
- namespace_uri: nokogiri_element.namespace&.href,
128
- prefix: nokogiri_element.namespace&.prefix,
129
- )
130
-
131
- # Build namespace nodes (includes inherited namespaces)
132
- build_namespace_nodes(nokogiri_element, element)
133
-
134
- # Build attribute nodes
135
- build_attribute_nodes(nokogiri_element, element)
136
-
137
- # Build child nodes
138
- nokogiri_element.children.each do |child|
139
- node = build_node_from_nokogiri(child)
140
- element.add_child(node) if node
141
- end
142
-
143
- element
144
- end
145
-
146
- # Build namespace nodes for an element
147
- def self.build_namespace_nodes(nokogiri_element, element)
148
- # Collect all in-scope namespaces
149
- namespaces = collect_in_scope_namespaces(nokogiri_element)
150
-
151
- namespaces.each do |prefix, uri|
152
- ns_node = Canon::Xml::Nodes::NamespaceNode.new(
153
- prefix: prefix,
154
- uri: uri,
106
+ scope = builder.merge_namespace_scope(
107
+ inherited_namespaces,
108
+ node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
155
109
  )
156
- element.add_namespace(ns_node)
157
- end
158
- end
159
-
160
- # Collect all in-scope namespaces for an element
161
- def self.collect_in_scope_namespaces(nokogiri_element)
162
- namespaces = {}
163
-
164
- # Walk up the tree to collect all namespace declarations
165
- current = nokogiri_element
166
- while current && !current.is_a?(Nokogiri::XML::Document)
167
- if current.is_a?(Nokogiri::XML::Element)
168
- current.namespace_definitions.each do |ns|
169
- prefix = ns.prefix || ""
170
- # Only add if not already defined (child overrides parent)
171
- unless namespaces.key?(prefix)
172
- namespaces[prefix] = ns.href
173
- end
174
- end
175
- end
176
- current = current.parent
177
- end
178
-
179
- # Always include xml namespace
180
- namespaces["xml"] ||= "http://www.w3.org/XML/1998/namespace"
181
-
182
- namespaces
183
- end
184
-
185
- # Build attribute nodes for an element
186
- def self.build_attribute_nodes(nokogiri_element, element)
187
- nokogiri_element.attributes.each do |name, attr|
188
- next if name.start_with?("xmlns")
189
-
190
- attr_node = Canon::Xml::Nodes::AttributeNode.new(
191
- name: attr.name,
192
- value: attr.value,
193
- namespace_uri: attr.namespace&.href,
194
- prefix: attr.namespace&.prefix,
110
+ element = builder.element(
111
+ name: node.name,
112
+ prefix: node.namespace&.prefix,
113
+ namespace_uri: node.namespace&.href,
114
+ # HTML attributes are namespace-free; xmlns declarations are
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,
121
+ namespace_scope: scope,
195
122
  )
196
- element.add_attribute(attr_node)
197
- end
198
- end
199
-
200
- # Build text node from Nokogiri text node
201
- # HTML-specific: handles whitespace-sensitive elements (pre, code, textarea, script, style)
202
- # and preserves whitespace between inline element siblings.
203
- def self.build_text_node(nokogiri_text)
204
- # Skip text nodes that are only whitespace between elements
205
- # EXCEPT in whitespace-sensitive elements (pre, code, textarea, script, style)
206
- # and when whitespace is between inline element siblings (semantically significant)
207
- content = nokogiri_text.content
208
-
209
- # NBSP (U+00A0) is never insignificant whitespace
210
- if content.strip.empty? && nokogiri_text.parent.is_a?(Nokogiri::XML::Element) && !content.include?("\u00A0")
211
- # Check if parent is whitespace-sensitive
212
- parent_name = nokogiri_text.parent.name.downcase
213
- whitespace_sensitive_tags = %w[pre code textarea script style]
214
-
215
- # Check if whitespace is between inline siblings
216
- unless whitespace_sensitive_tags.include?(parent_name) ||
217
- Canon::Comparison::WhitespaceSensitivity.inline_whitespace_significant?(nokogiri_text)
218
- return nil
123
+ node.children.each do |child|
124
+ built = walk(builder, child, inherited_namespaces: scope)
125
+ element.add_child(built) if built
219
126
  end
127
+ element
128
+ when Nokogiri::XML::Text
129
+ builder.text(
130
+ node.content,
131
+ keep: Canon::Xml::WhitespacePolicy.keep_html_text?(
132
+ node.content,
133
+ parent_name: node.parent.is_a?(Nokogiri::XML::Element) ? node.parent.name : nil,
134
+ inline_significant:
135
+ Canon::Comparison::WhitespaceSensitivity.inline_whitespace_significant?(node),
136
+ ),
137
+ )
138
+ when Nokogiri::XML::Comment
139
+ builder.comment(node.content)
140
+ when Nokogiri::XML::ProcessingInstruction
141
+ builder.processing_instruction(node.name, node.content)
220
142
  end
221
-
222
- # Nokogiri already handles CDATA conversion and entity resolution
223
- Canon::Xml::Nodes::TextNode.new(value: content)
224
- end
225
-
226
- # Build comment node from Nokogiri comment
227
- def self.build_comment_node(nokogiri_comment)
228
- Canon::Xml::Nodes::CommentNode.new(value: nokogiri_comment.content)
229
- end
230
-
231
- # Build PI node from Nokogiri PI
232
- def self.build_pi_node(nokogiri_pi)
233
- Canon::Xml::Nodes::ProcessingInstructionNode.new(
234
- target: nokogiri_pi.name,
235
- data: nokogiri_pi.content,
236
- )
237
143
  end
238
144
 
239
145
  class << self
240
- private :build_from_nokogiri, :build_node_from_nokogiri,
241
- :build_element_node, :build_namespace_nodes,
242
- :collect_in_scope_namespaces, :build_attribute_nodes,
243
- :build_text_node, :build_comment_node, :build_pi_node
146
+ private :build_from_nokogiri, :walk
244
147
  end
245
148
  end
246
149
  end
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.4"
4
+ VERSION = "0.3.6"
5
5
  end
@@ -126,149 +126,83 @@ module Canon
126
126
  end
127
127
  end
128
128
 
129
- # --- Shared construction kernels (identical semantics per engine) ---
130
-
131
- # Attach document-level children (prolog/epilog PIs, comments,
132
- # document-level text) to the canon root, in document order,
133
- # skipping the document element and the doctype. `converter`
134
- # converts one engine child to a canon node (or nil).
135
- def self.add_document_children(root, children, document_element,
136
- skip_types, converter)
137
- children.each do |child|
138
- next if child.equal?(document_element)
139
- next if skip_types.any? { |type| child.is_a?(type) }
140
-
141
- node = converter.call(child)
142
- root.add_child(node) if node
143
- end
144
- end
129
+ # --- Engine extractors ---
130
+ #
131
+ # Traversal and engine mapping only. Each walk maps its engine's
132
+ # shapes onto TreeBuilder's interface; all construction semantics
133
+ # (namespace scopes, attribute normalization, whitespace policy,
134
+ # node kinds, document ordering) live in TreeBuilder and
135
+ # WhitespacePolicy — one dialect, three feeds.
145
136
 
146
- # In-scope namespace bindings: the element's own declarations
147
- # shadow inherited ones; xml is prebound at the base. Declaration
148
- # pairs are [prefix-or-nil, uri]; nil and "" both mean the default
149
- # namespace.
150
- def self.merge_namespace_scope(inherited, declaration_pairs)
151
- scope = inherited ? inherited.dup : { "xml" => "http://www.w3.org/XML/1998/namespace" }
152
- declaration_pairs.each do |prefix, uri|
153
- scope[prefix || ""] = uri
154
- end
155
- scope
156
- end
137
+ # -- Nokogiri walk: top-down, scope flows down the recursion --
157
138
 
158
139
  def self.build_from_nokogiri(nokogiri_doc, preserve_whitespace: false)
159
140
  root = Nodes::RootNode.new
160
-
161
- converter = ->(child) { build_node_from_nokogiri(child, preserve_whitespace: preserve_whitespace) }
162
141
  skip_types = defined?(Nokogiri) ? [Nokogiri::XML::DTD] : []
163
142
 
164
143
  if nokogiri_doc.is_a?(Nokogiri::XML::Document) && nokogiri_doc.root
165
- root.add_child(build_element_node(nokogiri_doc.root,
166
- preserve_whitespace: preserve_whitespace))
167
- add_document_children(root, nokogiri_doc.children, nokogiri_doc.root,
168
- skip_types, converter)
144
+ root.add_child(walk_nokogiri(nokogiri_doc.root,
145
+ preserve_whitespace: preserve_whitespace))
146
+ TreeBuilder::DEFAULT.add_document_children(root, nokogiri_doc.children,
147
+ nokogiri_doc.root, skip_types) do |child|
148
+ walk_nokogiri(child, preserve_whitespace: preserve_whitespace)
149
+ end
169
150
  else
170
- add_document_children(root, nokogiri_doc.children, nil,
171
- skip_types, converter)
151
+ TreeBuilder::DEFAULT.add_document_children(root, nokogiri_doc.children,
152
+ nil, skip_types) do |child|
153
+ walk_nokogiri(child, preserve_whitespace: preserve_whitespace)
154
+ end
172
155
  end
173
156
 
174
157
  root
175
158
  end
176
159
 
177
- def self.build_node_from_nokogiri(nokogiri_node,
178
- preserve_whitespace: false, inherited_namespaces: nil)
179
- case nokogiri_node
160
+ def self.walk_nokogiri(node, preserve_whitespace:,
161
+ inherited_namespaces: nil)
162
+ case node
180
163
  when Nokogiri::XML::Element
181
- build_element_node(nokogiri_node,
182
- preserve_whitespace: preserve_whitespace,
183
- inherited_namespaces: inherited_namespaces)
164
+ scope = TreeBuilder::DEFAULT.merge_namespace_scope(
165
+ inherited_namespaces,
166
+ node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
167
+ )
168
+ element = TreeBuilder::DEFAULT.element(
169
+ name: node.name,
170
+ prefix: node.namespace&.prefix,
171
+ namespace_uri: node.namespace&.href,
172
+ attributes: node.attribute_nodes.map do |attr|
173
+ [attr.name, attr.value, attr.namespace&.href, attr.namespace&.prefix]
174
+ end,
175
+ namespace_scope: scope,
176
+ )
177
+ node.children.each do |child|
178
+ built = walk_nokogiri(child,
179
+ preserve_whitespace: preserve_whitespace,
180
+ inherited_namespaces: scope)
181
+ element.add_child(built) if built
182
+ end
183
+ element
184
184
  when Nokogiri::XML::Text, Nokogiri::XML::CDATA
185
- build_text_node(nokogiri_node,
186
- preserve_whitespace: preserve_whitespace)
185
+ TreeBuilder::DEFAULT.text(node.content,
186
+ keep: WhitespacePolicy.keep_dom_text?(
187
+ node.content,
188
+ preserve_whitespace: preserve_whitespace,
189
+ element_parent: node.parent.is_a?(Nokogiri::XML::Element),
190
+ ),
191
+ original: node.to_xml)
187
192
  when Nokogiri::XML::Comment
188
- build_comment_node(nokogiri_node)
193
+ TreeBuilder::DEFAULT.comment(node.content)
189
194
  when Nokogiri::XML::ProcessingInstruction
190
- build_pi_node(nokogiri_node)
191
- end
192
- end
193
-
194
- def self.build_element_node(nokogiri_element, preserve_whitespace: false,
195
- inherited_namespaces: nil)
196
- element = Nodes::ElementNode.new(
197
- name: nokogiri_element.name,
198
- namespace_uri: nokogiri_element.namespace&.href,
199
- prefix: nokogiri_element.namespace&.prefix,
200
- )
201
-
202
- scope = nokogiri_namespace_scope(nokogiri_element, inherited_namespaces)
203
- scope.each do |prefix, uri|
204
- element.add_namespace(Nodes::NamespaceNode.new(
205
- prefix: prefix,
206
- uri: uri,
207
- ))
208
- end
209
-
210
- build_attribute_nodes(nokogiri_element, element)
211
-
212
- nokogiri_element.children.each do |child|
213
- node = build_node_from_nokogiri(child,
214
- preserve_whitespace: preserve_whitespace,
215
- inherited_namespaces: scope)
216
- element.add_child(node) if node
217
- end
218
-
219
- element
220
- end
221
-
222
- # In-scope bindings via the shared kernel; the scope is passed down
223
- # the recursion — one definition fetch per element instead of an
224
- # ancestor walk per element (O(n) total, not O(n * depth)).
225
- def self.nokogiri_namespace_scope(nokogiri_element, inherited)
226
- merge_namespace_scope(inherited,
227
- nokogiri_element.namespace_definitions.map do |ns|
228
- [ns.prefix, ns.href]
229
- end)
230
- end
231
-
232
- def self.build_attribute_nodes(nokogiri_element, element)
233
- # attribute_nodes, not the attributes hash: the hash is keyed by
234
- # local name and collapses e.g. attr / a:attr / b:attr into one
235
- # entry, losing namespaced attributes (W3C C14N ex 3.3).
236
- nokogiri_element.attribute_nodes.each do |attr|
237
- attr_node = Nodes::AttributeNode.new(
238
- name: attr.name,
239
- value: attr.value,
240
- namespace_uri: attr.namespace&.href,
241
- prefix: attr.namespace&.prefix,
242
- )
243
- element.add_attribute(attr_node)
244
- end
245
- end
246
-
247
- def self.build_text_node(nokogiri_text, preserve_whitespace: false)
248
- content = nokogiri_text.content
249
-
250
- unless WhitespacePolicy.keep_dom_text?(content,
251
- preserve_whitespace: preserve_whitespace,
252
- element_parent: nokogiri_text.parent.is_a?(Nokogiri::XML::Element))
253
- return nil
195
+ TreeBuilder::DEFAULT.processing_instruction(node.name, node.content)
254
196
  end
255
-
256
- original = nokogiri_text.to_xml
257
- Nodes::TextNode.new(value: content, original: original)
258
197
  end
259
198
 
260
- def self.build_comment_node(nokogiri_comment)
261
- Nodes::CommentNode.new(value: nokogiri_comment.content)
262
- end
263
-
264
- def self.build_pi_node(nokogiri_pi)
265
- Nodes::ProcessingInstructionNode.new(
266
- target: nokogiri_pi.name,
267
- data: nokogiri_pi.content,
268
- )
269
- end
270
-
271
- # --- Moxml path ---
199
+ # -- moxml record stream: post-order frames adopt children --
200
+ # One flattened record stream (moxml#132/#138) instead of a
201
+ # wrapper walk, so document conversion costs no Moxml::Node
202
+ # allocation per node. Own namespace declarations ride an
203
+ # identity-keyed stash; assign_moxml_namespace_scopes expands
204
+ # them to in-scope scopes afterwards (post-order arrival means
205
+ # scopes cannot flow down during the stream).
272
206
 
273
207
  def self.from_moxml_xml(xml_string, preserve_whitespace:)
274
208
  doc = Canon::XmlParsing.moxml_context.parse(xml_string, readonly: true)
@@ -301,35 +235,26 @@ inherited_namespaces: nil)
301
235
 
302
236
  def self.build_from_moxml(moxml_doc, preserve_whitespace: false)
303
237
  root = Nodes::RootNode.new
304
-
305
- converter = ->(child) { build_moxml_node(child, preserve_whitespace: preserve_whitespace) }
306
238
  skip_types = [Moxml::Doctype]
307
239
 
308
240
  if moxml_doc.is_a?(Moxml::Document) && moxml_doc.root
309
241
  element = build_moxml_subtree_from_records(moxml_doc.root,
310
242
  preserve_whitespace: preserve_whitespace)
311
243
  root.add_child(element) if element
312
- add_document_children(root, moxml_doc.children, moxml_doc.root,
313
- skip_types, converter)
244
+ TreeBuilder::DEFAULT.add_document_children(root, moxml_doc.children,
245
+ moxml_doc.root, skip_types) do |child|
246
+ walk_moxml(child, preserve_whitespace: preserve_whitespace)
247
+ end
314
248
  else
315
- add_document_children(root, moxml_doc.children, nil,
316
- skip_types, converter)
249
+ TreeBuilder::DEFAULT.add_document_children(root, moxml_doc.children,
250
+ nil, skip_types) do |child|
251
+ walk_moxml(child, preserve_whitespace: preserve_whitespace)
252
+ end
317
253
  end
318
254
 
319
255
  root
320
256
  end
321
257
 
322
- # Document path: build the root subtree from materialize records —
323
- # one flattened record stream (moxml#132/#138) instead of a wrapper
324
- # walk, so conversion costs no Moxml::Node allocation per node.
325
- #
326
- # Records arrive post-order (children before parents): completed
327
- # nodes stack up with their depth, and an element record adopts
328
- # every completed node deeper than itself. Each element's OWN
329
- # namespace declarations ride an identity-keyed stash; afterwards
330
- # assign_moxml_namespace_scopes expands them to in-scope scopes
331
- # with the same first-wins/xml-prebound semantics as the Nokogiri
332
- # collector.
333
258
  def self.build_moxml_subtree_from_records(moxml_element,
334
259
  preserve_whitespace: false)
335
260
  frames = []
@@ -339,8 +264,8 @@ preserve_whitespace: false)
339
264
  # The record contract is root-subtree-only since moxml 0.5.11
340
265
  # (moxml#140). Older 0.5.x releases — still allowed by canon's
341
266
  # gemspec floor — leaked epilog document-level records at depth
342
- # 0, which the document-children enumeration below also covers;
343
- # one integer compare per record keeps those working.
267
+ # 0, which the document-children enumeration also covers; one
268
+ # integer compare per record keeps those working.
344
269
  next if record[:depth].zero? && record[:kind] != :element
345
270
 
346
271
  node = case record[:kind]
@@ -348,14 +273,16 @@ preserve_whitespace: false)
348
273
  build_moxml_element_from_record(record, frames,
349
274
  own_namespaces)
350
275
  when :text, :cdata
351
- build_moxml_text_from_record(record, preserve_whitespace)
276
+ content = record[:text].to_s
277
+ TreeBuilder::DEFAULT.text(content,
278
+ keep: WhitespacePolicy.keep_dom_text?(
279
+ content, preserve_whitespace: preserve_whitespace
280
+ ))
352
281
  when :comment
353
- Nodes::CommentNode.new(value: record[:text])
282
+ TreeBuilder::DEFAULT.comment(record[:text])
354
283
  when :processing_instruction
355
- Nodes::ProcessingInstructionNode.new(
356
- target: record[:qname],
357
- data: record[:text] || "",
358
- )
284
+ TreeBuilder::DEFAULT.processing_instruction(record[:qname],
285
+ record[:text] || "")
359
286
  end
360
287
 
361
288
  frames.push([record[:depth], node]) if node
@@ -364,34 +291,18 @@ preserve_whitespace: false)
364
291
  top = frames.last
365
292
  return nil unless top
366
293
 
367
- element = top[1]
368
- assign_moxml_namespace_scopes(element,
369
- { "xml" => "http://www.w3.org/XML/1998/namespace" },
370
- own_namespaces)
371
- element
294
+ assign_moxml_namespace_scopes(top[1], nil, own_namespaces)
295
+ top[1]
372
296
  end
373
297
 
374
298
  def self.build_moxml_element_from_record(record, frames, own_namespaces)
375
- element = Nodes::ElementNode.new(
299
+ element = TreeBuilder::DEFAULT.element(
376
300
  name: record[:qname],
377
- namespace_uri: record[:namespace_uri],
378
301
  prefix: record[:prefix],
302
+ namespace_uri: record[:namespace_uri],
303
+ attributes: record[:attributes],
379
304
  )
380
305
 
381
- seen = {}
382
- record[:attributes].each do |name, value, namespace_uri, prefix|
383
- key = [name, namespace_uri]
384
- next if seen.key?(key)
385
-
386
- seen[key] = true
387
- element.add_attribute(Nodes::AttributeNode.new(
388
- name: name,
389
- value: value,
390
- namespace_uri: namespace_uri,
391
- prefix: prefix,
392
- ))
393
- end
394
-
395
306
  # Adopt completed children: they pop in reverse order; reversing
396
307
  # once is O(n) (unshift per child would be O(n^2) on wide trees).
397
308
  children = []
@@ -402,133 +313,54 @@ preserve_whitespace: false)
402
313
  element
403
314
  end
404
315
 
405
- # Text records inside a document subtree always have an element
406
- # parent (only the document element sits at depth 0), so the
407
- # whitespace-only skip rule matches the wrapper path exactly.
408
- def self.build_moxml_text_from_record(record, preserve_whitespace)
409
- content = record[:text].to_s
410
- return nil unless WhitespacePolicy.keep_dom_text?(content, preserve_whitespace: preserve_whitespace)
411
-
412
- Nodes::TextNode.new(value: content, original: content)
413
- end
414
-
415
- # Expand each element's own declarations to in-scope bindings via
416
- # the shared kernel. Scope flows down the canon tree — no engine
417
- # calls.
418
316
  def self.assign_moxml_namespace_scopes(element, inherited, own_namespaces)
419
- scope = merge_namespace_scope(inherited, own_namespaces[element].to_a)
420
-
421
- scope.each do |prefix, uri|
422
- element.add_namespace(Nodes::NamespaceNode.new(
423
- prefix: prefix,
424
- uri: uri,
425
- ))
426
- end
317
+ scope = TreeBuilder::DEFAULT.merge_namespace_scope(inherited,
318
+ own_namespaces[element].to_a)
319
+ TreeBuilder::DEFAULT.attach_namespace_scope(element, scope)
427
320
 
428
321
  element.children.each do |child|
429
322
  assign_moxml_namespace_scopes(child, scope, own_namespaces) if child.is_a?(Nodes::ElementNode)
430
323
  end
431
324
  end
432
325
 
433
- def self.build_moxml_node(node, preserve_whitespace: false,
326
+ # -- moxml wrapper walk: fragments and user-supplied nodes --
327
+
328
+ def self.walk_moxml(node, preserve_whitespace: false,
434
329
  inherited_namespaces: nil)
435
330
  case node
436
331
  when Moxml::Element
437
- build_moxml_element_node(node,
438
- preserve_whitespace: preserve_whitespace,
439
- inherited_namespaces: inherited_namespaces)
332
+ scope = TreeBuilder::DEFAULT.merge_namespace_scope(
333
+ inherited_namespaces,
334
+ node.namespace_definitions.map { |ns| [ns.prefix, ns.uri] },
335
+ )
336
+ element = TreeBuilder::DEFAULT.element(
337
+ name: node.name,
338
+ prefix: node.namespace&.prefix,
339
+ namespace_uri: node.namespace&.uri,
340
+ attributes: node.attributes.map do |attr|
341
+ [attr.name, attr.value, attr.namespace&.uri, attr.namespace&.prefix]
342
+ end,
343
+ namespace_scope: scope,
344
+ )
345
+ node.children.each do |child|
346
+ built = walk_moxml(child,
347
+ preserve_whitespace: preserve_whitespace,
348
+ inherited_namespaces: scope)
349
+ element.add_child(built) if built
350
+ end
351
+ element
440
352
  when Moxml::Text, Moxml::Cdata
441
- build_moxml_text_node(node, preserve_whitespace: preserve_whitespace)
353
+ TreeBuilder::DEFAULT.text(node.content,
354
+ keep: WhitespacePolicy.keep_dom_text?(
355
+ node.content,
356
+ preserve_whitespace: preserve_whitespace,
357
+ element_parent: node.parent.is_a?(Moxml::Element),
358
+ ))
442
359
  when Moxml::Comment
443
- build_moxml_comment_node(node)
360
+ TreeBuilder::DEFAULT.comment(node.content)
444
361
  when Moxml::ProcessingInstruction
445
- build_moxml_pi_node(node)
446
- end
447
- end
448
-
449
- def self.build_moxml_element_node(moxml_element,
450
- preserve_whitespace: false,
451
- inherited_namespaces: nil)
452
- ns = moxml_element.namespace
453
- element = Nodes::ElementNode.new(
454
- name: moxml_element.name,
455
- namespace_uri: ns&.uri,
456
- prefix: ns&.prefix,
457
- )
458
-
459
- scope = moxml_namespace_scope(moxml_element, inherited_namespaces)
460
- scope.each do |prefix, uri|
461
- element.add_namespace(Nodes::NamespaceNode.new(
462
- prefix: prefix,
463
- uri: uri,
464
- ))
362
+ TreeBuilder::DEFAULT.processing_instruction(node.target, node.content)
465
363
  end
466
-
467
- build_moxml_attribute_nodes(moxml_element, element)
468
-
469
- moxml_element.children.each do |child|
470
- node = build_moxml_node(child,
471
- preserve_whitespace: preserve_whitespace,
472
- inherited_namespaces: scope)
473
- element.add_child(node) if node
474
- end
475
-
476
- element
477
- end
478
-
479
- # In-scope namespace bindings: the element's own declarations
480
- # shadow inherited ones, xml is prebound. The scope is passed down
481
- # the recursion so each element pays one declaration fetch instead
482
- # of an ancestor walk through the adapter layer (the Nokogiri
483
- # collector walks ancestors because those calls are cheap there).
484
- def self.moxml_namespace_scope(moxml_element, inherited)
485
- merge_namespace_scope(inherited,
486
- moxml_element.namespace_definitions.map do |decl|
487
- [decl.prefix, decl.uri]
488
- end)
489
- end
490
-
491
- def self.build_moxml_attribute_nodes(moxml_element, element)
492
- seen = {}
493
- moxml_element.attributes.each do |attr|
494
- # Duplicate attribute names are invalid XML; engines expose
495
- # them differently (libxml2 lists repeats, libleptris dedups
496
- # last-wins). Canon follows the Nokogiri presentation contract:
497
- # first occurrence wins, keyed by name + namespace.
498
- key = [attr.name, attr.namespace&.uri]
499
- next if seen.key?(key)
500
-
501
- seen[key] = true
502
- element.add_attribute(Nodes::AttributeNode.new(
503
- name: attr.name,
504
- value: attr.value,
505
- namespace_uri: attr.namespace&.uri,
506
- prefix: attr.namespace&.prefix,
507
- ))
508
- end
509
- end
510
-
511
- def self.build_moxml_text_node(moxml_text, preserve_whitespace: false)
512
- content = moxml_text.content
513
-
514
- unless WhitespacePolicy.keep_dom_text?(content,
515
- preserve_whitespace: preserve_whitespace,
516
- element_parent: moxml_text.parent.is_a?(Moxml::Element))
517
- return nil
518
- end
519
-
520
- Nodes::TextNode.new(value: content, original: content)
521
- end
522
-
523
- def self.build_moxml_comment_node(moxml_comment)
524
- Nodes::CommentNode.new(value: moxml_comment.content)
525
- end
526
-
527
- def self.build_moxml_pi_node(moxml_pi)
528
- Nodes::ProcessingInstructionNode.new(
529
- target: moxml_pi.target,
530
- data: moxml_pi.content,
531
- )
532
364
  end
533
365
  end
534
366
  end
@@ -9,6 +9,11 @@ module Canon
9
9
  # selects the driver. Much faster than DOM parsing + conversion —
10
10
  # no intermediate engine DOM tree, no traversal conversion pass.
11
11
  #
12
+ # Construction goes through TreeBuilder like every other feed:
13
+ # this class owns only what is SAX-specific — qname parsing, xmlns
14
+ # separation, character-reference decoding, adjacency combining,
15
+ # the namespace stack, and document-level reordering.
16
+ #
12
17
  # Usage:
13
18
  # root = SaxBuilder.parse(xml_string, preserve_whitespace: false)
14
19
  # # root is a Canon::Xml::Nodes::RootNode
@@ -123,26 +128,23 @@ strip_doctype: false)
123
128
  end
124
129
  end
125
130
 
126
- # Push new namespace scope with declarations
131
+ # Push new namespace scope with declarations (own shadows
132
+ # inherited — the same merge the TreeBuilder scope kernel applies)
127
133
  new_scope = @namespace_stack.last.merge(ns_hash)
128
134
  @namespace_stack.push(new_scope)
129
135
 
130
- # Find namespace URI from current scope
131
- ns_uri = new_scope[prefix.to_s]
132
-
133
- # Create element node
134
- element = Nodes::ElementNode.new(
136
+ element = TreeBuilder::DEFAULT.element(
135
137
  name: local_name,
136
- namespace_uri: ns_uri,
137
138
  prefix: prefix,
139
+ namespace_uri: new_scope[prefix.to_s],
140
+ namespace_scope: new_scope,
141
+ attributes: regular_attrs.map do |attr_name, attr_value|
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,
138
146
  )
139
147
 
140
- # Add namespace nodes from current scope
141
- add_namespace_nodes(element, new_scope)
142
-
143
- # Build and add attribute nodes (excluding xmlns declarations)
144
- add_attribute_nodes(element, regular_attrs)
145
-
146
148
  parent.add_child(element)
147
149
  @stack.push(element)
148
150
  end
@@ -200,13 +202,15 @@ strip_doctype: false)
200
202
 
201
203
  # Skip whitespace-only text nodes unless preserving, per the SAX
202
204
  # policy (CR-bearing content always survives for C14N;
203
- # non-ASCII whitespace is meaningful content).
205
+ # non-ASCII whitespace is meaningful content; document-level
206
+ # whitespace drops with every policy).
204
207
  return unless WhitespacePolicy.keep_sax_text?(decoded_string,
205
208
  preserve_whitespace: @preserve_whitespace,
206
209
  element_parent: parent.node_type == :element)
207
210
 
208
- text = Nodes::TextNode.new(value: decoded_string, original: raw_string)
209
- parent.add_child(text)
211
+ parent.add_child(
212
+ TreeBuilder::DEFAULT.text(decoded_string, keep: true, original: raw_string),
213
+ )
210
214
  end
211
215
  private :append_text
212
216
 
@@ -214,9 +218,7 @@ strip_doctype: false)
214
218
  #
215
219
  # @param string [String] Comment content
216
220
  def comment(string)
217
- parent = @stack.last
218
- comment_node = Nodes::CommentNode.new(value: string)
219
- parent.add_child(comment_node)
221
+ @stack.last.add_child(TreeBuilder::DEFAULT.comment(string))
220
222
  end
221
223
 
222
224
  # Called for processing instructions
@@ -224,10 +226,9 @@ strip_doctype: false)
224
226
  # @param name [String] PI target
225
227
  # @param content [String] PI content
226
228
  def processing_instruction(name, content)
227
- parent = @stack.last
228
- pi = Nodes::ProcessingInstructionNode.new(target: name,
229
- data: content || "")
230
- parent.add_child(pi)
229
+ @stack.last.add_child(
230
+ TreeBuilder::DEFAULT.processing_instruction(name, content || ""),
231
+ )
231
232
  end
232
233
 
233
234
  # Return the built tree
@@ -313,44 +314,7 @@ strip_doctype: false)
313
314
  [ns_decls, regular_attrs]
314
315
  end
315
316
 
316
- # Add namespace nodes to element
317
- #
318
- # @param element [Nodes::ElementNode] Element to add namespaces to
319
- # @param scope [Hash] Current namespace scope
320
- def add_namespace_nodes(element, scope)
321
- scope.each do |prefix, uri|
322
- ns_node = Nodes::NamespaceNode.new(prefix: prefix, uri: uri)
323
- element.add_namespace(ns_node)
324
- end
325
- end
326
-
327
- # Add attribute nodes to element
328
- #
329
- # @param element [Nodes::ElementNode] Element to add attributes to
330
- # @param attrs [Array] Array of [name, value] pairs
331
- def add_attribute_nodes(element, attrs)
332
- attrs.each do |attr_name, attr_value|
333
- attr_prefix, attr_local = parse_qname(attr_name)
334
-
335
- # Find namespace for attribute (if prefixed)
336
- attr_ns_uri = attr_prefix ? @namespace_stack.last[attr_prefix] : nil
337
-
338
- # Decode numeric character references (e.g., &#38; → &)
339
- # Nokogiri SAX leaves these as-is, but we need them decoded for C14N
340
- decoded_value = decode_character_references(attr_value || "")
341
-
342
- attr_node = Nodes::AttributeNode.new(
343
- name: attr_local,
344
- value: decoded_value,
345
- namespace_uri: attr_ns_uri,
346
- prefix: attr_prefix,
347
- )
348
- element.add_attribute(attr_node)
349
- end
350
- end
351
-
352
- # Decode numeric character references in a string
353
- # Handles both &#decimal; and &#xhex; forms
317
+ # Decode numeric character references (e.g., &#38; → &)
354
318
  #
355
319
  # @param value [String] String potentially containing character references
356
320
  # @return [String] String with character references decoded
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Xml
5
+ # The one place canon tree nodes are constructed from parsed XML.
6
+ #
7
+ # Owns construction semantics: namespace scopes, attribute
8
+ # normalization, node kinds, and document-level ordering. Keep/strip
9
+ # decisions come from WhitespacePolicy (one home for all three
10
+ # policies); engine walks — the Nokogiri and moxml extractors in
11
+ # Xml::DataModel, the HTML walk in Html::DataModel — only map their
12
+ # engine's shapes onto this interface.
13
+ #
14
+ # Attribute normalization: duplicate (name, namespace) pairs are
15
+ # invalid XML; engines expose them differently (libxml2 lists
16
+ # repeats, libleptris deduplicates), so the builder resolves them
17
+ # once — first occurrence wins.
18
+ class TreeBuilder
19
+ # Stateless module: one shared instance serves every feed.
20
+ DEFAULT = new
21
+
22
+ NO_ATTRIBUTES = [].freeze
23
+ XML_NAMESPACE_PREFIX = "xml"
24
+ XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"
25
+
26
+ # In-scope namespace bindings: the element's own declarations
27
+ # shadow inherited ones; xml is prebound at the base. Declaration
28
+ # pairs are [prefix-or-nil, uri]; nil and "" both mean the default
29
+ # namespace.
30
+ def merge_namespace_scope(inherited, declaration_pairs)
31
+ scope = inherited ? inherited.dup : { XML_NAMESPACE_PREFIX => XML_NAMESPACE_URI }
32
+ declaration_pairs.each do |prefix, uri|
33
+ scope[prefix || ""] = uri
34
+ end
35
+ scope
36
+ end
37
+
38
+ # Attach an in-scope scope to an element as namespace nodes.
39
+ def attach_namespace_scope(element, scope)
40
+ scope.each do |prefix, uri|
41
+ element.add_namespace(Nodes::NamespaceNode.new(
42
+ prefix: prefix,
43
+ uri: uri,
44
+ ))
45
+ end
46
+ end
47
+
48
+ # Build an element. `attributes` are [name, value, namespace_uri,
49
+ # prefix] pairs; `namespace_scope` is a merged scope (or nil for
50
+ # no namespace nodes).
51
+ def element(name:, prefix: nil, namespace_uri: nil,
52
+ attributes: NO_ATTRIBUTES, namespace_scope: nil)
53
+ element = Nodes::ElementNode.new(
54
+ name: name,
55
+ namespace_uri: namespace_uri,
56
+ prefix: prefix,
57
+ )
58
+ attach_namespace_scope(element, namespace_scope) if namespace_scope
59
+
60
+ # Duplicate (name, namespace) pairs are invalid XML — first
61
+ # occurrence wins. Attributes per element are few, so a pairwise
62
+ # scan beats a per-element seen-hash on allocation (this is the
63
+ # hot SAX path).
64
+ attributes.each_with_index do |(attr_name, value, attr_namespace_uri, attr_prefix), index|
65
+ duplicate = attributes[0...index].any? do |prior_name, _v, prior_ns, _p|
66
+ prior_name == attr_name && prior_ns == attr_namespace_uri
67
+ end
68
+ next if duplicate
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
+ ))
76
+ end
77
+
78
+ element
79
+ end
80
+
81
+ # Build a text node; `keep` comes from WhitespacePolicy (the
82
+ # caller knows the policy and parent context).
83
+ def text(content, keep:, original: content)
84
+ return nil unless keep
85
+
86
+ Nodes::TextNode.new(value: content, original: original)
87
+ end
88
+
89
+ def comment(content)
90
+ Nodes::CommentNode.new(value: content)
91
+ end
92
+
93
+ def processing_instruction(target, data)
94
+ Nodes::ProcessingInstructionNode.new(target: target, data: data)
95
+ end
96
+
97
+ # Attach document-level children (prolog/epilog PIs, comments,
98
+ # document-level text) to the canon root, in document order,
99
+ # skipping the document element and the given types. Yields each
100
+ # child to the caller's converter; nil results are dropped.
101
+ def add_document_children(root, children, document_element,
102
+ skip_types = [])
103
+ children.each do |child|
104
+ next if child.equal?(document_element)
105
+ next if skip_types.any? { |type| child.is_a?(type) }
106
+
107
+ node = yield child
108
+ root.add_child(node) if node
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -4,8 +4,15 @@ module Canon
4
4
  module Xml
5
5
  # Parse-time whitespace policy: whether a character-data node
6
6
  # survives conversion. One home for the keep/strip rules so the
7
- # DOM/SAX differences are visible here instead of implied by
7
+ # DOM/SAX/HTML differences are visible here instead of implied by
8
8
  # copy-paste across conversion sites.
9
+ #
10
+ # Document-level character data (outside the root element) can only
11
+ # be whitespace per the XML grammar, and the XPath data model —
12
+ # which canon's tree and C14N follow — has no root text children at
13
+ # all. Every policy therefore drops whitespace-only document-level
14
+ # text; engines that report it (libxml2 does not, libleptris 1.9.38+
15
+ # does) stay byte-compatible through here.
9
16
  module WhitespacePolicy
10
17
  module_function
11
18
 
@@ -15,9 +22,14 @@ module Canon
15
22
  #
16
23
  # NOTE: CR-only nodes are dropped on this path; the SAX rule keeps
17
24
  # them (character references must survive for C14N).
18
- def keep_dom_text?(content, preserve_whitespace:, element_parent: true)
25
+ def keep_dom_text?(content, preserve_whitespace:,
26
+ element_parent: true)
27
+ # The XPath data model has no root text children — drop
28
+ # whitespace-only document-level text even when preserving
29
+ # (engines that report it stay byte-compatible with libxml2).
30
+ return false if !element_parent && content.strip.empty?
31
+
19
32
  return true if preserve_whitespace
20
- return true unless element_parent
21
33
 
22
34
  !content.strip.empty?
23
35
  end
@@ -25,13 +37,33 @@ module Canon
25
37
  # SAX rule: same shape, plus CR-bearing content is always kept
26
38
  # (&#xD; must survive parsing for C14N) — only runs of pure ASCII
27
39
  # whitespace (space, tab, CR, LF) are dropped when not preserving.
28
- def keep_sax_text?(content, preserve_whitespace:, element_parent: true)
40
+ def keep_sax_text?(content, preserve_whitespace:,
41
+ element_parent: true)
42
+ return false if !element_parent && content.strip.empty?
43
+
29
44
  return true if preserve_whitespace
30
- return true unless element_parent
31
45
  return true if content.include?("\r")
32
46
 
33
47
  !content.gsub(/[ \t\r\n]/, "").empty?
34
48
  end
49
+
50
+ # HTML conversion rule: whitespace-only text is dropped except in
51
+ # whitespace-sensitive elements (pre/code/textarea/script/style),
52
+ # between inline siblings (semantically significant), and when it
53
+ # carries NBSP (U+00A0 — never insignificant; strip is ASCII-only
54
+ # so it is checked explicitly).
55
+ HTML_WHITESPACE_SENSITIVE_TAGS = %w[pre code textarea script style].freeze
56
+
57
+ def keep_html_text?(content, parent_name:, inline_significant: false)
58
+ return true unless content.strip.empty?
59
+ return true if content.include?(" ")
60
+
61
+ parent_name = parent_name.to_s.downcase
62
+ return true if HTML_WHITESPACE_SENSITIVE_TAGS.include?(parent_name)
63
+ return true if inline_significant
64
+
65
+ false
66
+ end
35
67
  end
36
68
  end
37
69
  end
data/lib/canon/xml.rb CHANGED
@@ -27,6 +27,7 @@ module Canon
27
27
  autoload :Processor, "canon/xml/processor"
28
28
  autoload :Sax, "canon/xml/sax"
29
29
  autoload :SaxBuilder, "canon/xml/sax_builder"
30
+ autoload :TreeBuilder, "canon/xml/tree_builder"
30
31
  autoload :WhitespaceNormalizer, "canon/xml/whitespace_normalizer"
31
32
  autoload :WhitespacePolicy, "canon/xml/whitespace_policy"
32
33
  autoload :XmlBaseHandler, "canon/xml/xml_base_handler"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -400,6 +400,7 @@ files:
400
400
  - lib/canon/xml/sax/moxml_driver.rb
401
401
  - lib/canon/xml/sax/nokogiri_driver.rb
402
402
  - lib/canon/xml/sax_builder.rb
403
+ - lib/canon/xml/tree_builder.rb
403
404
  - lib/canon/xml/whitespace_normalizer.rb
404
405
  - lib/canon/xml/whitespace_policy.rb
405
406
  - lib/canon/xml/xml_base_handler.rb