vehicles 0.4.1 → 0.8.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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +164 -0
  3. data/CHANGELOG.md +321 -0
  4. data/README.md +88 -15
  5. data/data/PROVENANCE-vehicles.md +12 -0
  6. data/data/plates/PROVENANCE.md +10 -0
  7. data/data/plates/_decode/at-districts.yml +287 -0
  8. data/data/plates/_decode/ch-cantons.yml +110 -0
  9. data/data/plates/_decode/cn-issuing-offices.yml +463 -0
  10. data/data/plates/_decode/cn-provinces.yml +614 -0
  11. data/data/plates/_decode/cz-kraj.yml +159 -0
  12. data/data/plates/_decode/dk-serial-blocks.yml +327 -0
  13. data/data/plates/_decode/es-provinces.yml +116 -0
  14. data/data/plates/_decode/fr-departements.yml +191 -0
  15. data/data/plates/_decode/gb-age-identifiers.yml +170 -0
  16. data/data/plates/_decode/gb-memory-tags.yml +202 -0
  17. data/data/plates/_decode/gr-prefectures.yml +1408 -0
  18. data/data/plates/_decode/hr-cities.yml +315 -0
  19. data/data/plates/_decode/ie-counties.yml +227 -0
  20. data/data/plates/_decode/in-state-codes.yml +169 -0
  21. data/data/plates/_decode/is-districts-1938.yml +116 -0
  22. data/data/plates/_decode/it-provinces.yml +348 -0
  23. data/data/plates/_decode/jp-office-marks.yml +960 -0
  24. data/data/plates/_decode/jp-use-marks.yml +205 -0
  25. data/data/plates/_decode/kr-authority-marks.yml +146 -0
  26. data/data/plates/_decode/kr-use-syllables.yml +194 -0
  27. data/data/plates/_decode/lu-series-order.yml +638 -0
  28. data/data/plates/_decode/mx-entity-series.yml +380 -0
  29. data/data/plates/_decode/my-index-marks.yml +271 -0
  30. data/data/plates/_decode/no-counties-1913.yml +148 -0
  31. data/data/plates/_decode/no-districts.yml +901 -0
  32. data/data/plates/_decode/pl-powiats.yml +567 -0
  33. data/data/plates/_decode/pt-trailer-services.yml +96 -0
  34. data/data/plates/_decode/ro-counties.yml +213 -0
  35. data/data/plates/_decode/se-diplomatic.yml +220 -0
  36. data/data/plates/_decode/si-regions.yml +123 -0
  37. data/data/plates/_decode/sk-districts.yml +314 -0
  38. data/data/plates/_decode/th-registration-elements.yml +208 -0
  39. data/data/plates/_decode/ua-regions.yml +393 -0
  40. data/data/plates/_decode/za-licence-marks.yml +215 -0
  41. data/data/plates/_meta/classes.yml +26 -0
  42. data/data/plates/_meta/separators.yml +102 -0
  43. data/data/plates/ad.yml +961 -0
  44. data/data/plates/ar.yml +762 -0
  45. data/data/plates/at.yml +1424 -0
  46. data/data/plates/au-act.yml +472 -0
  47. data/data/plates/au-nsw.yml +282 -0
  48. data/data/plates/au-nt.yml +431 -0
  49. data/data/plates/au-qld.yml +272 -0
  50. data/data/plates/au-sa.yml +248 -0
  51. data/data/plates/au-tas.yml +302 -0
  52. data/data/plates/au-vic.yml +215 -0
  53. data/data/plates/au-wa.yml +309 -0
  54. data/data/plates/be.yml +1840 -0
  55. data/data/plates/br.yml +1152 -0
  56. data/data/plates/ca-ab.yml +430 -0
  57. data/data/plates/ca-bc.yml +697 -0
  58. data/data/plates/ca-mb.yml +499 -0
  59. data/data/plates/ca-nb.yml +537 -0
  60. data/data/plates/ca-nl.yml +743 -0
  61. data/data/plates/ca-ns.yml +808 -0
  62. data/data/plates/ca-nt.yml +610 -0
  63. data/data/plates/ca-nu.yml +280 -0
  64. data/data/plates/ca-on.yml +881 -0
  65. data/data/plates/ca-pe.yml +592 -0
  66. data/data/plates/ca-qc.yml +1036 -0
  67. data/data/plates/ca-sk.yml +256 -0
  68. data/data/plates/ca-yt.yml +656 -0
  69. data/data/plates/ch.yml +1273 -0
  70. data/data/plates/cn.yml +1431 -0
  71. data/data/plates/cy.yml +688 -0
  72. data/data/plates/cz.yml +1179 -0
  73. data/data/plates/de.yml +1107 -0
  74. data/data/plates/dk.yml +1647 -0
  75. data/data/plates/ee.yml +1275 -0
  76. data/data/plates/es.yml +888 -0
  77. data/data/plates/fi.yml +1271 -0
  78. data/data/plates/fr.yml +1265 -0
  79. data/data/plates/gb.yml +1188 -0
  80. data/data/plates/gr.yml +721 -0
  81. data/data/plates/hr.yml +860 -0
  82. data/data/plates/hu.yml +959 -0
  83. data/data/plates/ie.yml +775 -0
  84. data/data/plates/in.yml +1172 -0
  85. data/data/plates/is.yml +667 -0
  86. data/data/plates/it.yml +1317 -0
  87. data/data/plates/jp.yml +3650 -0
  88. data/data/plates/kr.yml +1375 -0
  89. data/data/plates/li.yml +1010 -0
  90. data/data/plates/lt.yml +879 -0
  91. data/data/plates/lu.yml +842 -0
  92. data/data/plates/lv.yml +924 -0
  93. data/data/plates/mc.yml +1024 -0
  94. data/data/plates/mt.yml +854 -0
  95. data/data/plates/mx.yml +1372 -0
  96. data/data/plates/my.yml +685 -0
  97. data/data/plates/nl.yml +1016 -0
  98. data/data/plates/no.yml +1018 -0
  99. data/data/plates/nz.yml +1699 -0
  100. data/data/plates/pl.yml +1296 -0
  101. data/data/plates/pt.yml +847 -0
  102. data/data/plates/ro.yml +785 -0
  103. data/data/plates/se.yml +1091 -0
  104. data/data/plates/si.yml +1105 -0
  105. data/data/plates/sk.yml +1439 -0
  106. data/data/plates/sm.yml +1119 -0
  107. data/data/plates/th.yml +1150 -0
  108. data/data/plates/ua.yml +1672 -0
  109. data/data/plates/us-ak.yml +763 -0
  110. data/data/plates/us-al.yml +457 -0
  111. data/data/plates/us-ar.yml +427 -0
  112. data/data/plates/us-as.yml +454 -0
  113. data/data/plates/us-az.yml +430 -0
  114. data/data/plates/us-ca.yml +892 -0
  115. data/data/plates/us-co.yml +452 -0
  116. data/data/plates/us-ct.yml +427 -0
  117. data/data/plates/us-dc.yml +614 -0
  118. data/data/plates/us-de.yml +551 -0
  119. data/data/plates/us-fl.yml +741 -0
  120. data/data/plates/us-ga.yml +644 -0
  121. data/data/plates/us-gu.yml +658 -0
  122. data/data/plates/us-hi.yml +585 -0
  123. data/data/plates/us-ia.yml +438 -0
  124. data/data/plates/us-id.yml +531 -0
  125. data/data/plates/us-il.yml +516 -0
  126. data/data/plates/us-in.yml +324 -0
  127. data/data/plates/us-ks.yml +351 -0
  128. data/data/plates/us-ky.yml +770 -0
  129. data/data/plates/us-la.yml +568 -0
  130. data/data/plates/us-ma.yml +379 -0
  131. data/data/plates/us-md.yml +536 -0
  132. data/data/plates/us-me.yml +445 -0
  133. data/data/plates/us-mi.yml +408 -0
  134. data/data/plates/us-mn.yml +411 -0
  135. data/data/plates/us-mo.yml +356 -0
  136. data/data/plates/us-mp.yml +402 -0
  137. data/data/plates/us-ms.yml +432 -0
  138. data/data/plates/us-mt.yml +619 -0
  139. data/data/plates/us-nc.yml +602 -0
  140. data/data/plates/us-nd.yml +419 -0
  141. data/data/plates/us-ne.yml +483 -0
  142. data/data/plates/us-nh.yml +394 -0
  143. data/data/plates/us-nj.yml +547 -0
  144. data/data/plates/us-nm.yml +574 -0
  145. data/data/plates/us-nv.yml +655 -0
  146. data/data/plates/us-ny.yml +592 -0
  147. data/data/plates/us-oh.yml +404 -0
  148. data/data/plates/us-ok.yml +513 -0
  149. data/data/plates/us-or.yml +392 -0
  150. data/data/plates/us-pa.yml +506 -0
  151. data/data/plates/us-pr.yml +507 -0
  152. data/data/plates/us-ri.yml +445 -0
  153. data/data/plates/us-sc.yml +605 -0
  154. data/data/plates/us-sd.yml +428 -0
  155. data/data/plates/us-tn.yml +471 -0
  156. data/data/plates/us-tx.yml +535 -0
  157. data/data/plates/us-ut.yml +384 -0
  158. data/data/plates/us-va.yml +567 -0
  159. data/data/plates/us-vi.yml +489 -0
  160. data/data/plates/us-vt.yml +458 -0
  161. data/data/plates/us-wa.yml +484 -0
  162. data/data/plates/us-wi.yml +509 -0
  163. data/data/plates/us-wv.yml +446 -0
  164. data/data/plates/us-wy.yml +523 -0
  165. data/data/plates/va.yml +604 -0
  166. data/data/plates/za.yml +1141 -0
  167. data/data/vehicles.json +160653 -105090
  168. data/lib/vehicles/configuration.rb +18 -2
  169. data/lib/vehicles/dataset.rb +73 -18
  170. data/lib/vehicles/fuzzy.rb +115 -0
  171. data/lib/vehicles/http.rb +48 -0
  172. data/lib/vehicles/make.rb +36 -6
  173. data/lib/vehicles/mcp_server.rb +5 -5
  174. data/lib/vehicles/model.rb +42 -6
  175. data/lib/vehicles/plates/jurisdiction.rb +32 -0
  176. data/lib/vehicles/plates/series.rb +291 -0
  177. data/lib/vehicles/plates/template_fill.rb +188 -0
  178. data/lib/vehicles/plates/templates.rb +162 -0
  179. data/lib/vehicles/plates.rb +122 -0
  180. data/lib/vehicles/providers/hosted_provider.rb +49 -58
  181. data/lib/vehicles/providers/local_provider.rb +4 -3
  182. data/lib/vehicles/validators/vehicle_make_validator.rb +3 -1
  183. data/lib/vehicles/validators/vehicle_model_validator.rb +1 -1
  184. data/lib/vehicles/version.rb +1 -1
  185. data/lib/vehicles.rb +53 -16
  186. data/rakelib/data_refresh.rake +109 -0
  187. data/rakelib/plates_refresh.rake +29 -0
  188. data/sig/vehicles.rbs +18 -0
  189. metadata +174 -2
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ module Plates
5
+ # One registration series: a pattern identity with a period, a class
6
+ # (standard/taxi/diplomatic/…), categories, sourced design facts, and the
7
+ # two regexes of the dataset's regex policy — `regex` (recall: the
8
+ # pattern's own alphabet) and `regex_strict` (validation: the alphabet
9
+ # the authority actually issues). Validation prefers strict.
10
+ class Series
11
+ attr_reader :id, :klass, :categories, :period, :period_evidence,
12
+ :pattern, :display_pattern, :regex, :regex_strict, :design,
13
+ :variants, :sources, :notes, :validation_regexp, :lenient_regexp,
14
+ :issued_regexp, :issued_separators
15
+
16
+ def initialize(entry)
17
+ @id = entry["id"]
18
+ @klass = entry["class"] || "standard"
19
+ @categories = Array(entry["categories"]).freeze
20
+ @period = (entry["period"] || {}).freeze
21
+ @period_evidence = entry["period_evidence"]
22
+ @pattern = entry.dig("format", "pattern")
23
+ # "\L" escapes a LITERAL L (vs the any-letter placeholder) —
24
+ # ca-nb-truck-2003 introduced it; resolved for display and walks.
25
+ @display_pattern = @pattern&.gsub(/\\(.)/) { $1 }
26
+ @regex = entry.dig("format", "regex")
27
+ @regex_strict = entry.dig("format", "regex_strict")
28
+ @design = (entry["design"] || {}).freeze
29
+ @variants = Array(entry["variants"]).freeze
30
+ @sources = Array(entry["sources"]).freeze
31
+ @notes = entry["notes"]
32
+ # Compiled eagerly — the object is frozen, so no lazy memoization.
33
+ @validation_regexp = compile(@regex_strict || @regex)
34
+ @lenient_regexp = compile(self.class.strip_separators(@regex_strict || @regex))
35
+ segments, separators = self.class.segment_separators(@regex_strict || @regex)
36
+ @issued_separators = (separators || []).freeze
37
+ @issued_regexp = segments && compile("\\A(#{segments.join(')(')})\\z")
38
+ freeze
39
+ end
40
+
41
+ # A strict-validated series has an authority-alphabet regex twin; a
42
+ # recall-only series (export/dark-blue catch-alls that accept any
43
+ # current shape) matches loosely by design. Consumers validating user
44
+ # input should weight strict hits above recall-only ones — Match does.
45
+ def strict?
46
+ !regex_strict.nil?
47
+ end
48
+
49
+ # period_evidence values on which the recorded START is not (or not
50
+ # provenly) the series' birth — an instrument/consolidation date, an
51
+ # upper bound, or an unverified secondary (the dataset's own honesty
52
+ # discipline). The label must not present these as the era. Values
53
+ # like enabling-instrument / statutory-creation-date /
54
+ # instrument-dated-both-ends ARE true starts and stay unmarked.
55
+ INSTRUMENT_DATED_EVIDENCE = %w[
56
+ instrument-in-force instrument-window
57
+ instrument-in-force-upper-bound secondary-locator-unverified
58
+ ].freeze
59
+
60
+ # "2000 → today" (open, still issued), "1971–2000", or — when the
61
+ # start is only an instrument date — "~1999–2000" / "~2000 → today":
62
+ # the format is documented FROM that year and may be older.
63
+ # es-provincial's 1999–2000 instrument window over a format that ran
64
+ # for decades is the case this guards. approximate_start? tells a
65
+ # consumer to explain the "~".
66
+ def period_label
67
+ start = period["start"]
68
+ start = "~#{start}" if approximate_start?
69
+ finish = period["end"]
70
+ finish ? "#{start}–#{finish}" : "#{start} → today"
71
+ end
72
+
73
+ # True when the recorded start is an instrument date, not the series'
74
+ # birth — the display should read it as "documented from", not "began in".
75
+ def approximate_start?
76
+ INSTRUMENT_DATED_EVIDENCE.include?(period_evidence)
77
+ end
78
+
79
+ # Re-print a separator-less serial the way this series formats it.
80
+ # Primary path: match the serial against the regex SEGMENTED at its
81
+ # separators (the §2.6/§2.8 separator contract makes segmentation
82
+ # mechanical) and rejoin the captured groups with the separators the
83
+ # series actually prints — correct even when groups are
84
+ # variable-length (ES provincial: M-1234-LA and SE-1234-AB both live
85
+ # in [A-Z]{1,2}-\d{4}-[A-Z]{1,2}; the old pattern walk sliced
86
+ # "M1234LA" into "M1-234L-A"). Fallback: the pattern walk, but ONLY
87
+ # when the serial exactly fills the pattern's slots; otherwise the
88
+ # serial comes back untouched — unformatted is honest, misplaced
89
+ # separators are not.
90
+ def format_serial(serial)
91
+ return nil if serial.nil?
92
+
93
+ # The display pattern is authoritative when the serial fills it
94
+ # exactly — it knows splits the regex cannot (CC-999-999 over
95
+ # `\d+[- ]\d+` is ambiguous to a greedy match, unambiguous to the
96
+ # pattern).
97
+ slots = display_pattern&.each_char&.count { |ch| ![ "-", " ", "·", "." ].include?(ch) }
98
+ if slots && serial.length == slots
99
+ idx = 0
100
+ out = display_pattern.each_char.with_object(+"") do |ch, str|
101
+ if [ "-", " ", "·", "." ].include?(ch)
102
+ str << ch
103
+ else
104
+ str << (serial[idx] || "")
105
+ idx += 1
106
+ end
107
+ end
108
+ return out.freeze
109
+ end
110
+
111
+ # Length mismatch: the pattern is one shape of a variable family —
112
+ # reconstruct from the regex segmented at its separators instead
113
+ # (ES provincial: "M1234LA" is M-1234-LA, which the fixed-slot walk
114
+ # sliced into "M1-234L-A").
115
+ if issued_regexp && (m = issued_regexp.match(serial)) &&
116
+ m.captures.size == issued_separators.size + 1
117
+ return m.captures.each_with_index
118
+ .map { |part, i| i.zero? ? part : "#{issued_separators[i - 1]}#{part}" }
119
+ .join.freeze
120
+ end
121
+
122
+ # Neither path can know — unformatted is honest, misplaced
123
+ # separators are not.
124
+ serial.dup.freeze
125
+ end
126
+
127
+ # Tokens that read as "a separator" when they make up a whole
128
+ # character class — `[- ]` in the ES consular series is dash-or-space,
129
+ # i.e. a separator spelled as a class.
130
+ SEPARATOR_TOKENS = [ " ", "-", "·", ".", '\s', '\-', '\.', '\ ' ].freeze
131
+
132
+ # Removing a separator can join two repetitions of the same character
133
+ # class: `[A-Z0-9]+(?:-[A-Z0-9]+)*` becomes the redundant
134
+ # `[A-Z0-9]+(?:[A-Z0-9]+)*`. Ruby (3.1+) warns while compiling that shape.
135
+ # Its language is exactly `[A-Z0-9]+`, whether the trailing group is
136
+ # optional or repeated, so canonicalize it after the separator walk.
137
+ REDUNDANT_CHARACTER_CLASS_REPEAT = /(\[[^\]]+\])\+\(\?:\1\+\)[*?]/
138
+
139
+ # Split a regex source into its serial SEGMENTS and the separator
140
+ # character printed between each pair — the reconstruction data behind
141
+ # format_serial. Same scan as strip_separators (same class-vs-range
142
+ # discipline; a dropped separator's dangling quantifier goes with it),
143
+ # but instead of discarding separators it records what each one PRINTS
144
+ # (`\s` and "\ " print a space; a separator-only class prints its
145
+ # first token). Returns [segments, separators] with
146
+ # segments.length == separators.length + 1, or [nil, nil] when the
147
+ # source is nil, has no separators, or segments in a shape the
148
+ # reconstruction cannot trust (empty segment, capturing groups that
149
+ # would shift the positional captures).
150
+ def self.segment_separators(src)
151
+ return [ nil, nil ] if src.nil?
152
+
153
+ body = src.sub('\A', "").sub('\z', "")
154
+ segments = [ +"" ]
155
+ separators = []
156
+ emit = lambda do |printed|
157
+ if segments.last.empty? && separators.any?
158
+ # a run of separators prints once — keep the first
159
+ else
160
+ separators << printed
161
+ segments << +""
162
+ end
163
+ end
164
+
165
+ chars = body.chars
166
+ i = 0
167
+ while i < chars.size
168
+ ch = chars[i]
169
+
170
+ if ch == "\\" && i + 1 < chars.size
171
+ nxt = chars[i + 1]
172
+ if [ "s", " " ].include?(nxt)
173
+ i += 2
174
+ i += 1 if [ "?", "*" ].include?(chars[i])
175
+ emit.call(" ")
176
+ elsif [ "-", "." ].include?(nxt)
177
+ i += 2
178
+ i += 1 if [ "?", "*" ].include?(chars[i])
179
+ emit.call(nxt)
180
+ else
181
+ segments.last << ch << nxt
182
+ i += 2
183
+ end
184
+ next
185
+ end
186
+
187
+ if ch == "["
188
+ closing = i + 1
189
+ closing += 1 while closing < chars.size && chars[closing] != "]"
190
+ content = chars[(i + 1)...closing].join
191
+ tokens = content.scan(/\\.|./m)
192
+ if tokens.any? && tokens.all? { |t| SEPARATOR_TOKENS.include?(t) }
193
+ first = tokens.first
194
+ printed = first.start_with?("\\") ? (first[1] == "s" ? " " : first[1]) : first
195
+ i = closing + 1
196
+ i += 1 if [ "?", "*" ].include?(chars[i])
197
+ emit.call(printed)
198
+ else
199
+ segments.last << chars[i..closing].join
200
+ i = closing + 1
201
+ end
202
+ next
203
+ end
204
+
205
+ if [ "-", " ", "·" ].include?(ch)
206
+ i += 1
207
+ i += 1 if [ "?", "*" ].include?(chars[i])
208
+ emit.call(ch)
209
+ else
210
+ segments.last << ch
211
+ i += 1
212
+ end
213
+ end
214
+
215
+ return [ nil, nil ] if separators.empty?
216
+ return [ nil, nil ] if segments.any?(&:empty?)
217
+ # A capturing group inside a segment would shift the positional
218
+ # captures the reconstruction indexes by — bail to the fallback.
219
+ return [ nil, nil ] if segments.any? { |s| s.match?(/\((?!\?)/) }
220
+
221
+ [ segments, separators ]
222
+ end
223
+
224
+ # Remove separator LITERALS from a regex source, respecting character
225
+ # classes (the "-" inside [A-Z] is a range, not a separator; a class
226
+ # composed ONLY of separators, like `[- ]`, is a separator and goes).
227
+ # A quantifier left dangling by a removed separator ("-?", "[- ]?") is
228
+ # swallowed with it. Linear scan — regexes in this dataset are simple.
229
+ def self.strip_separators(src)
230
+ return nil if src.nil?
231
+
232
+ out = +""
233
+ chars = src.chars
234
+ i = 0
235
+ while i < chars.size
236
+ ch = chars[i]
237
+
238
+ if ch == "\\" && i + 1 < chars.size
239
+ nxt = chars[i + 1]
240
+ if [ "s", "-", ".", " " ].include?(nxt)
241
+ i += 2
242
+ i += 1 if [ "?", "*" ].include?(chars[i]) # optional separator: drop its quantifier too
243
+ else
244
+ out << ch << nxt
245
+ i += 2
246
+ end
247
+ next
248
+ end
249
+
250
+ if ch == "["
251
+ # Copy or drop the WHOLE class span in one decision.
252
+ closing = i + 1
253
+ closing += 1 while closing < chars.size && chars[closing] != "]"
254
+ content = chars[(i + 1)...closing].join
255
+ tokens = content.scan(/\\.|./m)
256
+ if tokens.any? && tokens.all? { |t| SEPARATOR_TOKENS.include?(t) }
257
+ i = closing + 1
258
+ i += 1 if [ "?", "*" ].include?(chars[i])
259
+ else
260
+ out << chars[i..closing].join
261
+ i = closing + 1
262
+ end
263
+ next
264
+ end
265
+
266
+ if [ "-", " ", "·" ].include?(ch)
267
+ i += 1
268
+ i += 1 if [ "?", "*" ].include?(chars[i])
269
+ else
270
+ out << ch
271
+ i += 1
272
+ end
273
+ end
274
+ out.gsub(REDUNDANT_CHARACTER_CLASS_REPEAT) { "#{Regexp.last_match(1)}+" }
275
+ end
276
+
277
+ def inspect = %(#<Vehicles::Plates::Series #{id} "#{pattern}">)
278
+
279
+ private
280
+
281
+ # Bad regex in data must degrade like every other lookup: nil, not raise.
282
+ def compile(source)
283
+ return nil if source.nil?
284
+
285
+ Regexp.new(source)
286
+ rescue RegexpError
287
+ nil
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Vehicles
6
+ module Plates
7
+ # The REFERENCE FILLER for VehiclesDB plate templates — the client half of
8
+ # the privacy-safe template endpoint
9
+ # (GET /plates/:code/:series_id/template.svg, VehiclesDB PRD-PLATE-ART §9).
10
+ # A client downloads a template once (it carries nothing personal, so it
11
+ # caches publicly) and writes the user's registration into it HERE, on its
12
+ # own machine — a registration is personal data and never travels:
13
+ #
14
+ # svg = Vehicles::Plates::TemplateFill.fill(template_svg, "1234 BCD")
15
+ #
16
+ # Moved here from vehiclesdb-web (app/models/plates/template_fill.rb; same logic,
17
+ # plus shape_of and label:), whose renderer will call `fit_size` /
18
+ # `recentered_baseline` from this gem
19
+ # so the fit formula lives once. Deliberately dependency-free (Ruby stdlib
20
+ # `json` only — no Rails, no ActiveSupport, no XML library). Everything it
21
+ # needs is in the template's own <metadata id="vdb-template"> block, written
22
+ # by the VehiclesDB renderer from the same layout pass that renders real
23
+ # serials:
24
+ # slots[] one per character group: shape slice, compact start offset,
25
+ # centre x, baseline, font size, nominal width, max width
26
+ # advance how the renderer prices glyph widths (em at size 1) — so a
27
+ # filled group gets the same textLength a server render would
28
+ #
29
+ # It VALIDATES before it writes: the registration must have exactly the
30
+ # template's shape (N = digit, L = letter; separators are free), so a
31
+ # filled plate never shows characters its layout was not computed for.
32
+ module TemplateFill
33
+ class Error < StandardError; end
34
+
35
+ # The template metadata schema this filler understands
36
+ # (`"version"` in the vdb-template block).
37
+ SCHEMA_VERSION = 1
38
+ SEPARATORS = "- .·"
39
+ CLASSES = { "N" => /\A[0-9]\z/, "L" => /\A[A-ZÄÖÜ]\z/ }.freeze
40
+ METADATA = %r{<metadata id="vdb-template"><!\[CDATA\[(.*?)\]\]></metadata>}m
41
+
42
+ # A filled template is meant to be inlined into a host page, i.e. trusted as
43
+ # HTML. Real templates are inert: shapes, text, and data: URIs for the face
44
+ # and region art. Anything active or external is refused, here at the fill
45
+ # boundary as well as wherever a template is fetched. data: payloads are
46
+ # blanked before the scan (base64 may spell "/onxy=" by chance).
47
+ ACTIVE_CONTENT = Regexp.union(
48
+ /<\s*(?:script|foreignObject|iframe|object|embed)\b/i,
49
+ %r{[\s/"']on[a-z]+\s*=}i, # also <svg/onload=…>, which HTML parsers accept
50
+ /javascript:/i, /@import/i,
51
+ /href\s*+=\s*+(?!["']?\s*(?:data:|#))/i, /url\(\s*+(?!["']?\s*(?:data:|#))/i
52
+ )
53
+ # Only a data: URI's own alphabet (base64 / percent-encoding) is blanked, so
54
+ # markup after a bare "data:" is still scanned.
55
+ DATA_URI = %r{data:[a-z]+/[a-z0-9.+-]+(?:;[a-z0-9=.+-]+)*,[A-Za-z0-9+/=%._~-]*}i
56
+
57
+ module_function
58
+
59
+ # True when the SVG carries nothing a browser would run or fetch.
60
+ def inert?(svg)
61
+ !svg.to_s.gsub(DATA_URI, "data:").match?(ACTIVE_CONTENT)
62
+ end
63
+
64
+ # The filled SVG document. Raises Error when the template is not one or
65
+ # the registration does not fit its shape. `label:` replaces the whole
66
+ # accessible name (default: "<Country> plate <REGISTRATION>"), e.g.
67
+ # "Matrícula 1234 BCD" for a Spanish-language page.
68
+ def fill(svg, registration, label: nil) # rubocop:disable Metrics/AbcSize -- same logic as the VehiclesDB copy
69
+ inert?(svg) or raise Error, "template carries active or external content; refusing to fill it"
70
+ meta = metadata(svg)
71
+ out = svg.sub(METADATA, "")
72
+ groups(meta, registration).each do |g|
73
+ slot = g[:slot]
74
+ attrs = %( textLength="#{g[:text_length]}")
75
+ pattern = %r{(<text id="#{Regexp.escape(slot["id"])}"[^>]*?)( lengthAdjust="[^"]*")?></text>}
76
+ out.match?(pattern) or raise Error, "placeholder #{slot["id"]} missing from the template"
77
+ out = out.sub(pattern) do
78
+ head = Regexp.last_match(1)
79
+ adjust = g[:squeezed] ? %( lengthAdjust="spacingAndGlyphs") : Regexp.last_match(2).to_s
80
+ head = resized(head, slot, g[:font_size], g[:baseline]) if g[:font_size] != slot["font_size"]
81
+ "#{head}#{adjust}#{attrs}>#{escape(g[:text])}</text>"
82
+ end
83
+ end
84
+ return out.sub(/aria-label="[^"]*?"/) { %(aria-label="#{escape(label.to_s)}") } if label
85
+
86
+ out.sub(/(aria-label="[^"]*?) plate template"/) do
87
+ %(#{Regexp.last_match(1)} plate #{escape(normalize(registration))}")
88
+ end
89
+ end
90
+
91
+ # The parsed <metadata> block of a template SVG.
92
+ def metadata(svg)
93
+ json = svg.to_s[METADATA, 1] or raise Error, "not a VehiclesDB plate template (no vdb-template metadata)"
94
+ JSON.parse(json)
95
+ end
96
+
97
+ # The SHAPE of a serial-like text, as the template endpoint takes it:
98
+ # digits → N, letters → L, separators kept ("1234 BCD" → "NNNN LLL").
99
+ # Nil when a character fits no class. A shape carries no characters, so
100
+ # it is the only thing about a registration that may leave the machine.
101
+ # Pattern escapes ("\9") classify their literal (VehiclesDB's
102
+ # Plates::Template.shape_of, same rules).
103
+ def shape_of(text)
104
+ out = +""
105
+ chars = text.to_s.upcase.chars
106
+ while (ch = chars.shift)
107
+ ch = chars.shift.to_s if ch == "\\"
108
+ out << case ch
109
+ when /\A[0-9]\z/ then "N"
110
+ when /\A[A-ZÄÖÜ]\z/ then "L"
111
+ when /\A[- .·]\z/ then ch
112
+ else return nil
113
+ end
114
+ end
115
+ out.squeeze(SEPARATORS)
116
+ end
117
+
118
+ # Per slot: the characters it prints and the width they need. Usable on
119
+ # its own by a client that draws text itself (the JSON template carries
120
+ # the same metadata).
121
+ def groups(meta, registration) # rubocop:disable Metrics/AbcSize -- same logic as the VehiclesDB copy
122
+ chars = compact(normalize(registration)).chars
123
+ shape = compact(meta.fetch("shape"))
124
+ unless chars.length == shape.length && chars.each_with_index.all? { |ch, i| CLASSES.fetch(shape[i]).match?(ch) }
125
+ raise Error, "registration does not have the template's shape #{meta["shape"].inspect} " \
126
+ "(N = digit, L = letter); fetch the template for its shape"
127
+ end
128
+
129
+ meta.fetch("slots").map do |slot|
130
+ source = chars[slot["start"], slot["max_chars"]].dup
131
+ text = slot["shape"].gsub(/[NL]/) { source.shift }
132
+ size = slot["font_size"]
133
+ baseline = slot["baseline"]
134
+ # A lone run (nothing printed beside it) refits exactly as the
135
+ # renderer fits a real serial: from its natural size, shrunk only
136
+ # when the real characters overflow the room.
137
+ if (refit = slot["refit"])
138
+ size = refit["size"]
139
+ natural = text_width(text, size, meta.fetch("advance"))
140
+ size = fit_size(size, refit["avail"], natural) if natural > refit["avail"]
141
+ rule = refit["baseline"]
142
+ baseline = recentered_baseline(rule["origin"], size, rule["per_size"]) if rule
143
+ end
144
+ width = text_width(text, size, meta.fetch("advance"))
145
+ squeezed = width > slot["max_width"]
146
+ { slot: slot, text: text, squeezed: squeezed, font_size: size, baseline: baseline,
147
+ text_length: (squeezed ? slot["max_width"] : width).round(1) }
148
+ end
149
+ end
150
+
151
+ # THE fit rule, shared with VehiclesDB's Plates::SvgRenderer: a run
152
+ # wider than its room shrinks to 98% of the exact fit.
153
+ def fit_size(size, room, natural_width) = (size * room / natural_width * 0.98).round(2)
154
+
155
+ # The re-centred baseline of a lone run, shared with the renderer.
156
+ def recentered_baseline(origin, size, per_size) = (origin + (size * per_size)).round(1)
157
+
158
+ # A placeholder's size-proportional attributes at a refitted size.
159
+ def resized(head, slot, size, baseline)
160
+ head = head.sub(/ y="[^"]*"/, %( y="#{baseline}")).sub(/ font-size="[^"]*"/, %( font-size="#{size}"))
161
+ if slot["letter_spacing_em"].to_f.positive?
162
+ head = head.sub(/ letter-spacing="[^"]*"/, %( letter-spacing="#{size * slot["letter_spacing_em"]}"))
163
+ end
164
+ if slot["stroke_width_em"]
165
+ head = head.sub(/ stroke-width="[^"]*"/, %( stroke-width="#{(size * slot["stroke_width_em"]).round(2)}"))
166
+ end
167
+ head
168
+ end
169
+
170
+ # The renderer's own width model (SvgRenderer#row_metrics): a uniform
171
+ # statutory pitch, or per-glyph measured advances with a fallback.
172
+ def text_width(text, size, advance)
173
+ return text.length * advance["uniform_em"] * size if advance["uniform_em"]
174
+
175
+ table = advance.fetch("em")
176
+ text.chars.sum { |ch| (table[ch] || advance.fetch("fallback_em")) * size }
177
+ end
178
+
179
+ def normalize(registration) = registration.to_s.strip.upcase
180
+
181
+ def compact(text) = text.delete(SEPARATORS)
182
+
183
+ def escape(text)
184
+ text.gsub(/[&<>"']/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;")
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ module Plates
5
+ # VehiclesDB's real plate art for a registration, WITHOUT sending the
6
+ # registration anywhere (VehiclesDB PRD-PLATE-ART §9: a plate number is
7
+ # personal data). The plate is fetched as a BLANK template — the series,
8
+ # the registration's shape ("NNNN LLL", character classes only) and an
9
+ # optional region — and filled locally by TemplateFill:
10
+ #
11
+ # Vehicles::Plates.render("1234 BCD", jurisdiction: :es)
12
+ # # => "<svg … aria-label=\"Spain plate 1234 BCD\">…</svg>", or nil
13
+ #
14
+ # Vehicles::Plates.template(code: :es, series: "es-national-2000", shape: "NNNN LLL")
15
+ # # => the blank template SVG, or nil
16
+ #
17
+ # The template route is public: no API key is needed, and none is sent.
18
+ # Templates are cached (in-process by default; `config.plate_template_cache`
19
+ # takes any store with read(key) / write(key, value, expires_in:), e.g.
20
+ # Rails.cache), so a page view costs at most one request per (series,
21
+ # shape, region) and TTL. Never raises: nil on any failure, so a caller
22
+ # keeps whatever it drew before.
23
+ TEMPLATE_PATH = "/plates/%<code>s/%<series>s/template.svg"
24
+ # A failed fetch is remembered briefly, so an unreachable server costs
25
+ # one timeout per key per window, not one per page view.
26
+ TEMPLATE_MISS_TTL = 300
27
+
28
+ # Client-side fences, so nothing but a template request can leave: a
29
+ # shape is N/L classes and separators ("_" is the URL-friendly space),
30
+ # never characters, and the other inputs are identifiers.
31
+ TEMPLATE_CODE = /\A[a-z]{2}(?:-[a-z]{2,3})?\z/
32
+ TEMPLATE_SERIES = /\A[a-z0-9-]+\z/
33
+ TEMPLATE_SHAPE = /\A[NL]+(?:[- .·_][NL]+)*\z/
34
+ # A region is a subdivision KEY, never free text, so a registration
35
+ # passed by mistake cannot leave in it: FR département (75, 2A, 971,
36
+ # 44-bzh), DE Land / CH canton (two letters), AT Land (one digit).
37
+ TEMPLATE_REGION = /\A(?:[1-9]|[0-9]{2}|97[1-6]|2[AB]|[A-Z]{2}|[0-9]{2}-[A-Z]{3})\z/i
38
+ # Inert-template fence: TemplateFill::ACTIVE_CONTENT (also enforced by
39
+ # TemplateFill.fill itself).
40
+ TEMPLATE_ACTIVE_CONTENT = TemplateFill::ACTIVE_CONTENT
41
+
42
+ module_function
43
+
44
+ # The blank template SVG for a series, or nil. `shape:` defaults to the
45
+ # series' canonical pattern on the server; `region:` picks region art
46
+ # (FR département, DE/AT Land, CH canton).
47
+ def template(code:, series:, shape: nil, region: nil)
48
+ code = code.to_s.downcase
49
+ series = series.to_s
50
+ shape = shape&.to_s&.tr(" ", "_")
51
+ region = region&.to_s
52
+ return nil unless code.match?(TEMPLATE_CODE) && series.match?(TEMPLATE_SERIES)
53
+ return nil unless (shape.nil? || shape.match?(TEMPLATE_SHAPE)) && (region.nil? || region.match?(TEMPLATE_REGION))
54
+
55
+ key = template_cache_key(code, series, shape, region)
56
+ cached = cache_read(key)
57
+ return cached.empty? ? nil : cached if cached.is_a?(String)
58
+
59
+ svg = fetch_template(code, series, shape, region)
60
+ cache_write(key, svg || "", svg ? Vehicles.configuration.plate_template_ttl : TEMPLATE_MISS_TTL)
61
+ svg
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ # `registration` drawn on its series' real plate, filled locally, or nil
67
+ # when it cannot be: not a plate of that jurisdiction, more than one
68
+ # standard series it could be (German districts, Italian eras: we would
69
+ # rather draw nothing than the wrong plate), or no template. Only the
70
+ # series, the shape and `region:` are sent. `label:` is the accessible
71
+ # name (TemplateFill.fill).
72
+ def render(registration, jurisdiction:, region: nil, label: nil)
73
+ found = match(registration, jurisdiction: jurisdiction)
74
+ series = template_series(found) or return nil
75
+ shape = TemplateFill.shape_of(series.format_serial(found.canon)) or return nil
76
+ svg = template(code: found.jurisdiction.code, series: series.id, shape: shape, region: region) or return nil
77
+ TemplateFill.fill(svg, registration, label: label)
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ # The one series a match's plate is drawn as: the only standard-class
83
+ # series among the hits, else the only hit. Nil when it is ambiguous.
84
+ def template_series(found)
85
+ standard = found.series.select { |series| series.klass == "standard" }
86
+ return standard.first if standard.one?
87
+
88
+ found.series.first if found.series.one?
89
+ end
90
+
91
+ def fetch_template(code, series, shape, region)
92
+ params = { shape: shape, region: region }.compact
93
+ body = HTTP.get(HTTP.url(format(TEMPLATE_PATH, code: code, series: series), params),
94
+ "Accept" => "image/svg+xml")
95
+ return nil unless body
96
+
97
+ # Net::HTTP hands back binary; the template is UTF-8 ("·" separators).
98
+ svg = body.dup.force_encoding(Encoding::UTF_8)
99
+ return nil unless svg.valid_encoding?
100
+ return nil unless TemplateFill.inert?(svg)
101
+
102
+ svg if TemplateFill.metadata(svg)["version"] == TemplateFill::SCHEMA_VERSION
103
+ rescue TemplateFill::Error, JSON::ParserError
104
+ nil
105
+ end
106
+
107
+ # The filler's schema and the gem version are in the key, so an upgrade
108
+ # never fills a template it was not written for; the base URL too, so a
109
+ # development server's templates never answer for production's.
110
+ def template_cache_key(code, series, shape, region)
111
+ ["vehicles/plate-template", "v#{TemplateFill::SCHEMA_VERSION}", Vehicles::VERSION,
112
+ Vehicles.configuration.api_base_url, code, series, shape, region].join("/")
113
+ end
114
+
115
+ def cache_read(key)
116
+ Vehicles.configuration.plate_template_cache.read(key)
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ def cache_write(key, value, ttl)
122
+ Vehicles.configuration.plate_template_cache.write(key, value, expires_in: ttl)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ # The default plate_template_cache: in-process, thread-safe, bounded.
128
+ class TemplateCache
129
+ MAX_ENTRIES = 256
130
+
131
+ def initialize
132
+ @entries = {}
133
+ @lock = Mutex.new
134
+ end
135
+
136
+ def read(key)
137
+ @lock.synchronize do
138
+ value, expires_at = @entries[key]
139
+ next value if expires_at.nil? || expires_at > now
140
+
141
+ @entries.delete(key)
142
+ nil
143
+ end
144
+ end
145
+
146
+ def write(key, value, expires_in: nil)
147
+ @lock.synchronize do
148
+ @entries.delete(key)
149
+ @entries.shift while @entries.size >= MAX_ENTRIES
150
+ @entries[key] = [value, expires_in && (now + expires_in.to_f)]
151
+ end
152
+ value
153
+ end
154
+
155
+ def clear = @lock.synchronize { @entries.clear }
156
+
157
+ private
158
+
159
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ end
161
+ end
162
+ end