zer0-image-generator 0.6.0 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Zer0ImageGenerator
7
+ module Freesvg
8
+ # API base and the tier TTLs, lifted verbatim from tools/svg/freesvg.py so
9
+ # an on-disk cache written by either language stays valid for the other.
10
+ API = "https://freesvg.org/api/v1"
11
+ SEARCH_TTL = 7 * 86_400 # searches: 7 days
12
+ META_TTL = 30 * 86_400 # per-item metadata: 30 days
13
+ TIMEOUT = 30
14
+
15
+ # -- error hierarchy -------------------------------------------------------
16
+ #
17
+ # freesvg.py leans on Python's exception types: AuthError subclasses
18
+ # RuntimeError, api_get raises bare RuntimeError, and transport failure
19
+ # surfaces as OSError/URLError. `_cached` catches (AuthError, RuntimeError)
20
+ # and re-raises them, but catches OSError separately to serve stale cache.
21
+ # The Ruby split preserves that: Error (and its subclasses) propagate;
22
+ # TransportError is the offline signal Cache#fetch_cached catches. So
23
+ # TransportError deliberately does NOT descend from Error.
24
+
25
+ class Error < StandardError; end
26
+
27
+ # Missing/invalid credentials or a failed login. Python: AuthError(RuntimeError).
28
+ class AuthError < Error; end
29
+
30
+ # A request the server answered but rejected (non-200), or a response we
31
+ # could not turn into art. Python: the bare RuntimeErrors in api_get /
32
+ # _extract_svg / promote.
33
+ class RequestError < Error; end
34
+
35
+ # Transport-level failure (DNS, refused, timeout, TLS). The offline signal:
36
+ # Cache#fetch_cached catches it to fall back to a stale entry. Mirrors
37
+ # Python relying on urllib raising OSError/URLError. IOError keeps it clearly
38
+ # distinct from the Error tree above.
39
+ class TransportError < IOError; end
40
+
41
+ # The tiered local cache that makes repeat FreeSVG lookups cost zero API
42
+ # calls. On-disk layout (git-ignored; safe to delete — it rebuilds):
43
+ #
44
+ # token.json JWT + parsed expiry, chmod 600
45
+ # searches/<key>.json search responses, TTL 7 days
46
+ # meta/<id>.json per-item metadata, TTL 30 days
47
+ # files/<id>.svg downloaded art — immutable, cached forever
48
+ #
49
+ # Cache entries wrap their payload as {"fetched_at": <epoch>, "data": ...};
50
+ # freshness is now - fetched_at < ttl. A fresh entry never touches the
51
+ # network; a fetch failure with any cached copy present serves it stale.
52
+ #
53
+ # The clock is injected (a callable returning epoch seconds) so TTL
54
+ # boundaries are testable without sleeping — the seam replacing freesvg.py's
55
+ # monkeypatched `_now`.
56
+ class Cache
57
+ attr_reader :dir
58
+
59
+ def initialize(dir:, clock: -> { Time.now.to_f }, warn: ->(_msg) {})
60
+ @dir = dir
61
+ @clock = clock
62
+ @warn = warn
63
+ end
64
+
65
+ def now
66
+ @clock.call
67
+ end
68
+
69
+ # Full path under the cache dir, with its parent directory created —
70
+ # freesvg.py's `_ensure`.
71
+ def ensure_path(*parts)
72
+ full = File.join(@dir, *parts)
73
+ FileUtils.mkdir_p(File.dirname(full))
74
+ full
75
+ end
76
+
77
+ def path(*parts)
78
+ File.join(@dir, *parts)
79
+ end
80
+
81
+ # Parsed JSON, or nil when absent/corrupt. freesvg.py's `_read_json`
82
+ # swallows exactly OSError/ValueError; the Ruby equivalents are
83
+ # SystemCallError/IOError (file trouble) and JSON::ParserError (bad JSON).
84
+ # Explicit, not a bare rescue: a damaged cache file must read as "absent"
85
+ # so the tier simply refetches.
86
+ def read_json(file)
87
+ JSON.parse(File.read(file))
88
+ rescue JSON::ParserError, SystemCallError, IOError
89
+ nil
90
+ end
91
+
92
+ # freesvg.py's `_write_json`. Cache files are git-ignored working memory,
93
+ # so the exact byte layout is not a contract (unlike the manifest) — only
94
+ # the schema and the chmod-600 on the token are. pretty_generate keeps
95
+ # them human-diffable; a trailing newline matches the Python writer.
96
+ def write_json(file, data, private: false)
97
+ FileUtils.mkdir_p(File.dirname(file))
98
+ File.write(file, JSON.pretty_generate(data) + "\n")
99
+ # The JWT is a bearer credential: keep it owner-only on disk.
100
+ File.chmod(0o600, file) if private
101
+ end
102
+
103
+ # The freshness core (freesvg.py's `_cached`). Returns [data, from_cache?].
104
+ # fresh hit + no refresh -> serve cache, no network
105
+ # miss / stale / refresh -> run the fetch block
106
+ # fetch raised TransportError with a cached copy present -> serve stale + warn
107
+ # AuthError/RequestError from the block propagate untouched, exactly as
108
+ # Python re-raises (AuthError, RuntimeError) ahead of its OSError branch.
109
+ def fetch_cached(file, ttl, refresh)
110
+ entry = read_json(file)
111
+ fresh = present_hash?(entry) && (now - (entry["fetched_at"] || 0) < ttl)
112
+ return [entry["data"], true] if fresh && !refresh
113
+
114
+ begin
115
+ data = yield
116
+ rescue TransportError => exc
117
+ if present_hash?(entry)
118
+ stamped = Time.at(entry["fetched_at"]).strftime("%Y-%m-%d")
119
+ @warn.call("network unavailable (#{exc.message}); serving stale cache from #{stamped}")
120
+ return [entry["data"], true]
121
+ end
122
+ raise
123
+ end
124
+
125
+ write_json(file, { "fetched_at" => now, "data" => data })
126
+ [data, false]
127
+ end
128
+
129
+ # freesvg.py's `prune`: walk files/meta/searches, and while the cache is
130
+ # over budget delete oldest-first (by mtime, then size, then path — the
131
+ # tuple order Python's sorted() uses). Returns [removed_count, total_bytes].
132
+ def prune(max_mb: 50.0)
133
+ victims = []
134
+ %w[files meta searches].each do |sub|
135
+ base = File.join(@dir, sub)
136
+ next unless File.directory?(base)
137
+
138
+ Dir.children(base).each do |name|
139
+ full = File.join(base, name)
140
+ victims << [File.mtime(full).to_f, File.size(full), full]
141
+ end
142
+ end
143
+
144
+ total = victims.sum { |_mtime, size, _path| size }
145
+ removed = 0
146
+ budget = max_mb * 1024 * 1024
147
+ victims.sort_by { |mtime, size, path| [mtime, size, path] }.each do |_mtime, size, path|
148
+ break if total <= budget
149
+
150
+ File.delete(path)
151
+ total -= size
152
+ removed += 1
153
+ end
154
+ [removed, total]
155
+ end
156
+
157
+ private
158
+
159
+ # Python truthiness for a parsed cache entry: a dict is truthy only when
160
+ # non-empty, so a stray `{}` cache file counts as "no usable entry" — both
161
+ # for the freshness check and the stale-serve fallback.
162
+ def present_hash?(entry)
163
+ entry.is_a?(Hash) && !entry.empty?
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "digest"
7
+ require "fileutils"
8
+
9
+ require_relative "cache"
10
+ require_relative "../library"
11
+ require_relative "../svg/lint"
12
+ require_relative "../svg/sanitizer"
13
+
14
+ module Zer0ImageGenerator
15
+ module Freesvg
16
+ # Fields that, on an item, may hold a downloadable SVG URL. Same tuple and
17
+ # order as freesvg.py's URL_KEYS — the order is load-bearing, since
18
+ # _extract_svg returns the first URL field that yields real SVG bytes.
19
+ URL_KEYS = %w[svg url file path download_url svg_url image link].freeze
20
+
21
+ # Default HTTP transport. Returns [status, body_bytes] for any answered
22
+ # request (including 4xx/5xx — freesvg.py's `_http` folds HTTPError back
23
+ # into a (code, body) pair) and raises TransportError only when the request
24
+ # never completed, which is the signal Cache#fetch_cached treats as offline.
25
+ #
26
+ # Injectable: the Client takes any object with this #call shape, so tests
27
+ # (and a Rails app that wants its own connection pooling) can supply their
28
+ # own without a live network.
29
+ class HttpTransport
30
+ def call(method:, url:, body: nil, token: nil)
31
+ uri = URI.parse(url)
32
+ request = build_request(method, uri, body, token)
33
+ http = Net::HTTP.new(uri.host, uri.port)
34
+ http.use_ssl = uri.scheme == "https"
35
+ http.open_timeout = TIMEOUT
36
+ http.read_timeout = TIMEOUT
37
+ response = http.request(request)
38
+ [response.code.to_i, response.body.to_s.b]
39
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError => exc
40
+ # urllib surfaces these as OSError/URLError; re-badge as the offline
41
+ # signal so the cache can serve stale instead of hard-failing.
42
+ raise TransportError, exc.message
43
+ end
44
+
45
+ private
46
+
47
+ def build_request(method, uri, body, token)
48
+ klass = method.to_s.upcase == "POST" ? Net::HTTP::Post : Net::HTTP::Get
49
+ request = klass.new(uri)
50
+ request["Accept"] = "application/json"
51
+ request["User-Agent"] = "zer0-image-generator/freesvg-cache"
52
+ if body
53
+ request["Content-Type"] = "application/json"
54
+ request.body = JSON.generate(body)
55
+ end
56
+ request["Authorization"] = "Bearer #{token}" if token
57
+ request
58
+ end
59
+ end
60
+
61
+ # FreeSVG.org API client with the tiered local cache (Cache) and the example
62
+ # library (Library) behind it. Public methods return structured Ruby values
63
+ # — item hashes, cache-hit booleans, filesystem paths — never printed text,
64
+ # so a Rails UI can drive search / preview / promote directly.
65
+ #
66
+ # client.search("rocket") => [items, from_cache?]
67
+ # client.item_info("123") => [item, from_cache?]
68
+ # client.fetch("123") => cache path to the linted .svg
69
+ # client.promote("123") => repo-relative path of the graduated file
70
+ # client.prune(max_mb: 50) => [removed, total_bytes]
71
+ # client.status => Hash of cache + auth health
72
+ #
73
+ # Determinism/testability seams: `transport` (network) and `clock` (time)
74
+ # are injected, replacing the module globals freesvg.py monkeypatches in its
75
+ # tests. Credentials resolve from explicit args, then the environment, then
76
+ # the repo's git-ignored .env — matching freesvg.py's precedence.
77
+ class Client
78
+ attr_reader :cache, :library
79
+
80
+ def initialize(root: self.class.default_root,
81
+ cache_dir: nil, manifest_path: nil,
82
+ transport: HttpTransport.new,
83
+ clock: -> { Time.now.to_f },
84
+ warn: nil,
85
+ email: nil, password: nil)
86
+ @root = root
87
+ @cache_dir = cache_dir || ENV["FREESVG_CACHE_DIR"] ||
88
+ File.join(root, "tools", "svg", "library", "cache", "freesvg")
89
+ @manifest_path = manifest_path || ENV["FREESVG_MANIFEST"] ||
90
+ File.join(root, "tools", "svg", "library", "MANIFEST.json")
91
+ @transport = transport
92
+ @clock = clock
93
+ @warn = warn || method(:default_warn)
94
+ @email_override = email
95
+ @password_override = password
96
+ @cache = Cache.new(dir: @cache_dir, clock: @clock, warn: @warn)
97
+ @library = Library.new(@manifest_path)
98
+ end
99
+
100
+ # freesvg.py derives ROOT from the file location (tools/svg/..); the gem's
101
+ # copy lives at lib/zer0_image_generator/freesvg/client.rb, so the repo
102
+ # root is four levels up. Overridable via the constructor for tests.
103
+ def self.default_root
104
+ File.expand_path("../../..", __dir__)
105
+ end
106
+
107
+ # -- search / info -------------------------------------------------------
108
+
109
+ def search(query, page: 1, refresh: false)
110
+ # Cache key: sha256("<trimmed lowercased query>|<page>")[:16]. Identical
111
+ # bytes to freesvg.py so a warm cache dir is shared across languages.
112
+ key = Digest::SHA256.hexdigest("#{query.strip.downcase}|#{page}")[0, 16]
113
+ file = @cache.ensure_path("searches", "#{key}.json")
114
+ query_string = URI.encode_www_form("query" => query, "page" => page)
115
+ payload, from_cache = @cache.fetch_cached(file, SEARCH_TTL, refresh) do
116
+ api_get("/search?#{query_string}")
117
+ end
118
+ [items_of(payload), from_cache]
119
+ end
120
+
121
+ def item_info(svg_id, refresh: false)
122
+ file = @cache.ensure_path("meta", "#{svg_id}.json")
123
+ payload, from_cache = @cache.fetch_cached(file, META_TTL, refresh) do
124
+ api_get("/svg/#{svg_id}")
125
+ end
126
+ items = items_of(payload)
127
+ [items.empty? ? payload : items.first, from_cache]
128
+ end
129
+
130
+ # -- download ------------------------------------------------------------
131
+
132
+ # Download one item's SVG into the immutable files/ tier and lint it. A
133
+ # cached file is returned untouched (never refetched) unless refresh:.
134
+ # A lint failure is surfaced as a warning but the file is kept — promote
135
+ # is the gate that actually refuses dirty art.
136
+ def fetch(svg_id, refresh: false)
137
+ file = @cache.ensure_path("files", "#{svg_id}.svg")
138
+ return file if File.exist?(file) && !refresh
139
+
140
+ item, = item_info(svg_id, refresh: refresh)
141
+ body = extract_svg(item)
142
+ File.binwrite(file, body)
143
+
144
+ report = lint(file)
145
+ unless report.clean?
146
+ @warn.call("downloaded file has lint errors (kept in cache, blocked from " \
147
+ "promote):\n#{report.stderr_lines.join("\n")}")
148
+ end
149
+ file
150
+ end
151
+
152
+ # -- promote -------------------------------------------------------------
153
+
154
+ # Graduate a cached keeper into the committed library with full
155
+ # provenance, appending its MANIFEST.json entry. Idempotent: a file
156
+ # already in the manifest is left unchanged.
157
+ #
158
+ # Two gates guard the library, both of which must pass:
159
+ # 1. Lint (svg-check parity) — a hard refusal on errors, as in Python.
160
+ # 2. Sanitizer — the security gate ADDED in this port (see the code
161
+ # comment and DEVIATIONS): external SVG is untrusted input, and the
162
+ # linter does not catch <script>/foreignObject/event-handlers/DOCTYPE.
163
+ def promote(svg_id, name: "", tags: nil)
164
+ local = fetch(svg_id)
165
+
166
+ report = lint(local)
167
+ unless report.clean?
168
+ raise RequestError,
169
+ "refusing to promote #{svg_id}: lint errors\n#{report.stderr_lines.join("\n")}"
170
+ end
171
+
172
+ reject_unsafe!(svg_id, local)
173
+
174
+ item, = item_info(svg_id)
175
+ title = (presence(item["title"]) || presence(item["name"]) || "freesvg #{svg_id}").to_s
176
+ slug = presence(name) || slugify(title)
177
+ dest_rel = "tools/svg/library/external/freesvg/freesvg-#{slug}.svg"
178
+ dest = File.join(@root, dest_rel)
179
+ FileUtils.mkdir_p(File.dirname(dest))
180
+ # Keep the pristine file: only write the destination on first promote.
181
+ File.binwrite(dest, File.binread(local)) unless File.exist?(dest)
182
+
183
+ if @library.has_file?(dest_rel)
184
+ @warn.call("#{dest_rel} already in manifest; leaving entry unchanged")
185
+ return dest_rel
186
+ end
187
+
188
+ @library.add_entry(build_entry(dest_rel, dest, title, svg_id, tags, item["tags"], report.warnings))
189
+ dest_rel
190
+ end
191
+
192
+ def prune(max_mb: 50.0)
193
+ @cache.prune(max_mb: max_mb)
194
+ end
195
+
196
+ # Structured cache + auth health for a UI. freesvg.py's `status` command
197
+ # prints this; here it is returned so the caller decides how to render it.
198
+ def status
199
+ counts = {}
200
+ %w[files meta searches].each do |sub|
201
+ base = @cache.path(sub)
202
+ counts[sub] = File.directory?(base) ? Dir.children(base).length : 0
203
+ end
204
+ email, password = credentials
205
+ token = @cache.read_json(@cache.path("token.json"))
206
+ {
207
+ cache_dir: @cache.dir,
208
+ counts: counts,
209
+ credentials_set: !presence(email).nil? && !presence(password).nil?,
210
+ token: token_status(token)
211
+ }
212
+ end
213
+
214
+ # -- credentials + token (exposed; a Rails app inspects auth state) ------
215
+
216
+ # [email, password], each resolved from override -> ENV -> .env, with
217
+ # Python's `or` semantics: an empty string is falsy and falls through to
218
+ # the next source. Either may be nil when unset.
219
+ def credentials
220
+ fallback = env_file_creds
221
+ email = presence(@email_override) || presence(ENV["FREESVG_EMAIL"]) || fallback["FREESVG_EMAIL"]
222
+ password = presence(@password_override) || presence(ENV["FREESVG_PASSWORD"]) || fallback["FREESVG_PASSWORD"]
223
+ [email, password]
224
+ end
225
+
226
+ # Cached JWT, reused until it is within 60s of expiry. force: re-logs in.
227
+ def get_token(force: false)
228
+ file = @cache.ensure_path("token.json")
229
+ unless force
230
+ cached = @cache.read_json(file)
231
+ if cached && (cached["exp"] || 0) > @clock.call + 60
232
+ return cached["token"]
233
+ end
234
+ end
235
+
236
+ email, password = credentials
237
+ unless presence(email) && presence(password)
238
+ raise AuthError,
239
+ "FreeSVG credentials missing. Set FREESVG_EMAIL and FREESVG_PASSWORD " \
240
+ "in the environment or the repo's git-ignored .env " \
241
+ "(register at https://freesvg.org — the API is for registered users)."
242
+ end
243
+
244
+ status, body = http("POST", "#{API}/auth/login", body: { "email" => email, "password" => password })
245
+ raise AuthError, "login failed (HTTP #{status}): #{decode_snippet(body)}" if status != 200
246
+
247
+ token = presence(parse_json(body)["token"])
248
+ raise AuthError, "login response had no token" if token.nil?
249
+
250
+ @cache.write_json(file, { "token" => token, "exp" => jwt_exp(token) }, private: true)
251
+ token
252
+ end
253
+
254
+ private
255
+
256
+ # Authenticated GET returning parsed JSON; one forced-relogin retry on 401.
257
+ def api_get(path)
258
+ token = get_token
259
+ status, body = http("GET", "#{API}#{path}", token: token)
260
+ if status == 401
261
+ token = get_token(force: true)
262
+ status, body = http("GET", "#{API}#{path}", token: token)
263
+ end
264
+ raise RequestError, "GET #{path} failed (HTTP #{status}): #{decode_snippet(body)}" if status != 200
265
+
266
+ parse_json(body)
267
+ end
268
+
269
+ def http(method, url, body: nil, token: nil)
270
+ @transport.call(method: method, url: url, body: body, token: token)
271
+ end
272
+
273
+ # Unwrap the list of items from the several response shapes FreeSVG has
274
+ # been seen to return. Mirrors freesvg.py's `_items_of`.
275
+ def items_of(payload)
276
+ return payload if payload.is_a?(Array)
277
+
278
+ if payload.is_a?(Hash)
279
+ %w[data svgs results items].each do |key|
280
+ return payload[key] if payload[key].is_a?(Array)
281
+ end
282
+ end
283
+ [payload]
284
+ end
285
+
286
+ # Locate SVG bytes in an item: inline markup on any field first, then the
287
+ # first URL_KEYS field pointing at a downloadable .svg. The failure names
288
+ # the fields present so the schema can be adapted. Mirrors `_extract_svg`.
289
+ def extract_svg(item)
290
+ item.each_value do |value|
291
+ if value.is_a?(String) && value.lstrip.start_with?("<?xml", "<svg")
292
+ return value
293
+ end
294
+ end
295
+
296
+ URL_KEYS.each do |key|
297
+ value = item[key]
298
+ next unless value.is_a?(String) && value.start_with?("http") &&
299
+ (value.downcase.include?(".svg") || value.downcase.include?("download"))
300
+
301
+ status, body = http("GET", value, token: get_token)
302
+ return body if status == 200 && looks_like_svg?(body)
303
+ end
304
+
305
+ raise RequestError,
306
+ "could not locate SVG content in item; fields present: " \
307
+ "#{item.keys.sort.join(', ')} — update URL_KEYS/_extract_svg in " \
308
+ "tools/svg/freesvg.py for this shape"
309
+ end
310
+
311
+ # Byte sniff for a downloaded body (freesvg.py's `_looks_like_svg`).
312
+ # Precedence matches: (starts <?xml AND contains <svg) OR starts <svg.
313
+ def looks_like_svg?(body)
314
+ bytes = body.b
315
+ head = bytes[0, 2048].to_s.lstrip.downcase
316
+ (head.start_with?("<?xml") && bytes[0, 4096].to_s.downcase.include?("<svg")) ||
317
+ head.start_with?("<svg")
318
+ end
319
+
320
+ # The lint gate, via the ported deterministic linter (svg/lint.rb, itself
321
+ # differential-tested against tools/svg/svg-check.py). freesvg.py shells
322
+ # out to svg-check.py with --max-kb 1024; this is the in-process, no-
323
+ # subprocess, no-network equivalent with identical findings and exit code.
324
+ def lint(file)
325
+ Zer0ImageGenerator::Svg::Lint.check(file, max_kb: 1024.0)
326
+ end
327
+
328
+ # SECURITY GATE — added in the Ruby port; freesvg.py has no equivalent.
329
+ # svg-check.py flags external <image>/network refs but NOT <script>,
330
+ # foreignObject, on* handlers, non-local url(), or DOCTYPE/ENTITY, so a
331
+ # weaponized third-party SVG can pass the lint gate. Before anything
332
+ # enters the committed library we additionally require it to survive the
333
+ # engine's own sanitizer with nothing stripped. The pristine file is left
334
+ # untouched (README: external files stay pristine) — a finding is a hard
335
+ # refusal, not a rewrite.
336
+ def reject_unsafe!(svg_id, file)
337
+ _clean, findings = Zer0ImageGenerator::Svg::Sanitizer.sanitize_svg(
338
+ File.binread(file).force_encoding("UTF-8")
339
+ )
340
+ return if findings.empty?
341
+
342
+ raise RequestError,
343
+ "refusing to promote #{svg_id}: sanitizer flagged unsafe content " \
344
+ "(#{findings.join('; ')}) — external SVG must survive sanitize_svg untouched"
345
+ rescue Zer0ImageGenerator::Svg::SvgError => exc
346
+ raise RequestError, "refusing to promote #{svg_id}: #{exc.message}"
347
+ end
348
+
349
+ # Build the provenance-bearing manifest entry. Key order and values match
350
+ # freesvg.py's promote exactly (file, title, source_url, license,
351
+ # license_url, tags, lint, size_kb) so PyJson reproduces its bytes.
352
+ def build_entry(dest_rel, dest_abs, title, svg_id, tags, item_tags, warnings)
353
+ normalized = normalize_item_tags(item_tags)
354
+ chosen = [tags, normalized].find { |candidate| truthy?(candidate) } || []
355
+ entry_tags = chosen.first(6)
356
+ entry_tags = ["illustration"] if entry_tags.empty?
357
+
358
+ lint_status =
359
+ if warnings.empty?
360
+ "clean"
361
+ else
362
+ "warn: " + warnings.join("; ")[0, 160]
363
+ end
364
+
365
+ {
366
+ "file" => dest_rel,
367
+ "title" => title,
368
+ "source_url" => "#{API}/svg/#{svg_id}",
369
+ "license" => "CC0 / Public domain",
370
+ "license_url" => "https://freesvg.org",
371
+ "tags" => entry_tags.map { |t| t.to_s.downcase } + ["freesvg"],
372
+ "lint" => lint_status,
373
+ # Guaranteed byte-match to Python's round(bytes/1024, 1): "%.1f" and
374
+ # CPython round both use IEEE round-half-to-even, and ties are
375
+ # impossible here (bytes/1024 is dyadic, a half-tenth never is). Float
376
+ # of that string is the exact double repr renders as "N.N".
377
+ "size_kb" => Float(format("%.1f", File.size(dest_abs) / 1024.0))
378
+ }
379
+ end
380
+
381
+ # item tags may arrive as a list or a comma-joined string; normalize to a
382
+ # list (or pass through nil). Mirrors the isinstance(str) branch in promote.
383
+ def normalize_item_tags(item_tags)
384
+ return item_tags unless item_tags.is_a?(String)
385
+
386
+ item_tags.split(",").map(&:strip).reject(&:empty?)
387
+ end
388
+
389
+ # slug: lowercase, non-alnum runs to "-", collapse dashes, trim dashes,
390
+ # default "untitled". Mirrors freesvg.py's `_slugify`.
391
+ def slugify(text)
392
+ slug = text.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/-+/, "-")
393
+ slug = slug.sub(/\A-+/, "").sub(/-+\z/, "")
394
+ slug.empty? ? "untitled" : slug
395
+ end
396
+
397
+ # Best-effort JWT expiry from the payload; a short conservative fallback
398
+ # when the token is unreadable. Mirrors freesvg.py's `_jwt_exp`, whose
399
+ # broad `except Exception` is faithfully reproduced here: the JWT is
400
+ # opaque third-party text, so any decode/parse failure just means "trust
401
+ # it for a few hours, no more".
402
+ def jwt_exp(token)
403
+ payload = token.split(".")[1].to_s.tr("-_", "+/")
404
+ payload += "=" * ((-payload.length) % 4)
405
+ # unpack1("m") is stdlib base64 decode — no dependency on the `base64`
406
+ # gem, which is no longer a default library. Mirrors Python's
407
+ # base64.urlsafe_b64decode after the -_/+/ translation + padding above.
408
+ Float(JSON.parse(payload.unpack1("m"))["exp"])
409
+ rescue StandardError
410
+ @clock.call + (6 * 3600)
411
+ end
412
+
413
+ def env_file_creds
414
+ creds = {}
415
+ path = File.join(@root, ".env")
416
+ return creds unless File.exist?(path)
417
+
418
+ File.foreach(path) do |line|
419
+ match = line.match(/\s*(?:export\s+)?(FREESVG_[A-Z_]+)\s*=\s*(.*)\s*$/)
420
+ next unless match
421
+
422
+ # Python strips only surrounding quotes here (str.strip("'\"")),
423
+ # deliberately not whitespace, so mirror that precisely.
424
+ creds[match[1]] = match[2].sub(/\A["']+/, "").sub(/["']+\z/, "")
425
+ end
426
+ creds
427
+ rescue SystemCallError
428
+ creds
429
+ end
430
+
431
+ def token_status(token)
432
+ return { cached: false } unless token
433
+
434
+ left = (token["exp"] || 0) - @clock.call
435
+ left.positive? ? { cached: true, valid_hours: left / 3600.0 } : { cached: true, expired: true }
436
+ end
437
+
438
+ def parse_json(body)
439
+ JSON.parse(body)
440
+ end
441
+
442
+ # First 200 bytes decoded as UTF-8 with replacement, for error messages.
443
+ # Mirrors body[:200].decode("utf-8", "replace").
444
+ def decode_snippet(body)
445
+ body.b[0, 200].to_s.force_encoding("UTF-8").scrub
446
+ end
447
+
448
+ # Python truthiness for `x or y`: nil, false, "", empty collections, and
449
+ # numeric zero are all falsy and fall through.
450
+ def truthy?(value)
451
+ return false if value.nil? || value == false
452
+ return false if value.respond_to?(:empty?) && value.empty?
453
+ return false if value.is_a?(Numeric) && value.zero?
454
+
455
+ true
456
+ end
457
+
458
+ def presence(value)
459
+ value if truthy?(value)
460
+ end
461
+
462
+ def default_warn(message)
463
+ warn("freesvg: #{message}")
464
+ end
465
+ end
466
+ end
467
+ end