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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- 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("&", "&").gsub("<", "<")
|
|
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
|
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.
|
|
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
|
|
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:
|