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,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