liquid_xlsx 0.1.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.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Parses and manipulates Excel cell references (A1 notation).
5
+ class CellReference
6
+ PATTERN = /\A
7
+ (?<col_absolute>\$)?(?<col>[A-Z]+)
8
+ (?<row_absolute>\$)?(?<row>\d+)
9
+ \z/x
10
+
11
+ attr_reader :col, :row, :col_absolute, :row_absolute
12
+
13
+ # @param ref [String] e.g. "A1", "$A$1", "A$1", "$A1", "AA10"
14
+ def initialize(ref)
15
+ match = PATTERN.match(ref)
16
+ raise ArgumentError, "Invalid cell reference: #{ref}" unless match
17
+
18
+ @col = match[:col]
19
+ @row = match[:row].to_i
20
+ @col_absolute = !match[:col_absolute].nil?
21
+ @row_absolute = !match[:row_absolute].nil?
22
+ end
23
+
24
+ # Shift the reference by given row and column deltas.
25
+ # @param row_delta [Integer]
26
+ # @param col_delta [Integer]
27
+ # @return [String] new cell reference
28
+ def shift(row_delta, col_delta = 0)
29
+ new_row = @row_absolute ? @row : @row + row_delta
30
+ new_col = @col_absolute ? @col : column_add(@col, col_delta)
31
+
32
+ col_prefix = @col_absolute ? "$" : ""
33
+ row_prefix = @row_absolute ? "$" : ""
34
+
35
+ "#{col_prefix}#{new_col}#{row_prefix}#{new_row}"
36
+ end
37
+
38
+ def to_s
39
+ col_prefix = @col_absolute ? "$" : ""
40
+ row_prefix = @row_absolute ? "$" : ""
41
+ "#{col_prefix}#{@col}#{row_prefix}#{@row}"
42
+ end
43
+
44
+ # Convert column letters to zero-based index.
45
+ # @param col [String] e.g. "A", "AA"
46
+ # @return [Integer]
47
+ def self.col_to_index(col)
48
+ result = 0
49
+ col.each_char do |c|
50
+ result = (result * 26) + (c.ord - "A".ord + 1)
51
+ end
52
+ result - 1
53
+ end
54
+
55
+ # Convert zero-based index to column letters.
56
+ # @param index [Integer]
57
+ # @return [String]
58
+ def self.index_to_col(index)
59
+ result = ""
60
+ n = index + 1
61
+ while n > 0
62
+ n -= 1
63
+ result = ((n % 26) + "A".ord).chr + result
64
+ n /= 26
65
+ end
66
+ result
67
+ end
68
+
69
+ private
70
+
71
+ # Add a delta to a column letter string.
72
+ # @param col [String] e.g. "A"
73
+ # @param delta [Integer]
74
+ # @return [String]
75
+ def column_add(col, delta)
76
+ index = self.class.col_to_index(col) + delta
77
+ raise ArgumentError, "Column index out of bounds: #{index}" if index < 0
78
+
79
+ self.class.index_to_col(index)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module LiquidXlsx
6
+ # Builds OpenXML drawing parts (drawingN.xml and relationships) from collected
7
+ # image operations for a single worksheet.
8
+ class DrawingBuilder
9
+ XDR_NS = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"
10
+ A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
11
+ R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
12
+
13
+ def self.px_to_emu(px, dpi = 96)
14
+ ((px.to_f / dpi) * 914_400).round
15
+ end
16
+
17
+ def self.col_to_index(col)
18
+ col.to_s.upcase.each_char.reduce(0) do |acc, ch|
19
+ (acc * 26) + (ch.ord - "A".ord + 1)
20
+ end - 1
21
+ end
22
+
23
+ def self.row_to_zero(row)
24
+ row.to_i - 1
25
+ end
26
+
27
+ # @param sheet_ops [Array<Hash>] image operations for one sheet
28
+ # @param images_options [Hash] { loader:, default_dpi: }
29
+ # @param merge_refs [Array<String>] e.g. ["A1:C2", "D5:D6"] from rendered sheet
30
+ def initialize(sheet_ops, images_options = {}, merge_refs = [])
31
+ @ops = sheet_ops
32
+ @dpi = images_options.fetch(:default_dpi, 96)
33
+ @loader = images_options[:loader]
34
+ @merge_refs = merge_refs
35
+ @next_r_id = 1
36
+ @seen_sha = {}
37
+ end
38
+
39
+ def build
40
+ images = @ops.map { |op| coerce_image(op) }
41
+ return empty_result if images.all?(&:nil?)
42
+
43
+ anchors = []
44
+ media_items = []
45
+ anchor_idx = 0
46
+
47
+ images.each_with_index do |img, op_idx|
48
+ next if img.nil?
49
+
50
+ anchor_idx += 1
51
+
52
+ sha = img.sha256
53
+ unless @seen_sha.key?(sha)
54
+ @seen_sha[sha] = @next_r_id
55
+ media_items << { sha: sha, binary: img.binary, ext: img.extension }
56
+ @next_r_id += 1
57
+ end
58
+
59
+ op = @ops[op_idx]
60
+ anchor = build_anchor(img, anchor_idx, @seen_sha[sha], op)
61
+ anchors << anchor
62
+ end
63
+
64
+ drawing_xml = build_drawing_xml(anchors)
65
+ drawing_rels_xml = build_rels_xml(media_items)
66
+
67
+ { drawing_xml: drawing_xml, drawing_rels_xml: drawing_rels_xml, media_items: media_items }
68
+ end
69
+
70
+ private
71
+
72
+ def coerce_image(op)
73
+ source = op[:source]
74
+ Image.coerce(source, loader: @loader)
75
+ rescue LiquidXlsx::Error => e
76
+ # Re-raise with sheet/cell context if missing
77
+ if !e.respond_to?(:cell) || e.cell.nil?
78
+ raise RenderError.new(
79
+ e.message,
80
+ sheet: op[:source_sheet],
81
+ cell: op[:source_cell]
82
+ )
83
+ end
84
+ raise
85
+ rescue StandardError => e
86
+ raise RenderError.new(
87
+ "Failed to load image: #{e.message}",
88
+ sheet: op[:source_sheet],
89
+ cell: op[:source_cell]
90
+ )
91
+ end
92
+
93
+ def build_anchor(img, idx, r_id, op)
94
+ from_col = self.class.col_to_index(op[:anchor_col]) if op[:anchor_col]
95
+ from_row = self.class.row_to_zero(op[:anchor_row]) if op[:anchor_row]
96
+ from_col ||= 0
97
+ from_row ||= 0
98
+
99
+ to_col, to_row, ext_cx, ext_cy = determine_range(from_col, from_row, op, img)
100
+
101
+ if ext_cx || ext_cy
102
+ build_one_cell_anchor(idx, r_id, from_col, from_row, ext_cx, ext_cy)
103
+ else
104
+ build_two_cell_anchor(idx, r_id, from_col, from_row, to_col, to_row)
105
+ end
106
+ end
107
+
108
+ def build_two_cell_anchor(idx, r_id, from_col, from_row, to_col, to_row)
109
+ <<~ANCHOR
110
+ <xdr:twoCellAnchor editAs="oneCell">
111
+ <xdr:from><xdr:col>#{from_col}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>#{from_row}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>
112
+ <xdr:to><xdr:col>#{to_col}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>#{to_row}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
113
+ #{pic_xml(idx, r_id)}
114
+ <xdr:clientData/>
115
+ </xdr:twoCellAnchor>
116
+ ANCHOR
117
+ end
118
+
119
+ def build_one_cell_anchor(idx, r_id, from_col, from_row, ext_cx, ext_cy)
120
+ <<~ANCHOR
121
+ <xdr:oneCellAnchor>
122
+ <xdr:from><xdr:col>#{from_col}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>#{from_row}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>
123
+ <xdr:ext cx="#{ext_cx}" cy="#{ext_cy}"/>
124
+ #{pic_xml(idx, r_id)}
125
+ <xdr:clientData/>
126
+ </xdr:oneCellAnchor>
127
+ ANCHOR
128
+ end
129
+
130
+ def pic_xml(idx, r_id)
131
+ <<~PIC
132
+ <xdr:pic>
133
+ <xdr:nvPicPr>
134
+ <xdr:cNvPr id="#{idx}" name="Image #{idx}" descr=""/>
135
+ <xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr>
136
+ </xdr:nvPicPr>
137
+ <xdr:blipFill>
138
+ <a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="rId#{r_id}"/>
139
+ <a:stretch><a:fillRect/></a:stretch>
140
+ </xdr:blipFill>
141
+ <xdr:spPr>
142
+ <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
143
+ </xdr:spPr>
144
+ </xdr:pic>
145
+ PIC
146
+ end
147
+
148
+ # Returns [to_col, to_row, ext_cx, ext_cy].
149
+ # ext_cx/ext_cy are EMU when width/height given, nil otherwise.
150
+ def determine_range(from_col, from_row, op, img = nil)
151
+ # Priority 1: explicit "to:" cell reference
152
+ if op[:to]
153
+ to_parts = op[:to].match(/\A([A-Z]+)(\d+)\z/i)
154
+ if to_parts
155
+ to_col = self.class.col_to_index(to_parts[1]) + 1
156
+ to_row = self.class.row_to_zero(to_parts[2]) + 1
157
+ # Shift to by the same row delta as anchor (for loops)
158
+ if op[:template_anchor_row]
159
+ delta = (op[:anchor_row] || 1) - op[:template_anchor_row]
160
+ to_row += delta
161
+ end
162
+ return [to_col, to_row, nil, nil]
163
+ end
164
+ end
165
+
166
+ # Priority 2: colspan/rowspan (relative)
167
+ if op[:colspan] || op[:rowspan]
168
+ to_col = from_col + (op[:colspan] || 1)
169
+ to_row = from_row + (op[:rowspan] || 1)
170
+ return [to_col, to_row, nil, nil]
171
+ end
172
+
173
+ # Priority 3: anchor is top-left of a merged range → fill the merge
174
+ merge_result = fill_merge_range(from_col, from_row)
175
+ return merge_result if merge_result
176
+
177
+ # Priority 4: width/height → single cell with fixed EMU extents.
178
+ # Both cx and cy are always produced (CT_Extent requires both); a
179
+ # missing dimension is derived from the image's native aspect ratio,
180
+ # falling back to a square when the header cannot be parsed.
181
+ if op[:width] || op[:height]
182
+ ext_cx, ext_cy = compute_extents(op, img)
183
+ return [from_col + 1, from_row + 1, ext_cx, ext_cy]
184
+ end
185
+
186
+ # Priority 5: default — single cell
187
+ [from_col + 1, from_row + 1, nil, nil]
188
+ end
189
+
190
+ # Compute EMU extents from width/height options. When only one dimension
191
+ # is supplied, the other is derived from the native pixel size so that
192
+ # the aspect ratio is preserved (or defaults to a square as a last resort).
193
+ def compute_extents(op, img)
194
+ width_px = op[:width]
195
+ height_px = op[:height]
196
+ native = img&.native_dimensions
197
+
198
+ if width_px && !height_px
199
+ nw, nh = native || [nil, nil]
200
+ height_px = (nw&.positive? ? (width_px.fdiv(nw) * nh).round : width_px)
201
+ elsif height_px && !width_px
202
+ nw, nh = native || [nil, nil]
203
+ width_px = (nh&.positive? ? (height_px.fdiv(nh) * nw).round : height_px)
204
+ end
205
+
206
+ [self.class.px_to_emu(width_px, @dpi), self.class.px_to_emu(height_px, @dpi)]
207
+ end
208
+
209
+ # If anchor (from_col, from_row) matches the top-left cell of any merged range,
210
+ # return [to_col, to_row, nil, nil] for the range's bottom-right + 1.
211
+ # Otherwise nil.
212
+ def fill_merge_range(from_col, from_row)
213
+ @merge_refs.each do |ref|
214
+ parts = ref.split(":")
215
+ next unless parts.length == 2
216
+
217
+ tl = CellReference.new(parts[0])
218
+ br = CellReference.new(parts[1])
219
+
220
+ tl_col = self.class.col_to_index(tl.col)
221
+ tl_row = self.class.row_to_zero(tl.row)
222
+ br_col = self.class.col_to_index(br.col) + 1
223
+ br_row = self.class.row_to_zero(br.row) + 1
224
+
225
+ if tl_col == from_col && tl_row == from_row
226
+ return [br_col, br_row, nil, nil]
227
+ end
228
+ end
229
+ nil
230
+ end
231
+
232
+ def build_drawing_xml(anchors)
233
+ <<~XML
234
+ <xdr:wsDr xmlns:xdr="#{XDR_NS}" xmlns:a="#{A_NS}">
235
+ #{anchors.join("\n")}
236
+ </xdr:wsDr>
237
+ XML
238
+ end
239
+
240
+ def build_rels_xml(media_items)
241
+ rels = media_items.each_with_index.map do |mi, idx|
242
+ format('<Relationship Id="rId%s" ' \
243
+ 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" ' \
244
+ 'Target="../media/%s"/>', idx + 1, sha_filename(mi[:sha], mi[:ext]))
245
+ end.join("\n")
246
+
247
+ <<~XML
248
+ <?xml version="1.0" encoding="UTF-8"?>
249
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
250
+ #{rels}
251
+ </Relationships>
252
+ XML
253
+ end
254
+
255
+ def sha_filename(sha, ext)
256
+ "image_#{sha[0..11]}.#{ext}"
257
+ end
258
+
259
+ def empty_result
260
+ { drawing_xml: nil, drawing_rels_xml: nil, media_items: [] }
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ class Error < StandardError; end
5
+
6
+ class TemplateSyntaxError < Error
7
+ attr_reader :sheet, :row, :cell, :tag
8
+
9
+ def initialize(message, sheet: nil, row: nil, cell: nil, tag: nil)
10
+ @sheet = sheet
11
+ @row = row
12
+ @cell = cell
13
+ @tag = tag
14
+ super(build_message(message))
15
+ end
16
+
17
+ private
18
+
19
+ def build_message(message)
20
+ parts = [message]
21
+ parts << "Sheet: #{sheet}" if sheet
22
+ parts << "Row: #{row}" if row
23
+ parts << "Cell: #{cell}" if cell
24
+ parts << "Tag: #{tag}" if tag
25
+ parts.join("\n")
26
+ end
27
+ end
28
+
29
+ class RenderError < Error
30
+ attr_reader :sheet, :row, :cell, :template
31
+
32
+ def initialize(message, sheet: nil, row: nil, cell: nil, template: nil)
33
+ @sheet = sheet
34
+ @row = row
35
+ @cell = cell
36
+ @template = template
37
+ super(build_message(message))
38
+ end
39
+
40
+ private
41
+
42
+ def build_message(message)
43
+ parts = [message]
44
+ parts << "Sheet: #{sheet}" if sheet
45
+ parts << "Row: #{row}" if row
46
+ parts << "Cell: #{cell}" if cell
47
+ parts << "Template: #{template}" if template
48
+ parts.join("\n")
49
+ end
50
+ end
51
+
52
+ class MissingVariableError < RenderError; end
53
+
54
+ class UnsupportedTemplateError < Error
55
+ attr_reader :sheet, :row, :cell, :block_rows
56
+
57
+ def initialize(message, sheet: nil, row: nil, cell: nil, block_rows: nil)
58
+ @sheet = sheet
59
+ @row = row
60
+ @cell = cell
61
+ @block_rows = block_rows
62
+ super(build_message(message))
63
+ end
64
+
65
+ private
66
+
67
+ def build_message(message)
68
+ parts = [message]
69
+ parts << "Sheet: #{sheet}" if sheet
70
+ parts << "Row: #{row}" if row
71
+ parts << "Cell: #{cell}" if cell
72
+ parts << "Block: rows #{block_rows}" if block_rows
73
+ parts.join("\n")
74
+ end
75
+ end
76
+
77
+ class InvalidXlsxError < Error; end
78
+
79
+ class OutputWriteError < Error; end
80
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Custom Liquid filters for xlsx template rendering.
5
+ module Filters
6
+ # No custom filters needed for MVP.
7
+ # Standard Liquid filters (date, etc.) are available.
8
+ end
9
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ module LiquidXlsx
5
+ # Translates Excel formulas when rows are copied or shifted, and widens
6
+ # ranges that reference loop bodies after loops have expanded.
7
+ #
8
+ # Loop expansions are registered in RENDER coordinates: for each template row
9
+ # of a loop body we know the list of rendered row numbers it produced. A range
10
+ # like SUM(E3:E3) whose rows point into a loop body is rewritten to span from
11
+ # the first to the last rendered body row; all other relative references are
12
+ # shifted by the formula's own row delta.
13
+ class FormulaTranslator
14
+ # Pattern for cell references including sheet-qualified ones.
15
+ # Guards: a reference must not be preceded or followed by an identifier
16
+ # character, so tails of function names (LOG10, ATAN2) and defined names
17
+ # are not mistaken for cell references.
18
+ CELL_REF = /
19
+ (?<![A-Za-z0-9_$])
20
+ (?<sheet>(?:'[^']*'|[A-Za-z0-9_.]+)!)? # optional sheet name
21
+ (?<col_abs>\$)?(?<col>[A-Z]{1,3})
22
+ (?<row_abs>\$)?(?<row>\d+)
23
+ (?![A-Za-z0-9_(])
24
+ /x
25
+
26
+ def initialize
27
+ # Each: { rows: {template_row => [rendered_row, ...]}, block_first:, block_last:, path: }
28
+ @expansions = []
29
+ end
30
+
31
+ # Register a loop expansion.
32
+ # @param rows [Hash{Integer => Array<Integer>}] template body row => rendered rows
33
+ # @param block_first [Integer] template row of {% for %}
34
+ # @param block_last [Integer] template row of {% endfor %}
35
+ # @param path [Array<Integer>] iteration indices of enclosing loops
36
+ def register_expansion(rows:, block_first:, block_last:, path: [])
37
+ @expansions << { rows: rows, block_first: block_first,
38
+ block_last: block_last, path: path.dup.freeze }
39
+ end
40
+
41
+ # Whether a template row lies inside a registered loop block (i.e. the row
42
+ # was copied per iteration rather than moved).
43
+ def copied_row?(template_row)
44
+ return false unless template_row
45
+
46
+ @expansions.any? { |e| template_row.between?(e[:block_first], e[:block_last]) }
47
+ end
48
+
49
+ # Translate a formula.
50
+ # @param formula [String] the original formula (without leading =)
51
+ # @param row_delta [Integer] how many rows to shift (positive = down)
52
+ # @param col_delta [Integer] how many columns to shift
53
+ # @param template_row [Integer, nil] template row the formula lives on
54
+ # (used to skip loop expansions for formulas inside the loop itself)
55
+ # @param copied [Boolean] copy semantics (references to other sheets shift
56
+ # too, as in Excel copy/paste); move semantics leaves them untouched
57
+ # @param formula_path [Array<Integer>] iteration path of the formula cell
58
+ # @return [String] translated formula
59
+ def translate(formula, row_delta, col_delta = 0, template_row: nil, copied: true, formula_path: [])
60
+ refs = scan_refs(formula)
61
+ return formula if refs.empty?
62
+
63
+ mark_expanded_ranges(formula, refs, template_row, formula_path)
64
+
65
+ out = +""
66
+ last_pos = 0
67
+ refs.each do |ref|
68
+ out << formula[last_pos...ref[:pos]]
69
+ out << rebuild_ref(ref, row_delta, col_delta, copied)
70
+ last_pos = ref[:end_pos]
71
+ end
72
+ out << formula[last_pos..]
73
+ out
74
+ end
75
+
76
+ private
77
+
78
+ # Scan cell references with positions, ignoring text inside double-quoted
79
+ # string literals.
80
+ def scan_refs(formula)
81
+ masked = mask_strings(formula)
82
+ refs = []
83
+ masked.scan(CELL_REF) do
84
+ m = $LAST_MATCH_INFO
85
+ refs << {
86
+ pos: m.begin(0),
87
+ end_pos: m.end(0),
88
+ text: formula[m.begin(0)...m.end(0)],
89
+ sheet: m[:sheet] || "",
90
+ col_abs: m[:col_abs],
91
+ col: m[:col],
92
+ row_abs: m[:row_abs],
93
+ row: m[:row].to_i
94
+ }
95
+ end
96
+ refs
97
+ end
98
+
99
+ # Replace the contents of double-quoted string literals with spaces so that
100
+ # cell-ref-looking text inside them is neither shifted nor expanded.
101
+ # Positions are preserved (replacement has the same length).
102
+ def mask_strings(formula)
103
+ formula.gsub(/"(?:[^"]|"")*"/) { |s| "\"#{' ' * (s.length - 2)}\"" }
104
+ end
105
+
106
+ # Find range pairs (r1:r2) pointing into an expanded loop body and pin
107
+ # their rows to absolute rendered coordinates (stored as :fixed_row).
108
+ def mark_expanded_ranges(formula, refs, template_row, formula_path)
109
+ refs.each_cons(2) do |r1, r2|
110
+ next unless formula[r1[:end_pos]...r2[:pos]] == ":"
111
+ next if r1[:row_abs] || r2[:row_abs]
112
+ next unless r1[:sheet].empty? && r2[:sheet].empty?
113
+
114
+ exp = find_expansion(r1[:row], r2[:row], template_row, formula_path)
115
+ next unless exp
116
+
117
+ all_rendered = exp[:rows].values.flatten
118
+ next if all_rendered.empty? # empty collection: fall back to plain shift
119
+
120
+ r1[:fixed_row] = all_rendered.min
121
+ r2[:fixed_row] = all_rendered.max
122
+ end
123
+ end
124
+
125
+ # An expansion applies when both range rows lie within the loop block
126
+ # (body rows or the tag rows), at least one of them is a body row, the
127
+ # formula itself is outside the block, and the expansion's path is a
128
+ # prefix of the formula's path (so inner-loop expansions are only
129
+ # visible to formulas in the same iteration scope).
130
+ def find_expansion(row1, row2, template_row, formula_path)
131
+ @expansions.reverse_each.find do |exp|
132
+ next false unless path_prefix_of?(exp[:path], formula_path)
133
+ next false if template_row && template_row >= exp[:block_first] && template_row <= exp[:block_last]
134
+ next false unless row1.between?(exp[:block_first], exp[:block_last])
135
+ next false unless row2.between?(exp[:block_first], exp[:block_last])
136
+
137
+ exp[:rows].key?(row1) || exp[:rows].key?(row2)
138
+ end
139
+ end
140
+
141
+ def path_prefix_of?(exp_path, formula_path)
142
+ return true if exp_path.empty?
143
+ return false if exp_path.length > formula_path.length
144
+
145
+ exp_path.each_with_index.all? { |idx, i| idx == formula_path[i] }
146
+ end
147
+
148
+ def rebuild_ref(ref, row_delta, col_delta, copied)
149
+ # Move semantics: references to other sheets do not change when rows
150
+ # shift on this sheet.
151
+ return ref[:text] if !copied && !ref[:sheet].empty? && !ref[:fixed_row]
152
+
153
+ new_col = if ref[:col_abs]
154
+ ref[:col]
155
+ else
156
+ CellReference.index_to_col(CellReference.col_to_index(ref[:col]) + col_delta)
157
+ end
158
+ new_row = if ref[:fixed_row]
159
+ ref[:fixed_row]
160
+ elsif ref[:row_abs]
161
+ ref[:row]
162
+ else
163
+ ref[:row] + row_delta
164
+ end
165
+ new_row = 1 if new_row < 1
166
+
167
+ "#{ref[:sheet]}#{ref[:col_abs] || ''}#{new_col}#{ref[:row_abs] || ''}#{new_row}"
168
+ end
169
+ end
170
+ end