Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version
* Performance memoization for hot paths
* Plugin system for custom validators and converters

=== Fixed
* `dcc:list` no longer parses as an empty `si:list`. The D-SI re-export was
overwriting the DCC `list` registration, so every list and the quantities
inside it were dropped on parse.
* `si:expandedUnc` and `si:coverageInterval` now survive a parse. Both are
members of the same `si:real` choice group and neither was mapped, so D-SI
v1 uncertainties were discarded.
* `dcc:mathml` now parses. It is a `dcc:xmlType` wrapper around `ml:math`,
not the `ml:math` element itself, so `formula.mathml` was always `nil`.
* Decimal XML lists serialize as one space-separated element again. Each
value was being written as its own element containing a Ruby array
literal, e.g. `<si:valueXMLList>[0.72e-1]</si:valueXMLList>`.
* `UncertaintyConsistency` accepts a single entry broadcast across every
value, and now checks `coverageFactorXMLList`, `coverageProbabilityXMLList`
and `distributionXMLList` rather than the uncertainty list alone.
* CSV export renders list values as plain decimals instead of `0.72e-1`.

=== Changed
* *Breaking:* `formula.mathml` returns a `Dcc::V2::Mathml` / `Dcc::V3::Mathml`
wrapper instead of an `Mml::V3::Math`. The MathML tree moves one level
down, so callers now read `formula.mathml.math`. This matches the XSD,
where `dcc:mathml` is a `dcc:xmlType` wrapper.

== [0.1.0] — 2026-07-22

=== Added
Expand Down
1 change: 1 addition & 0 deletions dcc.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = ">= 3.2.0"

spec.add_dependency "bigdecimal"
spec.add_dependency "liquid"
spec.add_dependency "lutaml-model", "~> 0.8"
spec.add_dependency "mml", "~> 2.4.0"
Expand Down
4 changes: 2 additions & 2 deletions lib/dcc/base/formula.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
module Dcc
module Base
# `dcc:formulaType` — formula expression. Contains `latex` string,
# `mathml` (a typed `Mml::V3::Math` model), or legacy `siunitx`.
# `mathml` (a `dcc:xmlType` wrapper around `ml:math`), or legacy `siunitx`.
module Formula
def self.included(klass)
klass.class_eval do
attribute :id, :string
attribute :ref_id, :string
attribute :ref_type, :string
attribute :latex, :string
attribute :mathml, ::Mml::V3::Math
attribute :mathml, :mathml
attribute :siunitx, :string

xml do
Expand Down
33 changes: 33 additions & 0 deletions lib/dcc/base/mathml.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require "mml"

module Dcc
module Base
# `dcc:mathml` — a `dcc:xmlType` wrapper carrying a single foreign-namespace
# child, which for a formula is `ml:math`. The MathML tree hangs below the
# wrapper rather than replacing it.
module Mathml
# rubocop:disable Metrics/MethodLength -- one lutaml mapping block,
# split the same way every sibling module in Dcc::Base is written.
def self.included(klass)
klass.class_eval do
attribute :id, :string
attribute :ref_id, :string
attribute :ref_type, :string
attribute :math, ::Mml::V3::Math

xml do
namespace ::Dcc::Namespace::Dcc
element "mathml"
map_attribute "id", to: :id
map_attribute "refId", to: :ref_id
map_attribute "refType", to: :ref_type
map_element "math", to: :math
end
end
end
# rubocop:enable Metrics/MethodLength
end
end
end
101 changes: 87 additions & 14 deletions lib/dcc/cli/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,29 +84,24 @@ def convert(format, file)
end

desc "extract TARGET FILE",
"Extract embedded files or quantities from a DCC"
"Extract embedded files or formulae from a DCC"
method_option :index, type: :numeric, desc: "Index of the file to extract"
method_option :output, type: :string, banner: "PATH",
desc: "Write extracted payload to PATH"
method_option :ring, type: :string,
enum: %w[administrativeData measurementResults comment document]
# `repeatable` and not `type: :array`: with an array type Thor lets a
# second `-v` overwrite the first, so `-v T=42 -v R0=1` would silently
# drop T. PTB's CLI accumulates, and so must this.
method_option :variable, type: :string, aliases: "-v", repeatable: true,
banner: "NAME=VALUE",
desc: "Override a formula variable (repeatable; NAME=V1,V2 for a list)"
def extract(target, file)
Cli.ensure_loaded!
dcc = Dcc.parse(File.read(file))
case target
when "files"
files = ::Dcc::Extract::File.each(dcc)
if options[:ring]
files = files.select do |f|
f.ring.to_s == options[:ring]
end
end
if options[:index]
f = files[options[:index].to_i] || abort("No file at index #{options[:index]}")
output_to(f.data, options[:output], f.file_name)
else
::Dcc::Cli::Formatters.print_files(files)
end
when "files" then extract_files(dcc)
when "formulae" then extract_formulae(dcc)
else
abort "Unknown extract target: #{target}"
end
Expand Down Expand Up @@ -162,6 +157,84 @@ def output_to(payload, path, file_name)
$stdout.write(payload)
end
end

def extract_files(dcc)
files = ::Dcc::Extract::File.each(dcc)
if options[:ring]
files = files.select { |f| f.ring.to_s == options[:ring] }
end
return ::Dcc::Cli::Formatters.print_files(files) unless options[:index]

index = options[:index].to_i
file = files[index] || abort("No file at index #{index}")
output_to(file.data, options[:output], file.file_name)
end
end

no_commands do
def extract_formulae(dcc)
variables = parse_variables(options[:variable])
print_formulae(::Dcc::Extract::Formula.call(dcc), variables)
rescue ::Dcc::ExtractionError => e
abort e.message
end
end

no_commands do
def parse_variables(assignments)
Array(assignments).each_with_object({}) do |assignment, variables|
name, values = split_assignment(assignment)
variables[name] = override_value(values)
end
end

# `-v T=42` is a scalar and broadcasts across the document's lists;
# only `-v T=1,2` is a list, and a list has to match every other
# list's length. Wrapping a lone value in an array made `-v R0=1`
# a one-element list that collided with the document's own.
def override_value(values)
decimals = values.map { |v| decimal_or_abort(v) }
decimals.size == 1 ? decimals.first : decimals
end
end

no_commands do
# `split(",", -1)` keeps trailing empty fields, so `T=42,` reaches
# validation instead of silently losing the empty entry.
def split_assignment(assignment)
name, values = assignment.split("=", 2)
if name.nil? || name.empty? || values.nil? || values.empty?
abort "#{assignment} is not a valid NAME=value"
end

[name, values.split(",", -1)]
end

# Validated here, at the command line, not where the value is used.
# The evaluator only coerces variables the formula references, so a
# bad `-v` for an unreferenced variable — or any `-v` against a
# document with no formulae — would otherwise pass in silence.
#
# Delegates rather than calling BigDecimal itself: a second
# coercion site is exactly the drift the single boundary exists to
# prevent, even when it starts out agreeing.
def decimal_or_abort(value)
::Dcc::Extract::Formula::Quantity.decimal(value)
rescue ::Dcc::ExtractionError
abort "#{value} is not a valid decimal value"
end
end

no_commands do
def print_formulae(formulae, variables)
return puts("No formulae found.") if formulae.empty?

formulae.each do |formula|
puts formula
values = formula.evaluate(variables)
values.each { |value| puts " #{value.to_s('F')}" }
end
end
end
end
end
Expand Down
6 changes: 5 additions & 1 deletion lib/dcc/convert/csv.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ def value_of(q)
return reals.first.value.to_s if reals.any?

lists = Array(q.real_list_xml_list)
return lists.first.value_xml_list.map(&:to_s).join("; ") if lists.any?
# Serialized rather than mapped over: BigDecimal#to_s renders 0.072
# as "0.72e-1", which is not what the document said.
if lists.any?
return ::Dcc::Type::DecimalXmlList.serialize(lists.first.value_xml_list)
end

""
end
Expand Down
19 changes: 13 additions & 6 deletions lib/dcc/diff.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "bigdecimal"
require "date"
require "lutaml/model"

module Dcc
Expand All @@ -12,6 +13,12 @@ module Diff
autoload :Result, "dcc/diff/result"
autoload :Change, "dcc/diff/change"

# Leaf types compared directly rather than descended into. Numeric covers
# BigDecimal and Date covers DateTime, so neither needs its own entry.
PRIMITIVE_TYPES = [
::String, ::Numeric, ::Symbol, ::Time, ::Date
].freeze

class << self
# @param a [Lutaml::Model::Serializable]
# @param b [Lutaml::Model::Serializable]
Expand Down Expand Up @@ -68,13 +75,13 @@ def collect_differences(a, b, path, changes)
end

def primitive?(value)
return true if value.nil?
return true if value.is_a?(::String) || value.is_a?(::Numeric) || value.is_a?(::Symbol)
return true if [true, false].include?(value)
return true if value.is_a?(::Time) || value.is_a?(::Date) || value.is_a?(::DateTime)
return true if value.is_a?(::BigDecimal)
return true if value.nil? || [true, false].include?(value)
return false if value.is_a?(::Array)
return true if PRIMITIVE_TYPES.any? { |type| value.is_a?(type) }

false
# Anything we cannot descend into is compared with `==`, which covers
# value objects such as decimal XML lists.
!value.class.respond_to?(:attributes)
end

def summarize(node)
Expand Down
110 changes: 110 additions & 0 deletions lib/dcc/extract/formula.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

require "bigdecimal"

module Dcc
module Extract
# `Dcc::Extract::Formula` pulls content-MathML formulae out of a parsed
# DCC. Each `dcc:formula` that carries a `dcc:mathml` becomes one `Ast`,
# bound to the `dcc:quantity` values sitting in the same `dcc:data`
# block — that is where PTB puts a formula's parameters and inputs.
module Formula
autoload :Ast, "dcc/extract/formula/ast"
autoload :Evaluator, "dcc/extract/formula/evaluator"
autoload :Parser, "dcc/extract/formula/parser"
autoload :Quantity, "dcc/extract/formula/quantity"

class << self
# @param dcc [Lutaml::Model::Serializable] a parsed DCC.
# @return [Array<Dcc::Extract::Formula::Ast>] in document order.
def call(dcc)
data_blocks(dcc).flat_map { |data| formulae_in(data) }
end

private

def formulae_in(data)
bindings = bindings_in(data)
data.formula.filter_map do |formula|
math = formula.mathml&.math
Parser.call(math, bindings: bindings) if math
end
end

# Parameters live in `dcc:data/dcc:list/dcc:quantity`, but the XSD
# also allows a bare `dcc:quantity` directly under `dcc:data`.
def bindings_in(data)
quantities_in(data).each_with_object({}) do |quantity, bindings|
next unless quantity.id

value = quantity_value(quantity)
bindings[quantity.id] = value if value
end
end

# `dcc:listType` is recursive, and both it and `dcc:dataType`
# expose `quantity` and `list`. Walking only the first level of
# lists loses every binding nested below it.
def quantities_in(container)
container.quantity +
container.list.flat_map { |list| quantities_in(list) }
end

# `si:value` is declared `attribute :value, :string` in
# `lib/dcc/si/base/real.rb` — completely unvalidated, so a real
# document can carry "banana", "Infinity", or nothing at all. Hand
# it to Quantity untouched and let the one coercion boundary decide;
# calling `BigDecimal` here would leak a raw ArgumentError out of a
# public extraction call.
#
# Both `real` and `real_list_xml_list` are collection attributes on
# `Dcc::V3::Quantity`, so both need `.first`.
def quantity_value(quantity)
real = quantity.real.first
return scalar(real) if real

list = quantity.real_list_xml_list.first
list_values(list) if list
end

def scalar(real)
Quantity.new(value: real.value, unit: real.unit, uncertainty: nil)
end

def list_values(list)
Quantity.new(
value: list.value_xml_list,
unit: list.unit_xml_list,
uncertainty: nil,
)
end

# Same shape as `Dcc::Extract::File.each`: walk the model graph and
# collect every `dcc:data` node in document order.
def data_blocks(dcc)
blocks = []
walk(dcc, ::Set.new, blocks)
blocks
end

# `Set#add?` returns nil when already present, so it doubles as the
# cycle guard for the self-referential list types.
def walk(node, visited, blocks)
return unless node.is_a?(::Lutaml::Model::Serializable)
return unless visited.add?(node.object_id)

blocks << node if data?(node)
node.class.attributes.each_key do |attr_name|
Array(node.public_send(attr_name)).each do |child|
walk(child, visited, blocks)
end
end
end

def data?(node)
node.class.name.end_with?("::Data")
end
end
end
end
end
Loading
Loading