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,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Zer0ImageGenerator
6
+ module Svg
7
+ # Deterministic SVG lint — a port of `tools/svg/svg-check.py`, which stays
8
+ # the oracle.
9
+ #
10
+ # These are the checks you do not want to burn model tokens on: vision is
11
+ # for composition, this is for correctness. The house rules it enforces
12
+ # live in `.claude/rules/svg.md` (viewBox required, role="img" + <title>,
13
+ # no raster/network payloads, coordinate sanity, live id references).
14
+ #
15
+ # Three things here are contract, not taste, because the PostToolUse hook
16
+ # (`tools/svg/svg-hook.sh` via `.claude/settings.json`) consumes them:
17
+ # - exit 0 means clean, exit 1 means the write was rejected;
18
+ # - findings go to stderr as `WARN …` / `ERROR …` lines, warnings first;
19
+ # - the exact wording is what Claude reads back as the reason to fix.
20
+ # Rewording a message or reordering the findings changes what the model is
21
+ # told, so both are pinned by the differential test against the Python.
22
+ module Lint
23
+ SVG_NS = "http://www.w3.org/2000/svg"
24
+ XLINK = "http://www.w3.org/1999/xlink"
25
+
26
+ # Same shape as svg-check.py's NUM. Ruby's `\d` is ASCII-only where
27
+ # Python's is Unicode-aware; SVG number syntax is ASCII either way.
28
+ NUM = /-?\d*\.?\d+(?:[eE][-+]?\d+)?/.freeze
29
+
30
+ # Absolute-coordinate commands only — see #coords_in_path.
31
+ PATH_SEGMENT = /[MLHVCSQTA][^A-Za-z]*/.freeze
32
+
33
+ DEFAULT_MAX_KB = 250.0
34
+
35
+ USAGE = "usage: lint [-h] [--max-kb MAX_KB] [--strict] path"
36
+
37
+ # A finished lint run.
38
+ #
39
+ # `fatal` models svg-check.py's two early `return 1` paths (missing file,
40
+ # unparseable XML): they print one ERROR line and never reach the
41
+ # WARN/ERROR accumulation, so a fatal report deliberately discards any
42
+ # findings gathered before the failure (the size check, notably).
43
+ class Report
44
+ attr_reader :path, :kilobytes, :errors, :warnings, :fatal
45
+
46
+ def initialize(path:, kilobytes: 0.0, errors: [], warnings: [], fatal: nil, strict: false)
47
+ @path = path
48
+ @kilobytes = kilobytes
49
+ @errors = errors
50
+ @warnings = warnings
51
+ @fatal = fatal
52
+ @strict = strict
53
+ end
54
+
55
+ def strict? = @strict
56
+
57
+ # Warnings are advisory in normal mode and fail the run under --strict;
58
+ # that is the only difference between the two modes at the exit code.
59
+ def exit_code
60
+ return 1 if @fatal
61
+ return 1 if !@errors.empty? || (@strict && !@warnings.empty?)
62
+
63
+ 0
64
+ end
65
+
66
+ def clean? = exit_code.zero?
67
+
68
+ def stderr_lines
69
+ return ["ERROR #{@fatal}"] if @fatal
70
+
71
+ @warnings.map { |w| "WARN #{w}" } + @errors.map { |e| "ERROR #{e}" }
72
+ end
73
+
74
+ # A run can be clean and still have warnings, and the "clean" line is
75
+ # the hook's only positive signal, so it is emitted on stdout alone.
76
+ def stdout_lines
77
+ return [] unless clean?
78
+
79
+ ["svg-check: #{@path} clean (#{format('%.1f', @kilobytes)}KB)"]
80
+ end
81
+ end
82
+
83
+ class << self
84
+ # The CLI shape of svg-check.py: returns the process exit code and
85
+ # writes the same lines to the same streams. Callers that want the
86
+ # findings as data should use .check instead.
87
+ def run(argv, stdout: $stdout, stderr: $stderr)
88
+ options = parse_argv(argv)
89
+ return usage_error(stderr, options[:error]) if options[:error]
90
+
91
+ if options[:help]
92
+ stdout.puts(USAGE)
93
+ return 0
94
+ end
95
+
96
+ report = check(options[:path], max_kb: options[:max_kb], strict: options[:strict])
97
+ report.stderr_lines.each { |line| stderr.puts(line) }
98
+ report.stdout_lines.each { |line| stdout.puts(line) }
99
+ report.exit_code
100
+ end
101
+
102
+ def check(path, max_kb: DEFAULT_MAX_KB, strict: false)
103
+ unless File.exist?(path)
104
+ return Report.new(path: path, fatal: "missing file: #{path}", strict: strict)
105
+ end
106
+
107
+ check_source(File.binread(path), path: path, max_kb: max_kb, strict: strict)
108
+ end
109
+
110
+ # Same checks against an in-memory document. `path` only labels the
111
+ # output, so a caller with SVG bytes in hand (a web request, say) never
112
+ # has to round-trip through a tempfile.
113
+ def check_source(source, path:, max_kb: DEFAULT_MAX_KB, strict: false)
114
+ errors = []
115
+ warnings = []
116
+ kb = source.bytesize / 1024.0
117
+
118
+ if kb > max_kb
119
+ errors << "file is #{format('%.0f', kb)}KB, budget is #{format('%.0f', max_kb)}KB " \
120
+ "(run svgo, or reduce path precision)"
121
+ end
122
+
123
+ root = parse_root(source)
124
+ if root.is_a?(String)
125
+ return Report.new(path: path, kilobytes: kb, fatal: root, strict: strict)
126
+ end
127
+
128
+ errors << "root element is <#{root.name}>, expected <svg>" if root.name != "svg"
129
+
130
+ viewbox = parse_viewbox(root)
131
+ if viewbox.nil?
132
+ errors << "no viewBox on root — the art will not scale. " \
133
+ 'Add viewBox="0 0 W H".'
134
+ end
135
+
136
+ collect_root_findings(root, warnings)
137
+ scan_elements(root, viewbox, strict, errors, warnings)
138
+
139
+ Report.new(path: path, kilobytes: kb, errors: errors, warnings: warnings, strict: strict)
140
+ end
141
+
142
+ # Rough absolute-coordinate sample. Ignores relative commands on
143
+ # purpose — we only want a cheap "is this wildly outside the canvas"
144
+ # signal, not a path interpreter.
145
+ def coords_in_path(data)
146
+ out = []
147
+ data.scan(PATH_SEGMENT) do |segment|
148
+ nums = segment[1..].scan(NUM).map(&:to_f)
149
+ case segment[0]
150
+ when "M", "L" then out.concat(stride_pairs(nums, 0, 1, 2)) if nums.length >= 2
151
+ when "C" then out.concat(stride_pairs(nums, 4, 5, 6))
152
+ end
153
+ end
154
+ out
155
+ end
156
+
157
+ # A viewBox needs exactly four numbers; anything else is treated as
158
+ # absent, matching the oracle (a 5-number viewBox is malformed anyway).
159
+ def parse_viewbox(root)
160
+ raw = plain_attribute(root, "viewBox")
161
+ return nil if raw.nil? || raw.empty?
162
+
163
+ parts = raw.scan(NUM).map(&:to_f)
164
+ parts.length == 4 ? parts : nil
165
+ end
166
+
167
+ private
168
+
169
+ # Returns the root element, or a String describing the fatal parse
170
+ # failure. REXML happily returns a document with no root where
171
+ # ElementTree raises, so the empty case is folded in here.
172
+ def parse_root(source)
173
+ root = REXML::Document.new(source).root
174
+ return "not well-formed XML: no element found" if root.nil?
175
+
176
+ root
177
+ rescue REXML::ParseException => e
178
+ # Mirrors svg-check.py's `except ET.ParseError` — the run stops here
179
+ # rather than reporting on a half-built tree.
180
+ "not well-formed XML: #{e.message}"
181
+ end
182
+
183
+ def collect_root_findings(root, warnings)
184
+ kids = []
185
+ root.elements.each { |child| kids << child.name }
186
+
187
+ unless kids.include?("title")
188
+ warnings << "no <title> as first child (screen readers, and it " \
189
+ "shows as a tooltip)"
190
+ end
191
+ warnings << 'missing role="img" on root' if plain_attribute(root, "role") != "img"
192
+ end
193
+
194
+ def scan_elements(root, viewbox, strict, errors, warnings)
195
+ ids = []
196
+ dupes = []
197
+ used_refs = []
198
+ ext_refs = []
199
+
200
+ each_element(root) do |el|
201
+ name = el.name
202
+
203
+ id = plain_attribute(el, "id")
204
+ if id && !id.empty?
205
+ if ids.include?(id)
206
+ dupes << id unless dupes.include?(id)
207
+ else
208
+ ids << id
209
+ end
210
+ end
211
+
212
+ classify_href(el, name, used_refs, ext_refs, errors)
213
+
214
+ el.attributes.each_attribute do |attribute|
215
+ match = /\Aurl\(#([^)]+)\)/.match(attribute.value.to_s.strip)
216
+ used_refs << match[1] if match
217
+ end
218
+
219
+ check_path(el, name, viewbox, warnings)
220
+
221
+ if name == "text" && strict
222
+ warnings << "<text> present — fonts are not guaranteed at " \
223
+ "render time. Convert to paths for distribution."
224
+ end
225
+ end
226
+
227
+ finish(ids, dupes, used_refs, ext_refs, errors)
228
+ end
229
+
230
+ def classify_href(el, name, used_refs, ext_refs, errors)
231
+ value = href(el)
232
+ return if value.nil? || value.empty?
233
+
234
+ if value.start_with?("#")
235
+ used_refs << value[1..]
236
+ elsif value.start_with?("http://", "https://", "//")
237
+ ext_refs << value
238
+ elsif value.start_with?("data:image") && name == "image"
239
+ errors << "embedded raster <image> — that is a PNG in a " \
240
+ "trenchcoat, not vector art"
241
+ elsif name == "image"
242
+ errors << "external <image> reference: #{value}"
243
+ end
244
+ end
245
+
246
+ def check_path(el, name, viewbox, warnings)
247
+ return unless name == "path"
248
+
249
+ data = plain_attribute(el, "d")
250
+ return if data.nil? || data.empty?
251
+
252
+ check_path_bounds(data, viewbox, warnings) if viewbox
253
+
254
+ # Sub-pixel precision is invisible and triples file size; a handful
255
+ # of long decimals is noise, a hundred is a generator that forgot to
256
+ # round.
257
+ if data.scan(/\d+\.\d{5,}/).length > 20
258
+ warnings << "path data carries 5+ decimal places; round " \
259
+ "to 2-3 to cut file size ~40%"
260
+ end
261
+ end
262
+
263
+ def check_path_bounds(data, viewbox, warnings)
264
+ minx, miny, width, height = viewbox
265
+ pad = [width, height].max * 2
266
+
267
+ coords_in_path(data).each do |x, y|
268
+ next if minx - pad <= x && x <= minx + width + pad &&
269
+ miny - pad <= y && y <= miny + height + pad
270
+
271
+ warnings << "path coordinate (#{format('%g', x)},#{format('%g', y)}) is far outside the " \
272
+ "viewBox — likely a stray control point"
273
+ break
274
+ end
275
+ end
276
+
277
+ def finish(ids, dupes, used_refs, ext_refs, errors)
278
+ errors << "external network references: #{ext_refs.first(3).join(', ')}" unless ext_refs.empty?
279
+ errors << "duplicate id(s): #{dupes.sort.join(', ')}" unless dupes.empty?
280
+
281
+ dangling = used_refs.uniq - ids
282
+ return if dangling.empty?
283
+
284
+ errors << "reference(s) to missing id(s): " \
285
+ "#{dangling.sort.join(', ')} — gradient or clipPath " \
286
+ "will silently render as black/none"
287
+ end
288
+
289
+ # ElementTree's iter() is a document-order walk that starts at the
290
+ # receiver; REXML's each_recursive is the same walk minus the receiver.
291
+ # Both yield elements only — comments and PIs are not visited.
292
+ def each_element(root, &block)
293
+ block.call(root)
294
+ root.each_recursive(&block)
295
+ end
296
+
297
+ # ElementTree's `el.get("id")` matches the attribute whose serialized
298
+ # name is exactly "id", i.e. the one in no namespace. REXML's
299
+ # Attributes#[] can fall through to a prefixed attribute of the same
300
+ # local name, and its Attribute#prefix inherits the element's prefix,
301
+ # so neither is a faithful stand-in — compare expanded_name directly.
302
+ def plain_attribute(el, name)
303
+ el.attributes.each_attribute do |attribute|
304
+ return attribute.value if attribute.expanded_name == name
305
+ end
306
+ nil
307
+ end
308
+
309
+ # The namespaced half of the same lookup: ElementTree keys these by
310
+ # namespace URI, so the document's choice of prefix must not matter.
311
+ def namespaced_attribute(el, uri, name)
312
+ el.attributes.each_attribute do |attribute|
313
+ prefix, separator, local = attribute.expanded_name.rpartition(":")
314
+ next if separator.empty? # unprefixed attributes are in no namespace
315
+ next unless local == name
316
+
317
+ return attribute.value if el.namespace(prefix) == uri
318
+ end
319
+ nil
320
+ end
321
+
322
+ # Python's `or` falls through on an empty string too, so an empty
323
+ # href="" still lets xlink:href be considered.
324
+ def href(el)
325
+ value = plain_attribute(el, "href")
326
+ return value unless value.nil? || value.empty?
327
+
328
+ namespaced_attribute(el, XLINK, "href")
329
+ end
330
+
331
+ # Python's zip() stops at the shorter leg; Ruby's Array#zip pads the
332
+ # short one with nil, which would fabricate coordinates here.
333
+ def stride_pairs(nums, x_offset, y_offset, step)
334
+ xs = stride(nums, x_offset, step)
335
+ ys = stride(nums, y_offset, step)
336
+ Array.new([xs.length, ys.length].min) { |i| [xs[i], ys[i]] }
337
+ end
338
+
339
+ def stride(nums, offset, step)
340
+ out = []
341
+ i = offset
342
+ while i < nums.length
343
+ out << nums[i]
344
+ i += step
345
+ end
346
+ out
347
+ end
348
+
349
+ def parse_argv(argv)
350
+ options = { path: nil, max_kb: DEFAULT_MAX_KB, strict: false, help: false, error: nil }
351
+ rest = argv.dup
352
+
353
+ while (arg = rest.shift)
354
+ case arg
355
+ when "-h", "--help"
356
+ options[:help] = true
357
+ return options
358
+ when "--strict"
359
+ options[:strict] = true
360
+ when "--max-kb"
361
+ value = rest.shift
362
+ return options.merge(error: "argument --max-kb: expected one argument") if value.nil?
363
+
364
+ options[:max_kb] = parse_float(value) { |err| return options.merge(error: err) }
365
+ when /\A--max-kb=(.*)\z/m
366
+ value = Regexp.last_match(1)
367
+ options[:max_kb] = parse_float(value) { |err| return options.merge(error: err) }
368
+ else
369
+ if options[:path] || arg.start_with?("-")
370
+ return options.merge(error: "unrecognized arguments: #{arg}")
371
+ end
372
+
373
+ options[:path] = arg
374
+ end
375
+ end
376
+
377
+ return options.merge(error: "the following arguments are required: path") if options[:path].nil?
378
+
379
+ options
380
+ end
381
+
382
+ # Yields the argparse-shaped complaint rather than letting Float()'s
383
+ # own ArgumentError escape as a backtrace.
384
+ def parse_float(value)
385
+ Float(value)
386
+ rescue ArgumentError, TypeError
387
+ yield "argument --max-kb: invalid float value: '#{value}'"
388
+ end
389
+
390
+ # argparse exits 2 for a usage error; the hook only distinguishes zero
391
+ # from non-zero, but keeping 2 leaves that distinction available.
392
+ def usage_error(stderr, message)
393
+ stderr.puts(USAGE)
394
+ stderr.puts("lint: error: #{message}")
395
+ 2
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end