canon 0.3.107 → 0.3.109

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: 71d7579e878aed3d0a9a47f8fefb19e6f4ab9f89473c00a60e8bc81b26b5d002
4
- data.tar.gz: e50f13237f3e56176571756f9358c94d300b04c07ee78fe3b4b0db75a8eb8894
3
+ metadata.gz: ebfe19fb1f87ac9ec65e4fc2ba727bbe97b3b8d0f9733184588217e82753e9f9
4
+ data.tar.gz: 301b3d96f9ffebce87022cdfef4d538ab512ea6f832bdfd3f2c39c164a996069
5
5
  SHA512:
6
- metadata.gz: 8c01df75477add69c04573c3b15c2a763f0159729f955be2433000b28a1e58fa8dd7954b313feb37abb109dc68ff91ac6ddf38fbf0ecb145b27b421dbb10bcee
7
- data.tar.gz: 37e3111d28f06a0d34942e79811796a14ecf69792b04cbe075dd7a69caf5bd225537e3b6ee3a6cdf166ea296a5c88545530748bd636d0cf9fec0829ff33d5541
6
+ metadata.gz: cb97c29a9ab623568ac2ea936710a42694b7d4863e0075ee90dce47f3ff01faba53a695e66abde1ce680ef3d53da4e31a7c9b8ed9a6f883dac08d48dc3757c66
7
+ data.tar.gz: e4b4a46d00abe224d9bf541cc248220aa984caec768f3d2173be3b00c8471234141ae872dbc6273445b3be6434c8a027ad98e8ffba1045b8a712ee54bf2d8f35
@@ -63,6 +63,12 @@ module Canon
63
63
  # regex per format call cost more than the gsub it drives.
64
64
  BLOCK_SPACING_PATTERN =
65
65
  Regexp.new("(</(?:#{BLOCK_ELEMENTS.join('|')})>)(?:\n*)(<(?:#{BLOCK_ELEMENTS.join('|')})[\s>])").freeze
66
+ # Engine-inserted newlines after a block close, before ANY next
67
+ # tag (the serializer's indent model breaks lines where canon's
68
+ # normalize step kept no text node) — folded to the canonical
69
+ # single space so serialization is idempotent.
70
+ AFTER_BLOCK_NEWLINE_PATTERN =
71
+ Regexp.new("(</(?:#{BLOCK_ELEMENTS.join('|')})>)(\n+)(<(?!/))").freeze
66
72
  # Format HTML using canonical form
67
73
  # @param html [String] HTML document to canonicalize
68
74
  # @return [String] Canonical form of HTML
@@ -108,21 +114,10 @@ module Canon
108
114
  end
109
115
 
110
116
  # Sort element attributes alphabetically throughout document
117
+ # (the shared canonical attribute order — TODO.sota/03)
111
118
  # @param doc [Moxml::Document] Parsed document
112
119
  def self.sort_attributes(doc)
113
- walk_elements(doc.root) do |element|
114
- attrs = element.attributes
115
- next if attrs.empty?
116
-
117
- names = attrs.map(&:name)
118
- # Already-sorted is the common case — removing and re-adding
119
- # every attribute is expensive, so check first.
120
- next if names.each_cons(2).all? { |a, b| (a <=> b) <= 0 }
121
-
122
- sorted_attrs = attrs.sort_by(&:name)
123
- names.each { |name| element.remove_attribute(name) }
124
- sorted_attrs.each { |attr| element[attr.name] = attr.value }
125
- end
120
+ Canon::Xml::AttributeSorter.sort!(doc)
126
121
  end
127
122
 
128
123
  # Depth-first element walk (pre-order).
@@ -211,9 +206,17 @@ module Canon
211
206
  # @param html [String] Serialized HTML string
212
207
  # @return [String] HTML with proper spacing between block elements
213
208
  def self.ensure_block_element_spacing(html)
214
- # Add space between closing and opening block element tags
209
+ # Space between closing and opening block element tags
215
210
  # (pattern compiled once — see BLOCK_SPACING_PATTERN)
216
- html.gsub(BLOCK_SPACING_PATTERN, '\1 \2')
211
+ html = html.gsub(BLOCK_SPACING_PATTERN, '\1 \2')
212
+ # The engine's serializer inserts its own newlines after a
213
+ # block close when the tree has no whitespace text node there
214
+ # (normalize_whitespace keeps a single space only where one
215
+ # existed). Collapse those self-inserted newlines to the same
216
+ # canonical space — without this, format is not idempotent:
217
+ # pass 1 emits "</p>\n<br>", pass 2 answers "</p> <br>"
218
+ # (the property suite, TODO.sota/03).
219
+ html.gsub(AFTER_BLOCK_NEWLINE_PATTERN, '\1 \3')
217
220
  end
218
221
 
219
222
  # Check if a node is a block-level element
@@ -11,13 +11,9 @@ module Canon
11
11
  end
12
12
  end
13
13
 
14
- def self.parse(xml)
14
+ def self.parse(xml, options = {})
15
15
  Canon::Validators::XmlValidator.validate!(xml)
16
- if XmlBackend.nokogiri?
17
- Nokogiri::XML(xml)
18
- else
19
- XmlParsing.parse(xml)
20
- end
16
+ XmlParsing.parse(xml, options)
21
17
  end
22
18
  end
23
19
  end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Canon
6
+ module Json
7
+ # RFC 8785 — the I-JSON Canonicalization Scheme (JCS).
8
+ #
9
+ # Produces the canonical JSON form the RFC defines: UTF-8, object
10
+ # keys sorted by their UTF-16 code units, ECMAScript
11
+ # `Number::toString` number serialization, RFC 8785 §3.2.2.2
12
+ # string escaping, and no insignificant whitespace. The result is
13
+ # byte-stable across platforms and suitable for hashing and
14
+ # signing.
15
+ #
16
+ # Input must be I-JSON (RFC 7493): valid UTF-8 without lone
17
+ # surrogates, no duplicate object keys, numbers expressible as
18
+ # IEEE 754 doubles. Violations raise {Canon::Json::JCS::Error}.
19
+ module JCS
20
+ class Error < ::Canon::Error; end
21
+
22
+ module_function
23
+
24
+ # @param json [String, Hash, Array] an I-JSON document or an
25
+ # already-parsed Ruby object graph
26
+ # @return [String] the RFC 8785 canonical form
27
+ def canonicalize(json)
28
+ doc = json.is_a?(String) ? ::JSON.parse(json) : json
29
+ serialize(doc)
30
+ end
31
+
32
+ def serialize(value)
33
+ out = +""
34
+ write(value, out)
35
+ out
36
+ end
37
+
38
+ def write(value, out)
39
+ case value
40
+ when Hash
41
+ write_object(value, out)
42
+ when Array
43
+ out << "["
44
+ value.each_with_index do |item, i|
45
+ out << "," if i.positive?
46
+ write(item, out)
47
+ end
48
+ out << "]"
49
+ when String
50
+ out << escape_string(value)
51
+ when Integer
52
+ out << ijson_integer(value)
53
+ when Float
54
+ out << es6_number(value)
55
+ when true then out << "true"
56
+ when false then out << "false"
57
+ when nil then out << "null"
58
+ else
59
+ raise Error, "JCS input must be JSON data; got #{value.class}"
60
+ end
61
+ end
62
+
63
+ def write_object(hash, out)
64
+ out << "{"
65
+ hash.keys.sort_by { |key| utf16_code_units(key) }
66
+ .each_with_index do |key, i|
67
+ out << "," if i.positive?
68
+ out << escape_string(key) << ":"
69
+ write(hash[key], out)
70
+ end
71
+ out << "}"
72
+ end
73
+
74
+ # RFC 8785 §3.2.2.2 — the JSON.stringify escaping set: short
75
+ # forms for \b \t \n \f \r, two-hex lowercase \u00xx for the
76
+ # remaining C0 controls, \" and \\; every other code point is
77
+ # emitted verbatim as UTF-8 (DEL U+007F is NOT escaped).
78
+ SHORT_ESCAPES = {
79
+ 0x08 => "\\b", 0x09 => "\\t", 0x0A => "\\n",
80
+ 0x0C => "\\f", 0x0D => "\\r"
81
+ }.freeze
82
+
83
+ def escape_string(string)
84
+ out = +"\""
85
+ string.each_codepoint do |cp|
86
+ out << case cp
87
+ when 0x22 then "\\\""
88
+ when 0x5C then "\\\\"
89
+ when 0x08, 0x09, 0x0A, 0x0C, 0x0D then SHORT_ESCAPES[cp]
90
+ when 0x00..0x1F then format("\\u%04x", cp)
91
+ else
92
+ cp.chr(Encoding::UTF_8)
93
+ end
94
+ end
95
+ out << "\""
96
+ rescue ArgumentError
97
+ raise Error, "JCS input must be valid UTF-8 without lone surrogates"
98
+ end
99
+
100
+ # RFC 8785 §2.1 (I-JSON): numbers must be expressible as IEEE
101
+ # 754 doubles; anything wider is a contract violation, not a
102
+ # rounding opportunity.
103
+ def ijson_integer(value)
104
+ if value.abs > 9_007_199_254_740_992 # 2**53
105
+ raise Error,
106
+ "JCS input must be I-JSON: #{value} exceeds the IEEE 754 " \
107
+ "double range for exact integers"
108
+ end
109
+ value.to_s
110
+ end
111
+
112
+ # ECMA-262 `Number::toString` (the JSON.stringify number shape):
113
+ # shortest-round-trip digits formatted per the spec's fixed /
114
+ # exponential cases, with the boundaries 1e-6 (inclusive fixed
115
+ # on the small side) and 1e21 (exclusive fixed on the large
116
+ # side). -0.0 serializes as "0"; non-finite values are not
117
+ # JSON data and raise.
118
+ def es6_number(value)
119
+ raise Error, "JCS input must be finite numbers" if
120
+ value.nan? || value.infinite?
121
+ return "0" if value.zero?
122
+
123
+ sign = value.negative? ? "-" : ""
124
+ s = value.abs.to_s
125
+ if (m = s.match(/\A(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?\z/))
126
+ int_part = m[1]
127
+ frac = m[2] || ""
128
+ exp = m[3] ? m[3].to_i : 0
129
+ digits = (int_part + frac).sub(/\A0+/, "")
130
+ k = int_part.length + exp - (int_part.length + frac.length - digits.length)
131
+ digits = digits.sub(/0+\z/, "")
132
+ digits = "0" if digits.empty?
133
+ n = digits.length
134
+ return sign + format_digits(digits, n, k)
135
+ end
136
+ # Float#to_s always matches above; the fallthrough is defensive
137
+ raise Error, "unserializable number: #{s}"
138
+ end
139
+
140
+ # n and k are ECMA-262 7.1.12.1's own notation (digit count and
141
+ # the decimal-point position); renamed parameters would lose the
142
+ # spec mapping.
143
+ # rubocop:disable Naming/MethodParameterName
144
+ def format_digits(digits, n, k)
145
+ if k <= 0
146
+ # 0.s × 10^k: fixed for -6 < k <= 0 ("0." + zeros + digits),
147
+ # exponential at k <= -6 (values below 1e-6, ECMA boundary)
148
+ return exp_form(digits, k) if k <= -6
149
+
150
+ ["0.", "0" * -k, digits].join
151
+ elsif k >= n
152
+ return digits if k == n
153
+
154
+ # integer-shaped: pad zeros while k <= 21 ("1" + twenty
155
+ # zeros for 1e20); exponential above (1e21 -> "1e+21")
156
+ return exp_form(digits, k) if k > 21
157
+
158
+ digits + ("0" * (k - n))
159
+ else
160
+ # 0 < k < n: the point sits inside the digit string
161
+ "#{digits[0, k]}.#{digits[k..]}"
162
+ end
163
+ end
164
+
165
+ def exp_form(digits, k)
166
+ mantissa = digits.length == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
167
+ e = k - 1
168
+ "#{mantissa}e#{e.negative? ? '-' : '+'}#{e.abs}"
169
+ end
170
+ # rubocop:enable Naming/MethodParameterName
171
+
172
+ # RFC 8785 §3.2.3: keys sort by their UTF-16 code units (the
173
+ # ECMAScript array sort). Ruby string <=> compares by codepoint,
174
+ # which diverges from UTF-16 order above the BMP (astral planes
175
+ # sort after U+E000..U+FFFF in UTF-16 order).
176
+ def utf16_code_units(string)
177
+ string.encode("UTF-16LE").unpack("v*")
178
+ rescue Encoding::UndefinedConversionError
179
+ raise Error,
180
+ "JCS keys must be valid UTF-16 (no lone surrogates): " \
181
+ "#{string.inspect}"
182
+ end
183
+ end
184
+ end
185
+ end
data/lib/canon/json.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Json
5
+ autoload :JCS, "canon/json/jcs"
6
+ end
7
+ 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.107"
4
+ VERSION = "0.3.109"
5
5
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Xml
5
+ # Canonical attribute ordering: every element's attributes are
6
+ # re-attached in lexicographic name order. Both canonical lanes
7
+ # consume this — the HTML formatter base and the XML pretty
8
+ # printer — so attribute order is invariant across formats and
9
+ # across input orderings (the equivalence-respect property,
10
+ # TODO.sota/03).
11
+ module AttributeSorter
12
+ module_function
13
+
14
+ def sort!(doc)
15
+ walk_elements(doc.root) do |element|
16
+ attrs = element.attributes
17
+ next if attrs.empty?
18
+
19
+ names = attrs.map(&:name)
20
+ # Already-sorted is the common case — removing and re-adding
21
+ # every attribute is expensive, so check first.
22
+ next if names.each_cons(2).all? { |a, b| (a <=> b) <= 0 }
23
+
24
+ sorted_attrs = attrs.sort_by(&:name)
25
+ names.each { |name| element.remove_attribute(name) }
26
+ sorted_attrs.each { |attr| element[attr.name] = attr.value }
27
+ end
28
+ doc
29
+ end
30
+
31
+ def walk_elements(node, &block)
32
+ return unless node.is_a?(Moxml::Element)
33
+
34
+ yield(node)
35
+ node.children.to_a.each { |child| walk_elements(child, &block) }
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/canon/xml.rb CHANGED
@@ -15,6 +15,7 @@ module Canon
15
15
  # Canon::Xml::Nodes.
16
16
  module Xml
17
17
  autoload :AttributeHandler, "canon/xml/attribute_handler"
18
+ autoload :AttributeSorter, "canon/xml/attribute_sorter"
18
19
  autoload :C14n, "canon/xml/c14n"
19
20
  autoload :DigestGate, "canon/xml/digest_gate"
20
21
  autoload :CharacterEncoder, "canon/xml/character_encoder"
@@ -303,11 +303,14 @@ module Canon
303
303
 
304
304
  # --- Moxml engine ---
305
305
 
306
- # Readonly: canon only ever reads engine documents (conversion,
306
+ # Readonly by default: canon reads engine documents (conversion,
307
307
  # serialization) — leptris memoizes reads on readonly documents,
308
308
  # and mutation is refused loudly rather than silently corrupting.
309
- def moxml_parse(xml_string, _options)
310
- moxml_context.parse(xml_string, readonly: true)
309
+ # Mutating lanes (the attribute sorter, TODO.sota/03) opt out
310
+ # explicitly with readonly: false.
311
+ def moxml_parse(xml_string, options)
312
+ readonly = options.fetch(:readonly, true)
313
+ moxml_context.parse(xml_string, readonly: readonly)
311
314
  end
312
315
 
313
316
  def moxml_node_type(node)
data/lib/canon.rb CHANGED
@@ -32,6 +32,7 @@ module Canon
32
32
  autoload :Cli, "canon/cli"
33
33
  autoload :ColorDetector, "canon/color_detector"
34
34
  autoload :DiffFormatter, "canon/diff_formatter"
35
+ autoload :Json, "canon/json"
35
36
 
36
37
  SUPPORTED_FORMATS = %i[xml yaml json html html4 html5 string].freeze
37
38
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.107
4
+ version: 0.3.109
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -347,6 +347,8 @@ files:
347
347
  - lib/canon/html.rb
348
348
  - lib/canon/html/data_model.rb
349
349
  - lib/canon/html/nokogiri_support.rb
350
+ - lib/canon/json.rb
351
+ - lib/canon/json/jcs.rb
350
352
  - lib/canon/json_parsing.rb
351
353
  - lib/canon/nokogiri_loader.rb
352
354
  - lib/canon/options.rb
@@ -403,6 +405,7 @@ files:
403
405
  - lib/canon/version.rb
404
406
  - lib/canon/xml.rb
405
407
  - lib/canon/xml/attribute_handler.rb
408
+ - lib/canon/xml/attribute_sorter.rb
406
409
  - lib/canon/xml/c14n.rb
407
410
  - lib/canon/xml/character_encoder.rb
408
411
  - lib/canon/xml/data_model.rb