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
+ module Zer0ImageGenerator
4
+ module Svg
5
+ # Retro pixel-art SVG toolkit — a port of `tools/svg/pixelkit.py`, which
6
+ # stays the oracle. The style, codified:
7
+ #
8
+ # - Integer pixel grid. Every mark is an axis-aligned run of whole cells;
9
+ # nothing lives off-grid, so nothing can be half a pixel wrong.
10
+ # - `shape-rendering="crispEdges"` on the root, so no engine anti-aliases
11
+ # the art into mush.
12
+ # - Small palettes (6 tokens, engine-kin) declared once. Sprites are ASCII
13
+ # maps, chars -> tokens.
14
+ # - Horizontal runs of same-colored cells merge into single <rect>s: a
15
+ # 64x48 scene stays a few KB instead of thousands of one-cell rects.
16
+ # - Optional CRT dressing (scanline veil + vignette bars).
17
+ #
18
+ # This is a LIBRARY the generator slices (invaders, pixelquest) build on, so
19
+ # both the API shape and the exact output bytes are contract. The committed
20
+ # scenes under `docs/samples/` and `docs/showcase/` were emitted through
21
+ # this code; the run-length merge in particular is output-shaping, and any
22
+ # difference there changes the SVG bytes.
23
+ module PixelKit
24
+ SVG_NS = "http://www.w3.org/2000/svg"
25
+
26
+ # [bg, shadow, low-mid, mid, accent, highlight] — same shape as the
27
+ # engine's RETRO_PALETTES rows so scenes read as kin to its banners.
28
+ PALETTES = {
29
+ "dusk" => ["#1a1423", "#3d2b3d", "#7a3045", "#f24236", "#f5a623", "#f7e8d3"],
30
+ "cyanic" => ["#0b132b", "#1c2541", "#1c7293", "#5bc0be", "#9fd8d6", "#e8f1f2"],
31
+ "moss" => ["#16211b", "#243b2a", "#3e6b48", "#6f9e6f", "#a3c9a8", "#e9edc9"],
32
+ "magma" => ["#120b16", "#2d1b2e", "#5c2a4a", "#b3324b", "#f2542d", "#ffd166"],
33
+ }.freeze
34
+
35
+ module_function
36
+
37
+ # Mirror each row left-to-right (classic invader symmetry). With
38
+ # shared_center the last column is the axis and is not doubled.
39
+ #
40
+ # Python slices `row[-2::-1]` (reverse minus last char) and `row[::-1]`
41
+ # (full reverse); the Ruby equivalents must agree on the empty and
42
+ # single-char rows, hence `row[0..-2]` rather than chopping by index.
43
+ def hmirror(half, shared_center: true)
44
+ half.map do |row|
45
+ tail = shared_center ? (row[0..-2] || "").reverse : row.reverse
46
+ row + tail
47
+ end
48
+ end
49
+ end
50
+
51
+ # A fixed-size integer cell grid. Colors are stored per cell and only
52
+ # collapsed into <rect> runs at serialization time (see #to_svg).
53
+ class Canvas
54
+ attr_reader :cols, :rows, :px, :bg
55
+
56
+ def initialize(cols, rows, px: 8, bg: "#000000")
57
+ @cols = cols
58
+ @rows = rows
59
+ @px = px
60
+ @bg = bg
61
+ @cells = {}
62
+ end
63
+
64
+ # Off-grid writes are silently ignored — the same clamp the oracle uses
65
+ # so blits and discs can run off the edge without special-casing callers.
66
+ def set(col, row, color)
67
+ return unless col >= 0 && col < @cols && row >= 0 && row < @rows
68
+
69
+ @cells[[col, row]] = color
70
+ end
71
+
72
+ def fill(col, row, width, height, color)
73
+ (row...(row + height)).each do |r|
74
+ (col...(col + width)).each do |c|
75
+ set(c, r, color)
76
+ end
77
+ end
78
+ end
79
+
80
+ # Stamp an ASCII sprite. '.' and ' ' are transparent; any other char must
81
+ # be in `colors`. A missing key is a typo in the sprite or its map, and
82
+ # like the oracle's `colors[ch]` it fails loudly (KeyError) rather than
83
+ # painting nothing and hiding the mistake.
84
+ def blit(art, colors, col, row)
85
+ art.each_with_index do |line, dr|
86
+ line.chars.each_with_index do |ch, dc|
87
+ next if ch == "." || ch == " "
88
+
89
+ set(col + dc, row + dr, colors.fetch(ch))
90
+ end
91
+ end
92
+ end
93
+
94
+ # Blocky pixel disc — the engine's celestial-body look on a grid. `r` may
95
+ # be fractional; Python's `int()` truncates toward zero, which Ruby's
96
+ # `Integer#to_i`/`Float#to_i` also does, so the row/col spans line up.
97
+ def disc(cx, cy, r, color)
98
+ rr = r * r
99
+ ((cy - r).to_i..(cy + r).to_i).each do |row|
100
+ ((cx - r).to_i..(cx + r).to_i).each do |col|
101
+ set(col, row, color) if (((col - cx)**2) + ((row - cy)**2)) <= rr
102
+ end
103
+ end
104
+ end
105
+
106
+ def to_svg(title, crt: false)
107
+ w = @cols * @px
108
+ h = @rows * @px
109
+ px = @px
110
+ safe = title.gsub("&", "&amp;").gsub("<", "&lt;")
111
+
112
+ parts = [
113
+ %(<svg xmlns="#{PixelKit::SVG_NS}" viewBox="0 0 #{w} #{h}" role="img" ) +
114
+ %(shape-rendering="crispEdges">),
115
+ "<title>#{safe}</title>",
116
+ %(<rect width="#{w}" height="#{h}" fill="#{@bg}"/>),
117
+ ]
118
+
119
+ (0...@rows).each do |row|
120
+ row_runs(row).each do |col, length, color|
121
+ parts << %(<rect x="#{col * px}" y="#{row * px}" width="#{length * px}" ) +
122
+ %(height="#{px}" fill="#{color}"/>)
123
+ end
124
+ end
125
+
126
+ if crt
127
+ (0...h).step(6) do |y|
128
+ parts << %(<rect y="#{y}" width="#{w}" height="1" fill="#000" opacity="0.10"/>)
129
+ end
130
+ [0, h - px].each do |y|
131
+ parts << %(<rect y="#{y}" width="#{w}" height="#{px}" fill="#000" opacity="0.30"/>)
132
+ end
133
+ end
134
+
135
+ parts << "</svg>"
136
+ "#{parts.join("\n")}\n"
137
+ end
138
+
139
+ private
140
+
141
+ # Merge each row's horizontal runs of identical color into (start, length,
142
+ # color) tuples. The sentinel column at `@cols` flushes the final run; a
143
+ # cell whose color differs from the current run closes it and opens the
144
+ # next. This mirrors the oracle exactly because the emitted rect list —
145
+ # and therefore the byte output — depends on precisely where runs break.
146
+ def row_runs(row)
147
+ runs = []
148
+ start = nil
149
+ color = nil
150
+ (0..@cols).each do |col|
151
+ cell = @cells[[col, row]]
152
+ next unless cell != color || col == @cols
153
+
154
+ runs << [start, col - start, color] unless color.nil? || start.nil?
155
+ if cell.nil?
156
+ start = nil
157
+ color = nil
158
+ else
159
+ start = col
160
+ color = cell
161
+ end
162
+ end
163
+ runs
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "sanitizer" # for SVG_WIDTH / SVG_HEIGHT — export dimensions
5
+
6
+ module Zer0ImageGenerator
7
+ module Svg
8
+ # SVG -> PNG through the first available external tool. Chain and order are
9
+ # a fidelity contract: rsvg-convert, then inkscape, then ImageMagick
10
+ # (magick then convert), then the vendored Playwright/Chromium helper.
11
+ module Rasterizer
12
+ PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b
13
+
14
+ class << self
15
+ # debug() output is VERBOSE-gated in the Python; warn() always prints.
16
+ # The real logging module lives outside this slice, so route through a
17
+ # minimal, explicit stderr sink here (see DEVIATIONS in the port notes).
18
+ attr_accessor :verbose
19
+ end
20
+ @verbose = false
21
+
22
+ module_function
23
+
24
+ def log_warn(msg)
25
+ warn("[WARNING] #{msg}") # Kernel#warn -> $stderr
26
+ end
27
+
28
+ def log_debug(msg)
29
+ warn("[DEBUG] #{msg}") if Rasterizer.verbose
30
+ end
31
+
32
+ # shutil.which equivalent, memoized like the Python's lru_cache: a batch
33
+ # of N files must not pay N x 4 PATH scans. Returns the resolved path or
34
+ # nil. Tool discovery is per-run stable, so the cache is never invalidated.
35
+ def which(tool)
36
+ @which_cache ||= {}
37
+ return @which_cache[tool] if @which_cache.key?(tool)
38
+
39
+ @which_cache[tool] = resolve_on_path(tool)
40
+ end
41
+
42
+ def resolve_on_path(tool)
43
+ # A name with a directory component is checked directly, not searched.
44
+ if tool.include?(File::SEPARATOR) ||
45
+ (File::ALT_SEPARATOR && tool.include?(File::ALT_SEPARATOR))
46
+ return executable_file?(tool) ? tool : nil
47
+ end
48
+
49
+ path = ENV["PATH"]
50
+ return nil if path.nil? || path.empty?
51
+
52
+ seen = {}
53
+ path.split(File::PATH_SEPARATOR).each do |dir|
54
+ next if dir.empty? || seen[dir]
55
+
56
+ seen[dir] = true
57
+ candidate = File.join(dir, tool)
58
+ return candidate if executable_file?(candidate)
59
+ end
60
+ nil
61
+ end
62
+
63
+ def executable_file?(path)
64
+ # os.access(X_OK) and not isdir: File.executable? is false for a
65
+ # missing path, and File.file? rules out directories.
66
+ File.file?(path) && File.executable?(path)
67
+ end
68
+
69
+ # Run a command with output captured and discarded; true on exit 0.
70
+ # Mirrors _run_quiet: swallows OSError/timeout as a false result (with a
71
+ # debug line) — explicitly, never a bare rescue.
72
+ def run_quiet(cmd, cwd: nil, timeout: 120)
73
+ opts = {}
74
+ opts[:chdir] = cwd.to_s if cwd
75
+ status = nil
76
+ stderr_bytes = "".b
77
+ begin
78
+ Open3.popen3(*cmd, opts) do |stdin, stdout, stderr, wait_thr|
79
+ stdin.close
80
+ # Drain both pipes on threads so a chatty child can't deadlock on a
81
+ # full pipe buffer while we wait.
82
+ out_reader = Thread.new { stdout.read }
83
+ err_reader = Thread.new { stderr.read }
84
+ if wait_thr.join(timeout).nil?
85
+ # subprocess.TimeoutExpired branch: kill, then report failure.
86
+ begin
87
+ Process.kill("KILL", wait_thr.pid)
88
+ rescue Errno::ESRCH
89
+ # Race: the child already exited between join and kill.
90
+ end
91
+ out_reader.join
92
+ err_reader.join
93
+ log_debug("rasterizer #{cmd[0]} failed: timeout after #{timeout}s")
94
+ return false
95
+ end
96
+ status = wait_thr.value
97
+ out_reader.join
98
+ stderr_bytes = err_reader.value.to_s
99
+ end
100
+ rescue SystemCallError => exc
101
+ # OSError family (e.g. the binary vanished between which() and spawn).
102
+ log_debug("rasterizer #{cmd[0]} failed: #{exc}")
103
+ return false
104
+ end
105
+
106
+ if status.exitstatus != 0
107
+ snippet = stderr_bytes.dup.force_encoding("UTF-8").scrub[0, 200]
108
+ log_debug("rasterizer #{cmd[0]} exit #{status.exitstatus}: #{snippet}")
109
+ return false
110
+ end
111
+ true
112
+ end
113
+
114
+ # Locate the vendored Playwright helper. Candidate order mirrors the
115
+ # Python: beside the engine (the gem layout, where it actually lives),
116
+ # then the theme/curl layouts.
117
+ def playwright_helper(project_root)
118
+ engine_dir = File.expand_path("..", __dir__) # .../lib/zer0_image_generator
119
+ candidates = [
120
+ File.join(engine_dir, "rasterize-svg.js"),
121
+ File.expand_path(File.join(engine_dir, "..", "dev", "rasterize-svg.js")),
122
+ File.join(project_root.to_s, "scripts", "dev", "rasterize-svg.js"),
123
+ ]
124
+ candidates.find { |c| File.file?(c) }
125
+ end
126
+
127
+ # SVG -> PNG through the first available tool. Returns the tool name used,
128
+ # or nil when nothing worked (caller keeps the .svg).
129
+ def rasterize_svg(svg_path, png_path, project_root, preference: "auto")
130
+ svg_path = svg_path.to_s
131
+ png_path = png_path.to_s
132
+ w = SVG_WIDTH
133
+ h = SVG_HEIGHT
134
+ helper = playwright_helper(project_root)
135
+
136
+ chain = [
137
+ ["rsvg", lambda {
138
+ which("rsvg-convert") && run_quiet(
139
+ ["rsvg-convert", "-w", w.to_s, "-h", h.to_s, "-o", png_path, svg_path]
140
+ )
141
+ }],
142
+ ["inkscape", lambda {
143
+ which("inkscape") && run_quiet(
144
+ ["inkscape", svg_path, "--export-type=png",
145
+ "--export-filename=#{png_path}", "-w", w.to_s, "-h", h.to_s]
146
+ )
147
+ }],
148
+ ["magick", lambda {
149
+ (which("magick") && run_quiet(
150
+ ["magick", svg_path, "-resize", "#{w}x#{h}", png_path]
151
+ )) ||
152
+ (which("convert") && run_quiet(
153
+ ["convert", svg_path, "-resize", "#{w}x#{h}", png_path]
154
+ ))
155
+ }],
156
+ ["playwright", lambda {
157
+ !helper.nil? && which("node") && run_quiet(
158
+ ["node", helper.to_s, svg_path, png_path, w.to_s, h.to_s],
159
+ cwd: project_root, timeout: 180
160
+ )
161
+ }],
162
+ ]
163
+
164
+ return nil if preference == "none"
165
+
166
+ if preference != "auto"
167
+ chain = chain.select { |entry| entry[0] == preference }
168
+ if chain.empty?
169
+ log_warn("Unknown rasterizer '#{preference}'")
170
+ return nil
171
+ end
172
+ end
173
+
174
+ chain.each do |name, attempt|
175
+ next unless attempt.call
176
+
177
+ begin
178
+ if File.file?(png_path) && File.binread(png_path)[0, 8] == PNG_SIGNATURE
179
+ log_debug("Rasterized with #{name}: #{File.basename(png_path)}")
180
+ return name
181
+ end
182
+ rescue SystemCallError
183
+ # OSError reading the file — fall through to unlink + next tool.
184
+ end
185
+ # png_path.unlink(missing_ok=True): drop a partial/invalid output.
186
+ begin
187
+ File.delete(png_path)
188
+ rescue Errno::ENOENT
189
+ # Already absent — nothing to remove.
190
+ end
191
+ end
192
+ nil
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Zer0ImageGenerator
6
+ # SVG toolkit — the sanitizer, deterministic local renderer and rasterizer
7
+ # chain ported from preview_generator.py's "SVG toolkit" section. The Python
8
+ # keeps these as module globals in one file; the Ruby port splits them across
9
+ # sanitizer.rb / local_renderer.rb / rasterizer.rb but they remain one
10
+ # namespace and share the geometry contract defined here.
11
+ module Svg
12
+ SVG_NS = "http://www.w3.org/2000/svg"
13
+ XLINK_NS = "http://www.w3.org/1999/xlink"
14
+
15
+ # Banner geometry for SVG-producing providers (claude, local). Every SVG
16
+ # is squashed into this frame by sanitize_svg, and the rasterizer exports
17
+ # at exactly these pixel dimensions. This is a shared cross-slice contract.
18
+ SVG_WIDTH = 1536
19
+ SVG_HEIGHT = 1024
20
+
21
+ # Raised when model-produced SVG is unusable (unparseable, wrong root,
22
+ # or carrying a rejected DOCTYPE/ENTITY). Mirrors Python's SvgError.
23
+ class SvgError < StandardError; end
24
+
25
+ module Sanitizer
26
+ # Elements deleted outright: active content (script/iframe/audio/video),
27
+ # the XML/HTML escape hatch (foreignObject) and raster payloads (<image>).
28
+ BANNED_SVG_ELEMENTS = %w[script foreignObject iframe audio video image].freeze
29
+
30
+ # url() references that are NOT #-local (i.e. not url(#id), url('#id') or
31
+ # url("#id")). Anything else — url(http://…), url(data:…) — is external
32
+ # and gets neutralized to "none". Case-insensitive, matches Python's
33
+ # re.compile(r"url\(\s*(?!#|'#|\"#)[^)]*\)", re.I).
34
+ URL_REF = /url\(\s*(?!#|'#|"#)[^)]*\)/i
35
+
36
+ # DOCTYPE/ENTITY sniff run on the RAW text before parsing. Entity
37
+ # declarations enable billion-laughs expansion and external-reference
38
+ # attacks; a legit banner SVG needs neither, so reject rather than clean.
39
+ DOCTYPE_RE = /<!\s*(DOCTYPE|ENTITY)/i
40
+
41
+ IMPORT_RE = /@import[^;]*;?/i
42
+
43
+ module_function
44
+
45
+ # Element/attribute local name — the part after "}" in a Clark-notation
46
+ # tag. REXML already exposes local names via #name, so this only exists
47
+ # for the raw-string comparisons that mirror Python's _localname.
48
+ def localname(tag)
49
+ i = tag.rindex("}")
50
+ i ? tag[(i + 1)..] : tag
51
+ end
52
+
53
+ # Neutralize a style/presentation value: drop @import rules, then rewrite
54
+ # every non-local url(...) to "none". Mirrors Python's _scrub_style_text —
55
+ # @import is stripped FIRST so a url() inside it is gone before URL_REF runs.
56
+ def scrub_style_text(value)
57
+ value = value.gsub(IMPORT_RE, "")
58
+ value.gsub(URL_REF, "none")
59
+ end
60
+
61
+ # Parse + sanitize model-produced SVG. Raises SvgError when unusable.
62
+ #
63
+ # Strips scripts/foreignObject/external references/event handlers; forces
64
+ # the banner viewBox/width/height. Returns [clean_svg, warnings].
65
+ #
66
+ # NOTE ON BYTE-IDENTITY: unlike render_local_svg, the sanitized *string*
67
+ # is intentionally NOT byte-identical to Python's ElementTree output —
68
+ # REXML and ElementTree serialize namespaces, attribute order and
69
+ # self-closing tags differently. The security contract is what must
70
+ # match: everything the Python strips, this strips, and the forced
71
+ # geometry is identical.
72
+ def sanitize_svg(svg_text)
73
+ warnings = []
74
+ if svg_text =~ DOCTYPE_RE
75
+ raise SvgError, "SVG contains a DOCTYPE/ENTITY declaration (rejected)"
76
+ end
77
+
78
+ begin
79
+ # REXML is more lenient about undeclared-prefix errors than
80
+ # ElementTree, but both reject malformed markup. Entity expansion is
81
+ # already precluded — DTDs were rejected above.
82
+ doc = REXML::Document.new(svg_text)
83
+ rescue REXML::ParseException => exc
84
+ raise SvgError, "SVG does not parse: #{exc.message}"
85
+ end
86
+
87
+ root = doc.root
88
+ raise SvgError, "SVG does not parse: no root element" if root.nil?
89
+ unless root.name == "svg"
90
+ raise SvgError, "Root element is <#{root.name}>, not <svg>"
91
+ end
92
+
93
+ scrub(root, warnings)
94
+
95
+ # Force banner geometry. add_attribute overwrites in place when the
96
+ # attribute already exists, matching ET's root.set().
97
+ root.add_attribute("viewBox", "0 0 #{SVG_WIDTH} #{SVG_HEIGHT}")
98
+ root.add_attribute("width", SVG_WIDTH.to_s)
99
+ root.add_attribute("height", SVG_HEIGHT.to_s)
100
+
101
+ # Serialize with no reformatting: the default formatter preserves the
102
+ # tree as-is (no pretty-print), which keeps text/whitespace intact.
103
+ out = +""
104
+ REXML::Formatters::Default.new.write(root, out)
105
+ [out, warnings]
106
+ end
107
+
108
+ # Depth-first scrub. Mirrors Python's nested scrub(): banned child
109
+ # elements are removed (and NOT recursed into), surviving children are
110
+ # recursed, then this element's attributes and <style> text are cleaned.
111
+ def scrub(element, warnings)
112
+ # Snapshot children — we mutate the tree while iterating. REXML's
113
+ # #elements yields only Element nodes (skips text/comments/CDATA),
114
+ # matching ElementTree's list(element).
115
+ element.elements.to_a.each do |child|
116
+ name = child.name
117
+ if BANNED_SVG_ELEMENTS.include?(name)
118
+ element.delete_element(child)
119
+ warnings << "removed <#{name}>"
120
+ next
121
+ end
122
+ scrub(child, warnings)
123
+ end
124
+
125
+ # REXML surfaces namespace declarations (xmlns, xmlns:*) as attributes
126
+ # too, which ElementTree does not — but they never match any removal
127
+ # condition (their local names aren't "on*"/"href", their values are
128
+ # namespace URIs), so processing them is behavior-equivalent.
129
+ element.attributes.each_attribute.to_a.each do |attr|
130
+ local = attr.name # REXML #name is already the local name
131
+ value = attr.value
132
+ if local.downcase.start_with?("on")
133
+ element.attributes.delete(attr)
134
+ warnings << "removed #{local} handler"
135
+ elsif local == "href" && !value.start_with?("#")
136
+ element.attributes.delete(attr)
137
+ warnings << "removed external href"
138
+ elsif value.downcase.include?("url(") || value.downcase.include?("@import")
139
+ # Covers style= AND presentation attributes (fill, stroke, filter,
140
+ # mask, clip-path, …) — url() must stay #-local.
141
+ cleaned = scrub_style_text(value)
142
+ if cleaned != value
143
+ element.attributes[attr.expanded_name] = cleaned
144
+ warnings << "scrubbed url() in #{local}"
145
+ end
146
+ end
147
+ end
148
+
149
+ if element.name == "style" && element.text
150
+ cleaned = scrub_style_text(element.text)
151
+ if cleaned != element.text
152
+ element.text = cleaned
153
+ warnings << "scrubbed <style> url()/@import"
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zer0ImageGenerator
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zer0-image-generator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-23 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -47,9 +47,50 @@ files:
47
47
  - LICENSE
48
48
  - README.md
49
49
  - lib/zer0-image-generator.rb
50
+ - lib/zer0_image_generator/abc.rb
51
+ - lib/zer0_image_generator/abc/style_pack.rb
52
+ - lib/zer0_image_generator/all.rb
53
+ - lib/zer0_image_generator/claude/client.rb
54
+ - lib/zer0_image_generator/claude/orchestration.rb
55
+ - lib/zer0_image_generator/cli.rb
50
56
  - lib/zer0_image_generator/command.rb
57
+ - lib/zer0_image_generator/config.rb
58
+ - lib/zer0_image_generator/constants.rb
59
+ - lib/zer0_image_generator/content.rb
60
+ - lib/zer0_image_generator/engine.rb
61
+ - lib/zer0_image_generator/freesvg/cache.rb
62
+ - lib/zer0_image_generator/freesvg/client.rb
63
+ - lib/zer0_image_generator/http.rb
64
+ - lib/zer0_image_generator/library.rb
65
+ - lib/zer0_image_generator/logging.rb
51
66
  - lib/zer0_image_generator/preview_generator.py
67
+ - lib/zer0_image_generator/prompt.rb
68
+ - lib/zer0_image_generator/providers.rb
69
+ - lib/zer0_image_generator/providers/base.rb
70
+ - lib/zer0_image_generator/providers/gemini.rb
71
+ - lib/zer0_image_generator/providers/local.rb
72
+ - lib/zer0_image_generator/providers/openai.rb
73
+ - lib/zer0_image_generator/providers/stability.rb
74
+ - lib/zer0_image_generator/providers/xai.rb
75
+ - lib/zer0_image_generator/providers/xai_auth.rb
52
76
  - lib/zer0_image_generator/rasterize-svg.js
77
+ - lib/zer0_image_generator/runner.rb
78
+ - lib/zer0_image_generator/settings.rb
79
+ - lib/zer0_image_generator/stats.rb
80
+ - lib/zer0_image_generator/support/py_random.rb
81
+ - lib/zer0_image_generator/svg/banner_seed.rb
82
+ - lib/zer0_image_generator/svg/generators/flowfield.rb
83
+ - lib/zer0_image_generator/svg/generators/invaders.rb
84
+ - lib/zer0_image_generator/svg/generators/lowpoly.rb
85
+ - lib/zer0_image_generator/svg/generators/lsystem.rb
86
+ - lib/zer0_image_generator/svg/generators/mandala.rb
87
+ - lib/zer0_image_generator/svg/generators/pixelquest.rb
88
+ - lib/zer0_image_generator/svg/generators/starmap.rb
89
+ - lib/zer0_image_generator/svg/lint.rb
90
+ - lib/zer0_image_generator/svg/local_renderer.rb
91
+ - lib/zer0_image_generator/svg/pixel_kit.rb
92
+ - lib/zer0_image_generator/svg/rasterizer.rb
93
+ - lib/zer0_image_generator/svg/sanitizer.rb
53
94
  - lib/zer0_image_generator/version.rb
54
95
  homepage: https://github.com/bamr87/zer0-image-generator
55
96
  licenses: