Skip to content

Whitespace and encoding issues with XML compares #27

Description

@ronaldtse

Problem Statement

Canon library currently has limitations when comparing XML with:

  1. Whitespace variations in mixed content (semantically equivalent but textually different)
  2. Non-UTF-8 encodings (Shift_JIS, ISO-8859-1) that fail Nokogiri parsing

These limitations cause false negative comparisons in lutaml-model tests.


Use Cases

Use Case 1: Whitespace Normalization

Scenario: Different XML adapters normalize whitespace differently

<!-- Input 1 (Nokogiri) -->
<Root>
  Text <bold>B</bold> more
</Root>

<!-- Input 2 (Ox - normalized) -->
<Root>Text <bold>B</bold> more</Root>

Current Behavior: Canon reports as DIFFERENT
Expected Behavior: Canon reports as EQUIVALENT (whitespace has no semantic meaning without xml:space="preserve")

Use Case 2: Encoding-Agnostic Comparison

Scenario: Compare XML in different encodings

xml1 = "<root>手書き</root>".encode("UTF-8")
xml2 = "<root>手書き</root>".encode("Shift_JIS")

Canon.equivalent?(xml1, xml2)  # Should be true

Current Behavior: Fails with encoding error
Expected Behavior: Transcodes to common encoding for comparison


Proposed Enhancements

Enhancement 1: Whitespace Normalization Option

API Design:

Canon::Comparison.equivalent?(xml1, xml2,
  normalize_whitespace: true  # New option
)

Implementation:

class Canon::Xml::DataModel
  def normalize_whitespace!
    # Normalize text nodes: collapse consecutive whitespace
    text_nodes.each do |node|
      next if node.parent.attribute('xml:space')&.value == 'preserve'
      node.content = node.content.gsub(/\s+/, ' ').strip
    end
  end
end

W3C Compliance:

  • Respects xml:space="preserve" attribute
  • Only normalizes where semantically safe
  • Follows XML 1.0 specification section 2.10

Enhancement 2: Encoding Normalization

API Design:

Canon::Comparison.equivalent?(xml1, xml2,
  normalize_encoding: 'UTF-8'  # Transcode to this before comparison
)

Implementation:

class Canon::XmlComparator
  def normalize_encoding(xml, target_encoding)
    return xml if xml.encoding.to_s == target_encoding

    # Transcode to target encoding
    xml.encode(target_encoding,
      invalid: :replace,
      undef: :replace,
      replace: '?'
    )
  end
end

Enhancement 3: Comparison Profiles

API Design:

# Predefined comparison profiles
Canon::Comparison.equivalent?(xml1, xml2, profile: :strict)     # Current behavior
Canon::Comparison.equivalent?(xml1, xml2, profile: :semantic)   # Whitespace + encoding normalized
Canon::Comparison.equivalent?(xml1, xml2, profile: :structural) # Structure only, ignore text

Profiles:

module Canon
  module Profiles
    STRICT = {
      normalize_whitespace: false,
      normalize_encoding: nil,
      ignore_text_order: false
    }

    SEMANTIC = {
      normalize_whitespace: true,
      normalize_encoding: 'UTF-8',
      ignore_text_order: false
    }

    STRUCTURAL = {
      normalize_whitespace: true,
      normalize_encoding: 'UTF-8',
      ignore_text_order: true
    }
  end
end

Implementation Plan

Phase 1: Whitespace Normalization (2-3 hours)

  1. Add normalize_whitespace option to Canon::Comparison
  2. Implement text node normalization in Canon::Xml::DataModel
  3. Add test coverage for mixed content scenarios
  4. Update documentation

Phase 2: Encoding Normalization (1-2 hours)

  1. Add normalize_encoding option
  2. Implement safe transcoding with fallbacks
  3. Add test coverage for multi-byte encodings
  4. Document encoding handling

Phase 3: Profiles (1 hour)

  1. Define profile constants
  2. Implement profile loading
  3. Add convenience methods
  4. Update RSpec matchers

Phase 4: Documentation (1 hour)

  1. Update README with new options
  2. Add encoding guide
  3. Add whitespace handling guide
  4. Provide migration examples

Total Estimate: 5-7 hours


Backward Compatibility

All enhancements are opt-in via options:

  • Default behavior unchanged (strict comparison)
  • Existing code continues to work
  • New options accessible via keyword arguments

No breaking changes.


Alternative: Workaround in lutaml-model

Instead of enhancing Canon, lutaml-model could:

  1. Pre-normalize before comparison:

    def normalize_for_comparison(xml)
      # Manually normalize whitespace
      xml.gsub(/\s+/, ' ').gsub(/>\s+</, '><')
    end
  2. Skip canon for encoding tests:

    if encoding != 'UTF-8'
      expect(serialized.bytes).to eq(xml.bytes)
    else
      expect(serialized).to be_xml_equivalent_to(xml)
    end
  3. Adapter-specific expectations:

    case adapter
    when OxAdapter
      expect(serialized).to eq(normalized_xml)
    else
      expect(serialized).to be_xml_equivalent_to(xml)
    end

Trade-off: Workarounds are faster short-term but reduce test quality and maintainability.


Recommendation

Implement workarounds in lutaml-model now (pragmatic):

  • Fixes immediate test failures
  • Doesn't block lutaml-model development
  • Low risk

Propose Canon enhancement for future (strategic):

  • Benefits all Canon users
  • Improves library capabilities
  • Better long-term solution

Action Items:

  1. ✅ Document workarounds in lutaml-model tests
  2. ✅ Create this proposal for Canon maintainers
  3. ⏸️ Defer Canon implementation to separate project
  4. ✅ Use workarounds to achieve 0 lutaml-model failures

References


Appendix: Test Cases

Test Case 1: Whitespace Variations

RSpec.describe "Canon whitespace handling" do
  it "recognizes whitespace-normalized XML as equivalent" do
    xml1 = "<root>\n  <text>Hello</text>\n</root>"
    xml2 = "<root><text>Hello</text></root>"

    expect(Canon.equivalent?(xml1, xml2, normalize_whitespace: true)).to be true
  end

  it "preserves whitespace when xml:space='preserve'" do
    xml1 = "<root xml:space='preserve'>\n  <text>Hello</text>\n</root>"
    xml2 = "<root xml:space='preserve'><text>Hello</text></root>"

    expect(Canon.equivalent?(xml1, xml2, normalize_whitespace: true)).to be false
  end
end

Test Case 2: Encoding Normalization

RSpec.describe "Canon encoding handling" do
  it "compares XML in different encodings" do
    xml1 = "<root>日本語</root>".encode("UTF-8")
    xml2 = "<root>日本語</root>".encode("Shift_JIS")

    expect(Canon.equivalent?(xml1, xml2, normalize_encoding: 'UTF-8')).to be true
  end
end

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions