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,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "units"
4
+ require_relative "css"
5
+ require_relative "accessibility"
6
+
7
+ module Odin
8
+ module Forms
9
+ # Renders a parsed OdinForm into a complete, accessible HTML string.
10
+ class Renderer
11
+ def render(form, data = nil, options = nil)
12
+ title = (form.metadata[:title] && !form.metadata[:title].empty? ? form.metadata[:title] : "ODIN Form")
13
+ class_name = options && options[:className] ? " #{options[:className]}" : ""
14
+ unit = (form.page_defaults && form.page_defaults[:unit]) || "inch"
15
+
16
+ plan = build_render_plan(form, data)
17
+ total_pages = plan.length
18
+ page_w = Units.to_pixels((form.page_defaults && form.page_defaults[:width]) || 8.5, unit)
19
+ page_h = Units.to_pixels((form.page_defaults && form.page_defaults[:height]) || 11, unit)
20
+
21
+ parts = []
22
+ parts << %(<form role="form" aria-label="#{escape_attr(title)}" class="odin-form#{class_name}">)
23
+ parts << Accessibility.skip_link_html(title)
24
+ parts << "<style>#{Css.generate_form_css}\n#{Css.generate_print_css}</style>"
25
+
26
+ plan.each_with_index do |planned, i|
27
+ ctx = {
28
+ page_number: i + 1,
29
+ total_pages: total_pages,
30
+ unit: unit,
31
+ data: data,
32
+ page_width_px: page_w,
33
+ page_height_px: page_h,
34
+ }
35
+ parts << render_planned_page(planned, ctx)
36
+ end
37
+
38
+ parts << "</form>"
39
+ parts.join
40
+ end
41
+
42
+ private
43
+
44
+ def build_render_plan(form, data)
45
+ plan = []
46
+
47
+ form.pages.each do |page|
48
+ plan << { elements: page.elements, item_slices: nil }
49
+ next unless data
50
+
51
+ page.elements.each do |el|
52
+ next unless el.type == ElementType::REGION
53
+
54
+ bind = el[:bind]
55
+ max = el[:max]
56
+ overflow = el[:overflow]
57
+ next if bind.nil? || max.nil? || overflow.nil?
58
+ next unless max.is_a?(Numeric) && max >= 1
59
+
60
+ count = bound_array_length(bind, data)
61
+ next if count <= max
62
+
63
+ consumed = max
64
+ template_name = overflow.start_with?("@") ? overflow[1..] : nil
65
+ guard = 0
66
+ while consumed < count && (guard += 1) < 10_000
67
+ tpl = template_name && form.templates ? form.templates[template_name] : nil
68
+ tpl_region = tpl&.elements&.find { |e| e.type == ElementType::REGION && e.name == el.name }
69
+ candidate_max = (tpl_region && tpl_region[:max]) || max
70
+ page_max = candidate_max.is_a?(Numeric) && candidate_max >= 1 ? candidate_max : max
71
+
72
+ slices = {}
73
+ slices[el.name] = {
74
+ start: consumed,
75
+ count: [page_max, count - consumed].min,
76
+ bind: bind,
77
+ }
78
+ elements = tpl ? tpl.elements : page.elements
79
+ plan << { elements: elements, item_slices: slices }
80
+ consumed += page_max
81
+
82
+ if tpl_region && tpl_region[:overflow]&.start_with?("@")
83
+ template_name = tpl_region[:overflow][1..]
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ plan
90
+ end
91
+
92
+ def render_planned_page(page, ctx)
93
+ page_index = ctx[:page_number] - 1
94
+
95
+ parts = []
96
+ parts << %(<div class="odin-form-page" id="odin-form-content" data-page="#{ctx[:page_number]}" ) +
97
+ %(style="width:#{ctx[:page_width_px]}px;height:#{ctx[:page_height_px]}px;">)
98
+
99
+ page[:elements].each do |el|
100
+ parts << render_element(el, page_index, ctx, page) if el.type == ElementType::IMG && el[:background]
101
+ end
102
+ page[:elements].each do |el|
103
+ next if el.type == ElementType::IMG && el[:background]
104
+ next if el.field?
105
+
106
+ parts << render_element(el, page_index, ctx, page)
107
+ end
108
+ Accessibility.tab_order_sort(page[:elements].dup).each do |el|
109
+ parts << render_element(el, page_index, ctx, page)
110
+ end
111
+
112
+ parts << "</div>"
113
+ parts.join
114
+ end
115
+
116
+ def render_element(el, page_index, ctx, page)
117
+ unit = ctx[:unit]
118
+ case el.type
119
+ when ElementType::LINE then render_line(el, unit)
120
+ when ElementType::RECT then render_rect(el, unit)
121
+ when ElementType::CIRCLE then render_circle(el, unit)
122
+ when ElementType::ELLIPSE then render_ellipse(el, unit)
123
+ when ElementType::POLYGON then render_polygon(el, unit)
124
+ when ElementType::POLYLINE then render_polyline(el, unit)
125
+ when ElementType::PATH then render_path(el, unit)
126
+ when ElementType::TEXT then render_text(el, ctx)
127
+ when ElementType::IMG then render_image(el, ctx)
128
+ when ElementType::BARCODE then render_barcode(el, ctx)
129
+ when ElementType::FIELD_TEXT then render_text_field(el, page_index, ctx)
130
+ when ElementType::FIELD_CHECKBOX then render_checkbox(el, page_index, ctx)
131
+ when ElementType::FIELD_RADIO then render_radio(el, page_index, ctx)
132
+ when ElementType::FIELD_SELECT then render_select(el, page_index, ctx)
133
+ when ElementType::FIELD_MULTISELECT then render_multiselect(el, page_index, ctx)
134
+ when ElementType::FIELD_DATE then render_date(el, page_index, ctx)
135
+ when ElementType::FIELD_SIGNATURE then render_signature(el, page_index, ctx)
136
+ when ElementType::REGION then render_region(el, ctx, page)
137
+ else ""
138
+ end
139
+ end
140
+
141
+ # ── Interpolation ─────────────────────────────────────────────────────
142
+
143
+ def interpolate(text, ctx)
144
+ text.gsub(/\{@odin\.([a-z_]+)\}/) do
145
+ case Regexp.last_match(1)
146
+ when "page" then ctx[:page_number].to_s
147
+ when "total_pages" then ctx[:total_pages].to_s
148
+ else Regexp.last_match(0)
149
+ end
150
+ end
151
+ end
152
+
153
+ # ── Geometric ─────────────────────────────────────────────────────────
154
+
155
+ def svg_wrap(inner)
156
+ %(<svg class="odin-form-element" style="position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;">) +
157
+ inner + "</svg>"
158
+ end
159
+
160
+ def stroke_color(el)
161
+ el[:stroke] || "#000000"
162
+ end
163
+
164
+ def stroke_width_px(el, unit)
165
+ el[:"stroke-width"] ? Units.to_pixels(el[:"stroke-width"], unit) : 1
166
+ end
167
+
168
+ def render_line(el, unit)
169
+ x1 = Units.to_pixels(el[:x1], unit)
170
+ y1 = Units.to_pixels(el[:y1], unit)
171
+ x2 = Units.to_pixels(el[:x2], unit)
172
+ y2 = Units.to_pixels(el[:y2], unit)
173
+ svg_wrap(%(<line x1="#{x1}" y1="#{y1}" x2="#{x2}" y2="#{y2}" stroke="#{stroke_color(el)}" stroke-width="#{stroke_width_px(el, unit)}"/>))
174
+ end
175
+
176
+ def render_rect(el, unit)
177
+ x = Units.to_pixels(el[:x], unit)
178
+ y = Units.to_pixels(el[:y], unit)
179
+ w = Units.to_pixels(el[:w], unit)
180
+ h = Units.to_pixels(el[:h], unit)
181
+ border = el[:stroke] ? "border:#{stroke_width_px(el, unit)}px solid #{el[:stroke]};" : ""
182
+ bg = el[:fill] && el[:fill] != "none" ? "background:#{el[:fill]};" : ""
183
+ rx = el[:rx] ? Units.to_pixels(el[:rx], unit) : 0
184
+ ry = el[:ry] ? Units.to_pixels(el[:ry], unit) : 0
185
+ radius = rx != 0 || ry != 0 ? "border-radius:#{rx}px #{ry}px;" : ""
186
+ %(<div class="odin-form-element" style="position:absolute;left:#{x}px;top:#{y}px;width:#{w}px;height:#{h}px;#{border}#{bg}#{radius}"></div>)
187
+ end
188
+
189
+ def render_circle(el, unit)
190
+ cx = Units.to_pixels(el[:cx], unit)
191
+ cy = Units.to_pixels(el[:cy], unit)
192
+ r = Units.to_pixels(el[:r], unit)
193
+ fill = el[:fill] || "none"
194
+ svg_wrap(%(<circle cx="#{cx}" cy="#{cy}" r="#{r}" stroke="#{stroke_color(el)}" stroke-width="#{stroke_width_px(el, unit)}" fill="#{fill}"/>))
195
+ end
196
+
197
+ def render_ellipse(el, unit)
198
+ cx = Units.to_pixels(el[:cx], unit)
199
+ cy = Units.to_pixels(el[:cy], unit)
200
+ rx = Units.to_pixels(el[:rx], unit)
201
+ ry = Units.to_pixels(el[:ry], unit)
202
+ fill = el[:fill] || "none"
203
+ svg_wrap(%(<ellipse cx="#{cx}" cy="#{cy}" rx="#{rx}" ry="#{ry}" stroke="#{stroke_color(el)}" stroke-width="#{stroke_width_px(el, unit)}" fill="#{fill}"/>))
204
+ end
205
+
206
+ def render_polygon(el, unit)
207
+ points = convert_points(el[:points], unit)
208
+ fill = el[:fill] || "none"
209
+ svg_wrap(%(<polygon points="#{points}" stroke="#{stroke_color(el)}" stroke-width="#{stroke_width_px(el, unit)}" fill="#{fill}"/>))
210
+ end
211
+
212
+ def render_polyline(el, unit)
213
+ points = convert_points(el[:points], unit)
214
+ svg_wrap(%(<polyline points="#{points}" stroke="#{stroke_color(el)}" stroke-width="#{stroke_width_px(el, unit)}" fill="none"/>))
215
+ end
216
+
217
+ def render_path(el, unit)
218
+ fill = el[:fill] || "none"
219
+ svg_wrap(%(<path d="#{el[:d]}" stroke="#{stroke_color(el)}" stroke-width="#{stroke_width_px(el, unit)}" fill="#{fill}"/>))
220
+ end
221
+
222
+ # ── Content ───────────────────────────────────────────────────────────
223
+
224
+ def render_text(el, ctx)
225
+ unit = ctx[:unit]
226
+ x = Units.to_pixels(el[:x], unit)
227
+ y = Units.to_pixels(el[:y], unit)
228
+ font_size = el[:"font-size"] ? Units.to_pixels(el[:"font-size"], "pt") : Units.to_pixels(12, "pt")
229
+ font_weight = el[:"font-weight"] || "normal"
230
+ color = el[:color] || "#000000"
231
+ font_family = el[:"font-family"] ? "font-family:#{el[:"font-family"]};" : ""
232
+ font_style = el[:"font-style"] == "italic" ? "font-style:italic;" : ""
233
+ text_align = el[:"text-align"] ? "text-align:#{el[:"text-align"]};" : ""
234
+ content = interpolate(el[:content], ctx)
235
+ %(<span class="odin-form-element" style="position:absolute;left:#{x}px;top:#{y}px;font-size:#{font_size}px;font-weight:#{font_weight};color:#{color};#{font_family}#{font_style}#{text_align}">#{escape_html(content)}</span>)
236
+ end
237
+
238
+ def render_image(el, ctx)
239
+ unit = ctx[:unit]
240
+ x = Units.to_pixels(el[:x], unit)
241
+ y = Units.to_pixels(el[:y], unit)
242
+ w = Units.to_pixels(el[:w], unit)
243
+ h = Units.to_pixels(el[:h], unit)
244
+ src = image_src_to_data_uri(el[:src])
245
+ alt = interpolate(el[:alt], ctx)
246
+ z_index = el[:background] ? "z-index:0;" : ""
247
+ %(<img class="odin-form-element" src="#{escape_attr(src)}" alt="#{escape_attr(alt)}" style="position:absolute;left:#{x}px;top:#{y}px;width:#{w}px;height:#{h}px;#{z_index}">)
248
+ end
249
+
250
+ def render_barcode(el, ctx)
251
+ unit = ctx[:unit]
252
+ x = Units.to_pixels(el[:x], unit)
253
+ y = Units.to_pixels(el[:y], unit)
254
+ w = Units.to_pixels(el[:w], unit)
255
+ h = Units.to_pixels(el[:h], unit)
256
+ alt = interpolate(el[:alt], ctx)
257
+ content = interpolate(el[:content], ctx)
258
+ %(<div class="odin-form-element odin-form-barcode" role="img" aria-label="#{escape_attr(alt)}" ) +
259
+ %(data-barcode-type="#{escape_attr(el[:barcodeType])}" data-content="#{escape_attr(content)}" ) +
260
+ %(style="position:absolute;left:#{x}px;top:#{y}px;width:#{w}px;height:#{h}px;"></div>)
261
+ end
262
+
263
+ def image_src_to_data_uri(src)
264
+ return src unless src.start_with?("^")
265
+
266
+ rest = src[1..]
267
+ colon = rest.index(":")
268
+ return "data:image/png;base64,#{rest}" if colon.nil?
269
+
270
+ format = rest[0...colon]
271
+ b64 = rest[(colon + 1)..]
272
+ "data:image/#{format};base64,#{b64}"
273
+ end
274
+
275
+ # ── Fields ────────────────────────────────────────────────────────────
276
+
277
+ def field_box(el, ctx)
278
+ unit = ctx[:unit]
279
+ {
280
+ x: Units.to_pixels(el[:x], unit),
281
+ y: Units.to_pixels(el[:y], unit),
282
+ w: Units.to_pixels(el[:w], unit),
283
+ h: Units.to_pixels(el[:h], unit),
284
+ }
285
+ end
286
+
287
+ def aria_required_attr(attrs)
288
+ attrs["aria-required"] ? ' aria-required="true"' : ""
289
+ end
290
+
291
+ def render_text_field(el, page_index, ctx)
292
+ box = field_box(el, ctx)
293
+ attrs = Accessibility.field_aria_attrs(el, page_index)
294
+ input_id = Accessibility.generate_field_id(el.name, page_index)
295
+ value = el[:value] || lookup_bound_value(el, ctx[:data])
296
+ value_attr = value.nil? ? "" : %( value="#{escape_attr(value)}")
297
+ required_attr = el[:required] ? " required" : ""
298
+ readonly_attr = el[:readonly] ? " readonly" : ""
299
+ placeholder_attr = el[:placeholder] ? %( placeholder="#{escape_attr(el[:placeholder])}") : ""
300
+ input_type = el[:inputType] || "text"
301
+
302
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
303
+ Accessibility.field_label_html(interpolate(el[:label], ctx), input_id) +
304
+ %(<input type="#{escape_attr(input_type)}" class="odin-form-input" id="#{attrs['id']}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)}#{value_attr}#{required_attr}#{readonly_attr}#{placeholder_attr}>) +
305
+ "</div>"
306
+ end
307
+
308
+ def render_checkbox(el, page_index, ctx)
309
+ box = field_box(el, ctx)
310
+ attrs = Accessibility.field_aria_attrs(el, page_index)
311
+ input_id = Accessibility.generate_field_id(el.name, page_index)
312
+ bound = lookup_bound_value(el, ctx[:data])
313
+ is_checked = el.key?(:checked) ? el[:checked] : (bound == "true")
314
+ checked = is_checked ? " checked" : ""
315
+
316
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
317
+ Accessibility.field_label_html(interpolate(el[:label], ctx), input_id) +
318
+ %(<input type="checkbox" class="odin-form-checkbox" id="#{attrs['id']}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)}#{checked}>) +
319
+ "</div>"
320
+ end
321
+
322
+ def render_radio(el, page_index, ctx)
323
+ box = field_box(el, ctx)
324
+ attrs = Accessibility.field_aria_attrs(el, page_index)
325
+ value = lookup_bound_value(el, ctx[:data])
326
+ checked = value == el[:value] ? " checked" : ""
327
+
328
+ radio_html =
329
+ %(<input type="radio" class="odin-form-radio" id="#{attrs['id']}" name="#{escape_attr(el[:group])}" value="#{escape_attr(el[:value])}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)}#{checked}>) +
330
+ %(<label for="#{attrs['id']}">#{escape_html(interpolate(el[:label], ctx))}</label>)
331
+
332
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
333
+ Accessibility.field_group_html(el[:group], interpolate(el[:label], ctx), radio_html) +
334
+ "</div>"
335
+ end
336
+
337
+ def render_select(el, page_index, ctx)
338
+ box = field_box(el, ctx)
339
+ attrs = Accessibility.field_aria_attrs(el, page_index)
340
+ input_id = Accessibility.generate_field_id(el.name, page_index)
341
+ value = el[:selected] || lookup_bound_value(el, ctx[:data])
342
+
343
+ options_html = +""
344
+ options_html << %(<option value="">#{escape_html(el[:placeholder])}</option>) if el[:placeholder]
345
+ (el[:options] || []).each do |opt|
346
+ selected = value == opt ? " selected" : ""
347
+ options_html << %(<option value="#{escape_attr(opt)}"#{selected}>#{escape_html(opt)}</option>)
348
+ end
349
+
350
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
351
+ Accessibility.field_label_html(interpolate(el[:label], ctx), input_id) +
352
+ %(<select class="odin-form-select" id="#{attrs['id']}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)}>) +
353
+ options_html +
354
+ "</select>" +
355
+ "</div>"
356
+ end
357
+
358
+ def render_multiselect(el, page_index, ctx)
359
+ box = field_box(el, ctx)
360
+ attrs = Accessibility.field_aria_attrs(el, page_index)
361
+ input_id = Accessibility.generate_field_id(el.name, page_index)
362
+ selected_values =
363
+ if el.key?(:selected)
364
+ Array(el[:selected])
365
+ else
366
+ value = lookup_bound_value(el, ctx[:data])
367
+ value ? value.split(",").map(&:strip) : []
368
+ end
369
+
370
+ options_html = +""
371
+ (el[:options] || []).each do |opt|
372
+ selected = selected_values.include?(opt) ? " selected" : ""
373
+ options_html << %(<option value="#{escape_attr(opt)}"#{selected}>#{escape_html(opt)}</option>)
374
+ end
375
+
376
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
377
+ Accessibility.field_label_html(interpolate(el[:label], ctx), input_id) +
378
+ %(<select multiple class="odin-form-select" id="#{attrs['id']}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)}>) +
379
+ options_html +
380
+ "</select>" +
381
+ "</div>"
382
+ end
383
+
384
+ def render_date(el, page_index, ctx)
385
+ box = field_box(el, ctx)
386
+ attrs = Accessibility.field_aria_attrs(el, page_index)
387
+ input_id = Accessibility.generate_field_id(el.name, page_index)
388
+ value = el[:value] || lookup_bound_value(el, ctx[:data])
389
+ value_attr = value.nil? ? "" : %( value="#{escape_attr(value)}")
390
+ required_attr = el[:required] ? " required" : ""
391
+
392
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
393
+ Accessibility.field_label_html(interpolate(el[:label], ctx), input_id) +
394
+ %(<input type="date" class="odin-form-input" id="#{attrs['id']}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)}#{value_attr}#{required_attr}>) +
395
+ "</div>"
396
+ end
397
+
398
+ def render_signature(el, page_index, ctx)
399
+ box = field_box(el, ctx)
400
+ attrs = Accessibility.field_aria_attrs(el, page_index)
401
+ input_id = Accessibility.generate_field_id(el.name, page_index)
402
+
403
+ %(<div class="odin-form-element" style="position:absolute;left:#{box[:x]}px;top:#{box[:y]}px;width:#{box[:w]}px;height:#{box[:h]}px;">) +
404
+ Accessibility.field_label_html(interpolate(el[:label], ctx), input_id) +
405
+ %(<div class="odin-form-signature" id="#{attrs['id']}" aria-label="#{escape_attr(interpolate(attrs['aria-label'], ctx))}"#{aria_required_attr(attrs)} role="img" tabindex="0" style="width:100%;height:100%;"></div>) +
406
+ "</div>"
407
+ end
408
+
409
+ # ── Region ────────────────────────────────────────────────────────────
410
+
411
+ def render_region(el, ctx, page)
412
+ unit = ctx[:unit]
413
+ region_x = Units.to_pixels(el[:x], unit)
414
+ region_y = Units.to_pixels(el[:y], unit)
415
+ region_w = Units.to_pixels(el[:w], unit)
416
+ region_h = Units.to_pixels(el[:h], unit)
417
+
418
+ slice = page[:item_slices] && page[:item_slices][el.name]
419
+ bind = el[:bind] || (slice && slice[:bind])
420
+ total = bind ? bound_array_length(bind, ctx[:data]) : 0
421
+ start = 0
422
+ if slice
423
+ start = slice[:start]
424
+ count = slice[:count]
425
+ elsif total.positive?
426
+ count = el[:max] ? [el[:max], total].min : total
427
+ else
428
+ count = 1
429
+ end
430
+
431
+ parts = []
432
+ parts << %(<div class="odin-form-element odin-form-region" data-region="#{escape_attr(el.name)}" ) +
433
+ %(style="position:absolute;left:#{region_x}px;top:#{region_y}px;width:#{region_w}px;height:#{region_h}px;">)
434
+
435
+ count.times do |i|
436
+ item_index = start + i
437
+ item_bind = bind ? "#{bind}[#{item_index}]" : nil
438
+ (el[:children] || []).each do |child|
439
+ parts << render_region_child(child, i, item_bind, ctx)
440
+ end
441
+ end
442
+
443
+ parts << "</div>"
444
+ parts.join
445
+ end
446
+
447
+ def render_region_child(child, i, item_bind, ctx)
448
+ y_offset = child[:"y-offset"] || 0
449
+ x_offset = child[:"x-offset"] || 0
450
+ dx = child[:x] + (x_offset * i)
451
+ dy = child[:y] + (y_offset * i)
452
+
453
+ if child.type == ElementType::TEXT
454
+ return render_text(FormElement.new(child.props.merge(x: dx, y: dy)), ctx)
455
+ end
456
+
457
+ resolved_bind = resolve_relative_bind(child[:bind], item_bind) || child[:bind]
458
+ rebased = FormElement.new(child.props.merge(
459
+ x: dx, y: dy,
460
+ name: "#{child.name}_#{i}",
461
+ bind: resolved_bind
462
+ ))
463
+ child_page_index = -1 - i
464
+ render_element(rebased, child_page_index, ctx, { elements: [] })
465
+ end
466
+
467
+ def resolve_relative_bind(bind, item_bind)
468
+ return nil if bind.nil? || bind.empty?
469
+
470
+ if bind.start_with?("@.")
471
+ return nil if item_bind.nil?
472
+
473
+ return "#{item_bind}.#{bind[2..]}"
474
+ end
475
+ bind
476
+ end
477
+
478
+ def bound_array_length(bind, data)
479
+ return 0 if data.nil?
480
+
481
+ path = bind.start_with?("@") ? bind[1..] : bind
482
+ re = /\A#{Regexp.escape(path)}\[(\d+)\](?:\.|\z)/
483
+ max = -1
484
+ data.paths.each do |p|
485
+ m = re.match(p)
486
+ if m
487
+ idx = m[1].to_i
488
+ max = idx if idx > max
489
+ end
490
+ end
491
+ max + 1
492
+ end
493
+
494
+ # ── Data binding ──────────────────────────────────────────────────────
495
+
496
+ def lookup_bound_value(el, data)
497
+ bind = el[:bind]
498
+ return nil if data.nil? || bind.nil? || bind.empty?
499
+
500
+ path = bind.start_with?("@") ? bind[1..] : bind
501
+ return nil if path.empty?
502
+
503
+ val = data.get(path)
504
+ return nil if val.nil?
505
+
506
+ case val.type
507
+ when :string then val.value
508
+ when :number, :integer, :boolean then val.value.to_s
509
+ end
510
+ end
511
+
512
+ # ── Utilities ─────────────────────────────────────────────────────────
513
+
514
+ def escape_html(str)
515
+ str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
516
+ end
517
+
518
+ def escape_attr(str)
519
+ str.to_s.gsub("&", "&amp;").gsub('"', "&quot;").gsub("'", "&#39;").gsub("<", "&lt;").gsub(">", "&gt;")
520
+ end
521
+
522
+ def convert_points(points, unit)
523
+ points.to_s.strip.split(/\s+/).map do |pair|
524
+ x, y = pair.split(",")
525
+ if x.nil? || y.nil?
526
+ pair
527
+ else
528
+ "#{Units.to_pixels(x.to_f, unit)},#{Units.to_pixels(y.to_f, unit)}"
529
+ end
530
+ end.join(" ")
531
+ end
532
+ end
533
+ end
534
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Forms
5
+ # Element type discriminators for form elements.
6
+ module ElementType
7
+ LINE = "line"
8
+ RECT = "rect"
9
+ CIRCLE = "circle"
10
+ ELLIPSE = "ellipse"
11
+ POLYGON = "polygon"
12
+ POLYLINE = "polyline"
13
+ PATH = "path"
14
+ TEXT = "text"
15
+ IMG = "img"
16
+ BARCODE = "barcode"
17
+ FIELD_TEXT = "field.text"
18
+ FIELD_CHECKBOX = "field.checkbox"
19
+ FIELD_RADIO = "field.radio"
20
+ FIELD_SELECT = "field.select"
21
+ FIELD_MULTISELECT = "field.multiselect"
22
+ FIELD_DATE = "field.date"
23
+ FIELD_SIGNATURE = "field.signature"
24
+ REGION = "region"
25
+
26
+ FIELD_TYPES = [
27
+ FIELD_TEXT, FIELD_CHECKBOX, FIELD_RADIO, FIELD_SELECT,
28
+ FIELD_MULTISELECT, FIELD_DATE, FIELD_SIGNATURE
29
+ ].freeze
30
+
31
+ def self.field?(type)
32
+ FIELD_TYPES.include?(type)
33
+ end
34
+ end
35
+
36
+ # A single form element. Backed by a property hash so optional attributes are
37
+ # present only when set in the source document.
38
+ class FormElement
39
+ attr_reader :props
40
+
41
+ def initialize(props)
42
+ @props = props
43
+ end
44
+
45
+ def type
46
+ @props[:type]
47
+ end
48
+
49
+ def name
50
+ @props[:name]
51
+ end
52
+
53
+ def [](key)
54
+ @props[key]
55
+ end
56
+
57
+ def key?(key)
58
+ @props.key?(key)
59
+ end
60
+
61
+ def field?
62
+ ElementType.field?(type)
63
+ end
64
+ end
65
+
66
+ # An ordered list of elements forming one page.
67
+ class FormPage
68
+ attr_reader :elements
69
+
70
+ def initialize(elements)
71
+ @elements = elements
72
+ end
73
+ end
74
+
75
+ # A page template instantiated for region overflow continuation pages.
76
+ class PageTemplate
77
+ attr_reader :name, :page_template, :continues, :form_id, :elements
78
+
79
+ def initialize(name:, page_template:, continues:, form_id:, elements:)
80
+ @name = name
81
+ @page_template = page_template
82
+ @continues = continues
83
+ @form_id = form_id
84
+ @elements = elements
85
+ end
86
+ end
87
+
88
+ # Root parsed form document.
89
+ class OdinForm
90
+ attr_reader :metadata, :page_defaults, :screen, :i18n, :pages, :templates
91
+
92
+ def initialize(metadata:, page_defaults:, screen:, i18n:, pages:, templates:)
93
+ @metadata = metadata
94
+ @page_defaults = page_defaults
95
+ @screen = screen
96
+ @i18n = i18n
97
+ @pages = pages
98
+ @templates = templates
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Forms
5
+ # Measurement-unit conversion between page units and CSS pixels.
6
+ module Units
7
+ DPI = 96.0
8
+
9
+ CONVERSIONS = {
10
+ "inch" => DPI,
11
+ "cm" => DPI / 2.54,
12
+ "mm" => DPI / 25.4,
13
+ "pt" => DPI / 72.0,
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ # Convert a value in the given unit to pixels, rounded to 3 decimals.
19
+ def to_pixels(value, unit)
20
+ factor = CONVERSIONS[unit.to_s]
21
+ raise ArgumentError, "Unknown unit: #{unit}" unless factor
22
+
23
+ round3(value * factor)
24
+ end
25
+
26
+ # Convert pixels back to the given unit, rounded to 3 decimals.
27
+ def from_pixels(px, unit)
28
+ factor = CONVERSIONS[unit.to_s]
29
+ raise ArgumentError, "Unknown unit: #{unit}" unless factor
30
+
31
+ round3(px / factor)
32
+ end
33
+
34
+ # Round to 3 decimal places, returning an integer when whole.
35
+ def round3(n)
36
+ r = (n * 1000).round / 1000.0
37
+ r == r.to_i ? r.to_i : r
38
+ end
39
+ end
40
+ end
41
+ end