dcc 0.2.0 → 0.2.2

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.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +19 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +560 -10
  6. data/CHANGELOG.adoc +34 -1
  7. data/CONTRIBUTING.adoc +8 -1
  8. data/Gemfile +5 -7
  9. data/README.adoc +162 -7
  10. data/dcc.gemspec +9 -2
  11. data/docs/schema-versions.adoc +20 -7
  12. data/examples/build_minimal_dcc.rb +0 -0
  13. data/examples/convert_all_formats.rb +2 -1
  14. data/examples/parse_and_inspect.rb +0 -0
  15. data/examples/quantity_math_demo.rb +7 -7
  16. data/examples/validate_full.rb +1 -1
  17. data/lib/dcc/base/calibration_laboratory.rb +2 -1
  18. data/lib/dcc/base/condition.rb +5 -2
  19. data/lib/dcc/base/core_data.rb +8 -3
  20. data/lib/dcc/base/formula.rb +3 -3
  21. data/lib/dcc/base/identification.rb +6 -3
  22. data/lib/dcc/base/mathml.rb +33 -0
  23. data/lib/dcc/base/quantity.rb +2 -1
  24. data/lib/dcc/base/resp_person.rb +4 -2
  25. data/lib/dcc/base/text.rb +1 -1
  26. data/lib/dcc/base.rb +2 -1
  27. data/lib/dcc/builder/dsl.rb +1 -1
  28. data/lib/dcc/builder/session.rb +16 -14
  29. data/lib/dcc/builder.rb +3 -3
  30. data/lib/dcc/cli/cli.rb +152 -26
  31. data/lib/dcc/cli/formatters.rb +35 -7
  32. data/lib/dcc/cli.rb +1 -1
  33. data/lib/dcc/context_configuration.rb +2 -1
  34. data/lib/dcc/context_options.rb +4 -2
  35. data/lib/dcc/convert/csv.rb +27 -9
  36. data/lib/dcc/convert/html.rb +26 -110
  37. data/lib/dcc/convert/json.rb +10 -7
  38. data/lib/dcc/convert/liquid_filters.rb +58 -0
  39. data/lib/dcc/convert/result.rb +1 -1
  40. data/lib/dcc/convert/templates/report.liquid +250 -0
  41. data/lib/dcc/convert/yaml.rb +1 -1
  42. data/lib/dcc/convert.rb +1 -0
  43. data/lib/dcc/diff/change.rb +1 -1
  44. data/lib/dcc/diff/result.rb +8 -4
  45. data/lib/dcc/diff.rb +43 -11
  46. data/lib/dcc/error.rb +7 -0
  47. data/lib/dcc/extract/file.rb +3 -4
  48. data/lib/dcc/extract/formula/ast.rb +137 -0
  49. data/lib/dcc/extract/formula/evaluator.rb +357 -0
  50. data/lib/dcc/extract/formula/parser.rb +305 -0
  51. data/lib/dcc/extract/formula/quantity.rb +89 -0
  52. data/lib/dcc/extract/formula.rb +110 -0
  53. data/lib/dcc/extract/ring.rb +1 -1
  54. data/lib/dcc/extract.rb +1 -1
  55. data/lib/dcc/i18n/text_lookup.rb +8 -4
  56. data/lib/dcc/i18n.rb +1 -1
  57. data/lib/dcc/inspect/report.rb +51 -14
  58. data/lib/dcc/inspect.rb +1 -1
  59. data/lib/dcc/migrate/route.rb +44 -0
  60. data/lib/dcc/migrate/v2_to_v3.rb +445 -0
  61. data/lib/dcc/migrate.rb +130 -26
  62. data/lib/dcc/plugin/base.rb +64 -0
  63. data/lib/dcc/plugin/registry.rb +7 -2
  64. data/lib/dcc/plugin.rb +6 -2
  65. data/lib/dcc/quantity_format/formatter.rb +16 -10
  66. data/lib/dcc/quantity_format.rb +1 -1
  67. data/lib/dcc/quantity_math/complex.rb +252 -0
  68. data/lib/dcc/quantity_math/matrix.rb +217 -0
  69. data/lib/dcc/quantity_math/quantity.rb +1 -1
  70. data/lib/dcc/quantity_math/real.rb +6 -6
  71. data/lib/dcc/quantity_math.rb +31 -3
  72. data/lib/dcc/schema/version.rb +2 -2
  73. data/lib/dcc/server/app.html +282 -0
  74. data/lib/dcc/server/app.rb +171 -87
  75. data/lib/dcc/server/demo_cert.xml +244 -0
  76. data/lib/dcc/server/storage/entry.rb +1 -1
  77. data/lib/dcc/server/storage/memory.rb +3 -2
  78. data/lib/dcc/server/storage.rb +1 -1
  79. data/lib/dcc/server.rb +1 -1
  80. data/lib/dcc/si/base/complex.rb +1 -1
  81. data/lib/dcc/si/base/complex_list_xml_list.rb +1 -1
  82. data/lib/dcc/si/base/constant.rb +1 -1
  83. data/lib/dcc/si/base/coverage_interval.rb +36 -0
  84. data/lib/dcc/si/base/coverage_interval_mu.rb +1 -1
  85. data/lib/dcc/si/base/expanded_mu.rb +1 -1
  86. data/lib/dcc/si/base/expanded_unc.rb +34 -0
  87. data/lib/dcc/si/base/expanded_unc_xml_list.rb +3 -2
  88. data/lib/dcc/si/base/hybrid.rb +3 -2
  89. data/lib/dcc/si/base/measurement_uncertainty_univariate.rb +1 -1
  90. data/lib/dcc/si/base/real.rb +13 -3
  91. data/lib/dcc/si/base/real_list_xml_list.rb +5 -3
  92. data/lib/dcc/si/base/si_list.rb +3 -2
  93. data/lib/dcc/si/base/standard_mu.rb +1 -1
  94. data/lib/dcc/si/base.rb +5 -2
  95. data/lib/dcc/si/v1/coverage_interval.rb +8 -0
  96. data/lib/dcc/si/v1/expanded_unc.rb +1 -16
  97. data/lib/dcc/si/v1/measurement_uncertainty_univariate.rb +2 -1
  98. data/lib/dcc/si/v1.rb +5 -2
  99. data/lib/dcc/si/v2/coverage_interval.rb +8 -0
  100. data/lib/dcc/si/v2/coverage_interval_mu_xml_list.rb +4 -2
  101. data/lib/dcc/si/v2/expanded_mu_xml_list.rb +2 -1
  102. data/lib/dcc/si/v2/expanded_unc.rb +8 -0
  103. data/lib/dcc/si/v2/measurement_uncertainty_univariate.rb +2 -1
  104. data/lib/dcc/si/v2.rb +8 -3
  105. data/lib/dcc/signature/result.rb +2 -2
  106. data/lib/dcc/signature/signer.rb +31 -18
  107. data/lib/dcc/signature/verifier.rb +23 -21
  108. data/lib/dcc/signature.rb +1 -1
  109. data/lib/dcc/streaming/reader.rb +246 -0
  110. data/lib/dcc/streaming.rb +79 -0
  111. data/lib/dcc/transform/result.rb +1 -1
  112. data/lib/dcc/transform/xslt.rb +22 -12
  113. data/lib/dcc/transform.rb +5 -4
  114. data/lib/dcc/type/base64_binary.rb +5 -3
  115. data/lib/dcc/type/decimal_xml_list.rb +99 -13
  116. data/lib/dcc/type/iso_country_code.rb +3 -2
  117. data/lib/dcc/type/iso_language_code.rb +3 -2
  118. data/lib/dcc/type/schema_version.rb +3 -2
  119. data/lib/dcc/type/si_unit.rb +3 -2
  120. data/lib/dcc/type_guards.rb +3 -3
  121. data/lib/dcc/v2/calibration_laboratory.rb +2 -1
  122. data/lib/dcc/v2/condition.rb +22 -1
  123. data/lib/dcc/v2/configuration.rb +1 -1
  124. data/lib/dcc/v2/digital_calibration_certificate.rb +2 -1
  125. data/lib/dcc/v2/identification.rb +14 -1
  126. data/lib/dcc/v2/mathml.rb +8 -0
  127. data/lib/dcc/v2/measurement_meta_data_list.rb +2 -1
  128. data/lib/dcc/v2/quantity.rb +4 -2
  129. data/lib/dcc/v2.rb +4 -2
  130. data/lib/dcc/v3/calibration_laboratory.rb +2 -1
  131. data/lib/dcc/v3/configuration.rb +1 -1
  132. data/lib/dcc/v3/digital_calibration_certificate.rb +2 -1
  133. data/lib/dcc/v3/mathml.rb +8 -0
  134. data/lib/dcc/v3/measurement_meta_data_list.rb +2 -1
  135. data/lib/dcc/v3/quantity.rb +4 -2
  136. data/lib/dcc/v3.rb +12 -2
  137. data/lib/dcc/validate/business_rules.rb +23 -9
  138. data/lib/dcc/validate/issue.rb +2 -1
  139. data/lib/dcc/validate/result.rb +6 -4
  140. data/lib/dcc/validate/schematron/profile.rb +73 -18
  141. data/lib/dcc/validate/schematron/rule.rb +13 -3
  142. data/lib/dcc/validate/schematron/rules/administrative_data_completeness.rb +11 -4
  143. data/lib/dcc/validate/schematron/rules/base.rb +1 -2
  144. data/lib/dcc/validate/schematron/rules/date_range_check.rb +11 -5
  145. data/lib/dcc/validate/schematron/rules/id_refid_linking.rb +0 -3
  146. data/lib/dcc/validate/schematron/rules/influence_conditions_placement.rb +22 -12
  147. data/lib/dcc/validate/schematron/rules/iso_code_validation.rb +31 -14
  148. data/lib/dcc/validate/schematron/rules/language_code_dedup.rb +12 -5
  149. data/lib/dcc/validate/schematron/rules/non_si_declaration.rb +7 -4
  150. data/lib/dcc/validate/schematron/rules/release_format_check.rb +7 -4
  151. data/lib/dcc/validate/schematron/rules/schema_version_check.rb +13 -9
  152. data/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +39 -10
  153. data/lib/dcc/validate/schematron/rules/unit_format_check.rb +2 -3
  154. data/lib/dcc/validate/schematron/rules/used_methods_placement.rb +15 -10
  155. data/lib/dcc/validate/schematron/rules/used_software_placement.rb +15 -10
  156. data/lib/dcc/validate/schematron/rules/xml_list_spacing.rb +4 -3
  157. data/lib/dcc/validate/schematron/rules.rb +28 -14
  158. data/lib/dcc/validate/schematron.rb +1 -1
  159. data/lib/dcc/validate/xsd.rb +9 -3
  160. data/lib/dcc/validate.rb +1 -1
  161. data/lib/dcc/version.rb +1 -1
  162. data/lib/dcc/versioned_parser.rb +1 -1
  163. data/lib/dcc.rb +68 -10
  164. data/sample_dcc.xml +140 -0
  165. data/web/index.html +12 -0
  166. data/web/package-lock.json +1599 -0
  167. data/web/package.json +22 -0
  168. data/web/src/App.vue +349 -0
  169. data/web/src/lib/builder.ts +171 -0
  170. data/web/src/lib/dcc.ts +338 -0
  171. data/web/src/lib/units.json +5581 -0
  172. data/web/src/main.ts +5 -0
  173. data/web/src/style.css +27 -0
  174. data/web/tsconfig.json +15 -0
  175. data/web/vite.config.ts +14 -0
  176. metadata +148 -9
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml"
4
+
5
+ module Dcc
6
+ module Streaming
7
+ # SAX handler that reconstructs each matched subtree as a standalone XML
8
+ # fragment, parses it into the version-appropriate model, yields it, and
9
+ # drops it. Only one subtree is buffered at a time, so peak memory tracks
10
+ # the largest single subtree rather than the document.
11
+ #
12
+ # The fragment reproduces the XML infoset, not the original bytes.
13
+ # Entity references arrive expanded, CDATA becomes text, processing
14
+ # instruction spacing is normalised, and every namespace in scope is
15
+ # re-declared on the fragment root so prefixes still resolve once the
16
+ # subtree is detached.
17
+ class Reader < ::Moxml::SAX::Handler
18
+ # Stream `io`, yielding one model per matched subtree.
19
+ #
20
+ # @param io [IO, StringIO] readable XML stream.
21
+ # @param model_id [Symbol] registered model id, `:item` or `:quantity`.
22
+ # @param version [Integer, nil] major DCC version. When nil it is read
23
+ # from the first `schemaVersion` attribute seen outside a matched
24
+ # subtree before the first model is yielded, and falls back to 3.
25
+ # `Dcc.detect_version` instead scans the whole document lexically,
26
+ # because it is not restricted to a single pass.
27
+ # @param context [Symbol, String, nil] substitution context.
28
+ # @return [void]
29
+ # @raise [Dcc::ParseError] if the stream is not well-formed XML. A
30
+ # failing IO also surfaces this way, with the original IO message
31
+ # replaced by the parser's — libxml2 does not pass it through.
32
+ def self.call(io, model_id:, version:, context:, &)
33
+ handler = new(model_id:, version:, context:, &)
34
+ adapter = ::Lutaml::Model::Config.xml_adapter_type
35
+ ::Moxml.new(adapter).sax_parse(io, handler)
36
+ end
37
+
38
+ # A handler carries one document's worth of state, so `.call` builds a
39
+ # fresh one per stream and construction stays closed.
40
+ private_class_method :new
41
+
42
+ def initialize(model_id:, version:, context:, &block)
43
+ super()
44
+ @model_id = model_id
45
+ @major = version
46
+ @context = context
47
+ @block = block
48
+ @scopes = [{}]
49
+ @subtree = nil
50
+ @depth = 0
51
+ end
52
+
53
+ # Declarations are merged and snapshotted before the element's own
54
+ # prefix is resolved, so an element that rebinds its prefix matches on
55
+ # its new binding rather than the inherited one.
56
+ def on_start_element(name, attributes = {}, namespaces = {})
57
+ # Almost no element declares a namespace, so merging every time would
58
+ # allocate a copy of the parent scope per element. Scopes are only
59
+ # ever read, so the unchanged ones can share one hash.
60
+ @scopes.push(
61
+ namespaces.empty? ? @scopes.last : @scopes.last.merge(namespaces),
62
+ )
63
+ return begin_subtree(name, attributes) unless @subtree
64
+
65
+ @depth += 1
66
+ @subtree.start_element(name, attributes, namespaces)
67
+ end
68
+
69
+ def on_end_element(name)
70
+ finish_element(name) if @subtree
71
+ @scopes.pop
72
+ end
73
+
74
+ def on_characters(text)
75
+ @subtree&.characters(text)
76
+ end
77
+
78
+ def on_cdata(text)
79
+ @subtree&.characters(text)
80
+ end
81
+
82
+ def on_comment(text)
83
+ @subtree&.comment(text)
84
+ end
85
+
86
+ def on_processing_instruction(target, data)
87
+ @subtree&.processing_instruction(target, data)
88
+ end
89
+
90
+ # Translated here rather than around the whole parse, so an exception
91
+ # raised by the consumer block travels untouched instead of being
92
+ # reported as malformed input.
93
+ def on_error(error)
94
+ raise ::Dcc::ParseError, error.message
95
+ end
96
+
97
+ private
98
+
99
+ # Only elements outside a matched subtree get a say in the version, so a
100
+ # `schemaVersion` on a descendant — or inside an opaque `dcc:xml` blob —
101
+ # cannot choose the parser for the whole stream.
102
+ def begin_subtree(name, attributes)
103
+ @major ||= schema_version_major(name, attributes)
104
+ return unless match?(name)
105
+
106
+ @depth = 0
107
+ @subtree = Subtree.new(name, attributes, @scopes.last)
108
+ end
109
+
110
+ def finish_element(name)
111
+ @subtree.end_element(name)
112
+ if @depth.zero?
113
+ emit(@subtree.to_xml)
114
+ @subtree = nil
115
+ else
116
+ @depth -= 1
117
+ end
118
+ end
119
+
120
+ def emit(xml)
121
+ @block.call(model_class.from_xml(xml, register: context_id))
122
+ end
123
+
124
+ def match?(name)
125
+ split(name).last == @model_id.to_s && dcc_element?(name)
126
+ end
127
+
128
+ def dcc_element?(name)
129
+ dcc_namespace?(@scopes.last[split(name).first])
130
+ end
131
+
132
+ # `Dcc::Namespace::Dcc` also accepts the `.xsd` alias that older PTB
133
+ # documents bind, so streaming matches whatever `Dcc.parse` accepts.
134
+ def dcc_namespace?(uri)
135
+ namespace = ::Dcc::Namespace::Dcc
136
+ uri == namespace.uri || namespace.uri_aliases.include?(uri)
137
+ end
138
+
139
+ def split(name)
140
+ prefix, local = name.split(":", 2)
141
+ local ? [prefix, local] : [nil, prefix]
142
+ end
143
+
144
+ # Resolved through the registry rather than referenced directly, so a
145
+ # configured custom model substitutes the root class too.
146
+ def model_class
147
+ @model_class ||=
148
+ ::Lutaml::Model::GlobalContext.resolve_type(@model_id, context_id)
149
+ end
150
+
151
+ def context_id
152
+ @context_id ||= ::Dcc::ContextOptions.normalize_context_option(
153
+ context: @context,
154
+ register: nil,
155
+ default_context: configuration.default_context_id,
156
+ warning_source: "Dcc::Streaming",
157
+ )
158
+ end
159
+
160
+ def configuration
161
+ ::Dcc.parser_for(major)::Configuration
162
+ end
163
+
164
+ # `schemaVersion` sits on the DCC root, which is not always the document
165
+ # root — a certificate can arrive wrapped in an envelope. Only a DCC
166
+ # element settles the version, so a wrapper carrying a `schemaVersion`
167
+ # of its own does not retype the certificate nested inside it.
168
+ def schema_version_major(name, attributes)
169
+ version = attributes["schemaVersion"]
170
+ return unless version && dcc_element?(name)
171
+
172
+ ::Dcc.major_version_from(version)
173
+ end
174
+
175
+ # A document that declares `schemaVersion` nowhere falls back the way an
176
+ # unusable one does, so `Dcc` keeps deciding that rather than a literal
177
+ # repeated here. The answer is written back rather than defaulted on
178
+ # each read: the model class and the context are memoised off this at
179
+ # the first yield, so a `schemaVersion` appearing later must not move
180
+ # the version out from under them.
181
+ def major
182
+ @major ||= ::Dcc.major_version_from(nil)
183
+ end
184
+
185
+ # Accumulates the raw XML of a single matched subtree.
186
+ #
187
+ # SAX hands over decoded characters, so control whitespace has to go
188
+ # back as character references. Written literally it would be
189
+ # normalised a second time on reparse — a carriage return in text
190
+ # would arrive as a newline, and a tab or newline in an attribute
191
+ # would collapse to a space.
192
+ #
193
+ # C14N mandates exactly that set of character references, so moxml's
194
+ # encoder is reused rather than restating the table here.
195
+ class Subtree
196
+ def initialize(name, attributes, scope)
197
+ @buffer = +""
198
+ write_start(name, attributes, scope)
199
+ end
200
+
201
+ def start_element(name, attributes, namespaces)
202
+ write_start(name, attributes, namespaces)
203
+ end
204
+
205
+ def end_element(name)
206
+ @buffer << "</#{name}>"
207
+ end
208
+
209
+ def characters(text)
210
+ @buffer << ::Moxml::C14n.escape_text(text)
211
+ end
212
+
213
+ def comment(text)
214
+ @buffer << "<!--#{text}-->"
215
+ end
216
+
217
+ def processing_instruction(target, data)
218
+ @buffer << "<?#{target} #{data}?>"
219
+ end
220
+
221
+ def to_xml
222
+ @buffer
223
+ end
224
+
225
+ private
226
+
227
+ def write_start(name, attributes, namespaces)
228
+ @buffer << "<#{name}"
229
+ namespaces.each do |prefix, href|
230
+ write_attribute(prefix ? "xmlns:#{prefix}" : "xmlns", href)
231
+ end
232
+ attributes.each do |key, value|
233
+ write_attribute(key, value)
234
+ end
235
+ @buffer << ">"
236
+ end
237
+
238
+ def write_attribute(name, value)
239
+ escaped = ::Moxml::C14n.escape_attribute(value)
240
+ @buffer << %( #{name}="#{escaped}")
241
+ end
242
+ end
243
+ private_constant :Subtree
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `Dcc::Streaming` walks a DCC document one subtree at a time, so a batch
4
+ # pipeline can process a multi-MB certificate without holding the whole
5
+ # object tree in memory. Each matched subtree is reconstructed on its own,
6
+ # parsed into the version-appropriate model, yielded, and dropped.
7
+ #
8
+ # @example Walk every calibrated item
9
+ # File.open("certificate.xml") do |io|
10
+ # Dcc::Streaming.each_item(io) { |item| puts item.id }
11
+ # end
12
+ #
13
+ # @example Take the first two quantities without reading the whole document
14
+ # Dcc::Streaming.each_quantity(io).first(2)
15
+ #
16
+ # Version detection differs from `Dcc.parse`. Streaming reads `schemaVersion`
17
+ # off a real DCC element, so a certificate wrapped in an envelope is detected
18
+ # from its own root even when the envelope carries a `schemaVersion` of its
19
+ # own. `Dcc.detect_version` scans the document lexically instead, so it also
20
+ # matches a `schemaVersion=` inside a comment, inside text or CDATA, on a
21
+ # prefixed attribute such as `f:schemaVersion`, or on a foreign wrapper
22
+ # element. The two can disagree on those documents, and streaming is the one
23
+ # reading the schema's actual attribute. Pass `version:` to settle it.
24
+ module Dcc
25
+ module Streaming
26
+ autoload :Reader, "dcc/streaming/reader"
27
+
28
+ class << self
29
+ # Yield each `dcc:item` in document order.
30
+ #
31
+ # @param io [IO, StringIO] readable XML stream.
32
+ # @param version [Integer, nil] major DCC version (2 or 3). When nil it
33
+ # is detected from the first `schemaVersion` attribute seen outside a
34
+ # matched subtree before the first item is yielded, and falls back to
35
+ # 3. See the note on version detection above.
36
+ # @param context [Symbol, String, nil] substitution context. Defaults
37
+ # to the version's configured context.
38
+ # @yieldparam [Dcc::V2::Item, Dcc::V3::Item]
39
+ # @return [Enumerator] when no block is given.
40
+ # @raise [Dcc::ParseError] if the stream is not well-formed XML.
41
+ def each_item(io, version: nil,
42
+ context: ::Dcc::UNSPECIFIED_CONTEXT, &block)
43
+ return enum_for(:each_item, io, version:, context:) unless block
44
+
45
+ stream(io, :item, version, context, &block)
46
+ end
47
+
48
+ # Yield each outermost `dcc:quantity` in document order.
49
+ #
50
+ # A quantity nested inside another quantity's `influenceConditions` is
51
+ # not yielded on its own — it is reachable from the outer object.
52
+ #
53
+ # @param io [IO, StringIO] readable XML stream.
54
+ # @param version [Integer, nil] major DCC version (2 or 3). Detected the
55
+ # same way `each_item` detects it when nil.
56
+ # @param context [Symbol, String, nil] substitution context.
57
+ # @yieldparam [Dcc::V2::Quantity, Dcc::V3::Quantity]
58
+ # @return [Enumerator] when no block is given.
59
+ # @raise [Dcc::ParseError] if the stream is not well-formed XML.
60
+ def each_quantity(io, version: nil,
61
+ context: ::Dcc::UNSPECIFIED_CONTEXT, &block)
62
+ return enum_for(:each_quantity, io, version:, context:) unless block
63
+
64
+ stream(io, :quantity, version, context, &block)
65
+ end
66
+
67
+ private
68
+
69
+ # `version` is validated here rather than on first match, so an
70
+ # unsupported value raises the same way regardless of what the
71
+ # document happens to contain.
72
+ def stream(io, model_id, version, context, &)
73
+ ::Dcc.parser_for(version) if version
74
+ ::Dcc.load_all!
75
+ Reader.call(io, model_id:, version:, context:, &)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -21,4 +21,4 @@ module Dcc
21
21
  end
22
22
  end
23
23
  end
24
- end
24
+ end
@@ -6,9 +6,9 @@ module Dcc
6
6
  module Transform
7
7
  # `Dcc::Transform::Xslt` applies an XSLT stylesheet to a DCC XML document.
8
8
  #
9
- # Nokogiri is OFFICIALLY banned outside validation, so we shell out to
10
- # the system `xsltproc` (libxslt) for XSLT 1.0 transforms. For XSLT
11
- # 2.0/3.0, set `ENV["SAXON_JAR"]` to point at saxon-he.jar.
9
+ # Nokogiri is banned outside XSD validation and the v2-to-v3 migration,
10
+ # so we shell out to the system `xsltproc` (libxslt) for XSLT 1.0
11
+ # transforms. For XSLT 2.0/3.0, set `ENV["SAXON_JAR"]` to saxon-he.jar.
12
12
  module Xslt
13
13
  class << self
14
14
  # @param xml [String, Lutaml::Model::Serializable]
@@ -21,7 +21,8 @@ module Dcc
21
21
  when :xsltproc then run_xsltproc(xml_string, stylesheet)
22
22
  when :saxon then run_saxon(xml_string, stylesheet)
23
23
  else
24
- raise ::ArgumentError, "unknown XSLT engine: #{engine.inspect}"
24
+ raise ::ArgumentError,
25
+ "unknown XSLT engine: #{engine.inspect}"
25
26
  end
26
27
  ::Dcc::Transform::Result.new(payload: output, engine: engine)
27
28
  end
@@ -31,22 +32,31 @@ module Dcc
31
32
  def run_xsltproc(xml, stylesheet)
32
33
  ensure_tool!("xsltproc")
33
34
  xsl_path = stylesheet_path(stylesheet)
34
- out, status = ::Open3.capture2("xsltproc", xsl_path, "-", stdin_data: xml)
35
- raise ::Dcc::TransformError, "xsltproc failed: #{status}" unless status.success?
35
+ out, status = ::Open3.capture2("xsltproc", xsl_path, "-",
36
+ stdin_data: xml)
37
+ unless status.success?
38
+ raise ::Dcc::TransformError,
39
+ "xsltproc failed: #{status}"
40
+ end
36
41
 
37
42
  out
38
43
  end
39
44
 
40
45
  def run_saxon(xml, stylesheet)
41
- jar = ENV["SAXON_JAR"]
42
- raise ::Dcc::MissingDependencyError.new(gem_name: "saxon-he",
43
- feature: "XSLT 2.0/3.0"),
44
- "SAXON_JAR env var is required for the :saxon engine" unless jar
46
+ jar = ENV.fetch("SAXON_JAR", nil)
47
+ unless jar
48
+ raise ::Dcc::MissingDependencyError.new(gem_name: "saxon-he",
49
+ feature: "XSLT 2.0/3.0"),
50
+ "SAXON_JAR env var is required for the :saxon engine"
51
+ end
45
52
 
46
53
  xsl_path = stylesheet_path(stylesheet)
47
54
  cmd = ["java", "-jar", jar, "-s:-", "-xsl:#{xsl_path}"]
48
55
  out, status = ::Open3.capture2(*cmd, stdin_data: xml)
49
- raise ::Dcc::TransformError, "saxon failed: #{status}" unless status.success?
56
+ unless status.success?
57
+ raise ::Dcc::TransformError,
58
+ "saxon failed: #{status}"
59
+ end
50
60
 
51
61
  out
52
62
  end
@@ -69,4 +79,4 @@ module Dcc
69
79
  end
70
80
  end
71
81
  end
72
- end
82
+ end
data/lib/dcc/transform.rb CHANGED
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # `Dcc::Transform` provides XSLT transformations. We cannot use Nokogiri
4
- # for this per project rule (only validation may touch Nokogiri); instead
5
- # we shell out to the system `xsltproc` CLI for XSLT 1.0, or to
6
- # `java -jar saxon-he.jar` for XSLT 2.0/3.0 when SAXON_JAR is set.
4
+ # for this per project rule (only XSD validation and the v2-to-v3 migration
5
+ # may touch Nokogiri); instead we shell out to the system `xsltproc` CLI for
6
+ # XSLT 1.0, or to `java -jar saxon-he.jar` for XSLT 2.0/3.0 when SAXON_JAR
7
+ # is set.
7
8
  module Dcc
8
9
  module Transform
9
10
  autoload :Xslt, "dcc/transform/xslt"
10
11
  autoload :Result, "dcc/transform/result"
11
12
  end
12
- end
13
+ end
@@ -15,7 +15,8 @@ module Dcc
15
15
  def self.cast(value)
16
16
  return nil if value.nil? || value == ""
17
17
  unless value.is_a?(::String)
18
- raise Lutaml::Model::Type::InvalidValueError.new(value, "expected binary String")
18
+ raise Lutaml::Model::Type::InvalidValueError.new(value,
19
+ "expected binary String")
19
20
  end
20
21
 
21
22
  value
@@ -25,7 +26,8 @@ module Dcc
25
26
  return nil if value.nil?
26
27
 
27
28
  unless value.is_a?(::String)
28
- raise Lutaml::Model::Type::InvalidValueError.new(value, "expected binary String")
29
+ raise Lutaml::Model::Type::InvalidValueError.new(value,
30
+ "expected binary String")
29
31
  end
30
32
 
31
33
  value
@@ -52,4 +54,4 @@ module Dcc
52
54
  end
53
55
  end
54
56
  end
55
- end
57
+ end
@@ -1,19 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bigdecimal"
4
+ require "forwardable"
4
5
  require "lutaml/model"
5
6
 
6
7
  module Dcc
7
8
  module Type
8
9
  # Space-separated list of decimals, as used by D-SI `*XMLList` elements
9
10
  # (e.g. `<si:valueXMLList>306 373 448</si:valueXMLList>`). Parses to/from
10
- # an Array of BigDecimal values.
11
+ # a `Values` list of BigDecimal.
12
+ #
13
+ # `cast` deliberately does not return a plain Array: lutaml-model treats an
14
+ # Array-valued attribute as a collection and would emit one element per
15
+ # decimal instead of a single space-separated list.
11
16
  class DecimalXmlList < Lutaml::Model::Type::Value
17
+ # An ordered list of BigDecimals that renders as space-separated text and
18
+ # compares equal to the equivalent Array.
19
+ class Values
20
+ include Enumerable
21
+ extend Forwardable
22
+
23
+ def_delegators :@decimals, :size, :length, :empty?, :last, :[]
24
+
25
+ def initialize(decimals)
26
+ @decimals = decimals
27
+ end
28
+
29
+ def each(&)
30
+ return to_enum(:each) unless block_given?
31
+
32
+ @decimals.each(&)
33
+ self
34
+ end
35
+
36
+ # Implicit conversion, so `Array#==` delegates back here instead of
37
+ # returning false and making equality asymmetric.
38
+ def to_ary
39
+ @decimals.dup
40
+ end
41
+
42
+ # Joined over the serialized decimals, not the raw BigDecimals, so it
43
+ # cannot disagree with `to_s` by emitting "0.72e-1" for "0.072".
44
+ def join(separator = " ")
45
+ map { |decimal| DecimalXmlList.serialize_one(decimal) }.join(separator)
46
+ end
47
+
48
+ def to_s
49
+ DecimalXmlList.serialize(self)
50
+ end
51
+
52
+ # Dumped as its wire form, so YAML matches JSON rather than leaking a
53
+ # `!ruby/object:` tag that `safe_load` refuses to read back.
54
+ def encode_with(coder)
55
+ coder.represent_scalar(nil, to_s)
56
+ end
57
+
58
+ def ==(other)
59
+ case other
60
+ when Values then to_a == other.to_a
61
+ when ::Array then to_a == other
62
+ else false
63
+ end
64
+ end
65
+
66
+ # Stricter than `==`, which accepts a plain Array. Hash lookup pairs
67
+ # `eql?` with `hash`, and an Array keyed alongside us would hash the
68
+ # same while comparing equal in only one direction.
69
+ def eql?(other)
70
+ other.is_a?(Values) && to_a == other.to_a
71
+ end
72
+
73
+ def hash
74
+ to_a.hash
75
+ end
76
+ end
77
+
12
78
  def self.cast(value)
13
- return [] if value.nil?
14
- return value.map { |v| cast_one(v) } if value.is_a?(::Array)
79
+ return value if value.is_a?(Values)
80
+ return Values.new([]) if value.nil?
15
81
 
16
- value.to_s.split(/\s+/).reject(&:empty?).map { |s| cast_one(s) }
82
+ tokens = value.is_a?(::Array) ? value : value.to_s.split
83
+ Values.new(tokens.map { |token| cast_one(token) })
17
84
  end
18
85
 
19
86
  def self.serialize(value)
@@ -22,24 +89,43 @@ module Dcc
22
89
  Array(value).map { |v| serialize_one(v) }.join(" ")
23
90
  end
24
91
 
92
+ # One decimal in the notation D-SI writes, e.g. 0.072 rather than
93
+ # BigDecimal's own "0.72e-1". `to_s("F")` renders every BigDecimal,
94
+ # finite or not, and `sub` only trims a trailing ".0". That is what
95
+ # lets `NaN` through, which D-SI admits in every decimal type
96
+ # (SI_Format.xsd:1195).
97
+ def self.serialize_one(value)
98
+ reject_infinite!(value)
99
+
100
+ case value
101
+ when ::BigDecimal then value.to_s("F").sub(/\.0\z/, "")
102
+ when ::Float then format("%.15g", value)
103
+ else value.to_s
104
+ end
105
+ end
106
+
25
107
  class << self
26
108
  private
27
109
 
28
110
  def cast_one(token)
29
- return token if token.is_a?(::BigDecimal)
30
-
31
- BigDecimal(token.to_s)
111
+ decimal = token.is_a?(::BigDecimal) ? token : BigDecimal(token.to_s)
112
+ reject_infinite!(decimal)
113
+ decimal
32
114
  rescue ::ArgumentError => e
33
115
  raise Lutaml::Model::Type::InvalidValueError.new(token,
34
116
  "invalid decimal in XML list: #{e.message}")
35
117
  end
36
118
 
37
- def serialize_one(value)
38
- case value
39
- when ::BigDecimal then value.to_i.to_s == value.to_s("F") ? value.to_i.to_s : value.to_s("F").sub(/\.0\z/, "")
40
- when ::Float then format("%.15g", value)
41
- else value.to_s
42
- end
119
+ # Called from `cast_one` and from the public `serialize_one`, since
120
+ # `serialize` takes a raw BigDecimal and casting is not the only way in.
121
+ def reject_infinite!(value)
122
+ return unless value.is_a?(::BigDecimal) && value.infinite?
123
+
124
+ raise Lutaml::Model::Type::InvalidValueError.new(
125
+ value,
126
+ "infinity is not a D-SI decimal: SI_Format.xsd:1195 admits " \
127
+ "numbers and NaN only",
128
+ )
43
129
  end
44
130
  end
45
131
  end
@@ -7,13 +7,14 @@ module Dcc
7
7
  # ISO 3166-1 alpha-2 country code (two uppercase letters, e.g. `"DE"`).
8
8
  # Used by `dcc:countryCodeISO3166_1`.
9
9
  class IsoCountryCode < Lutaml::Model::Type::String
10
- PATTERN = /\A[A-Z]{2}\z/.freeze
10
+ PATTERN = /\A[A-Z]{2}\z/
11
11
 
12
12
  def self.cast(value)
13
13
  return nil unless value.is_a?(::String) && value != ""
14
14
 
15
15
  unless PATTERN.match?(value)
16
- raise Lutaml::Model::Type::InvalidValueError.new(value, "invalid ISO 3166-1 alpha-2 country code: #{value.inspect}")
16
+ raise Lutaml::Model::Type::InvalidValueError.new(value,
17
+ "invalid ISO 3166-1 alpha-2 country code: #{value.inspect}")
17
18
  end
18
19
 
19
20
  value
@@ -7,13 +7,14 @@ module Dcc
7
7
  # ISO 639-1 language code (two lowercase letters, e.g. `"en"`).
8
8
  # Used by `dcc:usedLangCodeISO639_1` and `dcc:mandatoryLangCodeISO639_1`.
9
9
  class IsoLanguageCode < Lutaml::Model::Type::String
10
- PATTERN = /\A[a-z]{2}\z/.freeze
10
+ PATTERN = /\A[a-z]{2}\z/
11
11
 
12
12
  def self.cast(value)
13
13
  return nil unless value.is_a?(::String) && value != ""
14
14
 
15
15
  unless PATTERN.match?(value)
16
- raise Lutaml::Model::Type::InvalidValueError.new(value, "invalid ISO 639-1 language code: #{value.inspect}")
16
+ raise Lutaml::Model::Type::InvalidValueError.new(value,
17
+ "invalid ISO 639-1 language code: #{value.inspect}")
17
18
  end
18
19
 
19
20
  value
@@ -12,13 +12,14 @@ module Dcc
12
12
  (?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)
13
13
  (?:-(?<pre>[0-9A-Za-z.-]+))?
14
14
  (?:\+(?<build>[0-9A-Za-z.-]+))?
15
- \z/x.freeze
15
+ \z/x
16
16
 
17
17
  def self.cast(value)
18
18
  return nil unless value.is_a?(::String) && value != ""
19
19
 
20
20
  unless PATTERN.match?(value)
21
- raise Lutaml::Model::Type::InvalidValueError.new(value, "invalid schemaVersion")
21
+ raise Lutaml::Model::Type::InvalidValueError.new(value,
22
+ "invalid schemaVersion")
22
23
  end
23
24
 
24
25
  value
@@ -10,13 +10,14 @@ module Dcc
10
10
  # check: the string must not contain whitespace (Schematron rule), and the
11
11
  # non-SI declaration prefix (`|unit`) is allowed.
12
12
  class SiUnit < Lutaml::Model::Type::String
13
- NO_WHITESPACE_PATTERN = /\A\S+\z/.freeze
13
+ NO_WHITESPACE_PATTERN = /\A\S+\z/
14
14
 
15
15
  def self.cast(value)
16
16
  return nil unless value.is_a?(::String) && value != ""
17
17
 
18
18
  unless NO_WHITESPACE_PATTERN.match?(value)
19
- raise Lutaml::Model::Type::InvalidValueError.new(value, "invalid SI unit expression (whitespace not allowed): #{value.inspect}")
19
+ raise Lutaml::Model::Type::InvalidValueError.new(value,
20
+ "invalid SI unit expression (whitespace not allowed): #{value.inspect}")
20
21
  end
21
22
 
22
23
  value
@@ -130,11 +130,11 @@ module Dcc
130
130
  end
131
131
 
132
132
  # Safe method call: invokes the method if the node's class declares it.
133
- def call_if_present(node, method_name, *args)
133
+ def call_if_present(node, method_name, *)
134
134
  return nil unless node.is_a?(::Lutaml::Model::Serializable)
135
135
  return nil unless node.class.attributes.key?(method_name)
136
136
 
137
- node.public_send(method_name, *args)
137
+ node.public_send(method_name, *)
138
138
  end
139
139
  end
140
- end
140
+ end