canon 0.3.3 → 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: 52174949c77e8f33286b2875ac5e4052e6f5671b64e53e48a3b0b3128bb598a1
4
- data.tar.gz: 0b2efb1c4fa7e060f6d54f1c149bd775d45fab66e09ac7bbf306f0a6142360fa
3
+ metadata.gz: 8912c851a2e2b0e250fcae120cda428d7705f9c6262b57ec7fe1218aaf186229
4
+ data.tar.gz: 98a7b6c64818395e18c1de7b8918255368b6b4bf8766ecaa567b5502c7c084b4
5
5
  SHA512:
6
- metadata.gz: 4e78d8618af59c255979bf3987a1be3e7419e009354b73ca64df75600ce8c2f1cc90aa515eb14be97149fe5426243efacda4ff42eab63b5fc8f291407baa046e
7
- data.tar.gz: 2596286ac94921e6021590fe6f1ece71d827145743cc993f703cfca079fb91472845c67f25a3e9ad03aecd471c6cc482cd78854a5e9ce85d16bd8f0ea5643636
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.3"
4
+ VERSION = "0.3.5"
5
5
  end
@@ -126,130 +126,85 @@ module Canon
126
126
  end
127
127
  end
128
128
 
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.
136
+
137
+ TREE_BUILDER = TreeBuilder.new
138
+
139
+ # -- Nokogiri walk: top-down, scope flows down the recursion --
140
+
129
141
  def self.build_from_nokogiri(nokogiri_doc, preserve_whitespace: false)
130
142
  root = Nodes::RootNode.new
143
+ skip_types = defined?(Nokogiri) ? [Nokogiri::XML::DTD] : []
131
144
 
132
145
  if nokogiri_doc.is_a?(Nokogiri::XML::Document) && nokogiri_doc.root
133
- root.add_child(build_element_node(nokogiri_doc.root,
134
- preserve_whitespace: preserve_whitespace))
135
- nokogiri_doc.children.each do |child|
136
- next if child == nokogiri_doc.root
137
- next if child.is_a?(Nokogiri::XML::DTD)
138
-
139
- node = build_node_from_nokogiri(child,
140
- preserve_whitespace: preserve_whitespace)
141
- root.add_child(node) if node
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)
142
151
  end
143
152
  else
144
- nokogiri_doc.children.each do |child|
145
- next if child.is_a?(Nokogiri::XML::DTD)
146
-
147
- node = build_node_from_nokogiri(child,
148
- preserve_whitespace: preserve_whitespace)
149
- root.add_child(node) if node
153
+ TREE_BUILDER.add_document_children(root, nokogiri_doc.children,
154
+ nil, skip_types) do |child|
155
+ walk_nokogiri(child, preserve_whitespace: preserve_whitespace)
150
156
  end
151
157
  end
152
158
 
153
159
  root
154
160
  end
155
161
 
156
- def self.build_node_from_nokogiri(nokogiri_node,
157
- preserve_whitespace: false, inherited_namespaces: nil)
158
- case nokogiri_node
162
+ def self.walk_nokogiri(node, preserve_whitespace:,
163
+ inherited_namespaces: nil)
164
+ case node
159
165
  when Nokogiri::XML::Element
160
- build_element_node(nokogiri_node,
161
- preserve_whitespace: preserve_whitespace,
162
- 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
163
186
  when Nokogiri::XML::Text, Nokogiri::XML::CDATA
164
- build_text_node(nokogiri_node,
165
- 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)
166
194
  when Nokogiri::XML::Comment
167
- build_comment_node(nokogiri_node)
195
+ TREE_BUILDER.comment(node.content)
168
196
  when Nokogiri::XML::ProcessingInstruction
169
- build_pi_node(nokogiri_node)
170
- end
171
- end
172
-
173
- def self.build_element_node(nokogiri_element, preserve_whitespace: false,
174
- inherited_namespaces: nil)
175
- element = Nodes::ElementNode.new(
176
- name: nokogiri_element.name,
177
- namespace_uri: nokogiri_element.namespace&.href,
178
- prefix: nokogiri_element.namespace&.prefix,
179
- )
180
-
181
- scope = nokogiri_namespace_scope(nokogiri_element, inherited_namespaces)
182
- scope.each do |prefix, uri|
183
- element.add_namespace(Nodes::NamespaceNode.new(
184
- prefix: prefix,
185
- uri: uri,
186
- ))
187
- end
188
-
189
- build_attribute_nodes(nokogiri_element, element)
190
-
191
- nokogiri_element.children.each do |child|
192
- node = build_node_from_nokogiri(child,
193
- preserve_whitespace: preserve_whitespace,
194
- inherited_namespaces: scope)
195
- element.add_child(node) if node
196
- end
197
-
198
- element
199
- end
200
-
201
- # In-scope namespace bindings: the element's own declarations
202
- # shadow inherited ones, xml is prebound. The scope is passed down
203
- # the recursion — one definition fetch per element instead of an
204
- # ancestor walk per element (O(n) total, not O(n * depth)).
205
- def self.nokogiri_namespace_scope(nokogiri_element, inherited)
206
- scope = inherited ? inherited.dup : { "xml" => "http://www.w3.org/XML/1998/namespace" }
207
- nokogiri_element.namespace_definitions.each do |ns|
208
- scope[ns.prefix || ""] = ns.href
209
- end
210
- scope
211
- end
212
-
213
- def self.build_attribute_nodes(nokogiri_element, element)
214
- # attribute_nodes, not the attributes hash: the hash is keyed by
215
- # local name and collapses e.g. attr / a:attr / b:attr into one
216
- # entry, losing namespaced attributes (W3C C14N ex 3.3).
217
- nokogiri_element.attribute_nodes.each do |attr|
218
- attr_node = Nodes::AttributeNode.new(
219
- name: attr.name,
220
- value: attr.value,
221
- namespace_uri: attr.namespace&.href,
222
- prefix: attr.namespace&.prefix,
223
- )
224
- element.add_attribute(attr_node)
225
- end
226
- end
227
-
228
- def self.build_text_node(nokogiri_text, preserve_whitespace: false)
229
- content = nokogiri_text.content
230
-
231
- unless WhitespacePolicy.keep_dom_text?(content,
232
- preserve_whitespace: preserve_whitespace,
233
- element_parent: nokogiri_text.parent.is_a?(Nokogiri::XML::Element))
234
- return nil
197
+ TREE_BUILDER.processing_instruction(node.name, node.content)
235
198
  end
236
-
237
- original = nokogiri_text.to_xml
238
- Nodes::TextNode.new(value: content, original: original)
239
- end
240
-
241
- def self.build_comment_node(nokogiri_comment)
242
- Nodes::CommentNode.new(value: nokogiri_comment.content)
243
199
  end
244
200
 
245
- def self.build_pi_node(nokogiri_pi)
246
- Nodes::ProcessingInstructionNode.new(
247
- target: nokogiri_pi.name,
248
- data: nokogiri_pi.content,
249
- )
250
- end
251
-
252
- # --- 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).
253
208
 
254
209
  def self.from_moxml_xml(xml_string, preserve_whitespace:)
255
210
  doc = Canon::XmlParsing.moxml_context.parse(xml_string, readonly: true)
@@ -282,43 +237,26 @@ inherited_namespaces: nil)
282
237
 
283
238
  def self.build_from_moxml(moxml_doc, preserve_whitespace: false)
284
239
  root = Nodes::RootNode.new
240
+ skip_types = [Moxml::Doctype]
285
241
 
286
242
  if moxml_doc.is_a?(Moxml::Document) && moxml_doc.root
287
243
  element = build_moxml_subtree_from_records(moxml_doc.root,
288
244
  preserve_whitespace: preserve_whitespace)
289
245
  root.add_child(element) if element
290
- moxml_doc.children.each do |child|
291
- next if child.equal?(moxml_doc.root)
292
- next if child.is_a?(Moxml::Doctype)
293
-
294
- node = build_moxml_node(child,
295
- preserve_whitespace: preserve_whitespace)
296
- root.add_child(node) if node
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)
297
249
  end
298
250
  else
299
- moxml_doc.children.each do |child|
300
- next if child.is_a?(Moxml::Doctype)
301
-
302
- node = build_moxml_node(child,
303
- preserve_whitespace: preserve_whitespace)
304
- root.add_child(node) if node
251
+ TREE_BUILDER.add_document_children(root, moxml_doc.children,
252
+ nil, skip_types) do |child|
253
+ walk_moxml(child, preserve_whitespace: preserve_whitespace)
305
254
  end
306
255
  end
307
256
 
308
257
  root
309
258
  end
310
259
 
311
- # Document path: build the root subtree from materialize records —
312
- # one flattened record stream (moxml#132/#138) instead of a wrapper
313
- # walk, so conversion costs no Moxml::Node allocation per node.
314
- #
315
- # Records arrive post-order (children before parents): completed
316
- # nodes stack up with their depth, and an element record adopts
317
- # every completed node deeper than itself. Each element's OWN
318
- # namespace declarations ride an identity-keyed stash; afterwards
319
- # assign_moxml_namespace_scopes expands them to in-scope scopes
320
- # with the same first-wins/xml-prebound semantics as the Nokogiri
321
- # collector.
322
260
  def self.build_moxml_subtree_from_records(moxml_element,
323
261
  preserve_whitespace: false)
324
262
  frames = []
@@ -328,8 +266,8 @@ preserve_whitespace: false)
328
266
  # The record contract is root-subtree-only since moxml 0.5.11
329
267
  # (moxml#140). Older 0.5.x releases — still allowed by canon's
330
268
  # gemspec floor — leaked epilog document-level records at depth
331
- # 0, which the document-children enumeration below also covers;
332
- # 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.
333
271
  next if record[:depth].zero? && record[:kind] != :element
334
272
 
335
273
  node = case record[:kind]
@@ -337,14 +275,16 @@ preserve_whitespace: false)
337
275
  build_moxml_element_from_record(record, frames,
338
276
  own_namespaces)
339
277
  when :text, :cdata
340
- 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
+ ))
341
283
  when :comment
342
- Nodes::CommentNode.new(value: record[:text])
284
+ TREE_BUILDER.comment(record[:text])
343
285
  when :processing_instruction
344
- Nodes::ProcessingInstructionNode.new(
345
- target: record[:qname],
346
- data: record[:text] || "",
347
- )
286
+ TREE_BUILDER.processing_instruction(record[:qname],
287
+ record[:text] || "")
348
288
  end
349
289
 
350
290
  frames.push([record[:depth], node]) if node
@@ -353,34 +293,18 @@ preserve_whitespace: false)
353
293
  top = frames.last
354
294
  return nil unless top
355
295
 
356
- element = top[1]
357
- assign_moxml_namespace_scopes(element,
358
- { "xml" => "http://www.w3.org/XML/1998/namespace" },
359
- own_namespaces)
360
- element
296
+ assign_moxml_namespace_scopes(top[1], nil, own_namespaces)
297
+ top[1]
361
298
  end
362
299
 
363
300
  def self.build_moxml_element_from_record(record, frames, own_namespaces)
364
- element = Nodes::ElementNode.new(
301
+ element = TREE_BUILDER.element(
365
302
  name: record[:qname],
366
- namespace_uri: record[:namespace_uri],
367
303
  prefix: record[:prefix],
304
+ namespace_uri: record[:namespace_uri],
305
+ attributes: record[:attributes],
368
306
  )
369
307
 
370
- seen = {}
371
- record[:attributes].each do |name, value, namespace_uri, prefix|
372
- key = [name, namespace_uri]
373
- next if seen.key?(key)
374
-
375
- seen[key] = true
376
- element.add_attribute(Nodes::AttributeNode.new(
377
- name: name,
378
- value: value,
379
- namespace_uri: namespace_uri,
380
- prefix: prefix,
381
- ))
382
- end
383
-
384
308
  # Adopt completed children: they pop in reverse order; reversing
385
309
  # once is O(n) (unshift per child would be O(n^2) on wide trees).
386
310
  children = []
@@ -391,138 +315,55 @@ preserve_whitespace: false)
391
315
  element
392
316
  end
393
317
 
394
- # Text records inside a document subtree always have an element
395
- # parent (only the document element sits at depth 0), so the
396
- # whitespace-only skip rule matches the wrapper path exactly.
397
- def self.build_moxml_text_from_record(record, preserve_whitespace)
398
- content = record[:text].to_s
399
- return nil unless WhitespacePolicy.keep_dom_text?(content, preserve_whitespace: preserve_whitespace)
400
-
401
- Nodes::TextNode.new(value: content, original: content)
402
- end
403
-
404
- # Expand each element's own declarations to in-scope bindings:
405
- # the element's declarations shadow inherited ones; xml is
406
- # prebound. Scope flows down the canon tree — no engine calls.
407
318
  def self.assign_moxml_namespace_scopes(element, inherited, own_namespaces)
408
- scope = inherited.dup
409
- own_namespaces[element].to_a.each do |prefix, uri|
410
- scope[prefix || ""] = uri
411
- end
412
-
413
- scope.each do |prefix, uri|
414
- element.add_namespace(Nodes::NamespaceNode.new(
415
- prefix: prefix,
416
- uri: uri,
417
- ))
418
- end
319
+ scope = TREE_BUILDER.merge_namespace_scope(inherited,
320
+ own_namespaces[element].to_a)
321
+ TREE_BUILDER.attach_namespace_scope(element, scope)
419
322
 
420
323
  element.children.each do |child|
421
324
  assign_moxml_namespace_scopes(child, scope, own_namespaces) if child.is_a?(Nodes::ElementNode)
422
325
  end
423
326
  end
424
327
 
425
- 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,
426
331
  inherited_namespaces: nil)
427
332
  case node
428
333
  when Moxml::Element
429
- build_moxml_element_node(node,
430
- preserve_whitespace: preserve_whitespace,
431
- 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
432
354
  when Moxml::Text, Moxml::Cdata
433
- 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
+ ))
434
361
  when Moxml::Comment
435
- build_moxml_comment_node(node)
362
+ TREE_BUILDER.comment(node.content)
436
363
  when Moxml::ProcessingInstruction
437
- build_moxml_pi_node(node)
364
+ TREE_BUILDER.processing_instruction(node.target, node.content)
438
365
  end
439
366
  end
440
-
441
- def self.build_moxml_element_node(moxml_element,
442
- preserve_whitespace: false,
443
- inherited_namespaces: nil)
444
- ns = moxml_element.namespace
445
- element = Nodes::ElementNode.new(
446
- name: moxml_element.name,
447
- namespace_uri: ns&.uri,
448
- prefix: ns&.prefix,
449
- )
450
-
451
- scope = moxml_namespace_scope(moxml_element, inherited_namespaces)
452
- scope.each do |prefix, uri|
453
- element.add_namespace(Nodes::NamespaceNode.new(
454
- prefix: prefix,
455
- uri: uri,
456
- ))
457
- end
458
-
459
- build_moxml_attribute_nodes(moxml_element, element)
460
-
461
- moxml_element.children.each do |child|
462
- node = build_moxml_node(child,
463
- preserve_whitespace: preserve_whitespace,
464
- inherited_namespaces: scope)
465
- element.add_child(node) if node
466
- end
467
-
468
- element
469
- end
470
-
471
- # In-scope namespace bindings: the element's own declarations
472
- # shadow inherited ones, xml is prebound. The scope is passed down
473
- # the recursion so each element pays one declaration fetch instead
474
- # of an ancestor walk through the adapter layer (the Nokogiri
475
- # collector walks ancestors because those calls are cheap there).
476
- def self.moxml_namespace_scope(moxml_element, inherited)
477
- scope = inherited ? inherited.dup : { "xml" => "http://www.w3.org/XML/1998/namespace" }
478
- moxml_element.namespace_definitions.each do |decl|
479
- scope[decl.prefix || ""] = decl.uri
480
- end
481
- scope
482
- end
483
-
484
- def self.build_moxml_attribute_nodes(moxml_element, element)
485
- seen = {}
486
- moxml_element.attributes.each do |attr|
487
- # Duplicate attribute names are invalid XML; engines expose
488
- # them differently (libxml2 lists repeats, libleptris dedups
489
- # last-wins). Canon follows the Nokogiri presentation contract:
490
- # first occurrence wins, keyed by name + namespace.
491
- key = [attr.name, attr.namespace&.uri]
492
- next if seen.key?(key)
493
-
494
- seen[key] = true
495
- element.add_attribute(Nodes::AttributeNode.new(
496
- name: attr.name,
497
- value: attr.value,
498
- namespace_uri: attr.namespace&.uri,
499
- prefix: attr.namespace&.prefix,
500
- ))
501
- end
502
- end
503
-
504
- def self.build_moxml_text_node(moxml_text, preserve_whitespace: false)
505
- content = moxml_text.content
506
-
507
- unless WhitespacePolicy.keep_dom_text?(content,
508
- preserve_whitespace: preserve_whitespace,
509
- element_parent: moxml_text.parent.is_a?(Moxml::Element))
510
- return nil
511
- end
512
-
513
- Nodes::TextNode.new(value: content, original: content)
514
- end
515
-
516
- def self.build_moxml_comment_node(moxml_comment)
517
- Nodes::CommentNode.new(value: moxml_comment.content)
518
- end
519
-
520
- def self.build_moxml_pi_node(moxml_pi)
521
- Nodes::ProcessingInstructionNode.new(
522
- target: moxml_pi.target,
523
- data: moxml_pi.content,
524
- )
525
- end
526
367
  end
527
368
  end
528
369
  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.3
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