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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +862 -0
- data/lib/liquid_xlsx/cell_reference.rb +82 -0
- data/lib/liquid_xlsx/drawing_builder.rb +263 -0
- data/lib/liquid_xlsx/errors.rb +80 -0
- data/lib/liquid_xlsx/filters.rb +9 -0
- data/lib/liquid_xlsx/formula_translator.rb +170 -0
- data/lib/liquid_xlsx/image.rb +209 -0
- data/lib/liquid_xlsx/merge_cells_transformer.rb +76 -0
- data/lib/liquid_xlsx/package.rb +599 -0
- data/lib/liquid_xlsx/renderer.rb +775 -0
- data/lib/liquid_xlsx/shared_strings.rb +47 -0
- data/lib/liquid_xlsx/tags/image_tag.rb +97 -0
- data/lib/liquid_xlsx/tags/sheet_tag.rb +161 -0
- data/lib/liquid_xlsx/template.rb +69 -0
- data/lib/liquid_xlsx/template_nodes.rb +62 -0
- data/lib/liquid_xlsx/template_parser.rb +446 -0
- data/lib/liquid_xlsx/version.rb +5 -0
- data/lib/liquid_xlsx/workbook.rb +308 -0
- data/lib/liquid_xlsx/worksheet.rb +436 -0
- data/lib/liquid_xlsx.rb +89 -0
- metadata +142 -0
|
@@ -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,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
|