class Xlsxrb::Ooxml::WorksheetParser
Streaming parser for xl/worksheets/sheetN.xml. Uses a fast string-scanning approach for row/cell extraction, falling back to REXML SAX only for column definitions and unmapped data.
Constants
- ATTR_R
- ATTR_S
- ATTR_T
- CELL_FAST_RE
- CELL_GENERIC_RE
- COL_LETTERS
- COL_MAP
- EMPTY_ARRAY
- EMPTY_HASH
- ROW_ATTR_START_BYTES
- TAG_DELIM_BYTES
Public Class Methods
(String str) → String
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 492 def self.decode_xml_entities(str) XmlBuilder.unescape(str) end
SECURITY NOTE: Decodes only standard predefined XML entities in a single pass. This completely prevents “Billion Laughs” attacks (exponential entity expansion) because it avoids recursive expansion and ignores custom entities entirely.
(untyped xml_string, ?shared_strings: untyped, ?part_name: untyped) ?{ (?) → untyped } → untyped
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 84 def self.each_event(xml_string, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block) return enum_for(:each_event, xml_string, shared_strings: shared_strings, part_name: part_name) unless block return if xml_string.nil? || xml_string.empty? # 1. Parse and yield column events if xml_string.include?("<cols") listener = ColumnsListener.new XmlParser.parse(xml_string, listener) listener.columns.each do |col| block.call(Event.new( type: :column, args: [col[:min], col[:max], col[:width], col[:hidden], col[:custom_width], col[:outline_level]], source: { part: part_name } )) end end # 2. Parse and yield row/cell events fast_scan_events(xml_string, shared_strings, part_name, &block) # 3. Parse and yield hyperlink events parse_hyperlinks(xml_string).each do |hl| block.call(Event.new( type: :hyperlink, args: [hl[:ref], hl[:rid], hl[:display], hl[:tooltip], hl[:location]], source: { part: part_name, cell: hl[:ref] } )) end end
Yields Event objects for columns, rows, cells, and hyperlinks.
(untyped xml_source, ?shared_strings: untyped, ?part_name: untyped, ?styles: untyped, ?date1904: untyped, ?trim_empty_rows: untyped, ?pad_empty_rows: untyped, ?pad_empty_cells: untyped) ?{ (?) → untyped } → untyped
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 31 def self.each_row(xml_source, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", styles: nil, date1904: false, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block) return enum_for(:each_row, xml_source, shared_strings: shared_strings, part_name: part_name, styles: styles, date1904: date1904, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) unless block return if xml_source.nil? || (xml_source.respond_to?(:empty?) && xml_source.empty?) row_consumer = if trim_empty_rows pending_empty = [] lambda do |row| if row.empty? pending_empty << row else pending_empty.each(&block) pending_empty.clear block.call(row) end end else block end if pad_empty_rows current_expected_idx = 0 inner_consumer = row_consumer row_consumer = lambda do |row| while current_expected_idx < row.index empty_row = StreamRow.fast_create( current_expected_idx, "".b, 0, 0, shared_strings, "", nil, false, false, nil, nil, styles, date1904, false, pad_empty_cells ) inner_consumer.call(empty_row) current_expected_idx += 1 end inner_consumer.call(row) current_expected_idx = row.index + 1 end end if xml_source.is_a?(String) fast_scan_rows_direct(xml_source, shared_strings, part_name, styles, date1904, pad_empty_cells, &row_consumer) else scan_rows_stream(xml_source, shared_strings, part_name, styles, date1904, pad_empty_cells, &row_consumer) end end
Streaming parse: yields one raw row hash at a time.
(untyped xml, untyped from, untyped to, untyped shared_strings, untyped row_source, ?untyped _prefix, ?untyped styles, ?untyped date1904) ?{ (?) → untyped } → untyped
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 862 def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, _prefix = "", styles = nil, date1904 = false, &block) # rubocop:enable Style/OptionalBooleanParameter chunk = xml.byteslice(from, to - from) row_idx = row_source.is_a?(Hash) ? row_source[:row] : row_source col_idx = 0 matched_any = false chunk.scan(CELL_FAST_RE) do |r, s, t, f, v, is, self_r, self_s| matched_any = true ref = r || self_r s_val = s || self_s style_idx = s_val&.to_i c_idx = if ref expected_letter = COL_LETTERS[col_idx] if expected_letter && ref.start_with?(expected_letter) col_idx else c_len = 0 c_len += 1 while (b = ref.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122)) col_letters = ref.byteslice(0, c_len).upcase COL_MAP[col_letters] || col_idx end else col_idx end col_idx = c_idx + 1 v = v.strip if v f = f.strip if f val = if t == "s" shared_strings[v.to_i] || "" elsif t == "b" v == "1" elsif %w[inlineStr str e].include?(t) if is extract_inline_text(is, 0, is.bytesize) elsif v normalize_text(v) else "" end elsif v v.include?(".") ? v.to_f : v.to_i end raw_val = if is extract_inline_text(is, 0, is.bytesize) elsif v normalize_text(v) end formula_expr = f ? normalize_text(f) : nil formula_expr = nil if formula_expr && formula_expr.empty? fmt_code = NumberFormatter.format_code_for(style_idx, styles) if styles && style_idx cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr, raw_val, fmt_code, date1904) block.call(cell) end return if matched_any || (!chunk.include?("<c") && !chunk.include?(":c")) # Fallback for non-standard attribute ordering chunk.scan(CELL_GENERIC_RE) do |attrs, f, v, is| r = attrs[ATTR_R, 1] t = attrs[ATTR_T, 1] s = attrs[ATTR_S, 1] c_idx = if r expected_letter = COL_LETTERS[col_idx] if expected_letter && r.start_with?(expected_letter) col_idx else c_len = 0 c_len += 1 while (b = r.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122)) col_letters = r.byteslice(0, c_len).upcase COL_MAP[col_letters] || col_idx end else col_idx end col_idx = c_idx + 1 v = v.strip if v f = f.strip if f val = if t == "s" shared_strings[v.to_i] || "" elsif t == "b" v == "1" elsif %w[inlineStr str e].include?(t) if is extract_inline_text(is, 0, is.bytesize) elsif v normalize_text(v) else "" end elsif v v.include?(".") ? v.to_f : v.to_i end raw_val = if is extract_inline_text(is, 0, is.bytesize) elsif v normalize_text(v) end style_idx = s&.to_i formula_expr = f ? normalize_text(f) : nil formula_expr = nil if formula_expr && formula_expr.empty? fmt_code = NumberFormatter.format_code_for(style_idx, styles) if styles && style_idx cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr, raw_val, fmt_code, date1904) block.call(cell) end end
rubocop:disable Style/OptionalBooleanParameter
(String xml, Integer from, Integer to, Array[String] shared_strings, ?untyped styles, ?bool date1904, ?type_cast: bool) → Array[untyped]
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 978 def self.fast_scan_row_values(xml, from, to, shared_strings, styles = nil, date1904 = false, type_cast: false) # rubocop:enable Style/OptionalBooleanParameter chunk = xml.byteslice(from, to - from) return [] if chunk.nil? || chunk.empty? col_idx = 0 matched_any = false row_values = [] chunk.scan(CELL_FAST_RE) do |r, s, t, _f, v, is, self_r, self_s| matched_any = true ref = r || self_r s_val = s || self_s c_idx = if ref expected_letter = COL_LETTERS[col_idx] if expected_letter && ref.start_with?(expected_letter) col_idx else c_len = 0 c_len += 1 while (b = ref.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122)) col_letters = ref.byteslice(0, c_len).upcase COL_MAP[col_letters] || col_idx end else col_idx end col_idx = c_idx + 1 v = v.strip if v val = if t == "s" shared_strings[v.to_i] || "" elsif t == "b" v == "1" elsif %w[inlineStr str e].include?(t) if is extract_inline_text(is, 0, is.bytesize) elsif v normalize_text(v) else "" end elsif v v.include?(".") ? v.to_f : v.to_i end if type_cast && val.is_a?(Numeric) && s_val && styles fmt_type = NumberFormatter.format_type_for(s_val.to_i, styles) begin case fmt_type when :date val = Utils.serial_to_date(val, date1904: date1904) when :datetime, :time val = Utils.serial_to_datetime(val, date1904: date1904) end rescue StandardError # Keep numeric value on conversion failure end end row_values[c_idx] = val end return row_values if matched_any || (!chunk.include?("<c") && !chunk.include?(":c")) # Fallback for non-standard attribute ordering chunk.scan(CELL_GENERIC_RE) do |attrs, _f, v, is| r = attrs[ATTR_R, 1] t = attrs[ATTR_T, 1] s = attrs[ATTR_S, 1] c_idx = if r expected_letter = COL_LETTERS[col_idx] if expected_letter && r.start_with?(expected_letter) col_idx else c_len = 0 c_len += 1 while (b = r.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122)) col_letters = r.byteslice(0, c_len).upcase COL_MAP[col_letters] || col_idx end else col_idx end col_idx = c_idx + 1 v = v.strip if v val = if t == "s" shared_strings[v.to_i] || "" elsif t == "b" v == "1" elsif %w[inlineStr str e].include?(t) if is extract_inline_text(is, 0, is.bytesize) elsif v normalize_text(v) else "" end elsif v v.include?(".") ? v.to_f : v.to_i end if type_cast && val.is_a?(Numeric) && s && styles fmt_type = NumberFormatter.format_type_for(s.to_i, styles) begin case fmt_type when :date val = Utils.serial_to_date(val, date1904: date1904) when :datetime, :time val = Utils.serial_to_datetime(val, date1904: date1904) end rescue StandardError # Keep numeric value on conversion failure end end row_values[c_idx] = val end row_values end
rubocop:disable Style/OptionalBooleanParameter
(String str) → String
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 497 def self.normalize_text(str) s = decode_xml_entities(str) s.include?("\r") ? s.gsub("\r\n", "\n").tr("\r", "\n") : s end
(untyped xml_string, ?shared_strings: untyped, ?styles: untyped) → untyped
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 22 def self.parse(xml_string, shared_strings: [], styles: nil) return [] if xml_string.nil? || xml_string.empty? rows = [] each_row(xml_string, shared_strings: shared_strings, styles: styles) { |row| rows << row } rows end
Parses all rows from a worksheet XML string. Returns an Array of raw row hashes:
{ index:, cells: [{ ref:, type:, style_index:, value:, formula: }], attrs:, unmapped: }
(untyped xml_string, ?untyped _part_name) → untyped
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 74 def self.parse_columns(xml_string, _part_name = "xl/worksheets/sheet1.xml") return [] if xml_string.nil? || xml_string.empty? return [] unless xml_string.include?("<cols") listener = ColumnsListener.new XmlParser.parse(xml_string, listener) listener.columns end
Parses column definitions (<cols>) from a worksheet.
(String?) → Array[Hash[Symbol, untyped]]
Source
# File lib/xlsxrb/ooxml/worksheet_parser.rb, line 120 def self.parse_hyperlinks(xml_string) return [] if xml_string.nil? || xml_string.empty? return [] unless xml_string.include?("hyperlink") xml = xml_string.b hpos_term = xml.index("hyperlinks") return [] unless hpos_term hpos = xml.rindex("<", hpos_term) return [] unless hpos h_end_term = xml.index("/hyperlinks", hpos) h_end = h_end_term ? xml.index(">", h_end_term) : xml.size h_end ||= xml.size links = [] pos = hpos while pos < h_end hl_start_term = xml.index("hyperlink", pos) break unless hl_start_term && hl_start_term < h_end hl_start = xml.rindex("<", hl_start_term) break unless hl_start && hl_start < h_end hl_tag_end = xml.index(">", hl_start + 1) break unless hl_tag_end hl_tag = xml.byteslice(hl_start, hl_tag_end - hl_start) ref = tag_attr(hl_tag, ' ref="') rid = tag_attr(hl_tag, ' r:id="') || tag_attr(hl_tag, ' id="') || hl_tag[/(?:[a-zA-Z0-9_]+:)?id="([^"]+)"/, 1] display = tag_attr(hl_tag, ' display="') tooltip = tag_attr(hl_tag, ' tooltip="') location = tag_attr(hl_tag, ' location="') if ref entry = { ref: ref } entry[:rid] = rid if rid entry[:display] = display if display entry[:tooltip] = tooltip if tooltip entry[:location] = location if location links << entry end pos = hl_tag_end + 1 end links end
Parses all hyperlink elements from worksheet XML.
@param xml_string [String, nil] @return [Array<Hash[Symbol, untyped]>] @api public