Module: Xlsxrb

Defined in:
lib/xlsxrb.rb,
lib/xlsxrb/ooxml.rb,
lib/xlsxrb/version.rb,
lib/xlsxrb/elements.rb,
lib/xlsxrb/ooxml/cfb.rb,
lib/xlsxrb/stream_row.rb,
lib/xlsxrb/ooxml/utils.rb,
lib/xlsxrb/elements/row.rb,
lib/xlsxrb/ooxml/crypto.rb,
lib/xlsxrb/ooxml/reader.rb,
lib/xlsxrb/ooxml/writer.rb,
lib/xlsxrb/stream_sheet.rb,
lib/xlsxrb/chart_builder.rb,
lib/xlsxrb/elements/cell.rb,
lib/xlsxrb/stream_writer.rb,
lib/xlsxrb/style_builder.rb,
lib/xlsxrb/elements/types.rb,
lib/xlsxrb/elements/column.rb,
lib/xlsxrb/ooxml/xml_parser.rb,
lib/xlsxrb/ooxml/zip_reader.rb,
lib/xlsxrb/ooxml/zip_writer.rb,
lib/xlsxrb/workbook_builder.rb,
lib/xlsxrb/elements/workbook.rb,
lib/xlsxrb/ooxml/xml_builder.rb,
lib/xlsxrb/worksheet_builder.rb,
lib/xlsxrb/elements/worksheet.rb,
lib/xlsxrb/ooxml/crypto/agile.rb,
lib/xlsxrb/ooxml/styles_parser.rb,
lib/xlsxrb/ooxml/zip_generator.rb,
lib/xlsxrb/ooxml/crypto/standard.rb,
lib/xlsxrb/ooxml/workbook_parser.rb,
lib/xlsxrb/ooxml/workbook_writer.rb,
lib/xlsxrb/ooxml/reader/listeners.rb,
lib/xlsxrb/ooxml/worksheet_parser.rb,
lib/xlsxrb/ooxml/worksheet_writer.rb,
lib/xlsxrb/elements/coordinate_access.rb,
lib/xlsxrb/ooxml/shared_strings_parser.rb,
sig/generated/xlsxrb.rbs,
sig/generated/xlsxrb/ooxml.rbs,
sig/generated/xlsxrb/version.rbs,
sig/generated/xlsxrb/elements.rbs,
sig/generated/xlsxrb/ooxml/cfb.rbs,
sig/generated/xlsxrb/stream_row.rbs,
sig/generated/xlsxrb/ooxml/utils.rbs,
sig/generated/xlsxrb/elements/row.rbs,
sig/generated/xlsxrb/ooxml/crypto.rbs,
sig/generated/xlsxrb/ooxml/reader.rbs,
sig/generated/xlsxrb/ooxml/writer.rbs,
sig/generated/xlsxrb/stream_sheet.rbs,
sig/generated/xlsxrb/chart_builder.rbs,
sig/generated/xlsxrb/elements/cell.rbs,
sig/generated/xlsxrb/stream_writer.rbs,
sig/generated/xlsxrb/style_builder.rbs,
sig/generated/xlsxrb/elements/types.rbs,
sig/generated/xlsxrb/elements/column.rbs,
sig/generated/xlsxrb/ooxml/xml_parser.rbs,
sig/generated/xlsxrb/ooxml/zip_reader.rbs,
sig/generated/xlsxrb/ooxml/zip_writer.rbs,
sig/generated/xlsxrb/workbook_builder.rbs,
sig/generated/xlsxrb/elements/workbook.rbs,
sig/generated/xlsxrb/ooxml/xml_builder.rbs,
sig/generated/xlsxrb/worksheet_builder.rbs,
sig/generated/xlsxrb/elements/worksheet.rbs,
sig/generated/xlsxrb/ooxml/crypto/agile.rbs,
sig/generated/xlsxrb/ooxml/styles_parser.rbs,
sig/generated/xlsxrb/ooxml/zip_generator.rbs,
sig/generated/xlsxrb/ooxml/crypto/standard.rbs,
sig/generated/xlsxrb/ooxml/workbook_parser.rbs,
sig/generated/xlsxrb/ooxml/workbook_writer.rbs,
sig/generated/xlsxrb/ooxml/reader/listeners.rbs,
sig/generated/xlsxrb/ooxml/worksheet_parser.rbs,
sig/generated/xlsxrb/ooxml/worksheet_writer.rbs,
sig/generated/xlsxrb/elements/coordinate_access.rbs,
sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs

Overview

Modern, streaming-capable, low-memory XLSX reading and writing library for Ruby.

Provides high-level facade methods (Xlsxrb.read, Xlsxrb.write, Xlsxrb.build, Xlsxrb.modify) with full ECMA-376 OpenXML compliance, MS-OFFCRYPTO password protection, and zero core dependencies.

Defined Under Namespace

Modules: Elements, Ooxml Classes: ChartBuilder, DecryptionError, EncryptedFileError, Error, InvalidPasswordError, ParseError, StreamRow, StreamSheet, StreamWriter, StyleBuilder, ValidationError, WorkbookBuilder, WorksheetBuilder, ZipError

Constant Summary collapse

TRACER =

Returns:

  • (Object)
OpenTelemetry.tracer_provider.tracer("xlsxrb", Xlsxrb::VERSION)
VERSION =

Returns:

  • (::String)
"0.1.11"

Class Method Summary collapse

Class Method Details

.build(strict_excel_mode: true) {|builder| ... } ⇒ Elements::Workbook

Builds an in-memory Xlsxrb::Elements::Workbook using a declarative DSL.

: (?strict_excel_mode: bool) ?{ (WorkbookBuilder) -> void } -> Elements::Workbook

Examples:

Build an in-memory workbook

workbook = Xlsxrb.build do |builder|
  builder.sheet("Overview") do |sheet|
    sheet.row(["Title", "Date"])
    sheet.row(["Report", Date.today])
  end
end

Parameters:

  • strict_excel_mode (Boolean) (defaults to: true)

    Whether to enforce Microsoft Excel specification limits.

  • strict_excel_mode: (Boolean) (defaults to: true)

Yields:

  • (builder)

Yield Parameters:

Returns:



471
472
473
474
475
476
477
478
479
# File 'lib/xlsxrb.rb', line 471

def self.build(strict_excel_mode: true)
  raise Error, "block is required" unless block_given?

  Xlsxrb.in_span("Xlsxrb.build") do
    builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode)
    yield builder
    builder.build
  end
end

.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index) ⇒ Hash{Symbol => Object}

Builds a raw cell hash from a value for streaming writes.

: (Integer row_index, Integer col_index, untyped value, Array sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]

Parameters:

  • row_index (Integer)

    0-based row index.

  • col_index (Integer)

    0-based column index.

  • value (Object)

    Cell value.

  • sst (Array<String>)

    Shared strings array.

  • sst_index (Hash{String => Integer})

    Shared strings index mapping.

Returns:

  • (Hash{Symbol => Object})

    Raw cell hash.



641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# File 'lib/xlsxrb.rb', line 641

def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index)
  # simplecov:disable
  # Edge case / untested delegation block
  ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
  result = { ref: ref }

  case value
  when Elements::Formula
    result[:formula] = value.expression
    result[:formula_ca] = true if value.calculate_always
    result[:value] = value.cached_value if value.cached_value
  when String, Xlsxrb::Elements::RichText
    idx = sst_index[value] ||= begin
      sst << value
      sst.size - 1
    end
    result[:value] = idx
    result[:type] = "s"
  when true, false
    result[:value] = value
    result[:type] = "b"
  when Integer, Float
    result[:value] = value
  when Xlsxrb::Elements::CellError
    result[:value] = value.code
    result[:type] = "e"
  when Date
    result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
  when Time
    result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
  # simplecov:enable
  when NilClass
    # empty cell
  end

  # simplecov:disable
  # Edge case / untested delegation block
  result
  # simplecov:enable
end

.formula(expression, cached_value: nil) ⇒ Elements::Formula

Creates an Xlsxrb::Elements::Formula object for use in cell values.

: (String expression, ?cached_value: (String | Numeric | bool | nil)) -> Elements::Formula

Examples:

Create a formula without precalculated value

formula = Xlsxrb.formula("SUM(A1:A10)")

Create a formula with precomputed cached value

formula = Xlsxrb.formula("A1+B1", cached_value: 42)

Parameters:

  • expression (String)

    The formula expression without leading '=' (e.g. "SUM(A1:A10)").

  • cached_value (Object, nil) (defaults to: nil)

    Optional precomputed value for readers that do not evaluate formulas.

  • cached_value: (String, Numeric, bool, nil) (defaults to: nil)

Returns:



131
132
133
134
135
136
137
# File 'lib/xlsxrb.rb', line 131

def self.formula(expression, cached_value: nil)
  Elements::Formula.new(
    expression: expression,
    cached_value: cached_value,
    calculate_always: cached_value.nil? || nil
  )
end

.in_span(name, attributes: nil) {|arg0| ... } ⇒ Object

Executes the block within an OpenTelemetry tracer span if tracing is configured.

: (String name, ?attributes: Hash[String, untyped]?) { (*untyped) -> untyped } -> untyped

Parameters:

  • name (String)

    The span name.

  • attributes (Hash, nil) (defaults to: nil)

    Optional telemetry attributes.

  • attributes: (Hash[String, untyped], nil) (defaults to: nil)

Yields:

  • Block to execute inside the tracing span.

Yield Parameters:

  • arg0 (Object)

Yield Returns:

  • (Object)

Returns:

  • (Object)

    The result of the block.



82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/xlsxrb.rb', line 82

def self.in_span(name, attributes: nil, &)
  if defined?(Ractor) && Ractor.current != Ractor.main
    # simplecov:disable
    # Test suite runs in the main Ractor. This branch is for multi-threaded usage via Ractors.
    yield
    # simplecov:enable
  elsif attributes
    TRACER.in_span(name, attributes: attributes, &)
  else
    TRACER.in_span(name, &)
  end
end

.modify(source, target = nil, password: nil) {|workbook| ... } ⇒ void

This method returns an undefined value.

Modifies an existing XLSX file in-memory using an immutable transformation block.

Reads the workbook, passes it to the block, and writes the resulting workbook. If no target is specified, the source file is overwritten in-place.

: (untyped source, ?untyped target, ?password: String?) ?{ (Elements::Workbook) -> untyped } -> void

Examples:

Modify a template and save to new file

Xlsxrb.modify("template.xlsx", "output.xlsx") do |workbook|
  workbook.update_sheet("Sheet1") do |sheet|
    sheet.update_cell("B1", value: "Updated Title")
         .update_cell("B2", value: 100)
  end
end

Parameters:

  • source (String, IO, StringIO)

    The source file path or IO object.

  • target (String, IO, StringIO, nil) (defaults to: nil)

    Target file path or IO object (overwrites source if nil).

  • password (String, nil) (defaults to: nil)

    Optional password for encrypted files.

  • password: (String, nil) (defaults to: nil)

Yields:

  • (workbook)

Yield Parameters:

Yield Returns:



443
444
445
446
447
448
449
450
451
452
453
# File 'lib/xlsxrb.rb', line 443

def self.modify(source, target = nil, password: nil)
  raise Error, "source is required" if source.nil?
  raise Error, "block is required" unless block_given?

  workbook = read(source, password: password).load
  result_workbook = yield workbook
  result_workbook = workbook unless result_workbook.is_a?(Elements::Workbook)

  write_target = target || source
  write(write_target, result_workbook, password: password)
end

.read(source, password: nil) {|sheet| ... } ⇒ void .read(source, password: nil) ⇒ Elements::Workbook .read(source, password:) ⇒ void .read(source, password:) ⇒ Elements::Workbook

Reads an XLSX file (streaming and lazy-loaded by default) from a file path, IO stream, or binary String.

Sheets and rows are streamed lazily with O(1) constant memory. If a block is given, yields each StreamSheet sequentially. Call #load on the returned Workbook or Sheet to convert to an in-memory representation for coordinate random access (sheet["A1"]).

: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void : (untyped source, ?password: String?) -> Elements::Workbook

Examples:

Streaming read across sheets and rows (O(1) constant memory)

Xlsxrb.read("large.xlsx") do |sheet|
  puts "Sheet: #{sheet.name}"
  sheet.each_row do |row|
    row.each_cell { |cell| puts "#{cell.ref}: #{cell.value}" }
  end
end

Explicit in-memory loading for coordinate random access

wb = Xlsxrb.read("data.xlsx")
sheet = wb.sheets.first
doc_sheet = sheet.load         # explicitly load into memory
puts doc_sheet["A1"].value     # coordinate random access

Overloads:

  • .read(source, password: nil) {|sheet| ... } ⇒ void

    This method returns an undefined value.

    Yields each StreamSheet sequentially in streaming mode.

    Parameters:

    • source (String, IO, StringIO)

      File path, binary content string, or readable IO stream.

    • password (String, nil) (defaults to: nil)

      Optional password to decrypt password-protected XLSX files.

    Yields:

    • (sheet)

    Yield Parameters:

    • sheet (StreamSheet)

      The streaming worksheet object.

  • .read(source, password: nil) ⇒ Elements::Workbook

    Returns a lazy Xlsxrb::Elements::Workbook object.

    Parameters:

    • source (String, IO, StringIO)

      File path, binary content string, or readable IO stream.

    • password (String, nil) (defaults to: nil)

      Optional password to decrypt password-protected XLSX files.

    Returns:

  • .read(source, password:) ⇒ void

    This method returns an undefined value.

    Parameters:

    • source (Object)
    • password: (String, nil)
  • .read(source, password:) ⇒ Elements::Workbook

    Parameters:

    • source (Object)
    • password: (String, nil)

    Returns:

Yields:

Yield Parameters:

Yield Returns:

  • (void)

Raises:



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/xlsxrb.rb', line 179

def self.read(source, password: nil, &)
  if source.is_a?(String)
    if source.start_with?("PK\x03\x04") || source.include?("\x00") || Ooxml::Cfb::Reader.cfb?(source)
      if Ooxml::Cfb::Reader.cfb?(source)
        decrypted_zip = Ooxml::Crypto.decrypt(source, password)
        source = StringIO.new(decrypted_zip)
      else
        source = StringIO.new(source)
      end
    elsif File.file?(source)
      first_bytes = begin
        File.binread(source, 8)
      rescue StandardError
        nil
      end
      if Ooxml::Cfb::Reader.cfb?(first_bytes)
        encrypted_data = File.binread(source)
        decrypted_zip = Ooxml::Crypto.decrypt(encrypted_data, password)
        source = StringIO.new(decrypted_zip)
      end
    end
  elsif source.respond_to?(:read) && source.respond_to?(:pos) && source.respond_to?(:seek)
    begin
      cur_pos = source.pos
      first_bytes = source.read(8)
      source.seek(cur_pos)
      if Ooxml::Cfb::Reader.cfb?(first_bytes)
        full_data = source.read
        decrypted_zip = Ooxml::Crypto.decrypt(full_data, password)
        source = StringIO.new(decrypted_zip)
      end
    rescue StandardError
      # Fall through to standard reader if seeking fails
    end
  end

  attributes = source.is_a?(String) ? { "filepath" => source } : {}
  Xlsxrb.in_span("Xlsxrb.read", attributes: attributes) do
    entries = Ooxml::ZipReader.open(source, &:read_all)
    shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
    workbook_sheets = Ooxml::WorkbookParser.parse(entries["xl/workbook.xml"])
    rels = Ooxml::RelationshipsParser.parse(entries["xl/_rels/workbook.xml.rels"])
    styles = Ooxml::StylesParser.parse(entries["xl/styles.xml"])

    sheets = workbook_sheets.map do |sheet_info|
      target = rels[sheet_info[:r_id]]
      next nil unless target

      sheet_path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
      sheet_xml = entries[sheet_path]
      next nil if sheet_xml.nil? || sheet_xml.empty?

      StreamSheet.new(
        sheet_info[:name],
        sheet_xml,
        shared_strings,
        styles
      )
    end.compact

    wb = Elements::Workbook.new(sheets: sheets, shared_strings: shared_strings, styles: styles)

    if block_given?
      sheets.each(&)
      nil
    else
      wb
    end
  end
end

.rich_text(*runs, text: nil, **font_props) ⇒ Elements::RichText

Helper to construct Xlsxrb::Elements::RichText objects with formatted text runs.

: (*(Hash[Symbol, untyped] | String) runs, ?text: String?, **untyped font_props) -> Elements::RichText

Examples:

Create multi-run rich text

rt = Xlsxrb.rich_text({ text: "Total: ", font: { bold: true } }, { text: "$100" })

Create simple styled text

rt = Xlsxrb.rich_text(text: "Important Notice", bold: true, color: "FF0000")

Parameters:

  • runs (Array<Hash, String>)

    Optional array of rich text run hashes or string.

  • text (String, nil) (defaults to: nil)

    Plain text string (convenience parameter).

  • font_props (Hash)

    Inline font styling options (e.g. bold: true, color: "FF0000").

  • text: (String, nil) (defaults to: nil)

Returns:



109
110
111
112
113
114
115
116
# File 'lib/xlsxrb.rb', line 109

def self.rich_text(*runs, text: nil, **font_props)
  if text
    runs = [{ text: text, font: font_props }]
  elsif runs.size == 1 && runs.first.is_a?(String) && !font_props.empty?
    runs = [{ text: runs.first, font: font_props }]
  end
  Elements::RichText.new(runs: runs)
end

.write(target, password: nil, encryption_mode: :standard, strict_excel_mode: true) {|stream_writer| ... } ⇒ void .write(workbook, password: nil, encryption_mode: :standard) ⇒ String .write(target, workbook, password: nil, encryption_mode: :standard) ⇒ void .write(workbook, password:, encryption_mode:) ⇒ String .write(target, workbook, password:, encryption_mode:) ⇒ void .write(target_or_workbook, workbook_or_nil, password:, encryption_mode:, strict_excel_mode:) ⇒ void

Writes an XLSX file or IO stream (streaming or in-memory), or returns a binary string.

: (Elements::Workbook workbook, ?password: String?, ?encryption_mode: Symbol) -> String : (untyped target, Elements::Workbook | untyped workbook, ?password: String?, ?encryption_mode: Symbol) -> void : (untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped

Examples:

Streaming write to file

Xlsxrb.write("output.xlsx") do |writer|
  writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
end

Password-protected streaming write

Xlsxrb.write("protected.xlsx", password: "SecretPassword123") do |writer|
  writer.sheet("Confidential") { |s| s.row(["Private Data", 100]) }
end

In-memory export to binary string (for Rails send_data & mailers)

binary_data = Xlsxrb.write(workbook)

Overloads:

  • .write(target, password: nil, encryption_mode: :standard, strict_excel_mode: true) {|stream_writer| ... } ⇒ void

    This method returns an undefined value.

    Streaming write: yields a StreamWriter context for high-speed, zero-allocation XLSX generation.

    Parameters:

    • target (String, IO, StringIO)

      Destination file path or writable IO object.

    • password (String, nil) (defaults to: nil)

      Optional password to encrypt the generated XLSX file.

    • encryption_mode (Symbol) (defaults to: :standard)

      Encryption algorithm (:standard or :agile).

    • strict_excel_mode (Boolean) (defaults to: true)

      Whether to enforce Microsoft Excel specification limits.

    Yields:

    • (stream_writer)

    Yield Parameters:

  • .write(workbook, password: nil, encryption_mode: :standard) ⇒ String

    In-memory write: exports the workbook to an in-memory binary String.

    Parameters:

    • workbook (Elements::Workbook)

      The workbook to write.

    • password (String, nil) (defaults to: nil)

      Optional password to encrypt the binary string.

    • encryption_mode (Symbol) (defaults to: :standard)

      Encryption algorithm (:standard or :agile).

    Returns:

    • (String)

      Binary data representing the XLSX file.

  • .write(target, workbook, password: nil, encryption_mode: :standard) ⇒ void

    This method returns an undefined value.

    In-memory write: writes the workbook to a file path or IO stream.

    Parameters:

    • target (String, IO, StringIO)

      Destination file path or writable IO object.

    • workbook (Elements::Workbook)

      The workbook to write.

    • password (String, nil) (defaults to: nil)

      Optional password to encrypt the output file.

    • encryption_mode (Symbol) (defaults to: :standard)

      Encryption algorithm (:standard or :agile).

  • .write(workbook, password:, encryption_mode:) ⇒ String

    Parameters:

    Returns:

    • (String)
  • .write(target, workbook, password:, encryption_mode:) ⇒ void

    This method returns an undefined value.

    Parameters:

    • target (Object)
    • workbook (Elements::Workbook, untyped)
    • password: (String, nil)
    • encryption_mode: (Symbol)
  • .write(target_or_workbook, workbook_or_nil, password:, encryption_mode:, strict_excel_mode:) ⇒ void

    This method returns an undefined value.

    Parameters:

    • target_or_workbook (Object)
    • workbook_or_nil (Elements::Workbook, untyped)
    • password: (String, nil)
    • encryption_mode: (Symbol)
    • strict_excel_mode: (Boolean)


294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'lib/xlsxrb.rb', line 294

def self.write(target_or_workbook, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
  if block_given?
    target = target_or_workbook
    raise Error, "target is required" if target.nil?

    attributes = target.is_a?(String) ? { "filepath" => target } : {}
    return Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
      if password && !password.empty?
        buf = StringIO.new
        buf.binmode
        stream_writer = StreamWriter.new(buf, strict_excel_mode: strict_excel_mode)
        begin
          yield stream_writer
          stream_writer.close
          plain_bytes = buf.string.b
          encrypted_bytes = Ooxml::Crypto.encrypt(plain_bytes, password, mode: encryption_mode)
          if target.is_a?(String)
            File.binwrite(target, encrypted_bytes)
          elsif target.respond_to?(:write)
            target.write(encrypted_bytes)
          end
        ensure
          stream_writer.cleanup!
        end
      else
        stream_writer = StreamWriter.new(target, strict_excel_mode: strict_excel_mode)
        begin
          yield stream_writer
          stream_writer.close
        ensure
          stream_writer.cleanup!
        end
      end
    end
  end

  if workbook_or_nil.nil?
    wb = target_or_workbook
    raise Error, "workbook must be an Elements::Workbook" unless wb.is_a?(Elements::Workbook)

    io = StringIO.new
    io.binmode
    write(io, wb, password: password, encryption_mode: encryption_mode)
    return io.string.b
  end

  target = target_or_workbook
  workbook = workbook_or_nil
  raise Error, "target is required" if target.nil?
  raise Error, "workbook must be an Elements::Workbook" unless workbook.is_a?(Elements::Workbook)

  attributes = target.is_a?(String) ? { "filepath" => target } : {}
  Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
    sst = []
    sst_index = {}

    # Collect shared strings and build index without allocating new Hashes
    sheet_data = workbook.sheets.map do |raw_ws|
      ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
      ws.rows.each do |row|
        row.cells.each do |cell|
          val = cell.value
          if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
            sst << val
            sst_index[val] = sst.size - 1
          end
        end
      end
      columns = ws.columns.map do |col|
        # simplecov:disable
        # Edge case / untested delegation block
        { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
        # simplecov:enable
      end
      sd = { name: ws.name, rows: ws.rows, columns: columns }
      sd[:charts] = ws.charts unless ws.charts.empty?

      # Extract facade metadata from unmapped_data
      facade = ws.unmapped_data[:facade]
      facade&.each { |key, val| sd[key] = val }

      sd
    end

    # Extract workbook-level facade metadata
    wb_facade = workbook.unmapped_data[:facade] || {}

    if password && !password.empty?
      buf = StringIO.new
      buf.binmode
      Ooxml::WorkbookWriter.write(
        buf,
        sheets: sheet_data,
        shared_strings: sst,
        shared_strings_index: sst_index,
        styles: workbook.styles,
        defined_names: wb_facade[:defined_names],
        core_properties: wb_facade[:core_properties],
        app_properties: wb_facade[:app_properties],
        custom_properties: wb_facade[:custom_properties],
        workbook_protection: wb_facade[:workbook_protection],
        workbook_properties: wb_facade[:workbook_properties]
      )
      encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
      if target.is_a?(String)
        File.binwrite(target, encrypted_bytes)
      elsif target.respond_to?(:write)
        target.write(encrypted_bytes)
      end
    else
      Ooxml::WorkbookWriter.write(
        target,
        sheets: sheet_data,
        shared_strings: sst,
        shared_strings_index: sst_index,
        styles: workbook.styles,
        defined_names: wb_facade[:defined_names],
        core_properties: wb_facade[:core_properties],
        app_properties: wb_facade[:app_properties],
        custom_properties: wb_facade[:custom_properties],
        workbook_protection: wb_facade[:workbook_protection],
        workbook_properties: wb_facade[:workbook_properties]
      )
    end
  end
end