odin-foundation 1.0.4 → 1.2.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 +4 -4
- data/lib/odin/export.rb +1 -1
- data/lib/odin/forms/accessibility.rb +95 -0
- data/lib/odin/forms/css.rb +42 -0
- data/lib/odin/forms/parser.rb +719 -0
- data/lib/odin/forms/renderer.rb +534 -0
- data/lib/odin/forms/types.rb +102 -0
- data/lib/odin/forms/units.rb +41 -0
- data/lib/odin/forms.rb +55 -0
- data/lib/odin/parsing/parser.rb +25 -1
- data/lib/odin/parsing/tokenizer.rb +38 -20
- data/lib/odin/parsing/value_parser.rb +65 -7
- data/lib/odin/resolver/import_resolver.rb +40 -12
- data/lib/odin/resolver/type_registry.rb +54 -0
- data/lib/odin/transform/format_exporters.rb +88 -48
- data/lib/odin/transform/source_parsers.rb +2 -2
- data/lib/odin/transform/transform_engine.rb +1388 -246
- data/lib/odin/transform/transform_expr.rb +222 -0
- data/lib/odin/transform/transform_parser.rb +377 -19
- data/lib/odin/transform/transform_types.rb +23 -7
- data/lib/odin/transform/verb_context.rb +19 -1
- data/lib/odin/transform/verbs/aggregation_verbs.rb +2 -1
- data/lib/odin/transform/verbs/collection_verbs.rb +164 -89
- data/lib/odin/transform/verbs/datetime_verbs.rb +86 -15
- data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
- data/lib/odin/transform/verbs/financial_verbs.rb +116 -27
- data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
- data/lib/odin/transform/verbs/numeric_verbs.rb +85 -64
- data/lib/odin/transform/verbs/object_verbs.rb +31 -26
- data/lib/odin/types/errors.rb +9 -1
- data/lib/odin/types/schema.rb +20 -3
- data/lib/odin/utils/format_utils.rb +31 -15
- data/lib/odin/validation/format_validators.rb +7 -9
- data/lib/odin/validation/invariant_evaluator.rb +410 -0
- data/lib/odin/validation/schema_definition_validator.rb +357 -0
- data/lib/odin/validation/schema_parser.rb +234 -21
- data/lib/odin/validation/validator.rb +281 -123
- data/lib/odin/version.rb +1 -1
- data/lib/odin.rb +100 -4
- metadata +14 -2
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "types"
|
|
4
|
+
|
|
5
|
+
module Odin
|
|
6
|
+
module Forms
|
|
7
|
+
# Parses ODIN forms text into a typed OdinForm structure. Low-level ODIN
|
|
8
|
+
# parsing is delegated to Odin.parse; the flat path space is then mapped onto
|
|
9
|
+
# the Forms model.
|
|
10
|
+
class Parser
|
|
11
|
+
VALID_UNITS = %w[inch cm mm pt].freeze
|
|
12
|
+
VALID_INPUT_TYPES = %w[text email tel password number url].freeze
|
|
13
|
+
VALID_BARCODE_TYPES = %w[code39 code128 qr datamatrix pdf417].freeze
|
|
14
|
+
REGION_OWN_PROPS = %w[x y w h bind max overflow].freeze
|
|
15
|
+
REGION_CHILD_TYPES = %w[text field img barcode].freeze
|
|
16
|
+
|
|
17
|
+
TPL_HEADER = /\A\s*\{\s*@(tpl_[A-Za-z0-9_]+)\s*\}\s*\z/.freeze
|
|
18
|
+
TOP_LEVEL_HEADER = /\A\s*\{\s*(\$|page\[\d+\]|@tpl_)/.freeze
|
|
19
|
+
ANCHOR_HEADER = /\A\s*\{\s*(page\[\d+\]|tpl\.[A-Za-z0-9_]+)\s*\}\s*\z/.freeze
|
|
20
|
+
RELATIVE_HEADER = /\A\s*\{\s*\./.freeze
|
|
21
|
+
RELATIVE_TABULAR = /\A\s*\{\s*\.[^}]*\[\]\s*:/.freeze
|
|
22
|
+
PAGE_PATH = /\Apage\[(\d+)\]\./.freeze
|
|
23
|
+
|
|
24
|
+
def parse(text)
|
|
25
|
+
body, template_blocks = split_templates(text)
|
|
26
|
+
doc = Odin.parse(body)
|
|
27
|
+
|
|
28
|
+
i18n = extract_i18n(doc)
|
|
29
|
+
|
|
30
|
+
OdinForm.new(
|
|
31
|
+
metadata: extract_metadata(doc),
|
|
32
|
+
page_defaults: extract_page_defaults(doc),
|
|
33
|
+
screen: extract_screen(doc),
|
|
34
|
+
i18n: i18n,
|
|
35
|
+
pages: extract_pages(doc, i18n),
|
|
36
|
+
templates: extract_templates(template_blocks, i18n)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Split a forms document into its core-parseable body and the raw text of
|
|
43
|
+
# each {@tpl_*} template block.
|
|
44
|
+
def split_templates(text)
|
|
45
|
+
lines = text.split(/\r?\n/)
|
|
46
|
+
body_lines = []
|
|
47
|
+
blocks = []
|
|
48
|
+
current = nil
|
|
49
|
+
|
|
50
|
+
lines.each do |line|
|
|
51
|
+
if (m = TPL_HEADER.match(line))
|
|
52
|
+
current = { name: m[1], text: +"" }
|
|
53
|
+
blocks << current
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
if current
|
|
57
|
+
if TOP_LEVEL_HEADER.match?(line) && !TPL_HEADER.match?(line)
|
|
58
|
+
current = nil
|
|
59
|
+
body_lines << line
|
|
60
|
+
else
|
|
61
|
+
current[:text] << line << "\n"
|
|
62
|
+
end
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
body_lines << line
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
[reanchor(body_lines.join("\n")), blocks]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Re-emit the active top-level anchor after a relative tabular block so
|
|
72
|
+
# following relative siblings resolve against the page, not the field.
|
|
73
|
+
def reanchor(text, root_anchor = nil)
|
|
74
|
+
out = []
|
|
75
|
+
anchor = root_anchor
|
|
76
|
+
needs_reanchor = false
|
|
77
|
+
|
|
78
|
+
text.split(/\r?\n/).each do |line|
|
|
79
|
+
if (m = ANCHOR_HEADER.match(line))
|
|
80
|
+
anchor = "{#{m[1]}}"
|
|
81
|
+
needs_reanchor = false
|
|
82
|
+
out << line
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if RELATIVE_HEADER.match?(line)
|
|
87
|
+
if needs_reanchor && anchor
|
|
88
|
+
out << anchor
|
|
89
|
+
needs_reanchor = false
|
|
90
|
+
end
|
|
91
|
+
needs_reanchor = true if RELATIVE_TABULAR.match?(line)
|
|
92
|
+
out << line
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
out << line
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
out.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_templates(blocks, i18n)
|
|
103
|
+
return nil if blocks.empty?
|
|
104
|
+
|
|
105
|
+
templates = {}
|
|
106
|
+
blocks.each do |block|
|
|
107
|
+
root = "tpl.#{block[:name]}"
|
|
108
|
+
synthetic = reanchor("{#{root}}\n#{block[:text]}", "{#{root}}")
|
|
109
|
+
doc = Odin.parse(synthetic)
|
|
110
|
+
prefix = "#{root}."
|
|
111
|
+
|
|
112
|
+
page_template = bool_value(doc, "#{prefix}page-template")
|
|
113
|
+
page_template = true if page_template.nil?
|
|
114
|
+
|
|
115
|
+
templates[block[:name]] = PageTemplate.new(
|
|
116
|
+
name: block[:name],
|
|
117
|
+
page_template: page_template,
|
|
118
|
+
continues: string_value(doc, "#{prefix}continues"),
|
|
119
|
+
form_id: string_value(doc, "#{prefix}form-id"),
|
|
120
|
+
elements: extract_elements(doc, prefix, i18n)
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
templates
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ── Metadata and settings ────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def extract_metadata(doc)
|
|
129
|
+
meta = {
|
|
130
|
+
title: meta_string(doc, "title") || "",
|
|
131
|
+
id: meta_string(doc, "id") || "",
|
|
132
|
+
lang: meta_string(doc, "lang") || "en",
|
|
133
|
+
}
|
|
134
|
+
version = meta_string(doc, "forms")
|
|
135
|
+
meta[:version] = version if version
|
|
136
|
+
meta
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def extract_page_defaults(doc)
|
|
140
|
+
width = meta_number(doc, "page.width")
|
|
141
|
+
height = meta_number(doc, "page.height")
|
|
142
|
+
unit = meta_string(doc, "page.unit")
|
|
143
|
+
margin = extract_margins(doc)
|
|
144
|
+
|
|
145
|
+
return nil if width.nil? && height.nil? && unit.nil?
|
|
146
|
+
|
|
147
|
+
resolved_unit = VALID_UNITS.include?(unit) ? unit : "inch"
|
|
148
|
+
result = { width: width || 8.5, height: height || 11, unit: resolved_unit }
|
|
149
|
+
result[:margin] = margin if margin
|
|
150
|
+
result
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_margins(doc)
|
|
154
|
+
sides = {}
|
|
155
|
+
%w[top right bottom left].each do |side|
|
|
156
|
+
v = meta_number(doc, "page.margin.#{side}")
|
|
157
|
+
sides[side.to_sym] = v unless v.nil?
|
|
158
|
+
end
|
|
159
|
+
sides.empty? ? nil : sides
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def extract_screen(doc)
|
|
163
|
+
scale = meta_number(doc, "screen.scale")
|
|
164
|
+
scale.nil? ? nil : { scale: scale }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def extract_i18n(doc)
|
|
168
|
+
prefix = "i18n."
|
|
169
|
+
result = {}
|
|
170
|
+
doc.metadata.each do |key, val|
|
|
171
|
+
next unless key.start_with?(prefix)
|
|
172
|
+
|
|
173
|
+
sub = key[prefix.length..]
|
|
174
|
+
value = val.type == :string ? val.value : nil
|
|
175
|
+
result[sub] = value if sub && !sub.empty? && value
|
|
176
|
+
end
|
|
177
|
+
result.empty? ? nil : result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ── Pages and elements ───────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def extract_pages(doc, i18n)
|
|
183
|
+
indices = []
|
|
184
|
+
doc.paths.each do |path|
|
|
185
|
+
m = PAGE_PATH.match(path)
|
|
186
|
+
indices << m[1].to_i if m
|
|
187
|
+
end
|
|
188
|
+
indices = indices.uniq.sort
|
|
189
|
+
return [] if indices.empty?
|
|
190
|
+
|
|
191
|
+
indices.map { |i| FormPage.new(extract_elements(doc, "page[#{i}].", i18n)) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def extract_elements(doc, prefix, i18n)
|
|
195
|
+
keys_seen = {}
|
|
196
|
+
keys_ordered = []
|
|
197
|
+
|
|
198
|
+
doc.paths.each do |path|
|
|
199
|
+
next unless path.start_with?(prefix)
|
|
200
|
+
|
|
201
|
+
rest = path[prefix.length..]
|
|
202
|
+
parts = rest.split(".")
|
|
203
|
+
next if parts.length < 2
|
|
204
|
+
|
|
205
|
+
key = "#{parts[0]}.#{parts[1]}"
|
|
206
|
+
unless keys_seen[key]
|
|
207
|
+
keys_seen[key] = true
|
|
208
|
+
keys_ordered << key
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
id_counter = 0
|
|
213
|
+
elements = []
|
|
214
|
+
keys_ordered.each do |key|
|
|
215
|
+
element_type, element_name = key.split(".", 2)
|
|
216
|
+
element_prefix = "#{prefix}#{key}."
|
|
217
|
+
element = build_element(doc, element_type, element_name, element_prefix, id_counter, i18n)
|
|
218
|
+
id_counter += 1
|
|
219
|
+
elements << element if element
|
|
220
|
+
end
|
|
221
|
+
elements
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ── Element dispatch ─────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
def build_element(doc, element_type, element_name, prefix, id_counter, i18n)
|
|
227
|
+
id = "#{element_type}_#{element_name}_#{id_counter}"
|
|
228
|
+
|
|
229
|
+
case element_type
|
|
230
|
+
when "line" then build_line(doc, element_name, id, prefix)
|
|
231
|
+
when "rect" then build_rect(doc, element_name, id, prefix)
|
|
232
|
+
when "circle" then build_circle(doc, element_name, id, prefix)
|
|
233
|
+
when "ellipse" then build_ellipse(doc, element_name, id, prefix)
|
|
234
|
+
when "polygon" then build_polygon(doc, element_name, id, prefix)
|
|
235
|
+
when "polyline" then build_polyline(doc, element_name, id, prefix)
|
|
236
|
+
when "path" then build_path(doc, element_name, id, prefix)
|
|
237
|
+
when "text" then build_text(doc, element_name, id, prefix, i18n)
|
|
238
|
+
when "img" then build_image(doc, element_name, id, prefix, i18n)
|
|
239
|
+
when "barcode" then build_barcode(doc, element_name, id, prefix, i18n)
|
|
240
|
+
when "field" then build_field(doc, element_name, id, prefix, i18n)
|
|
241
|
+
when "region" then build_region(doc, element_name, id, prefix, i18n)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ── Geometric builders ───────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
def build_line(doc, name, id, prefix)
|
|
248
|
+
props = {
|
|
249
|
+
type: ElementType::LINE, name: name, id: id,
|
|
250
|
+
x1: number_value(doc, "#{prefix}x1") || 0,
|
|
251
|
+
y1: number_value(doc, "#{prefix}y1") || 0,
|
|
252
|
+
x2: number_value(doc, "#{prefix}x2") || 0,
|
|
253
|
+
y2: number_value(doc, "#{prefix}y2") || 0,
|
|
254
|
+
}
|
|
255
|
+
merge_stroked(props, doc, prefix)
|
|
256
|
+
FormElement.new(props)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def build_rect(doc, name, id, prefix)
|
|
260
|
+
props = {
|
|
261
|
+
type: ElementType::RECT, name: name, id: id,
|
|
262
|
+
x: number_value(doc, "#{prefix}x") || 0,
|
|
263
|
+
y: number_value(doc, "#{prefix}y") || 0,
|
|
264
|
+
w: number_value(doc, "#{prefix}w") || 0,
|
|
265
|
+
h: number_value(doc, "#{prefix}h") || 0,
|
|
266
|
+
}
|
|
267
|
+
rx = number_value(doc, "#{prefix}rx")
|
|
268
|
+
ry = number_value(doc, "#{prefix}ry")
|
|
269
|
+
props[:rx] = rx unless rx.nil?
|
|
270
|
+
props[:ry] = ry unless ry.nil?
|
|
271
|
+
merge_stroked(props, doc, prefix)
|
|
272
|
+
merge_filled(props, doc, prefix)
|
|
273
|
+
FormElement.new(props)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def build_circle(doc, name, id, prefix)
|
|
277
|
+
props = {
|
|
278
|
+
type: ElementType::CIRCLE, name: name, id: id,
|
|
279
|
+
cx: number_value(doc, "#{prefix}cx") || 0,
|
|
280
|
+
cy: number_value(doc, "#{prefix}cy") || 0,
|
|
281
|
+
r: number_value(doc, "#{prefix}r") || 0,
|
|
282
|
+
}
|
|
283
|
+
merge_stroked(props, doc, prefix)
|
|
284
|
+
merge_filled(props, doc, prefix)
|
|
285
|
+
FormElement.new(props)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def build_ellipse(doc, name, id, prefix)
|
|
289
|
+
props = {
|
|
290
|
+
type: ElementType::ELLIPSE, name: name, id: id,
|
|
291
|
+
cx: number_value(doc, "#{prefix}cx") || 0,
|
|
292
|
+
cy: number_value(doc, "#{prefix}cy") || 0,
|
|
293
|
+
rx: number_value(doc, "#{prefix}rx") || 0,
|
|
294
|
+
ry: number_value(doc, "#{prefix}ry") || 0,
|
|
295
|
+
}
|
|
296
|
+
merge_stroked(props, doc, prefix)
|
|
297
|
+
merge_filled(props, doc, prefix)
|
|
298
|
+
FormElement.new(props)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_polygon(doc, name, id, prefix)
|
|
302
|
+
props = {
|
|
303
|
+
type: ElementType::POLYGON, name: name, id: id,
|
|
304
|
+
points: string_value(doc, "#{prefix}points") || "",
|
|
305
|
+
}
|
|
306
|
+
merge_stroked(props, doc, prefix)
|
|
307
|
+
merge_filled(props, doc, prefix)
|
|
308
|
+
FormElement.new(props)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_polyline(doc, name, id, prefix)
|
|
312
|
+
props = {
|
|
313
|
+
type: ElementType::POLYLINE, name: name, id: id,
|
|
314
|
+
points: string_value(doc, "#{prefix}points") || "",
|
|
315
|
+
}
|
|
316
|
+
merge_stroked(props, doc, prefix)
|
|
317
|
+
FormElement.new(props)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def build_path(doc, name, id, prefix)
|
|
321
|
+
props = {
|
|
322
|
+
type: ElementType::PATH, name: name, id: id,
|
|
323
|
+
d: string_value(doc, "#{prefix}d") || "",
|
|
324
|
+
}
|
|
325
|
+
merge_stroked(props, doc, prefix)
|
|
326
|
+
merge_filled(props, doc, prefix)
|
|
327
|
+
FormElement.new(props)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# ── Content builders ─────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
def build_text(doc, name, id, prefix, i18n)
|
|
333
|
+
props = {
|
|
334
|
+
type: ElementType::TEXT, name: name, id: id,
|
|
335
|
+
content: label_value(doc, "#{prefix}content", i18n) || "",
|
|
336
|
+
x: number_value(doc, "#{prefix}x") || 0,
|
|
337
|
+
y: number_value(doc, "#{prefix}y") || 0,
|
|
338
|
+
}
|
|
339
|
+
rotate = number_value(doc, "#{prefix}rotate")
|
|
340
|
+
props[:rotate] = rotate unless rotate.nil?
|
|
341
|
+
merge_fonted(props, doc, prefix)
|
|
342
|
+
FormElement.new(props)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def build_image(doc, name, id, prefix, i18n)
|
|
346
|
+
background = bool_value(doc, "#{prefix}background")
|
|
347
|
+
props = {
|
|
348
|
+
type: ElementType::IMG, name: name, id: id,
|
|
349
|
+
src: binary_literal(doc, "#{prefix}src") || "",
|
|
350
|
+
alt: label_value(doc, "#{prefix}alt", i18n) || "",
|
|
351
|
+
x: number_value(doc, "#{prefix}x") || 0,
|
|
352
|
+
y: number_value(doc, "#{prefix}y") || 0,
|
|
353
|
+
w: number_value(doc, "#{prefix}w") || 0,
|
|
354
|
+
h: number_value(doc, "#{prefix}h") || 0,
|
|
355
|
+
}
|
|
356
|
+
props[:background] = background unless background.nil?
|
|
357
|
+
FormElement.new(props)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def build_barcode(doc, name, id, prefix, i18n)
|
|
361
|
+
raw = string_value(doc, "#{prefix}type") || string_value(doc, "#{prefix}barcode-type") || "code128"
|
|
362
|
+
resolved = VALID_BARCODE_TYPES.include?(raw) ? raw : "code128"
|
|
363
|
+
FormElement.new(
|
|
364
|
+
type: ElementType::BARCODE, name: name, id: id,
|
|
365
|
+
barcodeType: resolved,
|
|
366
|
+
content: label_value(doc, "#{prefix}content", i18n) || "",
|
|
367
|
+
alt: label_value(doc, "#{prefix}alt", i18n) || "",
|
|
368
|
+
x: number_value(doc, "#{prefix}x") || 0,
|
|
369
|
+
y: number_value(doc, "#{prefix}y") || 0,
|
|
370
|
+
w: number_value(doc, "#{prefix}w") || 0,
|
|
371
|
+
h: number_value(doc, "#{prefix}h") || 0
|
|
372
|
+
)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# ── Field builder ────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
def build_field(doc, name, id, prefix, i18n)
|
|
378
|
+
field_type = string_value(doc, "#{prefix}type") || "text"
|
|
379
|
+
base = extract_base_field(doc, name, id, prefix, i18n)
|
|
380
|
+
|
|
381
|
+
case field_type
|
|
382
|
+
when "text" then build_text_field(doc, prefix, base)
|
|
383
|
+
when "checkbox" then build_checkbox_field(doc, prefix, base)
|
|
384
|
+
when "radio" then build_radio_field(doc, prefix, base)
|
|
385
|
+
when "select" then build_select_field(doc, prefix, base)
|
|
386
|
+
when "multiselect" then build_multiselect_field(doc, prefix, base)
|
|
387
|
+
when "date" then build_date_field(doc, prefix, base)
|
|
388
|
+
when "signature" then build_signature_field(doc, prefix, base)
|
|
389
|
+
else build_text_field(doc, prefix, base)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def extract_base_field(doc, name, id, prefix, i18n)
|
|
394
|
+
required = bool_value(doc, "#{prefix}required")
|
|
395
|
+
tabindex = number_value(doc, "#{prefix}tabindex")
|
|
396
|
+
min_length = number_value(doc, "#{prefix}minLength")
|
|
397
|
+
max_length = number_value(doc, "#{prefix}maxLength")
|
|
398
|
+
min = number_value(doc, "#{prefix}min") || scalar_string(doc, "#{prefix}min")
|
|
399
|
+
max = number_value(doc, "#{prefix}max") || scalar_string(doc, "#{prefix}max")
|
|
400
|
+
aria_label = label_value(doc, "#{prefix}aria-label", i18n)
|
|
401
|
+
readonly = bool_value(doc, "#{prefix}readonly")
|
|
402
|
+
bind_ref = reference_value(doc, "#{prefix}bind")
|
|
403
|
+
|
|
404
|
+
base = {
|
|
405
|
+
name: name, id: id,
|
|
406
|
+
label: label_value(doc, "#{prefix}label", i18n) || "",
|
|
407
|
+
x: number_value(doc, "#{prefix}x") || 0,
|
|
408
|
+
y: number_value(doc, "#{prefix}y") || 0,
|
|
409
|
+
w: number_value(doc, "#{prefix}w") || 0,
|
|
410
|
+
h: number_value(doc, "#{prefix}h") || 0,
|
|
411
|
+
bind: bind_ref ? "@#{bind_ref}" : "",
|
|
412
|
+
}
|
|
413
|
+
base[:required] = required unless required.nil?
|
|
414
|
+
base[:tabindex] = tabindex unless tabindex.nil?
|
|
415
|
+
base[:minLength] = min_length unless min_length.nil?
|
|
416
|
+
base[:maxLength] = max_length unless max_length.nil?
|
|
417
|
+
base[:min] = min unless min.nil?
|
|
418
|
+
base[:max] = max unless max.nil?
|
|
419
|
+
base[:"aria-label"] = aria_label unless aria_label.nil?
|
|
420
|
+
base[:readonly] = readonly unless readonly.nil?
|
|
421
|
+
base
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def build_text_field(doc, prefix, base)
|
|
425
|
+
props = base.merge(type: ElementType::FIELD_TEXT)
|
|
426
|
+
value = scalar_string(doc, "#{prefix}value")
|
|
427
|
+
input_type = string_value(doc, "#{prefix}inputType")
|
|
428
|
+
mask = string_value(doc, "#{prefix}mask")
|
|
429
|
+
placeholder = string_value(doc, "#{prefix}placeholder")
|
|
430
|
+
multiline = bool_value(doc, "#{prefix}multiline")
|
|
431
|
+
max_lines = number_value(doc, "#{prefix}maxLines")
|
|
432
|
+
|
|
433
|
+
props[:value] = value unless value.nil?
|
|
434
|
+
props[:inputType] = input_type if VALID_INPUT_TYPES.include?(input_type)
|
|
435
|
+
props[:mask] = mask unless mask.nil?
|
|
436
|
+
props[:placeholder] = placeholder unless placeholder.nil?
|
|
437
|
+
props[:multiline] = multiline unless multiline.nil?
|
|
438
|
+
props[:maxLines] = max_lines unless max_lines.nil?
|
|
439
|
+
FormElement.new(props)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def build_checkbox_field(doc, prefix, base)
|
|
443
|
+
props = base.merge(type: ElementType::FIELD_CHECKBOX)
|
|
444
|
+
checked = bool_value(doc, "#{prefix}checked")
|
|
445
|
+
props[:checked] = checked unless checked.nil?
|
|
446
|
+
FormElement.new(props)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def build_radio_field(doc, prefix, base)
|
|
450
|
+
FormElement.new(base.merge(
|
|
451
|
+
type: ElementType::FIELD_RADIO,
|
|
452
|
+
group: string_value(doc, "#{prefix}group") || "",
|
|
453
|
+
value: string_value(doc, "#{prefix}value") || ""
|
|
454
|
+
))
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def build_select_field(doc, prefix, base)
|
|
458
|
+
props = base.merge(
|
|
459
|
+
type: ElementType::FIELD_SELECT,
|
|
460
|
+
options: extract_options(doc, prefix)
|
|
461
|
+
)
|
|
462
|
+
selected = string_value(doc, "#{prefix}selected")
|
|
463
|
+
placeholder = string_value(doc, "#{prefix}placeholder")
|
|
464
|
+
props[:selected] = selected unless selected.nil?
|
|
465
|
+
props[:placeholder] = placeholder unless placeholder.nil?
|
|
466
|
+
FormElement.new(props)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def build_multiselect_field(doc, prefix, base)
|
|
470
|
+
props = base.merge(
|
|
471
|
+
type: ElementType::FIELD_MULTISELECT,
|
|
472
|
+
options: extract_options(doc, prefix)
|
|
473
|
+
)
|
|
474
|
+
selected = extract_field_array(doc, prefix, "selected")
|
|
475
|
+
min_select = number_value(doc, "#{prefix}minSelect")
|
|
476
|
+
max_select = number_value(doc, "#{prefix}maxSelect")
|
|
477
|
+
props[:selected] = selected unless selected.nil?
|
|
478
|
+
props[:minSelect] = min_select unless min_select.nil?
|
|
479
|
+
props[:maxSelect] = max_select unless max_select.nil?
|
|
480
|
+
FormElement.new(props)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def build_date_field(doc, prefix, base)
|
|
484
|
+
props = base.merge(type: ElementType::FIELD_DATE)
|
|
485
|
+
value = scalar_string(doc, "#{prefix}value")
|
|
486
|
+
props[:value] = value unless value.nil?
|
|
487
|
+
FormElement.new(props)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def build_signature_field(doc, prefix, base)
|
|
491
|
+
props = base.merge(type: ElementType::FIELD_SIGNATURE)
|
|
492
|
+
value = binary_literal(doc, "#{prefix}value")
|
|
493
|
+
date_field = string_value(doc, "#{prefix}date_field")
|
|
494
|
+
props[:value] = value unless value.nil?
|
|
495
|
+
props[:date_field] = date_field unless date_field.nil?
|
|
496
|
+
FormElement.new(props)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def extract_options(doc, prefix)
|
|
500
|
+
extract_field_array(doc, prefix, "options") || []
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Extract a field's tabular string array, tolerating the extra path segment
|
|
504
|
+
# a relative tabular header leaves in place.
|
|
505
|
+
def extract_field_array(doc, prefix, name)
|
|
506
|
+
direct = collect_indexed(doc, "#{prefix}#{name}")
|
|
507
|
+
return direct unless direct.empty?
|
|
508
|
+
|
|
509
|
+
re = /\A#{Regexp.escape(prefix)}(?:[^.]+\.)*#{Regexp.escape(name)}\[(\d+)\]\z/
|
|
510
|
+
found = []
|
|
511
|
+
doc.paths.each do |p|
|
|
512
|
+
m = re.match(p)
|
|
513
|
+
found << [m[1].to_i, p] if m
|
|
514
|
+
end
|
|
515
|
+
return nil if found.empty?
|
|
516
|
+
|
|
517
|
+
found.sort_by!(&:first)
|
|
518
|
+
found.filter_map { |(_, path)| string_value(doc, path) }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def collect_indexed(doc, base)
|
|
522
|
+
out = []
|
|
523
|
+
i = 0
|
|
524
|
+
while doc.include?("#{base}[#{i}]")
|
|
525
|
+
v = string_value(doc, "#{base}[#{i}]")
|
|
526
|
+
out << v unless v.nil?
|
|
527
|
+
i += 1
|
|
528
|
+
end
|
|
529
|
+
out
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# ── Region builder ───────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
def build_region(doc, name, id, prefix, i18n)
|
|
535
|
+
bind = reference_value(doc, "#{prefix}bind")
|
|
536
|
+
max = number_value(doc, "#{prefix}max")
|
|
537
|
+
overflow_ref = reference_value(doc, "#{prefix}overflow")
|
|
538
|
+
overflow = overflow_ref || string_value(doc, "#{prefix}overflow")
|
|
539
|
+
|
|
540
|
+
props = {
|
|
541
|
+
type: ElementType::REGION, name: name, id: id,
|
|
542
|
+
x: number_value(doc, "#{prefix}x") || 0,
|
|
543
|
+
y: number_value(doc, "#{prefix}y") || 0,
|
|
544
|
+
w: number_value(doc, "#{prefix}w") || 0,
|
|
545
|
+
h: number_value(doc, "#{prefix}h") || 0,
|
|
546
|
+
children: extract_region_children(doc, prefix, i18n),
|
|
547
|
+
}
|
|
548
|
+
props[:bind] = "@#{bind}" unless bind.nil?
|
|
549
|
+
unless overflow.nil?
|
|
550
|
+
props[:overflow] = overflow_ref ? "@#{overflow}" : overflow
|
|
551
|
+
end
|
|
552
|
+
props[:max] = max unless max.nil?
|
|
553
|
+
FormElement.new(props)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def extract_region_children(doc, prefix, i18n)
|
|
557
|
+
keys_seen = {}
|
|
558
|
+
keys_ordered = []
|
|
559
|
+
|
|
560
|
+
doc.paths.each do |path|
|
|
561
|
+
next unless path.start_with?(prefix)
|
|
562
|
+
|
|
563
|
+
rest = path[prefix.length..]
|
|
564
|
+
parts = rest.split(".")
|
|
565
|
+
next if parts.length < 2
|
|
566
|
+
next if REGION_OWN_PROPS.include?(parts[0])
|
|
567
|
+
next unless REGION_CHILD_TYPES.include?(parts[0])
|
|
568
|
+
|
|
569
|
+
key = "#{parts[0]}.#{parts[1]}"
|
|
570
|
+
unless keys_seen[key]
|
|
571
|
+
keys_seen[key] = true
|
|
572
|
+
keys_ordered << key
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
id_counter = 0
|
|
577
|
+
children = []
|
|
578
|
+
keys_ordered.each do |key|
|
|
579
|
+
child_type, child_name = key.split(".", 2)
|
|
580
|
+
child_prefix = "#{prefix}#{key}."
|
|
581
|
+
built = build_element(doc, child_type, child_name, child_prefix, id_counter, i18n)
|
|
582
|
+
id_counter += 1
|
|
583
|
+
next unless built
|
|
584
|
+
|
|
585
|
+
y_offset = number_value(doc, "#{child_prefix}y-offset")
|
|
586
|
+
x_offset = number_value(doc, "#{child_prefix}x-offset")
|
|
587
|
+
props = built.props.dup
|
|
588
|
+
props[:"y-offset"] = y_offset unless y_offset.nil?
|
|
589
|
+
props[:"x-offset"] = x_offset unless x_offset.nil?
|
|
590
|
+
children << FormElement.new(props)
|
|
591
|
+
end
|
|
592
|
+
children
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# ── Style mixin extractors ───────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
def merge_stroked(props, doc, prefix)
|
|
598
|
+
v = string_value(doc, "#{prefix}stroke")
|
|
599
|
+
props[:stroke] = v unless v.nil?
|
|
600
|
+
v = number_value(doc, "#{prefix}stroke-width")
|
|
601
|
+
props[:"stroke-width"] = v unless v.nil?
|
|
602
|
+
v = number_value(doc, "#{prefix}stroke-opacity")
|
|
603
|
+
props[:"stroke-opacity"] = v unless v.nil?
|
|
604
|
+
v = string_value(doc, "#{prefix}stroke-dasharray")
|
|
605
|
+
props[:"stroke-dasharray"] = v unless v.nil?
|
|
606
|
+
v = string_value(doc, "#{prefix}stroke-linecap")
|
|
607
|
+
props[:"stroke-linecap"] = v unless v.nil?
|
|
608
|
+
v = string_value(doc, "#{prefix}stroke-linejoin")
|
|
609
|
+
props[:"stroke-linejoin"] = v unless v.nil?
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def merge_filled(props, doc, prefix)
|
|
613
|
+
v = string_value(doc, "#{prefix}fill")
|
|
614
|
+
props[:fill] = v unless v.nil?
|
|
615
|
+
v = number_value(doc, "#{prefix}fill-opacity")
|
|
616
|
+
props[:"fill-opacity"] = v unless v.nil?
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def merge_fonted(props, doc, prefix)
|
|
620
|
+
v = string_value(doc, "#{prefix}font-family")
|
|
621
|
+
props[:"font-family"] = v unless v.nil?
|
|
622
|
+
v = number_value(doc, "#{prefix}font-size")
|
|
623
|
+
props[:"font-size"] = v unless v.nil?
|
|
624
|
+
v = string_value(doc, "#{prefix}font-weight")
|
|
625
|
+
props[:"font-weight"] = v unless v.nil?
|
|
626
|
+
v = string_value(doc, "#{prefix}font-style")
|
|
627
|
+
props[:"font-style"] = v unless v.nil?
|
|
628
|
+
v = string_value(doc, "#{prefix}text-align")
|
|
629
|
+
props[:"text-align"] = v unless v.nil?
|
|
630
|
+
v = string_value(doc, "#{prefix}color")
|
|
631
|
+
props[:color] = v unless v.nil?
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# ── Value accessors ──────────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
def string_value(doc, path)
|
|
637
|
+
val = doc.get(path)
|
|
638
|
+
return nil if val.nil?
|
|
639
|
+
|
|
640
|
+
val.type == :string ? val.value : nil
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Resolve a string property that may instead be an @$.i18n.* reference.
|
|
644
|
+
def label_value(doc, path, i18n)
|
|
645
|
+
val = doc.get(path)
|
|
646
|
+
return nil if val.nil?
|
|
647
|
+
return val.value if val.type == :string
|
|
648
|
+
|
|
649
|
+
if val.type == :reference
|
|
650
|
+
ref = val.path
|
|
651
|
+
if ref.start_with?("$.i18n.")
|
|
652
|
+
key = ref["$.i18n.".length..]
|
|
653
|
+
return (i18n && i18n[key]) || ref
|
|
654
|
+
end
|
|
655
|
+
return ref
|
|
656
|
+
end
|
|
657
|
+
nil
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# Read a scalar as a string, preserving the raw source form for dates.
|
|
661
|
+
def scalar_string(doc, path)
|
|
662
|
+
val = doc.get(path)
|
|
663
|
+
return nil if val.nil?
|
|
664
|
+
|
|
665
|
+
case val.type
|
|
666
|
+
when :string then val.value
|
|
667
|
+
when :date, :timestamp then val.respond_to?(:raw) && val.raw ? val.raw : val.value.to_s
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Reconstruct an ODIN binary literal (^algorithm:base64).
|
|
672
|
+
def binary_literal(doc, path)
|
|
673
|
+
val = doc.get(path)
|
|
674
|
+
return nil if val.nil?
|
|
675
|
+
|
|
676
|
+
if val.type == :binary
|
|
677
|
+
return val.algorithm ? "^#{val.algorithm}:#{val.data}" : "^#{val.data}"
|
|
678
|
+
end
|
|
679
|
+
val.type == :string ? val.value : nil
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def number_value(doc, path)
|
|
683
|
+
val = doc.get(path)
|
|
684
|
+
return nil if val.nil?
|
|
685
|
+
return nil unless val.type == :number || val.type == :integer
|
|
686
|
+
|
|
687
|
+
v = val.value
|
|
688
|
+
v == v.to_i ? v.to_i : v
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def bool_value(doc, path)
|
|
692
|
+
val = doc.get(path)
|
|
693
|
+
return nil if val.nil?
|
|
694
|
+
|
|
695
|
+
val.type == :boolean ? val.value : nil
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def reference_value(doc, path)
|
|
699
|
+
val = doc.get(path)
|
|
700
|
+
return nil if val.nil?
|
|
701
|
+
|
|
702
|
+
val.type == :reference ? val.path : nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def meta_string(doc, key)
|
|
706
|
+
mv = doc.metadata[key]
|
|
707
|
+
mv && mv.type == :string ? mv.value : nil
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def meta_number(doc, key)
|
|
711
|
+
mv = doc.metadata[key]
|
|
712
|
+
return nil unless mv && (mv.type == :number || mv.type == :integer)
|
|
713
|
+
|
|
714
|
+
v = mv.value
|
|
715
|
+
v == v.to_i ? v.to_i : v
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|