Xlsxrb Architecture

Xlsxrb uses a multi-layered architecture to separate low-level OpenXML specification details from a high-level, idiomatic Ruby API. This separation of concerns ensures the library is both robust against the complex OpenXML spec and user-friendly for Ruby developers.

Dependency Constraints

Xlsxrb uses only the Ruby standard library and Bundled Gems:

Library Purpose
rexml (bundled gem) SAX2-based XML parsing and DOM-based XML generation
zlib (stdlib) ZIP deflate/inflate compression
stringio (stdlib) In-memory IO for streaming
date (stdlib) Excel serial-number ↔ Date / Time conversion
openssl, securerandom (stdlib) Password hashing for sheet/workbook protection

No third-party gems (e.g. Nokogiri, rubyzip) are permitted as runtime dependencies.


Coding Policies

🚫 The method_missing Policy

As a strict rule, we do not accept dynamic method definitions using method_missing anywhere in the codebase. All user-facing API methods and internal delegations must be explicitly defined in the code. This ensures: 1. Type Safety & Static Analysis: RBS and Steep can completely validate arguments and structures. 2. Developer Experience: IDE autocompletion, jump-to-definition, and YARD documentation work perfectly. 3. Traceability: If a method exists, you can grep for it.

Even in cases where proxy patterns (e.g. WorksheetProxy) or OOXML builder mappings (e.g. ChartBuilder, SeriesBuilder) would traditionally benefit from dynamic delegation to avoid boilerplate, we explicitly generate and write out those delegations in the source code.

1. Xlsxrb Module is the ONLY Entrypoint:

The Xlsxrb module provides the top-level methods: read, write, build, and modify. Users should never instantiate internal classes (like Xlsxrb::Ooxml::WorkbookWriter) directly.

  1. The @api public Contract (SemVer Guarantee): Any module, class, or method tagged with # @api public in its YARD documentation is guaranteed to follow Semantic Versioning.

  2. Patch versions (0.1.x -> 0.1.y) will not break these APIs.

  3. Minor versions (0.x.0 -> 0.y.0) will not break these APIs once 1.0.0 is released (during 0.x.x, it is a best-effort promise).

  4. Major versions (1.x -> 2.x) are the only time breaking changes to @api public components are permitted.

  5. Block-Yielded Objects are Public APIs: All builder objects yielded into blocks (e.g., writer in Xlsxrb.write { |writer| }, sheet in writer.sheet { |sheet| }, chart in sheet.chart { |chart| }) are explicitly marked as @api public. Their exposed methods constitute the DSL and are strictly protected by the SemVer contract.


Directory Structure

lib/
  xlsxrb.rb                          # Facade: Xlsxrb.read / .write / .build / .modify
  xlsxrb/
    version.rb                       # Xlsxrb::VERSION
    elements.rb                      # Requires for Elements layer
    stream_row.rb                    # Lazy/streaming row and cell reader (O(1) memory)
    ooxml.rb                         # Requires for Ooxml layer
    ooxml/                           # Layer 1 – Low-level OOXML
      reader.rb                      #   Xlsxrb::Ooxml::Reader (core reading logic)
      writer.rb                      #   Xlsxrb::Ooxml::Writer (core writing logic)
      zip_generator.rb               #   Xlsxrb::Ooxml::ZipGenerator
      utils.rb                       #   Xlsxrb::Ooxml::Utils (date/time/hash helpers)
      zip_reader.rb                  #   Xlsxrb::Ooxml::ZipReader
      zip_writer.rb                  #   Xlsxrb::Ooxml::ZipWriter
      xml_parser.rb                  #   Xlsxrb::Ooxml::XmlParser
      xml_builder.rb                 #   Xlsxrb::Ooxml::XmlBuilder
      shared_strings_parser.rb       #   Streaming SST reader
      styles_parser.rb               #   styles.xml reader
      worksheet_parser.rb            #   Streaming sheetN.xml reader
      worksheet_writer.rb            #   Streaming sheetN.xml writer
      workbook_parser.rb             #   workbook.xml reader
      workbook_writer.rb             #   workbook.xml / styles / SST writer
    elements/                        # Layer 2 – Domain model
      types.rb                       #   Xlsxrb::Elements::Formula, CellError, RichText
      cell.rb                        #   Xlsxrb::Elements::Cell
      row.rb                         #   Xlsxrb::Elements::Row
      column.rb                      #   Xlsxrb::Elements::Column
      worksheet.rb                   #   Xlsxrb::Elements::Worksheet
      workbook.rb                    #   Xlsxrb::Elements::Workbook

Core Architecture

The library is structured into three distinct layers:

1. Low-Level Infrastructure (The β€œOOXML” Layer)

Namespace: Xlsxrb::Ooxml

Responsibility: This layer directly handles ZIP extraction, XML parsing (via SAX), and XML generation. It adheres strictly to the ECMA-376 OpenXML specification.

2. High-Level Domain Model (The β€œElements” Layer) & Streaming Row Layer

Namespace: Xlsxrb::Elements and Xlsxrb::StreamRow

Responsibility: This layer provides idiomatic, easy-to-use Ruby objects representing Excel concepts. It utilizes Ruby 3.2+ Data classes for immutability and precise structural definition. All domain models are encapsulated here to keep the top-level namespace clean.

Core Objects: * Xlsxrb::Elements::Workbook: Represents the entire file structure (Data class). Contains sheets (Array of Worksheet), shared styles metadata, and unmapped_data. * Xlsxrb::Elements::Worksheet: Represents a single sheet (Data class). Contains name, rows (Array of Row), columns (Array of Column), and sheet-level properties. * Xlsxrb::Elements::Row: Represents one in-memory row (Data class). Contains index (0-based), cells (Array of Cell), and row-level attributes. * Xlsxrb::StreamRow: Represents a streaming row with lazy cell parsing. Provides row.each_cell / row.each for $O(1)$ constant memory streaming, caching cells on-demand if indexed or converted to an array. * Xlsxrb::Elements::Column: Represents column formatting (Data class). Contains index (0-based), width, and column-level attributes. * Xlsxrb::Elements::Cell: Represents a single cell (Data class). Contains row_index, column_index (both 0-based), value (Ruby native type), formula, style, and unmapped_data.

Design Principles: * Zero-based Indexing: To maintain consistency with Ruby’s core language (Arrays/Enumerable), all indices (rows, columns, and worksheets) are 0-based. For Excel-style coordination, use string references like cell("A1"). * Fail-safe Design (Lazy Validation): Exceptions are not raised during XML parsing. Each class has an errors property (Array of String) and a valid? method that returns errors.empty?. * Forward Compatibility: All classes have an unmapped_data property (Hash) to ensure that any unknown XML attributes or elements are retained, preserving file integrity during round-trips.

3. The Facade / Entrypoint Layer

Namespace: Xlsxrb

Responsibility: Acts as the primary bridge, offering symmetric In-Memory and Streaming APIs.

Method Type Description
Xlsxrb.read(source, &block) Streaming Streams sheets (StreamSheet) and rows (StreamRow) with $O(1)$ constant memory.
Xlsxrb.write(target, &block) Streaming Streams rows directly to file/IO with minimal memory.
Xlsxrb.read(source) In-Memory Loads from file path, IO, or raw binary string into a Workbook.
Xlsxrb.write(target, wb) / Xlsxrb.write(wb) In-Memory Saves Workbook to file/IO, or returns raw binary string (single argument).
Xlsxrb.build(&block) In-Memory Builds an immutable Workbook using DSL.
Xlsxrb.modify(source, target, &block) In-Memory Updates cells/sheets of an existing workbook.

Facade Expansion Policy

The long-term API goal is that all spreadsheet features implemented in the low-level Ooxml::Writer layer should be available through the high-level Facade DSL as well.

This applies to both:

The Facade should not expose only a hand-picked subset forever. If a feature is stable and supported in the low-level writer, the default expectation is that it should eventually gain a high-level entry point.

Facade DSL Conventions

When adding a new high-level feature, follow these API rules unless there is a clear technical reason not to.

1. Support both a concise options form and a block form

Each DSL feature should prefer the same dual entry style now used by chart and style configuration:

Examples:

s.add_style("header", bold: true, size: 14, font_color: "FFFF0000")

s.add_style("header") do |style|
  style.bold.size(14).font_color("FFFF0000")
end

w.add_chart(type: :bar, title: "Sales", series: [{ cat_ref: "A1:A3", val_ref: "B1:B3" }])

w.add_chart do |chart|
  chart.type :bar
  chart.title "Sales"
  chart.series(cat_ref: "A1:A3", val_ref: "B1:B3")
end

The options form should stay compact. The block form should become the preferred place for nested or verbose configuration.

2. Keep naming consistent

Use naming by intent:

Do not introduce one-off verbs for similar concepts unless the low-level feature truly behaves differently.

3. Preserve scope boundaries

Each feature should appear in the builder scope that matches its OOXML ownership:

If a low-level feature is workbook-scoped, do not force it into a worksheet-only API just because it is convenient.

4. Prefer one canonical high-level shape

For each feature, choose a single primary Facade shape and reuse it across modes:

5. Keep an escape hatch for advanced cases

The high-level DSL should cover common and intermediate use cases directly. But it does not need to mirror every obscure OOXML knob one-for-one on day one.

When a feature has a long tail of advanced attributes:

6. Never break existing call sites to add symmetry

High-level API growth must be backward compatible:

Symmetry is valuable, but compatibility is mandatory.

Facade Rollout Strategy

When promoting a low-level feature into the high-level DSL, use this order of operations:

  1. Identify the owning scope (WorkbookBuilder, WorksheetBuilder, StreamWriter, or a nested builder)

  2. Add the options form

  3. Add the block form if the shape becomes nested or verbose

  4. Ensure the streaming and in-memory APIs expose the same concept with the same names whenever possible

  5. Document the shortest example and the richer builder example together

This keeps the public API coherent as coverage grows.

High-Priority Features For Facade Promotion

The following low-level features are especially strong candidates for high-level exposure because they are common, composable, and fit the chart/style pattern well:

These should be treated as backlog for Facade parity, not as permanently low-level-only features.


Data-Flow & Lifecycle

Xlsxrb.read(source) β€” In-Memory Read

source (path / IO)
  β”‚
  β–Ό
Ooxml::ZipReader          ── extracts ZIP entries ──►  raw bytes per part
  β”‚
  β–Ό
Ooxml::SharedStringsParser ── SAX parse xl/sharedStrings.xml ──► string table (Array)
Ooxml::StylesParser        ── SAX parse xl/styles.xml        ──► styles hash
Ooxml::WorkbookParser      ── SAX parse xl/workbook.xml      ──► sheet list
  β”‚
  β–Ό  (for each sheet)
Ooxml::WorksheetParser     ── SAX parse xl/worksheets/sheetN.xml ──►
  β”‚                           yields (row_index, cells_array, row_attrs, unmapped)
  β–Ό
Elements::Cell / Row / Column / Worksheet
  β”‚
  β–Ό
Elements::Workbook          ◄── assembled from all worksheets

Xlsxrb.write(target, workbook) β€” In-Memory Write

Elements::Workbook
  β”‚
  β–Ό  (for each worksheet)
Ooxml::WorksheetWriter     ── converts Row/Cell β†’ XML fragments ──►
  β”‚                           streams into Ooxml::ZipWriter entry
  β–Ό
Ooxml::WorkbookWriter      ── writes workbook.xml, styles.xml, sharedStrings.xml,
  β”‚                           [Content_Types].xml, .rels
  β–Ό
Ooxml::ZipWriter           ── writes ZIP output ──► target (path / IO)

Xlsxrb.read(source, &block) β€” Streaming Read

source (path / IO / binary string)
  β”‚
  β–Ό
Ooxml::ZipReader           ── locates xl/sharedStrings.xml, xl/worksheets/sheetN.xml
  β”‚
  β–Ό  (SAX parse SST first β€” kept in memory as a flat Array of strings)
  β”‚
  β–Ό  (then SAX stream worksheet with StreamRow lazy cell scanner)
Ooxml::WorksheetParser     ── yields StreamRow to caller's block
  β”‚
  β–Ό
caller's block receives StreamRow, streams cells via each_cell with O(1) memory

Key memory invariant: only one Row / Cell (plus the shared-string table) is parsed at any time.

Xlsxrb.write(target, &block) β€” Streaming Write

caller's block
  β”‚
  β–Ό  block receives a StreamWriter context object
  β”‚  context.add_row([val1, val2, ...])
  β”‚
  β–Ό
Ooxml::WorksheetWriter     ── converts array β†’ <row><c>…</c></row> XML
  β”‚                           writes directly to ZipWriter entry stream
  β–Ό
Ooxml::ZipWriter           ── compresses & writes to target

Key memory invariant: rows are written and flushed immediately; no row Array accumulates.


Streaming Internals

Unified Event-Based Streaming (Ooxml::Event)

To unify parsing across both streaming and in-memory paths, the OOXML layer utilizes a unified event-based parsing model. Individual parsers (such as WorksheetParser and SharedStringsParser) implement a streaming each_event method that emits a sequence of Xlsxrb::Ooxml::Event objects.

An event contains: - type: a Symbol representing the event type (e.g., :row_start, :cell, :row_end, :column, :hyperlink, :sst_item). - args: an Array containing the event data arguments. - source: a Hash providing context for error reporting (e.g., { part: "xl/worksheets/sheet1.xml", row: 0, cell: "A1" }).

Event Vocabulary:

  1. Worksheet Events:

  2. :row_start - args: [row_index, attrs]

  3. :cell - args: [ref, type, style_index, value, formula]

  4. :row_end - args: []

  5. :column - args: [min, max, width, hidden, custom_width, outline_level]

  6. :hyperlink - args: [ref, rid, display, tooltip, location]

  7. Shared Strings (SST) Events:

  8. :sst_item - args: [string_value]

The streaming parser each_row (or parse) consumes the event stream and folds it into raw row hashes, maintaining a minimal state machine and constant memory footprint.

ZIP Streaming

Ooxml::ZipReader scans local file headers sequentially using Zlib::Inflate. It does not seek to the central directory β€” this allows reading from non-seekable IO (pipes, HTTP streams).

Ooxml::ZipWriter writes local file headers immediately, accumulates a central directory index in memory (entry names + offsets only), and writes the central directory + EOCD at #close.


unmapped_data & Forward-Compatibility

When the Ooxml layer encounters an XML element or attribute not in its recognized set:

  1. Capture: The element is stored as a Hash { tag: String, attrs: Hash, children: Array, text: String? }.

  2. Attach: The Hash is pushed onto the nearest recognized parent’s unmapped_children array.

  3. Surface: The Elements layer receives these as the unmapped_data field β€” a Hash keyed by parent-context (e.g. { row: [...], cell: [...], worksheet: [...] }).

  4. Restore: During write-back (Ooxml::WorksheetWriter), unmapped_data entries are re-serialized to XML in their original order using XmlBuilder, preserving any future spec extensions or vendor-specific markup.

This ensures that reading then writing an XLSX file does not silently discard unknown content.


Error Handling & Validation Boundaries

Ooxml Layer (parse-time)

Elements Layer (model-time)

Facade Layer


Benefits of this Approach


Facade Quality Gates

Every new high-level DSL feature must satisfy the following quality rules before it is considered complete.

1. Both API paths must be covered

If a feature is intended to exist in both writing modes, tests must cover:

If a feature can only exist in one mode for a technical reason, that restriction must be documented explicitly in code comments and user-facing docs.

2. Both entry forms must be covered when both are supported

If a feature exposes both:

then Facade tests should exercise both forms at least once.

3. Facade tests are mandatory

Add or extend tests in test/facade_test.rb so the feature is validated at the public API level.

These tests should verify:

4. Contract tests are preferred when structure parity matters

If the feature should produce equivalent OOXML across streaming and in-memory modes, add or extend test/contract_test.rb.

This is especially important for:

5. E2E coverage is required for new structural output

If a feature introduces new XML elements, attributes, relationships, or package parts, add an interoperability or E2E test.

At minimum, verify one of:

6. Documentation must ship with the feature

Every new high-level feature should update user-facing docs with:

7. Keep surface area smaller than implementation detail

Do not promote every low-level flag into a top-level public method immediately.

Prefer this order:

  1. common user-facing options

  2. nested builder methods for grouped concepts

  3. advanced keyword passthrough for rare flags

This keeps the DSL readable while still allowing high feature coverage.

8. Backward compatibility is a release gate

A feature is not complete if it improves symmetry but breaks older call sites, examples, or tests.

Backward compatibility must be verified before merging any Facade DSL expansion.


Testing Strategy

To ensure library robustness and consistency across execution paths, we organize tests into four distinct layers:

  1. Unit Tests (test/xlsxrb/):

  2. Focus on isolated components (such as parsers and writers) without external system dependencies.

  3. Includes Round-trip testing to verify that generated XML can be successfully parsed back by the reader.

  4. Run via: bundle exec rake test:unit

  5. Contract Tests (test/contract/):

  6. Ensures semantic parity between the Streaming and In-Memory API paths.

  7. Operates by executing identical data scenarios on both APIs and asserting that they serialize to equivalent structures.

  8. Run via: bundle exec rake test:contract

  9. Interop (E2E) Tests (test/e2e/):

  10. Exercises real-world interoperability by validating generated files using the official .NET-based Open XML SDK validator, and reading spreadsheets dynamically created by the SDK.

  11. Run via: bundle exec rake test:e2e

  12. Visual Examples & VRT (test/visual/):

  13. Living Documentation: Compiles visual DSL scripts under examples/visual/ into the Visual Examples Gallery.

  14. Visual Regression Testing: Renders the generated spreadsheets into PNG files using headless LibreOffice Calc, and calculates pixel differences against reference baselines using ImageMagick.

  15. Run via: bundle exec rake test:visual