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,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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def escape_attr(str)
|
|
519
|
+
str.to_s.gsub("&", "&").gsub('"', """).gsub("'", "'").gsub("<", "<").gsub(">", ">")
|
|
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
|