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