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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/odin/export.rb +1 -1
  3. data/lib/odin/forms/accessibility.rb +95 -0
  4. data/lib/odin/forms/css.rb +42 -0
  5. data/lib/odin/forms/parser.rb +719 -0
  6. data/lib/odin/forms/renderer.rb +534 -0
  7. data/lib/odin/forms/types.rb +102 -0
  8. data/lib/odin/forms/units.rb +41 -0
  9. data/lib/odin/forms.rb +55 -0
  10. data/lib/odin/parsing/parser.rb +25 -1
  11. data/lib/odin/parsing/tokenizer.rb +38 -20
  12. data/lib/odin/parsing/value_parser.rb +65 -7
  13. data/lib/odin/resolver/import_resolver.rb +40 -12
  14. data/lib/odin/resolver/type_registry.rb +54 -0
  15. data/lib/odin/transform/format_exporters.rb +88 -48
  16. data/lib/odin/transform/source_parsers.rb +2 -2
  17. data/lib/odin/transform/transform_engine.rb +1388 -246
  18. data/lib/odin/transform/transform_expr.rb +222 -0
  19. data/lib/odin/transform/transform_parser.rb +377 -19
  20. data/lib/odin/transform/transform_types.rb +23 -7
  21. data/lib/odin/transform/verb_context.rb +19 -1
  22. data/lib/odin/transform/verbs/aggregation_verbs.rb +2 -1
  23. data/lib/odin/transform/verbs/collection_verbs.rb +164 -89
  24. data/lib/odin/transform/verbs/datetime_verbs.rb +86 -15
  25. data/lib/odin/transform/verbs/extra_verbs.rb +613 -0
  26. data/lib/odin/transform/verbs/financial_verbs.rb +116 -27
  27. data/lib/odin/transform/verbs/geo_verbs.rb +7 -0
  28. data/lib/odin/transform/verbs/numeric_verbs.rb +85 -64
  29. data/lib/odin/transform/verbs/object_verbs.rb +31 -26
  30. data/lib/odin/types/errors.rb +9 -1
  31. data/lib/odin/types/schema.rb +20 -3
  32. data/lib/odin/utils/format_utils.rb +31 -15
  33. data/lib/odin/validation/format_validators.rb +7 -9
  34. data/lib/odin/validation/invariant_evaluator.rb +410 -0
  35. data/lib/odin/validation/schema_definition_validator.rb +357 -0
  36. data/lib/odin/validation/schema_parser.rb +234 -21
  37. data/lib/odin/validation/validator.rb +281 -123
  38. data/lib/odin/version.rb +1 -1
  39. data/lib/odin.rb +100 -4
  40. 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