module Xlsxrb
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.
@api public
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
rbs_inline: enabled
Constants
- VERSION
Public Class Methods
(String expression, ?String? ref, ?cached_value: (String | Numeric | bool | nil)) → Elements::Formula
Source
# File lib/xlsxrb.rb, line 180 def self.array_formula(expression, ref = nil, cached_value: nil) Elements::Formula.new( expression: expression, cached_value: cached_value, type: :array, ref: ref, calculate_always: cached_value.nil? || nil ) end
Creates an array or dynamic array {Elements::Formula} object.
@example Create a multi-cell array formula
formula = Xlsxrb.array_formula("SUM(B1:B10*C1:C10)", "A1:C1", cached_value: 0)
@example Create a dynamic array formula
formula = Xlsxrb.array_formula("SORT(UNIQUE(A1:A10))", "D1")
@param expression [String] The formula expression without leading ‘=’. @param ref [String, nil] Target cell or range reference (e.g. “A1:C1” or “D1”). @param cached_value [Object, nil] Optional precomputed value. @return [Elements::Formula] @api public
(?strict_excel_mode: bool) ?{ (WorkbookBuilder) → void } → Elements::Workbook
Source
# File lib/xlsxrb.rb, line 819 def self.build(strict_excel_mode: true) raise Error, "block is required" unless block_given? builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode) yield builder builder.build end
Builds an in-memory {Elements::Workbook} using a declarative DSL.
@example 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
@param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits. @yield [builder] @yieldparam builder [Xlsxrb::WorkbookBuilder] @return [Elements::Workbook] The compiled in-memory workbook. @api public
(Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) → Hash[Symbol, untyped]
Source
# File lib/xlsxrb.rb, line 1418 def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index, date1904: false) # 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, date1904: date1904) when Time result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904) # simplecov:enable when NilClass # empty cell end # simplecov:disable # Edge case / untested delegation block result # simplecov:enable end
Builds a raw cell hash from a value for streaming writes.
@param row_index [Integer] 0-based row index. @param col_index [Integer] 0-based column index. @param value [Object] Cell value. @param sst [Array<String>] Shared strings array. @param sst_index [Hash{String => Integer}] Shared strings index mapping. @return [Hash{Symbol => Object}] Raw cell hash. @api public
(Date | Time date_or_time, ?date1904: bool) → (Integer | Float)
Source
# File lib/xlsxrb.rb, line 231 def self.date_to_serial(date_or_time, date1904: false) Utils.date_to_serial(date_or_time, date1904: date1904) end
Converts a Date or Time to an Excel numeric serial number (1900 or 1904 system).
@example
Xlsxrb.date_to_serial(Date.new(2026, 1, 1)) #=> 46023
@param date_or_time [Date, Time] Date or Time instance. @param date1904 [Boolean] Whether to use the 1904 date system. @return [Integer, Float] Serial number. @api public
(Time | Date time, ?date1904: bool) → Float
Source
# File lib/xlsxrb.rb, line 259 def self.datetime_to_serial(time, date1904: false) Utils.datetime_to_serial(time, date1904: date1904) end
Converts a Time or Date to a fractional Excel serial number.
@example
Xlsxrb.datetime_to_serial(Time.utc(2026, 1, 1, 12, 0, 0)) #=> 46023.5
@param time [Time, Date] Time or Date instance. @param date1904 [Boolean] Whether to use the 1904 date system. @return [Float] Fractional serial number. @api public
(untyped value, ?String? format_code, ?date1904: bool) → String
Source
# File lib/xlsxrb.rb, line 217 def self.format(value, format_code = nil, date1904: false) NumberFormatter.format(value, format_code, date1904: date1904) end
Formats a cell value according to an Excel format code string.
@param value [Object, nil] Value to format. @param format_code [String, nil] Excel format code string. @param date1904 [Boolean] Whether the 1904 date system is active. @return [String] Formatted string representation. @api public
(String expression, ?cached_value: (String | Numeric | bool | nil), ?type: (Symbol | String)?, ?ref: String?) → Elements::Formula
Source
# File lib/xlsxrb.rb, line 156 def self.formula(expression, cached_value: nil, type: nil, ref: nil) Elements::Formula.new( expression: expression, cached_value: cached_value, type: type, ref: ref, calculate_always: cached_value.nil? || nil ) end
Creates an {Elements::Formula} object for use in cell values.
@example Create a formula without precalculated value
formula = Xlsxrb.formula("SUM(A1:A10)")
@example Create a formula with precomputed cached value
formula = Xlsxrb.formula("A1+B1", cached_value: 42)
@param expression [String] The formula expression without leading ‘=’ (e.g. “SUM(A1:A10)”). @param cached_value [Object, nil] Optional precomputed value for readers that do not evaluate formulas. @param type [Symbol, String, nil] Formula type (:shared, :array, etc.). @param ref [String, nil] Target cell or range reference (e.g. “A1:C1”). @return [Elements::Formula] @api public
(?String? url, ?display: String?, ?tooltip: String?, ?location: String?) → Elements::Hyperlink
Source
# File lib/xlsxrb.rb, line 205 def self.hyperlink(url = nil, display: nil, tooltip: nil, location: nil) Elements::Hyperlink.new(url: url, display: display, tooltip: tooltip, location: location) end
Creates an {Elements::Hyperlink} object for use in cell hyperlinks.
@example Create an external hyperlink with a ScreenTip tooltip
link = Xlsxrb.hyperlink("https://example.com", tooltip: "Visit Example")
@example Create an internal worksheet link
link = Xlsxrb.hyperlink(location: "Sheet2!A1", tooltip: "Go to Sheet 2")
@param url [String, nil] External hyperlink URL. @param display [String, nil] Optional display label. @param tooltip [String, nil] Optional hover ScreenTip text. @param location [String, nil] Optional internal sheet coordinate. @return [Elements::Hyperlink] @api public
(untyped source, ?untyped target, ?password: String?) ?{ (Elements::Workbook) → untyped } → void
Source
# File lib/xlsxrb.rb, line 673 def self.modify(source, target = nil, password: nil) raise Error, "source is required" if source.nil? raise Error, "block is required" unless block_given? prepared_source = prepare_source_io(source, password) in_place = target.nil? || target == source || (source.is_a?(String) && target.is_a?(String) && File.file?(target) && File.identical?(source, target)) temp_target_path = nil begin write_dest = if in_place && source.is_a?(String) temp_target = Tempfile.new(["xlsxrb_mod", ".xlsx"], File.dirname(source)) temp_target_path = temp_target.path temp_target.close temp_target_path elsif in_place && source.is_a?(StringIO) StringIO.new else target || source end replaced_entries = {} structural_change = false Ooxml::ZipReader.open(prepared_source) do |zip_reader| shared_strings_xml = zip_reader.read_entry("xl/sharedStrings.xml") shared_strings = shared_strings_xml ? Ooxml::SharedStringsParser.parse(shared_strings_xml) : [] sst_index = {} shared_strings.each_with_index { |s, idx| sst_index[s] ||= idx } initial_sst_size = shared_strings.size workbook_xml = zip_reader.read_entry("xl/workbook.xml") wb_parsed = workbook_xml ? Ooxml::WorkbookParser.parse_with_properties(workbook_xml) : { sheets: [], date1904: false, defined_names: [] } workbook_sheets = wb_parsed[:sheets] rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels") rels = rels_xml ? Ooxml::RelationshipsParser.parse(rels_xml) : {} styles_xml = zip_reader.read_entry("xl/styles.xml") styles = styles_xml ? Ooxml::StylesParser.parse(styles_xml) : {} sheet_entry_map = {} loaded_sheets = workbook_sheets.map do |sheet_info| target_path = rels[sheet_info[:r_id]] next nil unless target_path sheet_path = target_path.start_with?("/") ? target_path.delete_prefix("/") : "xl/#{target_path}" next nil unless zip_reader.entry?(sheet_path) sheet_entry_map[sheet_info[:name]] = sheet_path sheet_xml = zip_reader.read_entry(sheet_path) || "" build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles, state: sheet_info[:state] || :visible, zip_reader: zip_reader, entry_name: sheet_path) end.compact original_workbook = Elements::Workbook.new( sheets: loaded_sheets, shared_strings: shared_strings, styles: styles, defined_names: wb_parsed[:defined_names] || [] ) mutable_shared_strings = shared_strings.dup result_workbook = yield original_workbook result_workbook = original_workbook unless result_workbook.is_a?(Elements::Workbook) structural_change = result_workbook.sheets.size != original_workbook.sheets.size || result_workbook.sheets.map(&:name) != original_workbook.sheets.map(&:name) if structural_change write_target = target || source write(write_target, result_workbook, password: password) else result_workbook.sheets.each_with_index do |new_sheet, idx| orig_sheet = original_workbook.sheets[idx] next if new_sheet.equal?(orig_sheet) || new_sheet == orig_sheet entry_name = sheet_entry_map[new_sheet.name] || "xl/worksheets/sheet#{idx + 1}.xml" orig_sheet_xml = zip_reader.read_entry(entry_name) || "" new_sheet_data = serialize_sheet_data_xml( new_sheet.rows, mutable_shared_strings, sst_index, use_sst: !shared_strings_xml.nil? ) updated_sheet_xml = replace_sheet_data(orig_sheet_xml, new_sheet_data) updated_sheet_xml = update_dimension(updated_sheet_xml, new_sheet.rows) replaced_entries[entry_name] = updated_sheet_xml end if mutable_shared_strings.size > initial_sst_size && shared_strings_xml new_strings = mutable_shared_strings[initial_sst_size..] replaced_entries["xl/sharedStrings.xml"] = append_to_shared_strings_xml( shared_strings_xml, new_strings ) end perform_modify_write( write_dest, zip_reader, replaced_entries, password: password ) end end if in_place && !structural_change if source.is_a?(String) && temp_target_path FileUtils.mv(temp_target_path, source) temp_target_path = nil elsif source.is_a?(StringIO) && write_dest.is_a?(StringIO) source.string = write_dest.string elsif source.respond_to?(:write) && source.respond_to?(:seek) && write_dest.is_a?(StringIO) source.seek(0) source.write(write_dest.string) source.truncate(source.pos) if source.respond_to?(:truncate) source.rewind end end ensure if temp_target_path && File.exist?(temp_target_path) begin File.unlink(temp_target_path) rescue SystemCallError nil end end end end
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.
@example 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
@param source [String, IO, StringIO] The source file path or IO object. @param target [String, IO, StringIO, nil] Target file path or IO object (overwrites source if nil). @param password [String, nil] Optional password for encrypted files. @yield [workbook] @yieldparam workbook [Elements::Workbook] The loaded workbook. @yieldreturn [Elements::Workbook] The modified workbook. @return [void] @api public
(untyped index, ?alpha: bool) → String?
Source
# File lib/xlsxrb.rb, line 292 def self.palette_color(index, alpha: false) Colors.palette_color(index, alpha: alpha) end
Resolves an ECMA-376 indexed color palette index (0..63) to an RRGGBB hex string (or AARRGGBB if alpha: true).
@example
Xlsxrb.palette_color(12) #=> "0000FF" Xlsxrb.palette_color(12, alpha: true) #=> "FF0000FF"
@param index [Object, nil] Palette index (0..63). @param alpha [Boolean] Whether to prefix “FF” alpha channel. @return [String, nil] @api public
(untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) { (StreamSheet) → void } → void
(untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) → Elements::Workbook
Source
# File lib/xlsxrb.rb, line 363 def self.read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &) prepared_source = prepare_source_io(source, password) zip_reader = Ooxml::ZipReader.open(prepared_source) begin shared_strings_xml = zip_reader.read_entry("xl/sharedStrings.xml") shared_strings = Ooxml::SharedStringsParser.parse(shared_strings_xml) workbook_xml = zip_reader.read_entry("xl/workbook.xml") wb_parsed = Ooxml::WorkbookParser.parse_with_properties(workbook_xml) workbook_sheets = wb_parsed[:sheets] is_date1904 = wb_parsed[:date1904] rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels") rels = Ooxml::RelationshipsParser.parse(rels_xml) styles_xml = zip_reader.read_entry("xl/styles.xml") styles = Ooxml::StylesParser.parse(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}" next nil unless zip_reader.entry?(sheet_path) chunk_supplier = ->(&blk) { zip_reader.each_entry_chunk(sheet_path, &blk) } StreamSheet.new( sheet_info[:name], chunk_supplier, shared_strings, styles, zip_reader: zip_reader, entry_name: sheet_path, state: sheet_info[:state] || :visible, date1904: is_date1904, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells ) end.compact wb_unmapped = {} wb_unmapped[:workbook_properties] = { date1904: true } if is_date1904 defined_names = wb_parsed[:defined_names] || [] wb = Elements::Workbook.new( sheets: sheets, shared_strings: shared_strings, styles: styles, unmapped_data: wb_unmapped, defined_names: defined_names ) if block_given? sheets.each(&) nil else wb end ensure zip_reader.close if block_given? end end
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`).
@overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block)
Yields each {StreamSheet} sequentially in streaming mode.
@param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
@param password [String, nil] Optional password to decrypt password-protected XLSX files.
@param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
@param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
@param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
@yield [sheet]
@yieldparam sheet [StreamSheet] The streaming worksheet object.
@return [void]
@overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
Returns a lazy {Elements::Workbook} object.
@param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
@param password [String, nil] Optional password to decrypt password-protected XLSX files.
@param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
@param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
@param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
@return [Elements::Workbook]
@example 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
@example 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
@raise [EncryptedFileError] If the file is encrypted and no password was supplied. @raise [InvalidPasswordError] If the supplied password is incorrect. @raise [ParseError] If the spreadsheet XML structure is invalid. @api public
(*(Hash[Symbol, untyped] | Elements::RichTextRun | String | Array[untyped] | untyped) runs, ?text: String?, **untyped font_props) → Elements::RichText
Source
# File lib/xlsxrb.rb, line 100 def self.rich_text(*runs, text: nil, **font_props) if text runs = [Elements::RichTextRun.new(text: text, font: font_props.empty? ? nil : font_props)] elsif runs.size == 1 && runs.first.is_a?(String) && !font_props.empty? runs = [Elements::RichTextRun.new(text: runs.first, font: font_props)] else normalized_runs = [] pending_font = nil runs.each do |item| if item.respond_to?(:to_font_hash) pending_font = item.to_font_hash elsif item.is_a?(Hash) && !item.key?(:text) pending_font = item elsif item.is_a?(Elements::RichTextRun) normalized_runs << item pending_font = nil elsif item.is_a?(Hash) f = item[:font] || pending_font f = f.to_font_hash if f.respond_to?(:to_font_hash) normalized_runs << Elements::RichTextRun.new(text: item[:text].to_s, font: f) pending_font = nil elsif item.is_a?(Array) t = item[0].to_s f = item[1] || pending_font f = f.to_font_hash if f.respond_to?(:to_font_hash) f = f.to_h if f.respond_to?(:to_h) && !f.is_a?(Hash) normalized_runs << Elements::RichTextRun.new(text: t, font: f.is_a?(Hash) ? f : nil) pending_font = nil elsif pending_font normalized_runs << Elements::RichTextRun.new(text: item.to_s, font: pending_font) pending_font = nil else normalized_runs << Elements::RichTextRun.new(text: item.to_s) end end runs = normalized_runs end Elements::RichText.new(runs: runs) end
Helper to construct {Elements::RichText} objects with formatted text runs.
@example Create multi-run rich text with hashes
rt = Xlsxrb.rich_text({ text: "Total: ", font: { bold: true } }, { text: "$100" })
@example Create rich text with text/font pairs
rt = Xlsxrb.rich_text("Normal text, ", ["bold text, ", { bold: true }], ["red text", { color: "FF0000" }])
@example Create rich text with format objects implementing to_font_hash
rt = Xlsxrb.rich_text("Normal text, ", bold_format, "bold text")
@example Create simple styled text
rt = Xlsxrb.rich_text(text: "Important Notice", bold: true, color: "FF0000")
@param runs [Array<Hash, String, Array, Elements::RichTextRun, Object>] Optional array of rich text run items. @param text [String, nil] Plain text string (convenience parameter). @param font_props [Hash] Inline font styling options (e.g. bold: true, color: “FF0000”). @return [Elements::RichText] The compiled rich text element. @api public
(Numeric serial, ?date1904: bool) → Date
Source
# File lib/xlsxrb.rb, line 245 def self.serial_to_date(serial, date1904: false) Utils.serial_to_date(serial, date1904: date1904) end
Converts an Excel serial number to a Date.
@example
Xlsxrb.serial_to_date(46023) #=> #<Date: 2026-01-01>
@param serial [Numeric] Excel serial number. @param date1904 [Boolean] Whether to use the 1904 date system. @return [Date] @api public
(Numeric serial, ?date1904: bool) → Time
Source
# File lib/xlsxrb.rb, line 273 def self.serial_to_time(serial, date1904: false) Utils.serial_to_time(serial, date1904: date1904) end
Converts an Excel serial number to a Time (UTC).
@example
Xlsxrb.serial_to_time(46023.5) #=> 2026-01-01 12:00:00 UTC
@param serial [Numeric] Excel serial number. @param date1904 [Boolean] Whether to use the 1904 date system. @return [Time] @api public
(untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) { (StreamSheet) → void } → void
(untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) → Elements::Workbook
(Integer | String | Symbol | nil color, ?alpha: bool) → String?
Source
# File lib/xlsxrb.rb, line 309 def self.to_hex_color(color, alpha: false) Colors.to_hex(color, alpha: alpha) end
Converts any supported color representation (palette index 0..63, CSS name, #RGB, #RRGGBB, #AARRGGBB, integer RGB) to a normalized hex string.
@example
Xlsxrb.to_hex_color(12) #=> "0000FF" Xlsxrb.to_hex_color(:navy) #=> "000080" Xlsxrb.to_hex_color("#FF0000") #=> "FF0000"
@param color [Integer, String, Symbol, nil] @param alpha [Boolean] Whether to include the alpha channel (default false -> “RRGGBB”). @return [String, nil] @api public
(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
Source
# File lib/xlsxrb.rb, line 481 def self.write(target_or_workbook = OMITTED_TARGET, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &) if block_given? if target_or_workbook.equal?(OMITTED_TARGET) io = StringIO.new io.binmode write(io, password: password, encryption_mode: encryption_mode, strict_excel_mode: strict_excel_mode, &) return io.string.b end target = target_or_workbook raise Error, "target is required" if target.nil? 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 return end if workbook_or_nil.nil? raise Error, "workbook must be an Elements::Workbook" if target_or_workbook.equal?(OMITTED_TARGET) 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) sst = [] sst_index = {} if workbook.shared_strings && !workbook.shared_strings.empty? workbook.shared_strings.each do |val| 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 # 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 style_idx = col.unmapped_data&.[](:style_index) || col.unmapped_data&.[](:style) || (col.respond_to?(:style_index) && col.style_index) { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level, style_index: style_idx } # simplecov:enable end sd = { name: ws.name, rows: ws.rows, columns: columns } sd[:charts] = ws.charts unless ws.charts.empty? sd[:conditional_formats] = ws.conditional_formatting unless ws.conditional_formatting.empty? sd[:data_validations] = ws.data_validations unless ws.data_validations.empty? # Extract facade metadata from unmapped_data facade = ws.unmapped_data[:facade] facade&.each { |key, val| sd[key] ||= val } sd[:state] ||= ws.state if ws.respond_to?(:state) && ws.state && ws.state != :visible sd[:comments] ||= ws.comments if ws.respond_to?(:comments) && !ws.comments.empty? if ws.respond_to?(:hyperlinks) && !ws.hyperlinks.empty? sd[:hyperlinks] ||= ws.hyperlinks.is_a?(Hash) ? ws.hyperlinks.values : ws.hyperlinks end sd[:sparkline_groups] ||= ws.sparkline_groups if ws.respond_to?(:sparkline_groups) && !ws.sparkline_groups.empty? sd end # Extract workbook-level facade metadata wb_facade = workbook.unmapped_data[:facade] || {} wb_properties = workbook.unmapped_data[:workbook_properties] || wb_facade[:workbook_properties] raw_dns = wb_facade[:defined_names] || (workbook.respond_to?(:defined_names) && !workbook.defined_names.empty? ? workbook.defined_names : nil) defined_names = raw_dns ? raw_dns.map(&:dup) : [] # Auto-generate _xlnm.Print_Area and _xlnm.Print_Titles from worksheet definitions if not already present workbook.sheets.each_with_index do |ws, idx| sheet_name = ws.name || "Sheet#{idx + 1}" if ws.respond_to?(:print_area) && ws.print_area && defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Area" && (dn[:local_sheet_id] == idx || dn[:local_sheet_name] == sheet_name) } val = "'#{sheet_name}'!#{DslHelpers.absolute_range(ws.print_area)}" defined_names << { name: "_xlnm.Print_Area", value: val, local_sheet_id: idx } end next unless ws.respond_to?(:print_titles) && ws.print_titles && defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Titles" && (dn[:local_sheet_id] == idx || dn[:local_sheet_name] == sheet_name) } pt = ws.print_titles parts = [] parts << "'#{sheet_name}'!$#{pt[:cols].sub(":", ":$")}" if pt[:cols] parts << "'#{sheet_name}'!$#{pt[:rows].sub(":", ":$")}" if pt[:rows] defined_names << { name: "_xlnm.Print_Titles", value: parts.join(","), local_sheet_id: idx } unless parts.empty? end defined_names = nil if defined_names.empty? 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: 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_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: 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_properties ) end end
Writes an XLSX file or IO stream (streaming or in-memory), or returns a binary string.
@overload write(?target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
Streaming write: yields a {StreamWriter} context for high-speed, zero-allocation XLSX generation.
If target is omitted, returns the generated XLSX as an in-memory binary String.
@param target [String, IO, StringIO, nil] Optional destination file path or writable IO object.
@param password [String, nil] Optional password to encrypt the generated XLSX file.
@param encryption_mode [Symbol] Encryption algorithm (:standard or :agile).
@param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits.
@yield [stream_writer]
@yieldparam stream_writer [Xlsxrb::StreamWriter]
@return [String, void] Binary data if target is omitted, or void if written to target.
@overload write(workbook, password: nil, encryption_mode: :standard)
In-memory write: exports the workbook to an in-memory binary String. @param workbook [Elements::Workbook] The workbook to write. @param password [String, nil] Optional password to encrypt the binary string. @param encryption_mode [Symbol] Encryption algorithm (:standard or :agile). @return [String] Binary data representing the XLSX file.
@overload write(target, workbook, password: nil, encryption_mode: :standard)
In-memory write: writes the workbook to a file path or IO stream. @param target [String, IO, StringIO] Destination file path or writable IO object. @param workbook [Elements::Workbook] The workbook to write. @param password [String, nil] Optional password to encrypt the output file. @param encryption_mode [Symbol] Encryption algorithm (:standard or :agile). @return [void]
@example Streaming write to file
Xlsxrb.write("output.xlsx") do |writer| writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) } end
@example Streaming write to in-memory binary string (target omitted)
binary_data = Xlsxrb.write do |writer| writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) } end
@example Password-protected streaming write
Xlsxrb.write("protected.xlsx", password: "SecretPassword123") do |writer| writer.sheet("Confidential") { |s| s.row(["Private Data", 100]) } end
@example In-memory export to binary string (for Rails send_data & mailers)
binary_data = Xlsxrb.write(workbook)
@api public