rbxl 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b16579845423af49cff940ed7557164236d66b2e3e7f92e8bcece2a69c486e0
4
- data.tar.gz: 3976e30db04742ea0b22b5d1edf95bccaf269e0ee6b6d209af3fe14bbaace7f0
3
+ metadata.gz: d17b36b2ac6f09ffead848335751f05b6213d804243ba9056f7522cf54c683db
4
+ data.tar.gz: 790a5a387010f137caedd61a88d89569a238c165eeac3b5d5f6fe1b58c032712
5
5
  SHA512:
6
- metadata.gz: 2dcba5f510b571dd546ca36b8c86dd607dd406298691e71bf01575e124c15bac6f526bcdb8aa7183f4980fb06c16e7dfe8b4d0f7ce3743c57b214f5e90d6c1c4
7
- data.tar.gz: 2cd7f062d75b0af0ae1fdbe13606dcbe5b1c6df0d2aa48be94e7ef2b06bf178ef8ae315d8c9c1c11629b265d9f2829beef60f4127294ac7a91b4de088f4d2a09
6
+ metadata.gz: c4ea18b5ddba87bc83950fd4b92267963ab213b5ef95740e1abdde9abf4688f07b2c59f4bcf33f5bbd59b57811347bee5924f7338b2ac0411e42a628aec3e06e
7
+ data.tar.gz: c9e84af9e9dca0758f6799b8569d91f9e18218e1fbe0bba9a3f6827c050ebf262c793a5a4fcf19d97fab5c64630f197a15ee9e7cbcd6b8431b382da334e44228
data/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to this project are documented here. The format is based
4
4
  on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.5.0] - 2026-10-31
8
+
9
+ ### Changed
10
+
11
+ - The `rubyzip` dependency is widened from `~> 2.3` to `>= 2.4, < 4`, so
12
+ rbxl now installs alongside rubyzip 3.x. The floor moves to 2.4, the
13
+ first release accepting the `create:` keyword that 3.x requires. No
14
+ library code changes were needed; the test suite passes against both
15
+ rubyzip 2.4 and 3.x.
16
+
17
+ ## [1.4.0] - 2026-05-01
18
+
19
+ ### Added
20
+
21
+ - `Rbxl.open(path, edit: true)` opens a new `Rbxl::EditableWorkbook` for
22
+ surgical read-modify-save passes against an existing `.xlsx`. The design
23
+ promise is borrowed from rbpptx: parts you don't touch round-trip
24
+ byte-for-byte (`copy_raw_entry` straight from the source ZIP), and inside
25
+ a worksheet you do edit only the targeted `<c>` element is rewritten —
26
+ sibling cells, row attributes, `<mergeCells>`, `<conditionalFormatting>`,
27
+ `<dataValidations>`, comments, drawings, charts, pivot caches, and any
28
+ unknown OOXML extensions stay in place. The cell's `s` (style index)
29
+ attribute survives an overwrite so template number formats, fonts, and
30
+ fills carry through to the new value. Cells written by this mode become
31
+ inline strings (`t="inlineStr"`); `xl/sharedStrings.xml` is never
32
+ mutated, so the output is deterministic without a second SST pass.
33
+ Touched sheets are parsed as full Nokogiri DOMs, so this is the right
34
+ tool for template fill-ins — not for rewriting the data area of a large
35
+ worksheet (use `Rbxl.new` for that). `Rbxl::EditableCell#value=` accepts
36
+ `nil`, `String`, `Integer`, `Float`, and `true`/`false`; `Date`/`Time`
37
+ raise `Rbxl::EditableCellTypeError` so 1.4.0 doesn't ship a half-baked
38
+ numFmt write. New cells (and their enclosing rows) are inserted in
39
+ column- and row-sorted positions.
40
+ - `Rbxl::EditableWorkbook#save` accepts no path to save in place,
41
+ rewriting the original via temp file plus atomic rename so a crash
42
+ mid-save never produces a half-written workbook.
43
+
44
+ ### Changed
45
+
46
+ - Shared-strings parsing factored into `Rbxl::SharedStringsLoader` so the
47
+ read-only and editable workbooks share a single SST decoder rather than
48
+ carrying parallel copies. Behavior and limits (`Rbxl.max_shared_strings`,
49
+ `Rbxl.max_shared_string_bytes`) are unchanged.
50
+
7
51
  ## [1.3.0] - 2026-04-27
8
52
 
9
53
  ### Added
data/README.md CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/rbxl.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/rbxl)
4
4
 
5
- Fast, memory-friendly Ruby gem for row-by-row `.xlsx` reads and append-only writes.
5
+ Fast, memory-friendly Ruby gem for row-by-row `.xlsx` reads, surgical edits
6
+ of existing `.xlsx` files, and append-only writes.
6
7
 
7
- `rbxl` is built for the two workbook workflows that scale cleanly:
8
+ `rbxl` is built for the three workbook workflows that scale cleanly:
8
9
 
9
10
  - read-only row-by-row iteration
11
+ - read-modify-save surgical edits ("template fill-in") that round-trip
12
+ every untouched part byte-for-byte
10
13
  - write-only workbook generation
11
14
 
12
15
  The API is intentionally small and `openpyxl`-inspired, with an optional
@@ -16,30 +19,42 @@ Supported:
16
19
 
17
20
  - write-only workbook generation
18
21
  - read-only row-by-row iteration
22
+ - read-modify-save surgical edits via `Rbxl.open(path, edit: true)` —
23
+ byte-for-byte preservation of untouched parts (styles, drawings, charts,
24
+ comments, pivot caches, custom XML, untouched sheets)
19
25
  - opt-in date/time conversion driven by the workbook's `numFmt` styles
20
26
  - optional C extension (`rbxl/native`) for maximum performance
21
27
 
22
28
  Out of scope:
23
29
 
24
- - in-place editing of an existing `.xlsx` file — rbxl opens workbooks
25
- read-only and generates new workbooks write-only, with no read-modify-save
26
- path. If you need to open a file, tweak a handful of cells, and write it
27
- back preserving everything else, use a full-object-model library instead.
30
+ - bulk rewrites of a worksheet's data area in edit mode — use the
31
+ write-only mode (`Rbxl.new`) for that. Edit mode is the right tool for
32
+ template fill-ins (a handful of cells in a templated workbook); the
33
+ touched sheet is loaded into a Nokogiri DOM, so memory scales with that
34
+ sheet's on-disk size
35
+ - inserting / deleting / reordering / duplicating sheets
36
+ - editing styles, formulas, named ranges, drawings, or shared strings
37
+ - `Date` / `Time` / `DateTime` writes via edit mode (raise
38
+ `Rbxl::EditableCellTypeError`); convert to a numeric Excel serial
39
+ yourself if you need a date cell
28
40
  - legacy `.xls` (BIFF/CFB) input — rbxl reads OOXML `.xlsx` only. Convert
29
41
  first, e.g. `libreoffice --headless --convert-to xlsx file.xls` or
30
42
  `ssconvert file.xls file.xlsx` (Gnumeric). `Rbxl.open` detects the OLE
31
43
  compound-file magic on open and raises `Rbxl::UnsupportedFormatError`
32
44
  with the conversion hint rather than surfacing an opaque ZIP parse
33
45
  error from rubyzip.
34
- - preserving arbitrary workbook structure on save
35
- - rich style round-tripping
36
- - formulas, images, charts, comments
46
+ - preserving arbitrary workbook structure on _write-only_ save (edit mode
47
+ preserves every untouched part)
48
+ - rich style round-tripping when generating new workbooks
49
+ - formulas, images, charts, comments — readable in edit mode, but not
50
+ introspected or edited
37
51
 
38
52
  ## Usage
39
53
 
40
- `Rbxl.open` defaults to read-only and `Rbxl.new` defaults to write-only;
41
- the `read_only:` / `write_only:` keywords remain for call-site clarity and
42
- to leave room for a future read/write mode.
54
+ `Rbxl.open` defaults to read-only, `Rbxl.open(path, edit: true)` opens an
55
+ existing workbook for surgical edits, and `Rbxl.new` defaults to
56
+ write-only. The mode is selected by the wrapper at the module level so the
57
+ call site doesn't have to juggle backend classes.
43
58
 
44
59
  ### Writing a new workbook
45
60
 
@@ -233,6 +248,83 @@ default; leaving it off skips the styles parse entirely and keeps the
233
248
  native fast path in use. Turning it on routes reads through the pure-Ruby
234
249
  worksheet parser.
235
250
 
251
+ ### Editing an existing workbook
252
+
253
+ Open a workbook in edit mode to surgically replace cell values without
254
+ rebuilding the file from scratch. The classic use case is template
255
+ fill-in: open a stylized template, write a handful of named cells, save
256
+ back. Every part you don't touch — styles, drawings, charts, comments,
257
+ pivot caches, custom XML, untouched worksheets — round-trips byte-for-byte
258
+ straight from the source ZIP, so unknown OOXML extensions and
259
+ PowerPoint-style add-ins survive the save.
260
+
261
+ ```ruby
262
+ require "rbxl"
263
+
264
+ Rbxl.open("template.xlsx", edit: true) do |book|
265
+ sheet = book.sheet("Invoice")
266
+ sheet["B2"].value = "Acme Inc."
267
+ sheet["B3"].value = Date.today.strftime("%Y-%m-%d") # Strings are fine
268
+ sheet["E10"].value = 1_250.0 # Numbers are fine
269
+ book.save("invoice-acme.xlsx")
270
+ end
271
+ ```
272
+
273
+ `book.save` with no argument overwrites the original file via temp file
274
+ plus atomic rename, so a crash mid-save never produces a half-written
275
+ workbook:
276
+
277
+ ```ruby
278
+ Rbxl.open("template.xlsx", edit: true) do |book|
279
+ book.sheet(0)["A1"].value = "Q3 results"
280
+ book.save # in-place, atomic
281
+ end
282
+ ```
283
+
284
+ Inside an edited worksheet, only the targeted `<c>` element is rewritten;
285
+ sibling cells, the row's other attributes, `<mergeCells>`,
286
+ `<conditionalFormatting>`, `<dataValidations>`, and any unknown OOXML
287
+ extensions remain in place. The cell's `s` (style index) attribute is
288
+ preserved when you overwrite an existing cell, so template formatting
289
+ (number format, font, fill, alignment) carries through. New cells (and
290
+ their enclosing rows) are inserted in column- and row-sorted positions.
291
+
292
+ `EditableCell#value=` accepts:
293
+
294
+ | Ruby | XLSX representation |
295
+ |-------------------------|----------------------------------|
296
+ | `nil` | empty cell (preserves `s` style) |
297
+ | `String` | `t="inlineStr"` with `<is><t/>` |
298
+ | `Integer`, `Float` | `<v>` numeric (no `t` attribute) |
299
+ | `true`, `false` | `t="b"` with `<v>1</v>`/`<v>0</v>` |
300
+ | `Date`, `Time` | raises `Rbxl::EditableCellTypeError` |
301
+
302
+ Strings always round-trip as inline strings — `xl/sharedStrings.xml` is
303
+ never mutated, so the SST entries that the cells you _didn't_ touch still
304
+ reference stay byte-identical, and the touched cells get their text
305
+ inlined. The trade-off is that overwriting a previously-shared-string
306
+ cell leaves an orphaned SST entry; for template fill-ins that's
307
+ negligible, and it's the simplest design that guarantees deterministic
308
+ output.
309
+
310
+ `Date`/`Time`/`DateTime` writes raise `Rbxl::EditableCellTypeError` in
311
+ 1.4.0 — the cell's `numFmt` style would also have to be the right
312
+ date-pattern style for Excel to render the value, and silently picking
313
+ one is the kind of magic this design promise is built to avoid. Convert
314
+ to an Excel serial yourself if you need a date cell, and rely on the
315
+ template's existing date-formatted style index to render it.
316
+
317
+ #### Out of scope for edit mode
318
+
319
+ - inserting / deleting / reordering / duplicating sheets
320
+ - editing styles, formulas, named ranges, drawings, or shared strings
321
+ - recomputing the worksheet `<dimension>` when a write expands the bounds
322
+ (Excel recomputes on open; openpyxl-style normalization may arrive in
323
+ a later release)
324
+ - bulk rewrites of a worksheet's data area — touched sheets are loaded
325
+ into a Nokogiri DOM, so memory scales with that sheet's size on disk.
326
+ For data-area rewrites, use `Rbxl.new` instead.
327
+
236
328
  ## Native C Extension
237
329
 
238
330
  Add a single `require` to opt-in to the libxml2-based C extension for
@@ -0,0 +1,176 @@
1
+ module Rbxl
2
+ # A view onto a single +<c>+ element inside an {EditableWorksheet}.
3
+ #
4
+ # Cells are not stored — each call to {EditableWorksheet#cell} returns a
5
+ # fresh {EditableCell} that resolves the underlying +<c>+ node on demand.
6
+ # Reads decode the current XML; writes mutate the worksheet's DOM and
7
+ # mark the sheet dirty so the next {EditableWorkbook#save} re-serializes
8
+ # it.
9
+ #
10
+ # == Type matrix on write
11
+ #
12
+ # * +nil+ — clears the cell's value (children + +t+ attribute removed),
13
+ # leaving an empty +<c>+ that retains its +s+ (style index)
14
+ # * +true+ / +false+ — boolean cell (+t="b"+)
15
+ # * +Integer+ / +Float+ — number cell (no +t+ attribute)
16
+ # * +String+ — inline string cell (+t="inlineStr"+); +xl/sharedStrings.xml+
17
+ # is never mutated, so this round-trips deterministically without a
18
+ # second pass over the SST
19
+ # * +Date+ / +Time+ / +DateTime+ — raises {EditableCellTypeError}; convert
20
+ # to a numeric serial yourself if you need a date cell. Date support is
21
+ # intentionally deferred so 1.4.0 doesn't ship a half-baked numFmt write
22
+ #
23
+ # When overwriting an existing cell, the +s+ (style index) attribute is
24
+ # preserved so template formatting (number format, font, fill, alignment)
25
+ # carries through to the new value. Any +<f>+ (formula) and cached +<v>+
26
+ # are dropped — assigning a value means the cell is no longer a formula.
27
+ class EditableCell
28
+ # Namespace for the main SpreadsheetML schema.
29
+ MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main".freeze
30
+
31
+ # @return [String] Excel-style coordinate, e.g. +"B5"+
32
+ attr_reader :coordinate
33
+
34
+ # @api private
35
+ # Construct via {EditableWorksheet#cell}; not for direct use.
36
+ #
37
+ # @param worksheet [EditableWorksheet]
38
+ # @param coordinate [String] already-normalized +A1+-style coordinate
39
+ def initialize(worksheet:, coordinate:)
40
+ @worksheet = worksheet
41
+ @coordinate = coordinate
42
+ end
43
+
44
+ # Decodes the current value of the cell.
45
+ #
46
+ # @return [String, Integer, Float, true, false, nil] the cell's value, or
47
+ # +nil+ if the cell does not exist or has no stored value. Boolean
48
+ # cells return +true+/+false+; numeric cells return +Integer+ when the
49
+ # stored value is integer-shaped, +Float+ otherwise; +t="s"+ cells
50
+ # resolve through the workbook's shared strings table; +t="inlineStr"+
51
+ # and +t="str"+ cells return the literal text
52
+ def value
53
+ node = @worksheet.find_or_create_cell_node(@coordinate, create: false)
54
+ return nil unless node
55
+
56
+ decode(node)
57
+ end
58
+
59
+ # Sets the cell's value. See the class-level "Type matrix on write"
60
+ # documentation for accepted Ruby types and how each is serialized.
61
+ #
62
+ # @param new_value [String, Integer, Float, true, false, nil]
63
+ # @return [Object] +new_value+
64
+ # @raise [Rbxl::EditableCellTypeError] for unsupported types
65
+ # (+Date+/+Time+, arbitrary objects)
66
+ def value=(new_value)
67
+ reject_unsupported_type!(new_value)
68
+
69
+ node = @worksheet.find_or_create_cell_node(@coordinate, create: true)
70
+ apply_value(node, new_value)
71
+ @worksheet.mark_dirty!
72
+ new_value
73
+ end
74
+
75
+ private
76
+
77
+ WHITESPACE_BYTES = [" ".ord, "\t".ord, "\n".ord, "\r".ord].freeze
78
+ private_constant :WHITESPACE_BYTES
79
+
80
+ def reject_unsupported_type!(value)
81
+ case value
82
+ when nil, true, false, Integer, Float, String
83
+ # supported
84
+ when Date, Time, DateTime
85
+ raise EditableCellTypeError,
86
+ "Date/Time/DateTime are not supported by EditableCell in 1.4.0; " \
87
+ "convert to a numeric Excel serial yourself if you need a date cell"
88
+ when Numeric
89
+ # other Numerics (Rational, BigDecimal) — coerce to Float on apply
90
+ else
91
+ raise EditableCellTypeError,
92
+ "unsupported cell value type: #{value.class}"
93
+ end
94
+ end
95
+
96
+ def apply_value(node, value)
97
+ node.children.unlink
98
+ node.delete("t")
99
+
100
+ case value
101
+ when nil
102
+ # empty cell — preserve <c r="..." s="..."/>
103
+ when true
104
+ node["t"] = "b"
105
+ node.add_child("<v>1</v>")
106
+ when false
107
+ node["t"] = "b"
108
+ node.add_child("<v>0</v>")
109
+ when Integer
110
+ node.add_child("<v>#{value}</v>")
111
+ when Float
112
+ # Ruby's Float#to_s gives the shortest round-trippable form. Excel
113
+ # accepts standard decimal and scientific notation as <v> text.
114
+ node.add_child("<v>#{value}</v>")
115
+ when String
116
+ node["t"] = "inlineStr"
117
+ text = CGI.escapeHTML(value)
118
+ space_attr = preserve_whitespace?(value) ? ' xml:space="preserve"' : ""
119
+ node.add_child("<is><t#{space_attr}>#{text}</t></is>")
120
+ when Numeric
121
+ node.add_child("<v>#{value.to_f}</v>")
122
+ end
123
+ end
124
+
125
+ def decode(node)
126
+ type = node["t"]
127
+ case type
128
+ when "s"
129
+ text = first_text_at(node, "v")
130
+ text ? @worksheet.shared_string_at(text.to_i) : nil
131
+ when "inlineStr"
132
+ decode_inline_string(node)
133
+ when "str"
134
+ first_text_at(node, "v")
135
+ when "b"
136
+ first_text_at(node, "v") == "1"
137
+ when "e"
138
+ first_text_at(node, "v")
139
+ else
140
+ raw = first_text_at(node, "v")
141
+ decode_numeric(raw)
142
+ end
143
+ end
144
+
145
+ def first_text_at(node, local_name)
146
+ child = node.at_xpath("./main:#{local_name}", "main" => MAIN_NS)
147
+ child&.text
148
+ end
149
+
150
+ def decode_inline_string(node)
151
+ is_node = node.at_xpath("./main:is", "main" => MAIN_NS)
152
+ return nil unless is_node
153
+
154
+ is_node.xpath(".//main:t", "main" => MAIN_NS).map(&:text).join
155
+ end
156
+
157
+ def decode_numeric(raw)
158
+ return nil if raw.nil? || raw.empty?
159
+
160
+ if raw.match?(/\A-?\d+\z/)
161
+ raw.to_i
162
+ elsif raw.match?(/\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/)
163
+ raw.to_f
164
+ else
165
+ raw
166
+ end
167
+ end
168
+
169
+ def preserve_whitespace?(string)
170
+ return false if string.empty?
171
+
172
+ WHITESPACE_BYTES.include?(string.getbyte(0)) ||
173
+ WHITESPACE_BYTES.include?(string.getbyte(string.bytesize - 1))
174
+ end
175
+ end
176
+ end