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,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module LiquidXlsx
|
|
7
|
+
# Polymorphic image source with format detection and deduplication.
|
|
8
|
+
#
|
|
9
|
+
# Accepts:
|
|
10
|
+
# - Raw binary String (auto-detects format from magic bytes)
|
|
11
|
+
# - Image (idempotent)
|
|
12
|
+
# - IO / object with #read
|
|
13
|
+
# - Hash: { data:, io:, path:, url:, content_type:, width:, height: }
|
|
14
|
+
# - String that looks like a path/URL (requires images.loader)
|
|
15
|
+
class Image
|
|
16
|
+
attr_reader :binary, :content_type, :extension
|
|
17
|
+
|
|
18
|
+
# Magic byte signatures for format detection.
|
|
19
|
+
SIGNATURES = {
|
|
20
|
+
"PNG" => ["\x89PNG\r\n\x1a\n".b, "image/png", "png"],
|
|
21
|
+
"JPEG" => ["\xFF\xD8\xFF".b, "image/jpeg", "jpeg"],
|
|
22
|
+
"GIF87a" => ["GIF87a".b, "image/gif", "gif"],
|
|
23
|
+
"GIF89a" => ["GIF89a".b, "image/gif", "gif"]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def initialize(binary:, content_type: nil, extension: nil)
|
|
27
|
+
@binary = binary.b
|
|
28
|
+
@content_type = content_type || detect_content_type(@binary)
|
|
29
|
+
raise_unknown_format(@content_type) unless @content_type
|
|
30
|
+
|
|
31
|
+
@extension = extension || detect_extension(@content_type)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Polymorphic coercion.
|
|
35
|
+
# @param value [String, Image, IO, Hash] input value
|
|
36
|
+
# @param loader [#call] optional proc for resolving path/URL strings
|
|
37
|
+
# @return [Image]
|
|
38
|
+
# Maximum depth of nested coercion (loader indirections etc.). Prevents
|
|
39
|
+
# infinite recursion when a loader returns a string that itself looks like
|
|
40
|
+
# a path/URL (or the same value it was given).
|
|
41
|
+
MAX_COERCE_DEPTH = 4
|
|
42
|
+
|
|
43
|
+
def self.coerce(value, loader: nil, depth: 0)
|
|
44
|
+
if depth > MAX_COERCE_DEPTH
|
|
45
|
+
raise RenderError,
|
|
46
|
+
"Image source could not be resolved to binary data " \
|
|
47
|
+
"(loader kept returning unresolvable values)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
case value
|
|
51
|
+
when Image
|
|
52
|
+
value
|
|
53
|
+
when String
|
|
54
|
+
if value.b.start_with?(*signature_bytes)
|
|
55
|
+
# Raw binary — wrap directly
|
|
56
|
+
new(binary: value)
|
|
57
|
+
else
|
|
58
|
+
# Path or URL string — requires loader
|
|
59
|
+
raise_loader_required(loader, value) unless loader
|
|
60
|
+
|
|
61
|
+
raw = loader.call(value)
|
|
62
|
+
if raw.is_a?(String) && !raw.b.start_with?(*signature_bytes)
|
|
63
|
+
raise RenderError,
|
|
64
|
+
"images.loader returned a string that is not recognized " \
|
|
65
|
+
"image data for source '#{value}'"
|
|
66
|
+
end
|
|
67
|
+
coerce(raw, loader: loader, depth: depth + 1)
|
|
68
|
+
end
|
|
69
|
+
when Hash
|
|
70
|
+
coerce_hash(value, loader: loader)
|
|
71
|
+
else
|
|
72
|
+
if value.respond_to?(:read)
|
|
73
|
+
coerce(value.read, loader: loader, depth: depth + 1)
|
|
74
|
+
else
|
|
75
|
+
raise RenderError, "Unsupported image source type: #{value.class}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# SHA-256 hash for deduplication.
|
|
81
|
+
# @return [String]
|
|
82
|
+
def sha256
|
|
83
|
+
@sha256 ||= Digest::SHA256.hexdigest(@binary)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Native pixel dimensions [width, height] parsed from the binary header.
|
|
87
|
+
# Used to preserve aspect ratio when only width OR height is given.
|
|
88
|
+
# Returns nil when the format/size cannot be determined.
|
|
89
|
+
def native_dimensions
|
|
90
|
+
case @content_type
|
|
91
|
+
when "image/png"
|
|
92
|
+
png_dimensions
|
|
93
|
+
when "image/gif"
|
|
94
|
+
gif_dimensions
|
|
95
|
+
when "image/jpeg"
|
|
96
|
+
jpeg_dimensions
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class << self
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def signature_bytes
|
|
106
|
+
SIGNATURES.values.map(&:first)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def coerce_hash(hash, loader:)
|
|
110
|
+
if hash.key?(:data)
|
|
111
|
+
new(
|
|
112
|
+
binary: hash[:data],
|
|
113
|
+
content_type: hash[:content_type],
|
|
114
|
+
extension: hash[:extension]
|
|
115
|
+
)
|
|
116
|
+
elsif hash.key?(:io) || hash.key?(:path) || hash.key?(:url)
|
|
117
|
+
source = hash[:io] || hash[:path] || hash[:url]
|
|
118
|
+
raise RenderError, "Image source is a path/URL but no loader configured" unless loader
|
|
119
|
+
|
|
120
|
+
raw = loader.call(source)
|
|
121
|
+
new(
|
|
122
|
+
binary: raw,
|
|
123
|
+
content_type: hash[:content_type],
|
|
124
|
+
extension: hash[:extension]
|
|
125
|
+
)
|
|
126
|
+
else
|
|
127
|
+
raise RenderError, "Image hash requires :data, :io, :path, or :url"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def raise_loader_required(_loader, value)
|
|
132
|
+
raise RenderError, "Image source '#{value}' appears to be a path/URL " \
|
|
133
|
+
"but no images.loader configured"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# PNG: IHDR chunk holds 4-byte big-endian width/height at offsets 16/20
|
|
140
|
+
# (8-byte signature + 4-byte length + 4-byte "IHDR" type).
|
|
141
|
+
def png_dimensions
|
|
142
|
+
return nil if @binary.bytesize < 24
|
|
143
|
+
|
|
144
|
+
[@binary[16, 4].unpack1("N"), @binary[20, 4].unpack1("N")]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# GIF: logical screen descriptor holds 2-byte little-endian width/height
|
|
148
|
+
# at offsets 6/8 (right after the 6-byte "GIF87a"/"GIF89a" header).
|
|
149
|
+
def gif_dimensions
|
|
150
|
+
return nil if @binary.bytesize < 10
|
|
151
|
+
|
|
152
|
+
[@binary[6, 2].unpack1("v"), @binary[8, 2].unpack1("v")]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# JPEG: scan marker segments for a SOFn marker and read its 2-byte
|
|
156
|
+
# big-endian height/width. SOFn = C0-C3, C5-C7, C9-CB, CD-CF
|
|
157
|
+
# (C4=DHT, C8=JPG, CC=DAC are excluded).
|
|
158
|
+
def jpeg_dimensions
|
|
159
|
+
bytes = @binary.bytes
|
|
160
|
+
return nil if bytes.size < 9
|
|
161
|
+
return nil unless bytes[0] == 0xFF && bytes[1] == 0xD8
|
|
162
|
+
|
|
163
|
+
i = 2
|
|
164
|
+
while i < bytes.size - 9
|
|
165
|
+
return nil unless bytes[i] == 0xFF
|
|
166
|
+
|
|
167
|
+
marker = bytes[i + 1]
|
|
168
|
+
if (0xC0..0xC3).cover?(marker) || (0xC5..0xC7).cover?(marker) ||
|
|
169
|
+
(0xC9..0xCB).cover?(marker) || (0xCD..0xCF).cover?(marker)
|
|
170
|
+
height = (bytes[i + 5] << 8) | bytes[i + 6]
|
|
171
|
+
width = (bytes[i + 7] << 8) | bytes[i + 8]
|
|
172
|
+
return [width, height]
|
|
173
|
+
end
|
|
174
|
+
# Standalone markers carry no length payload.
|
|
175
|
+
return nil if [0xD8, 0xD9].include?(marker) || (0xD0..0xD7).cover?(marker) || marker == 0x01
|
|
176
|
+
# SOS (0xDA) marks the start of entropy-coded data; SOF must appear
|
|
177
|
+
# before it. Stop scanning to avoid misreading stuffed 0xFF bytes.
|
|
178
|
+
return nil if marker == 0xDA
|
|
179
|
+
|
|
180
|
+
length = (bytes[i + 2] << 8) | bytes[i + 3]
|
|
181
|
+
return nil if length < 2
|
|
182
|
+
|
|
183
|
+
i += 2 + length
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def detect_content_type(binary)
|
|
189
|
+
SIGNATURES.each_value do |(sig_prefix, ct, _ext)|
|
|
190
|
+
return ct if binary.start_with?(sig_prefix)
|
|
191
|
+
end
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def detect_extension(content_type)
|
|
196
|
+
{
|
|
197
|
+
"image/png" => "png",
|
|
198
|
+
"image/jpeg" => "jpeg",
|
|
199
|
+
"image/gif" => "gif"
|
|
200
|
+
}[content_type]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def raise_unknown_format(_content_type)
|
|
204
|
+
raise UnsupportedTemplateError,
|
|
205
|
+
"Unrecognized image format. Supported: PNG, JPEG, GIF. " \
|
|
206
|
+
"Set content_type explicitly if format is known."
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
# Handles transformation of merged cells when rows are inserted or deleted.
|
|
5
|
+
class MergeCellsTransformer
|
|
6
|
+
# Clone a merge range for a repeated row.
|
|
7
|
+
# @param merge_range [String] e.g., "A3:B3"
|
|
8
|
+
# @param row_offset [Integer] shift from original row
|
|
9
|
+
# @return [String] new merge range
|
|
10
|
+
def clone_for_row(merge_range, row_offset)
|
|
11
|
+
return nil if row_offset == 0
|
|
12
|
+
|
|
13
|
+
start_ref, end_ref = merge_range.split(":")
|
|
14
|
+
start = CellReference.new(start_ref)
|
|
15
|
+
end_ref = CellReference.new(end_ref)
|
|
16
|
+
|
|
17
|
+
new_start = start.shift(row_offset, 0)
|
|
18
|
+
new_end = end_ref.shift(row_offset, 0)
|
|
19
|
+
"#{new_start}:#{new_end}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Shift merge ranges that are below a block boundary.
|
|
23
|
+
# @param merge_range [String] original merge range
|
|
24
|
+
# @param boundary_start [Integer] first row of the block
|
|
25
|
+
# @param boundary_end [Integer] last row of the block
|
|
26
|
+
# @param row_delta [Integer] net row shift (positive = insert, negative = delete)
|
|
27
|
+
# @return [String, nil] new merge range or nil if it should be removed
|
|
28
|
+
def shift_below(merge_range, boundary_start, boundary_end, row_delta)
|
|
29
|
+
start_ref, end_ref = merge_range.split(":")
|
|
30
|
+
start = CellReference.new(start_ref)
|
|
31
|
+
end_ref = CellReference.new(end_ref)
|
|
32
|
+
|
|
33
|
+
# Check if merge crosses the boundary
|
|
34
|
+
if crosses_boundary?(start.row, end_ref.row, boundary_start, boundary_end)
|
|
35
|
+
return nil # marked for error
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# If merge is completely below the boundary, shift it
|
|
39
|
+
if start.row >= boundary_start
|
|
40
|
+
new_start = start.shift(row_delta, 0)
|
|
41
|
+
new_end = end_ref.shift(row_delta, 0)
|
|
42
|
+
"#{new_start}:#{new_end}"
|
|
43
|
+
else
|
|
44
|
+
merge_range # above boundary, keep as is
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if a merge range crosses a structural block boundary.
|
|
49
|
+
# @param merge_start_row [Integer]
|
|
50
|
+
# @param merge_end_row [Integer]
|
|
51
|
+
# @param block_start [Integer]
|
|
52
|
+
# @param block_end [Integer]
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def crosses_boundary?(merge_start_row, merge_end_row, block_start, block_end)
|
|
55
|
+
# Merge is completely inside the block - not crossing
|
|
56
|
+
return false if merge_start_row >= block_start && merge_end_row <= block_end
|
|
57
|
+
# Merge is completely above the block - not crossing
|
|
58
|
+
return false if merge_end_row < block_start
|
|
59
|
+
# Merge is completely below the block - not crossing
|
|
60
|
+
return false if merge_start_row > block_end
|
|
61
|
+
|
|
62
|
+
# Merge overlaps with block boundaries — crosses
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if merge is inside a range
|
|
67
|
+
# @param merge_start_row [Integer]
|
|
68
|
+
# @param merge_end_row [Integer]
|
|
69
|
+
# @param range_start [Integer]
|
|
70
|
+
# @param range_end [Integer]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def inside_range?(merge_start_row, merge_end_row, range_start, range_end)
|
|
73
|
+
merge_start_row >= range_start && merge_end_row <= range_end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|