canon 0.3.4 → 0.3.5

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: 8912c851a2e2b0e250fcae120cda428d7705f9c6262b57ec7fe1218aaf186229
4
+ data.tar.gz: 98a7b6c64818395e18c1de7b8918255368b6b4bf8766ecaa567b5502c7c084b4
5
5
  SHA512:
6
- metadata.gz: 67710ade1d18f99c23e4477540303f45867d145c7f92df59f2f1277cf442c576eca2a3d8562cb92972f56d80dbe111e6104d28353cb7c5091d690490605772ff
7
- data.tar.gz: e8d2783baa8fba878cb3cbb7a602dff3563f23dfb15df95ca5c24c075475d53c8319f2579bf3ce3c533e1989b1c75b91b72913c57c30279278a59de68202aef0
6
+ metadata.gz: 2fee687e2469d42193c4f58a294179dc8332aca3fefca5c3ad476cdc061e40c2370d2183790b20c1e43ae83b420ae78567cf7f27114c128f21b0ab8d76be46fe
7
+ data.tar.gz: 61a58938d48f48ce066c93b09e734bc62a05cfccec1c7fea80ecdb4de97663e589940108f89f0d8f947737d096ca9c21b9f41edbe51569da5bcc2ad5c990fd0f
@@ -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.new
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.5"
5
5
  end
@@ -126,149 +126,85 @@ 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
+ TREE_BUILDER = TreeBuilder.new
138
+
139
+ # -- Nokogiri walk: top-down, scope flows down the recursion --
157
140
 
158
141
  def self.build_from_nokogiri(nokogiri_doc, preserve_whitespace: false)
159
142
  root = Nodes::RootNode.new
160
-
161
- converter = ->(child) { build_node_from_nokogiri(child, preserve_whitespace: preserve_whitespace) }
162
143
  skip_types = defined?(Nokogiri) ? [Nokogiri::XML::DTD] : []
163
144
 
164
145
  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)
146
+ root.add_child(walk_nokogiri(nokogiri_doc.root,
147
+ preserve_whitespace: preserve_whitespace))
148
+ TREE_BUILDER.add_document_children(root, nokogiri_doc.children,
149
+ nokogiri_doc.root, skip_types) do |child|
150
+ walk_nokogiri(child, preserve_whitespace: preserve_whitespace)
151
+ end
169
152
  else
170
- add_document_children(root, nokogiri_doc.children, nil,
171
- skip_types, converter)
153
+ TREE_BUILDER.add_document_children(root, nokogiri_doc.children,
154
+ nil, skip_types) do |child|
155
+ walk_nokogiri(child, preserve_whitespace: preserve_whitespace)
156
+ end
172
157
  end
173
158
 
174
159
  root
175
160
  end
176
161
 
177
- def self.build_node_from_nokogiri(nokogiri_node,
178
- preserve_whitespace: false, inherited_namespaces: nil)
179
- case nokogiri_node
162
+ def self.walk_nokogiri(node, preserve_whitespace:,
163
+ inherited_namespaces: nil)
164
+ case node
180
165
  when Nokogiri::XML::Element
181
- build_element_node(nokogiri_node,
182
- preserve_whitespace: preserve_whitespace,
183
- inherited_namespaces: inherited_namespaces)
166
+ scope = TREE_BUILDER.merge_namespace_scope(
167
+ inherited_namespaces,
168
+ node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
169
+ )
170
+ element = TREE_BUILDER.element(
171
+ name: node.name,
172
+ prefix: node.namespace&.prefix,
173
+ namespace_uri: node.namespace&.href,
174
+ attributes: node.attribute_nodes.map do |attr|
175
+ [attr.name, attr.value, attr.namespace&.href, attr.namespace&.prefix]
176
+ end,
177
+ namespace_scope: scope,
178
+ )
179
+ node.children.each do |child|
180
+ built = walk_nokogiri(child,
181
+ preserve_whitespace: preserve_whitespace,
182
+ inherited_namespaces: scope)
183
+ element.add_child(built) if built
184
+ end
185
+ element
184
186
  when Nokogiri::XML::Text, Nokogiri::XML::CDATA
185
- build_text_node(nokogiri_node,
186
- preserve_whitespace: preserve_whitespace)
187
+ TREE_BUILDER.text(node.content,
188
+ keep: WhitespacePolicy.keep_dom_text?(
189
+ node.content,
190
+ preserve_whitespace: preserve_whitespace,
191
+ element_parent: node.parent.is_a?(Nokogiri::XML::Element),
192
+ ),
193
+ original: node.to_xml)
187
194
  when Nokogiri::XML::Comment
188
- build_comment_node(nokogiri_node)
195
+ TREE_BUILDER.comment(node.content)
189
196
  when Nokogiri::XML::ProcessingInstruction
190
- build_pi_node(nokogiri_node)
197
+ TREE_BUILDER.processing_instruction(node.name, node.content)
191
198
  end
192
199
  end
193
200
 
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
254
- end
255
-
256
- original = nokogiri_text.to_xml
257
- Nodes::TextNode.new(value: content, original: original)
258
- end
259
-
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 ---
201
+ # -- moxml record stream: post-order frames adopt children --
202
+ # One flattened record stream (moxml#132/#138) instead of a
203
+ # wrapper walk, so document conversion costs no Moxml::Node
204
+ # allocation per node. Own namespace declarations ride an
205
+ # identity-keyed stash; assign_moxml_namespace_scopes expands
206
+ # them to in-scope scopes afterwards (post-order arrival means
207
+ # scopes cannot flow down during the stream).
272
208
 
273
209
  def self.from_moxml_xml(xml_string, preserve_whitespace:)
274
210
  doc = Canon::XmlParsing.moxml_context.parse(xml_string, readonly: true)
@@ -301,35 +237,26 @@ inherited_namespaces: nil)
301
237
 
302
238
  def self.build_from_moxml(moxml_doc, preserve_whitespace: false)
303
239
  root = Nodes::RootNode.new
304
-
305
- converter = ->(child) { build_moxml_node(child, preserve_whitespace: preserve_whitespace) }
306
240
  skip_types = [Moxml::Doctype]
307
241
 
308
242
  if moxml_doc.is_a?(Moxml::Document) && moxml_doc.root
309
243
  element = build_moxml_subtree_from_records(moxml_doc.root,
310
244
  preserve_whitespace: preserve_whitespace)
311
245
  root.add_child(element) if element
312
- add_document_children(root, moxml_doc.children, moxml_doc.root,
313
- skip_types, converter)
246
+ TREE_BUILDER.add_document_children(root, moxml_doc.children,
247
+ moxml_doc.root, skip_types) do |child|
248
+ walk_moxml(child, preserve_whitespace: preserve_whitespace)
249
+ end
314
250
  else
315
- add_document_children(root, moxml_doc.children, nil,
316
- skip_types, converter)
251
+ TREE_BUILDER.add_document_children(root, moxml_doc.children,
252
+ nil, skip_types) do |child|
253
+ walk_moxml(child, preserve_whitespace: preserve_whitespace)
254
+ end
317
255
  end
318
256
 
319
257
  root
320
258
  end
321
259
 
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
260
  def self.build_moxml_subtree_from_records(moxml_element,
334
261
  preserve_whitespace: false)
335
262
  frames = []
@@ -339,8 +266,8 @@ preserve_whitespace: false)
339
266
  # The record contract is root-subtree-only since moxml 0.5.11
340
267
  # (moxml#140). Older 0.5.x releases — still allowed by canon's
341
268
  # 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.
269
+ # 0, which the document-children enumeration also covers; one
270
+ # integer compare per record keeps those working.
344
271
  next if record[:depth].zero? && record[:kind] != :element
345
272
 
346
273
  node = case record[:kind]
@@ -348,14 +275,16 @@ preserve_whitespace: false)
348
275
  build_moxml_element_from_record(record, frames,
349
276
  own_namespaces)
350
277
  when :text, :cdata
351
- build_moxml_text_from_record(record, preserve_whitespace)
278
+ content = record[:text].to_s
279
+ TREE_BUILDER.text(content,
280
+ keep: WhitespacePolicy.keep_dom_text?(
281
+ content, preserve_whitespace: preserve_whitespace
282
+ ))
352
283
  when :comment
353
- Nodes::CommentNode.new(value: record[:text])
284
+ TREE_BUILDER.comment(record[:text])
354
285
  when :processing_instruction
355
- Nodes::ProcessingInstructionNode.new(
356
- target: record[:qname],
357
- data: record[:text] || "",
358
- )
286
+ TREE_BUILDER.processing_instruction(record[:qname],
287
+ record[:text] || "")
359
288
  end
360
289
 
361
290
  frames.push([record[:depth], node]) if node
@@ -364,34 +293,18 @@ preserve_whitespace: false)
364
293
  top = frames.last
365
294
  return nil unless top
366
295
 
367
- element = top[1]
368
- assign_moxml_namespace_scopes(element,
369
- { "xml" => "http://www.w3.org/XML/1998/namespace" },
370
- own_namespaces)
371
- element
296
+ assign_moxml_namespace_scopes(top[1], nil, own_namespaces)
297
+ top[1]
372
298
  end
373
299
 
374
300
  def self.build_moxml_element_from_record(record, frames, own_namespaces)
375
- element = Nodes::ElementNode.new(
301
+ element = TREE_BUILDER.element(
376
302
  name: record[:qname],
377
- namespace_uri: record[:namespace_uri],
378
303
  prefix: record[:prefix],
304
+ namespace_uri: record[:namespace_uri],
305
+ attributes: record[:attributes],
379
306
  )
380
307
 
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
308
  # Adopt completed children: they pop in reverse order; reversing
396
309
  # once is O(n) (unshift per child would be O(n^2) on wide trees).
397
310
  children = []
@@ -402,133 +315,54 @@ preserve_whitespace: false)
402
315
  element
403
316
  end
404
317
 
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
318
  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
319
+ scope = TREE_BUILDER.merge_namespace_scope(inherited,
320
+ own_namespaces[element].to_a)
321
+ TREE_BUILDER.attach_namespace_scope(element, scope)
427
322
 
428
323
  element.children.each do |child|
429
324
  assign_moxml_namespace_scopes(child, scope, own_namespaces) if child.is_a?(Nodes::ElementNode)
430
325
  end
431
326
  end
432
327
 
433
- def self.build_moxml_node(node, preserve_whitespace: false,
328
+ # -- moxml wrapper walk: fragments and user-supplied nodes --
329
+
330
+ def self.walk_moxml(node, preserve_whitespace: false,
434
331
  inherited_namespaces: nil)
435
332
  case node
436
333
  when Moxml::Element
437
- build_moxml_element_node(node,
438
- preserve_whitespace: preserve_whitespace,
439
- inherited_namespaces: inherited_namespaces)
334
+ scope = TREE_BUILDER.merge_namespace_scope(
335
+ inherited_namespaces,
336
+ node.namespace_definitions.map { |ns| [ns.prefix, ns.uri] },
337
+ )
338
+ element = TREE_BUILDER.element(
339
+ name: node.name,
340
+ prefix: node.namespace&.prefix,
341
+ namespace_uri: node.namespace&.uri,
342
+ attributes: node.attributes.map do |attr|
343
+ [attr.name, attr.value, attr.namespace&.uri, attr.namespace&.prefix]
344
+ end,
345
+ namespace_scope: scope,
346
+ )
347
+ node.children.each do |child|
348
+ built = walk_moxml(child,
349
+ preserve_whitespace: preserve_whitespace,
350
+ inherited_namespaces: scope)
351
+ element.add_child(built) if built
352
+ end
353
+ element
440
354
  when Moxml::Text, Moxml::Cdata
441
- build_moxml_text_node(node, preserve_whitespace: preserve_whitespace)
355
+ TREE_BUILDER.text(node.content,
356
+ keep: WhitespacePolicy.keep_dom_text?(
357
+ node.content,
358
+ preserve_whitespace: preserve_whitespace,
359
+ element_parent: node.parent.is_a?(Moxml::Element),
360
+ ))
442
361
  when Moxml::Comment
443
- build_moxml_comment_node(node)
362
+ TREE_BUILDER.comment(node.content)
444
363
  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
- ))
364
+ TREE_BUILDER.processing_instruction(node.target, node.content)
465
365
  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
366
  end
533
367
  end
534
368
  end
@@ -200,7 +200,8 @@ strip_doctype: false)
200
200
 
201
201
  # Skip whitespace-only text nodes unless preserving, per the SAX
202
202
  # policy (CR-bearing content always survives for C14N;
203
- # non-ASCII whitespace is meaningful content).
203
+ # non-ASCII whitespace is meaningful content; document-level
204
+ # whitespace drops with every policy).
204
205
  return unless WhitespacePolicy.keep_sax_text?(decoded_string,
205
206
  preserve_whitespace: @preserve_whitespace,
206
207
  element_parent: parent.node_type == :element)
@@ -0,0 +1,106 @@
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
+ NO_ATTRIBUTES = [].freeze
20
+ XML_NAMESPACE_PREFIX = "xml"
21
+ XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"
22
+
23
+ # In-scope namespace bindings: the element's own declarations
24
+ # shadow inherited ones; xml is prebound at the base. Declaration
25
+ # pairs are [prefix-or-nil, uri]; nil and "" both mean the default
26
+ # namespace.
27
+ def merge_namespace_scope(inherited, declaration_pairs)
28
+ scope = inherited ? inherited.dup : { XML_NAMESPACE_PREFIX => XML_NAMESPACE_URI }
29
+ declaration_pairs.each do |prefix, uri|
30
+ scope[prefix || ""] = uri
31
+ end
32
+ scope
33
+ end
34
+
35
+ # Attach an in-scope scope to an element as namespace nodes.
36
+ def attach_namespace_scope(element, scope)
37
+ scope.each do |prefix, uri|
38
+ element.add_namespace(Nodes::NamespaceNode.new(
39
+ prefix: prefix,
40
+ uri: uri,
41
+ ))
42
+ end
43
+ end
44
+
45
+ # Build an element. `attributes` are [name, value, namespace_uri,
46
+ # prefix] pairs; `namespace_scope` is a merged scope (or nil for
47
+ # no namespace nodes).
48
+ def element(name:, prefix: nil, namespace_uri: nil,
49
+ attributes: NO_ATTRIBUTES, namespace_scope: nil)
50
+ element = Nodes::ElementNode.new(
51
+ name: name,
52
+ namespace_uri: namespace_uri,
53
+ prefix: prefix,
54
+ )
55
+ attach_namespace_scope(element, namespace_scope) if namespace_scope
56
+
57
+ seen = {}
58
+ attributes.each do |attr_name, value, attr_namespace_uri, attr_prefix|
59
+ key = [attr_name, attr_namespace_uri]
60
+ next if seen.key?(key)
61
+
62
+ seen[key] = true
63
+ element.add_attribute(Nodes::AttributeNode.new(
64
+ name: attr_name,
65
+ value: value,
66
+ namespace_uri: attr_namespace_uri,
67
+ prefix: attr_prefix,
68
+ ))
69
+ end
70
+
71
+ element
72
+ end
73
+
74
+ # Build a text node; `keep` comes from WhitespacePolicy (the
75
+ # caller knows the policy and parent context).
76
+ def text(content, keep:, original: content)
77
+ return nil unless keep
78
+
79
+ Nodes::TextNode.new(value: content, original: original)
80
+ end
81
+
82
+ def comment(content)
83
+ Nodes::CommentNode.new(value: content)
84
+ end
85
+
86
+ def processing_instruction(target, data)
87
+ Nodes::ProcessingInstructionNode.new(target: target, data: data)
88
+ end
89
+
90
+ # Attach document-level children (prolog/epilog PIs, comments,
91
+ # document-level text) to the canon root, in document order,
92
+ # skipping the document element and the given types. Yields each
93
+ # child to the caller's converter; nil results are dropped.
94
+ def add_document_children(root, children, document_element,
95
+ skip_types = [])
96
+ children.each do |child|
97
+ next if child.equal?(document_element)
98
+ next if skip_types.any? { |type| child.is_a?(type) }
99
+
100
+ node = yield child
101
+ root.add_child(node) if node
102
+ end
103
+ end
104
+ end
105
+ end
106
+ 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.5
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