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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Reads and provides access to shared strings from xl/sharedStrings.xml.
5
+ class SharedStrings
6
+ # @param xml [String] XML content of sharedStrings.xml or nil if not present
7
+ def initialize(xml)
8
+ @strings = []
9
+ return unless xml
10
+ return if xml.strip.empty?
11
+
12
+ doc = Nokogiri::XML(xml)
13
+ ns = doc.root&.namespace
14
+ return unless ns
15
+
16
+ doc.xpath("//xmlns:si", "xmlns" => ns.href).each do |si|
17
+ t = si.at_xpath("xmlns:t")
18
+ @strings << if t
19
+ t.text
20
+ else
21
+ # Handle rich text or other cases - extract all text
22
+ si.xpath(".//xmlns:t").map(&:text).join
23
+ end
24
+ end
25
+ end
26
+
27
+ # Get string by index.
28
+ # @param index [Integer]
29
+ # @return [String, nil]
30
+ def [](index)
31
+ @strings[index]
32
+ end
33
+
34
+ # Get the number of shared strings.
35
+ # @return [Integer]
36
+ def count
37
+ @strings.length
38
+ end
39
+
40
+ # Get the raw XML (preserved as-is for re-insertion).
41
+ # @return [String, nil]
42
+ def raw_xml
43
+ # We keep the original content — we never modify shared strings.
44
+ nil
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ module Tags
5
+ # Handles the {% image_tag %} Liquid tag for inserting images.
6
+ #
7
+ # Syntax:
8
+ # {% image_tag <src_expr> [, colspan: N, rowspan: N, to: "F10",
9
+ # width: N, height: N] %}
10
+ #
11
+ # Collects image insertion operations in context.registers[:liquid_xlsx_image_ops].
12
+ # Returns empty string (image lives in the drawing layer, not in the cell).
13
+ class ImageTag < Liquid::Tag
14
+ SYNTAX = /\A\s*(.+?)\s*(?:,\s*(.+))?\s*\z/
15
+ OPT_PAIR = /(\w+)\s*:\s*(?:"([^"]*)"|([^\s,]+))/
16
+
17
+ def initialize(tag_name, markup, options)
18
+ super
19
+
20
+ match = markup.match(SYNTAX)
21
+ raise_syntax("Expected: {% image_tag <expr> [, options] %}") unless match
22
+
23
+ @src_expr = match[1]&.strip
24
+ raise_syntax("Missing source expression") if @src_expr.nil? || @src_expr.empty?
25
+
26
+ @options = {}
27
+ match[2]&.scan(OPT_PAIR) do |key, quoted, bare|
28
+ val = quoted || bare
29
+ raise_syntax("Invalid option: #{key.inspect}") unless val
30
+
31
+ @options[key] = normalize_option(key, val)
32
+ end
33
+ end
34
+
35
+ def render(context)
36
+ src_val = evaluate_expr(@src_expr, context)
37
+
38
+ op = {
39
+ op: :insert_image,
40
+ source: src_val,
41
+ sheet_r_id: context.registers[:liquid_xlsx_sheet_r_id],
42
+ anchor_col: context.registers[:liquid_xlsx_anchor_col],
43
+ anchor_row: context.registers[:liquid_xlsx_anchor_row],
44
+ template_anchor_row: context.registers[:liquid_xlsx_template_anchor_row],
45
+ colspan: @options["colspan"],
46
+ rowspan: @options["rowspan"],
47
+ to: @options["to"],
48
+ width: @options["width"],
49
+ height: @options["height"],
50
+ source_sheet: context.registers[:liquid_xlsx_sheet_name],
51
+ source_cell: context.registers[:liquid_xlsx_source_cell]
52
+ }
53
+
54
+ ops = context.registers[:liquid_xlsx_image_ops] ||= []
55
+ ops << op
56
+
57
+ ""
58
+ end
59
+
60
+ private
61
+
62
+ def evaluate_expr(expr, context)
63
+ # Handle string literals
64
+ if expr.match?(/\A".*"\z/)
65
+ return expr[1..-2]
66
+ end
67
+
68
+ context[expr]
69
+ rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod
70
+ raise RenderError.new(
71
+ "Undefined variable in image_tag: #{expr}",
72
+ sheet: context.registers[:liquid_xlsx_sheet_name],
73
+ row: context.registers[:liquid_xlsx_source_row],
74
+ cell: context.registers[:liquid_xlsx_source_cell],
75
+ template: "{% image_tag ... %}"
76
+ )
77
+ end
78
+
79
+ def normalize_option(key, val)
80
+ case key
81
+ when "colspan", "rowspan", "width", "height"
82
+ return val.to_i if val.match?(/\A\d+\z/)
83
+
84
+ raise_syntax("#{key} must be a positive integer, got: #{val.inspect}")
85
+ when "to"
86
+ val
87
+ else
88
+ raise_syntax("Unknown option: #{key.inspect}")
89
+ end
90
+ end
91
+
92
+ def raise_syntax(message)
93
+ raise Liquid::SyntaxError, "image_tag: #{message}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ module Tags
5
+ # Handles the {% sheet %} Liquid tag for dynamic sheet creation.
6
+ #
7
+ # Syntax:
8
+ # {% sheet name: <expr> template: "SheetName" data: <expr> as: "var_name" %}
9
+ #
10
+ # - +name+: Liquid expression for the new sheet name (e.g. "invoice.number")
11
+ # - +template+: quoted string — name of existing template sheet to clone
12
+ # - +data+: Liquid expression — object to pass as local variable
13
+ # - +as+: (optional) quoted string — local variable name, defaults to "item"
14
+ class SheetTag < Liquid::Tag
15
+ SYNTAX = /\A\s*(.+)\s*\z/
16
+ # Known argument keys. Values are split on the positions of these keys
17
+ # (not on whitespace) so that a name/data value may itself contain
18
+ # spaces and Liquid filters, e.g.: name: inv.number | append: " - "
19
+ KNOWN_KEYS = %w[name template data as].freeze
20
+ KEY_PATTERN = /\b(?:name|template|data|as)\s*:/
21
+
22
+ def initialize(tag_name, markup, options)
23
+ super
24
+
25
+ match = markup.match(SYNTAX)
26
+ unless match
27
+ raise_template_err(
28
+ "Invalid sheet tag syntax. Expected: {% sheet name: ... template: ... data: ... %}"
29
+ )
30
+ end
31
+
32
+ @args = parse_args(match[1])
33
+
34
+ raise_template_err("Missing required argument: name") unless @args["name"]
35
+ raise_template_err("Missing required argument: template") unless @args["template"]
36
+ raise_template_err("Missing required argument: data") unless @args["data"]
37
+
38
+ @name_arg = @args["name"]
39
+ @template_name = @args["template"][:value]
40
+ @data_arg = @args["data"]
41
+
42
+ # Validate that template and as are literals
43
+ raise_template_err("template must be a quoted string literal") unless @args["template"][:quoted]
44
+ raise_template_err("as must be a valid identifier") \
45
+ if @args["as"] && !@args["as"][:value].match?(/\A[A-Za-z_]\w*\z/)
46
+
47
+ @as_name = @args["as"] ? @args["as"][:value] : "item"
48
+ end
49
+
50
+ # Split "name: EXPR template: \"X\" data: EXPR as: VAR" into a hash of
51
+ # { key => { value:, quoted: } }. Values may contain spaces and Liquid
52
+ # filters because we cut the markup at the positions of the known keys.
53
+ #
54
+ # Keys are matched only OUTSIDE double-quoted strings, so a quoted value
55
+ # like name: "data: foo" is not mistaken for the `data:` key.
56
+ def parse_args(markup)
57
+ occurrences = []
58
+ in_quote = false
59
+ i = 0
60
+ while i < markup.length
61
+ ch = markup[i]
62
+ if ch == '"'
63
+ in_quote = !in_quote
64
+ i += 1
65
+ next
66
+ end
67
+
68
+ if !in_quote && (m = markup.match(KEY_PATTERN, i)) && m.begin(0) == i
69
+ key = m[0][/\A\w+/]
70
+ occurrences << { key: key, key_start: i, val_start: m.end(0) }
71
+ i = m.end(0)
72
+ next
73
+ end
74
+
75
+ i += 1
76
+ end
77
+ occurrences.sort_by! { |o| o[:key_start] }
78
+
79
+ args = {}
80
+ occurrences.each_with_index do |occ, i|
81
+ next_start = i + 1 < occurrences.length ? occurrences[i + 1][:key_start] : markup.length
82
+ raw = markup[occ[:val_start], next_start - occ[:val_start]]
83
+ raw = raw.strip.sub(/,\s*\z/, "").strip
84
+ raise_template_err("Empty value for argument: #{occ[:key].inspect}") if raw.empty?
85
+ raise_template_err("Duplicate argument: #{occ[:key].inspect}") if args.key?(occ[:key])
86
+
87
+ quoted = raw.start_with?('"') && raw.end_with?('"') && raw.length >= 2
88
+ val = quoted ? raw[1..-2] : raw
89
+ args[occ[:key]] = { value: val, quoted: quoted }
90
+ end
91
+ args
92
+ end
93
+
94
+ def render(context)
95
+ # Raise error if dynamic_sheets is disabled
96
+ unless context.registers[:liquid_xlsx_dynamic_sheets]
97
+ raise RenderError.new(
98
+ "Dynamic sheet creation is disabled. Pass dynamic_sheets: true.",
99
+ sheet: context.registers[:liquid_xlsx_sheet_name],
100
+ row: context.registers[:liquid_xlsx_source_row],
101
+ cell: context.registers[:liquid_xlsx_source_cell],
102
+ template: "{% sheet ... %}"
103
+ )
104
+ end
105
+
106
+ # Resolve name expression through Liquid context (supports filters)
107
+ name_val = evaluate_arg(@name_arg, context, expression: true)
108
+ name_val = name_val.to_s.strip
109
+
110
+ # Resolve data expression through Liquid context (preserves raw object)
111
+ data_val = evaluate_arg(@data_arg, context)
112
+
113
+ # Build operation with source context for error reporting
114
+ op = {
115
+ op: :create_sheet,
116
+ template_sheet: @template_name,
117
+ name: name_val,
118
+ data: data_val,
119
+ as: @as_name,
120
+ source_sheet: context.registers[:liquid_xlsx_sheet_name],
121
+ source_row: context.registers[:liquid_xlsx_source_row],
122
+ source_cell: context.registers[:liquid_xlsx_source_cell]
123
+ }
124
+
125
+ # Store operation in registers
126
+ ops = context.registers[:liquid_xlsx_workbook_ops] ||= []
127
+ ops << op
128
+
129
+ ""
130
+ end
131
+
132
+ private
133
+
134
+ def evaluate_arg(arg, context, expression: false)
135
+ # Quoted values are string literals (quotes were consumed by parse_args)
136
+ return arg[:value] if arg[:quoted]
137
+
138
+ if expression
139
+ # name supports Liquid filters/expressions; rendered to a string
140
+ ::Liquid::Template.parse("{{ #{arg[:value]} }}",
141
+ environment: LiquidXlsx.liquid_environment).render(context)
142
+ else
143
+ # data is resolved as a variable path to preserve the raw object
144
+ context[arg[:value]]
145
+ end
146
+ rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod
147
+ raise RenderError.new(
148
+ "Undefined variable in sheet tag: #{arg[:value]}",
149
+ sheet: context.registers[:liquid_xlsx_sheet_name],
150
+ row: context.registers[:liquid_xlsx_source_row],
151
+ cell: context.registers[:liquid_xlsx_source_cell],
152
+ template: "{% sheet ... %}"
153
+ )
154
+ end
155
+
156
+ def raise_template_err(message)
157
+ raise Liquid::SyntaxError, message
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Main template class for rendering .xlsx files.
5
+ class Template
6
+ # @param template_path [String] path to the .xlsx template
7
+ # @param options [Hash]
8
+ def initialize(template_path, options = {})
9
+ @template_path = template_path
10
+ @options = options
11
+ end
12
+
13
+ # Render template and save to file.
14
+ # @param data [Hash, Liquid::Drop, #to_liquid] data to render (Hash/Drop,
15
+ # or any object whose `#to_liquid` returns one)
16
+ # @param output_path [String]
17
+ def render_to_file(data, output_path)
18
+ package = render_package(data)
19
+ package.write(output_path)
20
+ rescue LiquidXlsx::Error
21
+ raise
22
+ rescue Zip::Error, Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::EISDIR => e
23
+ raise OutputWriteError,
24
+ "Failed to write output '#{output_path}': #{e.class}: #{e.message}"
25
+ end
26
+
27
+ # Render template and return binary content.
28
+ # @param data [Hash, Liquid::Drop, #to_liquid] data to render (Hash/Drop,
29
+ # or any object whose `#to_liquid` returns one)
30
+ # @return [String] binary .xlsx content
31
+ def render(data)
32
+ package = render_package(data)
33
+ package.to_binary
34
+ rescue LiquidXlsx::Error
35
+ raise
36
+ rescue Zip::Error, Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::EISDIR => e
37
+ raise OutputWriteError,
38
+ "Failed to generate binary output: #{e.class}: #{e.message}"
39
+ end
40
+
41
+ private
42
+
43
+ def render_package(data)
44
+ package = Package.new(@template_path)
45
+
46
+ # Phase 1: read zip entries (no XML parsing yet).
47
+ begin
48
+ package.read
49
+ rescue Zip::Error, Errno::ENOENT, Errno::EACCES => e
50
+ raise InvalidXlsxError,
51
+ "Failed to read template '#{@template_path}': #{e.class}: #{e.message}"
52
+ end
53
+
54
+ workbook = Workbook.new(package, @options)
55
+
56
+ # Phase 2: render — lazy XML parsing of workbook_xml, rels, worksheet
57
+ # parts happens here. Nokogiri::XML::SyntaxError means the input xlsx
58
+ # contains corrupt XML and is not a valid template.
59
+ begin
60
+ workbook.render(data)
61
+ rescue Nokogiri::XML::SyntaxError => e
62
+ raise InvalidXlsxError,
63
+ "Failed to read template '#{@template_path}': #{e.class}: #{e.message}"
64
+ end
65
+
66
+ package
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ module Nodes
5
+ # Base node for the template AST.
6
+ class Base
7
+ def rows_after_render
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+
12
+ # A single row in the template, unmodified.
13
+ class RowNode < Base
14
+ attr_reader :row_data
15
+
16
+ # row_data: {:row => row_xml_element, :cells => [cell_hash, ...]}
17
+ def initialize(row_data)
18
+ @row_data = row_data
19
+ end
20
+
21
+ def rows_after_render
22
+ 1
23
+ end
24
+ end
25
+
26
+ # A for loop node: iterates over a collection.
27
+ class ForNode < Base
28
+ attr_reader :variable_name, :collection_name, :body, :else_body, :for_row, :endfor_row
29
+
30
+ def initialize(variable_name, collection_name, body, else_body = nil, for_row: nil, endfor_row: nil)
31
+ @variable_name = variable_name
32
+ @collection_name = collection_name
33
+ @body = body
34
+ @else_body = else_body
35
+ @for_row = for_row
36
+ @endfor_row = endfor_row
37
+ end
38
+
39
+ def rows_after_render
40
+ # Dynamic: depends on collection size
41
+ 0 # mark as dynamic
42
+ end
43
+ end
44
+
45
+ # An if/elsif/else node: conditional block.
46
+ class IfNode < Base
47
+ attr_reader :branches, :if_row, :endif_row
48
+
49
+ # branches: [{condition:, body:}, ...]
50
+ # Last branch may have condition: nil for else
51
+ def initialize(branches, if_row: nil, endif_row: nil)
52
+ @branches = branches
53
+ @if_row = if_row
54
+ @endif_row = endif_row
55
+ end
56
+
57
+ def rows_after_render
58
+ 0 # dynamic
59
+ end
60
+ end
61
+ end
62
+ end