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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of the constants block of lib/zer0_image_generator/preview_generator.py
4
+ # (the Python engine remains the oracle). Values here are reproduced BYTE FOR
5
+ # BYTE: the palettes, composition variants and geometry feed the deterministic
6
+ # `local` provider, whose output is committed under docs/samples and
7
+ # docs/showcase — a changed literal silently re-skins every committed piece.
8
+ # test/ruby/test_constants.rb diffs the whole table against the Python module.
9
+
10
+ module Zer0ImageGenerator
11
+ # Built-in fallbacks — used only when a key is absent from _config.yml, env,
12
+ # and CLI. Claude orchestration (analysis + review) is on by default and
13
+ # degrades gracefully without a Claude credential.
14
+ #
15
+ # String keys, because the same table is indexed by keys read straight out of
16
+ # YAML; `resolve_settings` looks up DEFAULTS[cfg_key] with the config key.
17
+ DEFAULTS = {
18
+ "enabled" => true,
19
+ "provider" => "openai",
20
+ "model" => "", # empty → the active provider's default_model
21
+ "size" => "1536x1024",
22
+ "quality" => "auto",
23
+ "style" => "retro pixel art, 8-bit video game aesthetic, vibrant colors, " \
24
+ "nostalgic, clean pixel graphics",
25
+ "style_modifiers" => "pixelated, retro gaming style, CRT screen glow effect, " \
26
+ "limited color palette",
27
+ "output_dir" => "assets/images/previews",
28
+ "assets_prefix" => "/assets",
29
+ "auto_prefix" => true,
30
+ "collections" => ["posts"].freeze, # or "auto": discover from Jekyll `collections:`
31
+ "collections_dir" => "", # Jekyll's collections_dir (zer0-mistakes: "pages")
32
+ "front_matter_key" => "preview", # jekyll-seo-tag users typically want "image"
33
+ "authors_file" => "_data/authors.yml", # "" disables author overrides
34
+ "prompt_engine" => "claude", # claude analyzes the article; falls back to template
35
+ "review_engine" => "claude", # claude vision-reviews the render; `none` disables
36
+ "claude_model" => "", # empty → DEFAULT_CLAUDE_MODEL
37
+ "claude_effort" => "low", # output_config.effort for brief/review calls
38
+ # (short creative tasks; "" sends no effort)
39
+ "rasterizer" => "auto" # SVG→PNG tool for the SVG providers (local,
40
+ # claude): auto|rsvg|inkscape|magick|playwright
41
+ # |none. "none" is SVG-only mode — the vector
42
+ # banner IS the deliverable and no PNG is made.
43
+ }.freeze
44
+
45
+ # Enhance mode (OpenAI /v1/images/edits — see the OpenAI provider's `edit`).
46
+ ENHANCE_DEFAULTS = {
47
+ "model" => "gpt-image-2",
48
+ "quality" => "auto",
49
+ "fidelity" => "high",
50
+ "format" => "png"
51
+ }.freeze
52
+
53
+ DEFAULT_ENHANCE_PROMPT = "Improve this preview banner image: fix any misspelled, garbled, or " \
54
+ "incorrect text so it reads clearly and accurately. Sharpen visual details " \
55
+ "and improve composition while preserving the original art style, color " \
56
+ "palette, and theme. Ensure the image is clean and professional."
57
+
58
+ # Anthropic wire constants — keep in sync with templates/deploy/chat-proxy/worker.js.
59
+ ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
60
+ ANTHROPIC_VERSION = "2023-06-01"
61
+ OAUTH_BETA = "oauth-2025-04-20"
62
+ # Claude Code OAuth tokens require the FIRST system block to identify as Claude
63
+ # Code, otherwise the API rejects the call with a misleading `rate_limit_error`.
64
+ CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."
65
+ DEFAULT_CLAUDE_MODEL = "claude-opus-4-8"
66
+ CLAUDE_MAX_TOKENS = 16_000 # non-streaming ceiling; SVG banners fit comfortably
67
+
68
+ # Model-name prefix → provider family. Used to ignore a configured/author model
69
+ # that belongs to a different vendor than the active provider (never send a
70
+ # request that is guaranteed to 400).
71
+ #
72
+ # Insertion order is part of the contract: `model_family` returns the FIRST
73
+ # matching prefix, exactly as the Python dict iteration does.
74
+ MODEL_FAMILIES = {
75
+ "claude-" => "claude",
76
+ "gpt-image-" => "openai",
77
+ "dall-e-" => "openai",
78
+ "grok-" => "xai",
79
+ "stable-" => "stability",
80
+ "sd3" => "stability",
81
+ "gemini-" => "gemini",
82
+ "imagen-" => "gemini"
83
+ }.freeze
84
+
85
+ # Banner geometry for SVG-producing providers (claude, local).
86
+ SVG_WIDTH = 1536
87
+ SVG_HEIGHT = 1024
88
+
89
+ # Curated retro palettes: (sky/background, far, mid, near, accent, glow).
90
+ # Selected deterministically per slug so re-runs stay visually stable.
91
+ RETRO_PALETTES = [
92
+ ["#1a1a2e", "#16213e", "#0f3460", "#533483", "#e94560", "#f9ed69"].freeze,
93
+ ["#0d1b2a", "#1b263b", "#415a77", "#778da9", "#e0e1dd", "#ffb703"].freeze,
94
+ ["#2d132c", "#801336", "#c72c41", "#ee4540", "#f9d276", "#f4f4f4"].freeze,
95
+ ["#10002b", "#3c096c", "#7b2cbf", "#c77dff", "#e0aaff", "#72efdd"].freeze,
96
+ ["#001219", "#005f73", "#0a9396", "#94d2bd", "#e9d8a6", "#ee9b00"].freeze,
97
+ ["#03071e", "#370617", "#9d0208", "#dc2f02", "#f48c06", "#ffba08"].freeze,
98
+ ["#0b132b", "#1c2541", "#3a506b", "#5bc0be", "#6fffe9", "#ff6b6b"].freeze,
99
+ ["#232931", "#393e46", "#4ecca3", "#a5ecd7", "#eeeeee", "#f95959"].freeze,
100
+ ["#1f0a24", "#571089", "#ab51e3", "#f7aef8", "#b388eb", "#8093f1"].freeze,
101
+ ["#141e30", "#243b55", "#3c6382", "#82ccdd", "#f8c291", "#e55039"].freeze
102
+ ].freeze
103
+
104
+ COMPOSITION_VARIANTS = [
105
+ "a low horizon with a huge rising sun disk banded by scanlines",
106
+ "layered diagonal mountain silhouettes receding into haze",
107
+ "a vaporwave perspective grid floor vanishing toward the horizon",
108
+ "floating terraced islands with cascading pixel waterfalls",
109
+ "a night starfield with a large ringed planet arcing across the frame",
110
+ "a stepped city skyline of blocky towers with lit windows",
111
+ "rolling desert dunes with a lone monolith and long shadows",
112
+ "an ocean of chunky pixel waves under drifting square clouds"
113
+ ].freeze
114
+
115
+ # System prompt for Claude's ART-DIRECTOR role (prompt_engine: claude): read
116
+ # the article, then write the brief a raster image model will render. NOTE:
117
+ # "no text" matches the long-standing prompt rule of this feature — image
118
+ # models garble lettering.
119
+ #
120
+ # `.chomp` because Python's triple-quoted literal ends right after "kind." —
121
+ # a heredoc would otherwise append a newline the oracle never sends.
122
+ ART_DIRECTOR_SYSTEM = <<~PROMPT.chomp
123
+ You are an art director for a technical blog. You will be given an article
124
+ (title, description, tags, an excerpt) plus mandatory style directions. Your
125
+ job is to design ONE preview banner image and describe it to an AI image
126
+ model.
127
+
128
+ Respond with ONLY the image-generation prompt — no preamble, no quotes, no
129
+ markdown. One vivid paragraph of at most 130 words that:
130
+ - captures the article's actual SUBJECT as a concrete visual metaphor or
131
+ scene (specific objects, actions and spatial arrangement — never a generic
132
+ 'technology background');
133
+ - specifies composition for a wide banner (what sits left/center/right,
134
+ foreground/background, focal point);
135
+ - weaves in the given art style and palette directions verbatim in spirit;
136
+ - states that the image must contain NO text, letters, words, numbers, logos
137
+ or UI copy of any kind.
138
+ PROMPT
139
+
140
+ # System prompt for Claude's REVIEWER role (review_engine: claude): look at
141
+ # the rendered image and decide whether it represents the article.
142
+ REVIEWER_SYSTEM = <<~PROMPT.chomp
143
+ You are reviewing an AI-generated blog preview banner against the article it
144
+ illustrates. Judge three things: (1) does the image clearly evoke the
145
+ article's actual subject, (2) does it follow the requested art style, and
146
+ (3) is it free of text/lettering artifacts and visual glitches.
147
+
148
+ Respond with ONLY a JSON object, no markdown fences:
149
+ {"verdict": "approve" | "revise",
150
+ "critique": "<one or two sentences on what is wrong or right>",
151
+ "revised_prompt": "<empty when approving; otherwise a complete replacement
152
+ image-generation prompt (max 130 words) that fixes the problems while keeping
153
+ the required style and the no-text rule>"}
154
+
155
+ Approve unless the image genuinely misrepresents the subject, breaks the
156
+ style, or contains text/glitches — minor taste differences are not grounds
157
+ for revision.
158
+ PROMPT
159
+
160
+ PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
161
+
162
+ # Pause after each successful non-dry generation (Bash parity: polite pacing
163
+ # between paid API calls).
164
+ POST_GENERATION_SLEEP = 2.0
165
+
166
+ # Settings fields an override block (author preview: / collection_styles:)
167
+ # may replace per file.
168
+ OVERRIDE_KEYS = %w[style style_modifiers size quality model].freeze
169
+
170
+ class << self
171
+ # The Python module-global POST_GENERATION_SLEEP is rebindable and the test
172
+ # suite sets it to 0; a Ruby constant is not, so the *runtime* value lives
173
+ # here and the constant stays the immutable published default.
174
+ attr_accessor :post_generation_sleep
175
+ end
176
+ self.post_generation_sleep = POST_GENERATION_SLEEP
177
+ end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ module Zer0ImageGenerator
8
+ # A parsed content file. `categories` is the already-joined "a, b" string the
9
+ # prompt layer wants, not a list — the Python dataclass shipped it that way and
10
+ # callers depend on it.
11
+ ContentFile = Struct.new(:path, :title, :description, :categories, :preview,
12
+ :author, :content, :front_matter)
13
+
14
+ # Front-matter layer: read a Jekyll content file, and — the dangerous half —
15
+ # write one key back into a file a human owns without disturbing a byte of
16
+ # anything else.
17
+ #
18
+ # Ported from lib/zer0_image_generator/preview_generator.py (the oracle).
19
+ # Several helpers below reimplement CPython string semantics rather than using
20
+ # the nearest Ruby method, because the nearest Ruby method is subtly different
21
+ # and this layer rewrites user source files.
22
+ module Content
23
+ # Front matter opens on the very first line; trailing spaces/tabs are
24
+ # tolerated because editors leave them and Jekyll accepts them.
25
+ FM_OPEN = /\A---[ \t]*\r?\n/.freeze
26
+ FM_CLOSE = /^---[ \t]*\r?$/.freeze
27
+
28
+ # CPython's `str.isspace()` set — verified identical to what `re`'s `\s`
29
+ # matches for str patterns, and to what `str.strip()` removes. Ruby's `\s`
30
+ # is ASCII-only and `[[:space:]]` follows Unicode White_Space (which excludes
31
+ # U+001C..U+001F), so neither is a drop-in substitute. Built from codepoints
32
+ # so the source stays ASCII; none of these characters are class-special.
33
+ PY_SPACE_CODEPOINTS = [
34
+ 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x85, 0xA0,
35
+ 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007,
36
+ 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000
37
+ ].freeze
38
+ PY_SPACE_CLASS = PY_SPACE_CODEPOINTS.map { |cp| cp.chr(Encoding::UTF_8) }.join.freeze
39
+ PY_SPACE = /[#{PY_SPACE_CLASS}]/.freeze
40
+
41
+ # `str.splitlines(keepends=True)` breaks on far more than `\n`: VT, FF, the
42
+ # C0 separators (but NOT U+001F, which isspace() counts yet splitlines does
43
+ # not), NEL, and the Unicode line/paragraph separators all end a line.
44
+ # `String#lines` only knows `\n`, which would merge two front-matter lines
45
+ # into one and put the new key in the wrong place.
46
+ PY_LINE_BREAK_CODEPOINTS = [
47
+ 0x0A, 0x0B, 0x0C, 0x0D, 0x1C, 0x1D, 0x1E, 0x85, 0x2028, 0x2029
48
+ ].freeze
49
+ PY_LINE_BREAK = PY_LINE_BREAK_CODEPOINTS.map { |cp| cp.chr(Encoding::UTF_8) }.join.freeze
50
+ PY_SPLITLINES = /[^#{PY_LINE_BREAK}]*(?:\r\n|[#{PY_LINE_BREAK}])?/.freeze
51
+
52
+ # Offsets of the raw front-matter body between the opening and closing `---`
53
+ # fences: [fm_start, fm_end, fm_text], or nil when the file has none.
54
+ # Offsets are character indices, as in the oracle.
55
+ def self.split_front_matter(text)
56
+ open_match = FM_OPEN.match(text)
57
+ return nil unless open_match
58
+
59
+ fm_start = open_match.end(0)
60
+ close = FM_CLOSE.match(text, fm_start)
61
+ return nil unless close
62
+
63
+ [fm_start, close.begin(0), text[fm_start...close.begin(0)]]
64
+ end
65
+
66
+ def self.parse_front_matter(path, fm_key = "preview")
67
+ text = read_text(path)
68
+ return nil unless text
69
+
70
+ parts = split_front_matter(text)
71
+ unless parts
72
+ log(:debug, "No front matter found in: #{path}")
73
+ return nil
74
+ end
75
+ fm_end = parts[1]
76
+
77
+ begin
78
+ data = YAML.safe_load(parts[2], permitted_classes: [Date, Time], aliases: true)
79
+ rescue Psych::Exception => e
80
+ log(:warn, "Failed to parse YAML in #{path}: #{e.message}")
81
+ return nil
82
+ end
83
+ return nil unless data.is_a?(Hash)
84
+
85
+ categories = data.fetch("categories", [])
86
+ categories = [categories] if categories.is_a?(String)
87
+ categories = [] unless categories.is_a?(Array)
88
+
89
+ body_start = text.index("\n", fm_end)
90
+ body = body_start ? text[(body_start + 1)..] : ""
91
+
92
+ preview = data[fm_key]
93
+ ContentFile.new(
94
+ path.to_s,
95
+ py_str(truthy?(data["title"]) ? data["title"] : ""),
96
+ py_str(truthy?(data["description"]) ? data["description"] : ""),
97
+ categories.map { |c| py_str(c) }.join(", "),
98
+ preview.is_a?(String) ? preview : nil,
99
+ data["author"],
100
+ body,
101
+ data
102
+ )
103
+ end
104
+
105
+ # Set `<key>:` inside the front-matter block ONLY (first match).
106
+ #
107
+ # Replaces an existing `<key>:` line, else inserts after the first
108
+ # `description:` line, else after the first `title:` line. Everything outside
109
+ # the front-matter block is preserved byte-for-byte (the former sed/regex
110
+ # implementations operated file-wide and could corrupt body lines that start
111
+ # with the key). Writes a transient `.bak`, replaces atomically, removes the
112
+ # `.bak` on success — CI asserts no `.bak` survives a successful run.
113
+ def self.update_front_matter(path, preview_path, dry_run: false, key: "preview")
114
+ if dry_run
115
+ log(:info, "[DRY RUN] Would update #{key} in #{path} to: #{preview_path}")
116
+ return true
117
+ end
118
+
119
+ # Bytes round-trip: the newline-translating read used by parse_front_matter
120
+ # would silently rewrite a CRLF file as LF.
121
+ text = read_text(path, universal_newlines: false)
122
+ return false unless text
123
+
124
+ parts = split_front_matter(text)
125
+ unless parts
126
+ log(:warn, "No front matter block in #{path}; not updating")
127
+ return false
128
+ end
129
+ fm_start, fm_end, fm_text = parts
130
+
131
+ eol = fm_text.include?("\r\n") ? "\r\n" : "\n"
132
+ lines = splitlines(fm_text)
133
+
134
+ preview_idx = find_key(lines, "#{key}:")
135
+ if preview_idx != -1
136
+ line = lines[preview_idx]
137
+ line_eol = if line.end_with?("\r\n") then "\r\n"
138
+ elsif line.end_with?("\n") then "\n"
139
+ else ""
140
+ end
141
+ lines[preview_idx] = "#{key}: #{preview_path}#{line_eol}"
142
+ else
143
+ anchor = find_key(lines, "description:")
144
+ anchor = find_key(lines, "title:") if anchor == -1
145
+ if anchor == -1
146
+ log(:warn, "No #{key}:/description:/title: anchor in #{path} front matter")
147
+ return false
148
+ end
149
+ insert_at = end_of_block(lines, anchor)
150
+ if !lines[anchor].end_with?("\n") && insert_at == anchor + 1
151
+ lines[anchor] += eol # anchor was the final unterminated line
152
+ end
153
+ lines.insert(insert_at, "#{key}: #{preview_path}#{eol}")
154
+ end
155
+
156
+ new_text = text[0, fm_start] + lines.join + text[fm_end..]
157
+ write_atomically(path, new_text) or return false
158
+
159
+ log(:success, "Updated front matter with #{key}: #{preview_path}")
160
+ true
161
+ end
162
+
163
+ # Slug identical to the historical Bash chain (trim before the 50-cut).
164
+ def self.generate_filename(title)
165
+ slug = title.downcase.gsub(/[^a-z0-9]/, "-")
166
+ slug = slug.gsub(/-+/, "-").sub(/\A-+/, "").sub(/-+\z/, "")
167
+ slug[0, 50]
168
+ end
169
+
170
+ # Front-matter path -> site-absolute path (/assets/... form) for existence
171
+ # checks. External URLs pass through unchanged.
172
+ def self.normalize_preview_path(preview, settings)
173
+ return preview if preview.nil? || preview.empty?
174
+ return preview if preview.start_with?("http://", "https://")
175
+
176
+ # Substring (not prefix) check is deliberate: it mirrors the Liquid
177
+ # `contains` logic in components/preview-image.html and content/seo.html —
178
+ # the engine's idea of "already prefixed" must match what the site renders.
179
+ if settings.auto_prefix && !preview.include?(settings.assets_prefix)
180
+ return "#{settings.assets_prefix}#{preview}"
181
+ end
182
+
183
+ preview
184
+ end
185
+
186
+ def self.check_preview_exists(preview, settings, root)
187
+ return false if preview.nil? || preview.empty?
188
+ # External URLs count as present (matches the Liquid component + SEO tags;
189
+ # the old Bash engine would silently regenerate over them).
190
+ return true if preview.start_with?("http://", "https://")
191
+
192
+ normalized = normalize_preview_path(preview, settings) || ""
193
+ clean = normalized.sub(%r{\A/+}, "")
194
+ [join_path(root, clean), join_path(root, "assets", clean)].any? { |p| File.file?(p) }
195
+ end
196
+
197
+ # The value written to front matter — WITHOUT the assets prefix (Liquid adds
198
+ # it): assets/images/previews -> /images/previews/<filename>.
199
+ def self.preview_front_matter_path(settings, filename)
200
+ out = strip_slashes(settings.output_dir)
201
+ prefix = strip_slashes(settings.assets_prefix)
202
+ if !prefix.empty? && (out == prefix || out.start_with?("#{prefix}/"))
203
+ out = strip_slashes(out[prefix.length..])
204
+ end
205
+ out.empty? ? "/#{filename}" : "/#{out}/#{filename}"
206
+ end
207
+
208
+ # Locate an existing preview image on disk (for --enhance).
209
+ def self.find_preview_image(preview, settings, root)
210
+ return nil if preview.nil? || preview.empty?
211
+ return nil if preview.start_with?("http://", "https://")
212
+
213
+ clean = preview.sub(%r{\A/+}, "")
214
+ candidates = [
215
+ join_path(root, clean),
216
+ join_path(root, "assets", clean),
217
+ join_path(root, settings.output_dir, File.basename(clean)),
218
+ ]
219
+ candidates.find { |candidate| File.file?(candidate) }
220
+ end
221
+
222
+ # pathlib's `/` operator, which is what the oracle joins with: empty and "."
223
+ # components are dropped, runs of slashes collapse, and an absolute
224
+ # component resets the path. `File.join` does none of that, and
225
+ # find_preview_image hands the joined string back to its caller.
226
+ def self.join_path(*parts)
227
+ absolute = false
228
+ segments = []
229
+ parts.each do |part|
230
+ str = part.to_s
231
+ if str.start_with?("/")
232
+ absolute = true
233
+ segments.clear
234
+ end
235
+ segments.concat(str.split("/").reject { |seg| seg.empty? || seg == "." })
236
+ end
237
+ joined = segments.join("/")
238
+ return "/#{joined}" if absolute
239
+
240
+ joined.empty? ? "." : joined
241
+ end
242
+
243
+ # --- CPython string semantics -------------------------------------------
244
+
245
+ # `str.splitlines(keepends=True)`.
246
+ def self.splitlines(text)
247
+ parts = text.scan(PY_SPLITLINES)
248
+ parts.pop if parts.last == "" # the scan always closes with an empty match
249
+ parts
250
+ end
251
+
252
+ # `re.sub(r"\s+", " ", text).strip()` — the excerpt normalizer shared with
253
+ # the prompt layer. Exposed because `String#strip` also eats NUL, which
254
+ # Python's does not, and because both `\s+` and the final strip must use the
255
+ # CPython whitespace set.
256
+ def self.collapse_whitespace(text)
257
+ collapsed = text.gsub(/[#{PY_SPACE_CLASS}]+/, " ")
258
+ # After collapsing, every whitespace run is a single ASCII space, so
259
+ # trimming leading/trailing spaces reproduces str.strip() exactly.
260
+ collapsed.sub(/\A +/, "").sub(/ +\z/, "")
261
+ end
262
+
263
+ # Python truthiness, which is what `data.get("title") or ""` tests.
264
+ def self.truthy?(value)
265
+ case value
266
+ when nil, false then false
267
+ when String, Array, Hash then !value.empty?
268
+ when Numeric then !value.zero?
269
+ else true
270
+ end
271
+ end
272
+
273
+ # Python's `str()` over the scalar shapes a YAML front-matter value can take.
274
+ # Containers and floats fall through to Ruby's #to_s, which formats
275
+ # differently from CPython's repr() — see DEVIATIONS in the port notes.
276
+ def self.py_str(value)
277
+ case value
278
+ when String then value
279
+ when true then "True"
280
+ when false then "False"
281
+ when nil then "None"
282
+ else value.to_s
283
+ end
284
+ end
285
+
286
+ # --- internals ------------------------------------------------------------
287
+
288
+ def self.strip_slashes(text)
289
+ text.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
290
+ end
291
+ private_class_method :strip_slashes
292
+
293
+ def self.find_key(lines, prefix)
294
+ lines.index { |line| line.start_with?(prefix) } || -1
295
+ end
296
+ private_class_method :find_key
297
+
298
+ # Index just past a key line and any indented continuation lines
299
+ # (folded/literal scalars, nested maps).
300
+ def self.end_of_block(lines, idx)
301
+ j = idx + 1
302
+ while j < lines.length && (lines[j].start_with?(" ", "\t") || blank?(lines[j]))
303
+ # A blank line only continues the block if an indented line follows.
304
+ break if blank?(lines[j]) &&
305
+ !(j + 1 < lines.length && lines[j + 1].start_with?(" ", "\t"))
306
+
307
+ j += 1
308
+ end
309
+ j
310
+ end
311
+ private_class_method :end_of_block
312
+
313
+ # `line.strip() == ""`: True when the line is empty or all-whitespace.
314
+ def self.blank?(line)
315
+ line.match?(/\A[#{PY_SPACE_CLASS}]*\z/)
316
+ end
317
+ private_class_method :blank?
318
+
319
+ # `Path.read_text(encoding="utf-8")` (universal newlines) or `read_bytes()
320
+ # .decode("utf-8")`. Both raise on unreadable/undecodable input; the oracle
321
+ # warns and returns None for either, so this returns nil.
322
+ def self.read_text(path, universal_newlines: true)
323
+ raw = begin
324
+ File.binread(path.to_s)
325
+ rescue SystemCallError, IOError => e
326
+ log(:warn, "Failed to read #{path}: #{e.message}")
327
+ return nil
328
+ end
329
+
330
+ text = raw.force_encoding(Encoding::UTF_8)
331
+ unless text.valid_encoding?
332
+ # Python raises UnicodeDecodeError here and the oracle handles it
333
+ # identically to an OSError.
334
+ log(:warn, "Failed to read #{path}: invalid UTF-8")
335
+ return nil
336
+ end
337
+
338
+ universal_newlines ? text.gsub(/\r\n?/, "\n") : text
339
+ end
340
+ private_class_method :read_text
341
+
342
+ # `.bak` then atomic rename. On failure the `.bak` is deliberately left
343
+ # behind (the oracle only unlinks the temp file) — it is the user's only
344
+ # copy if the rename tore.
345
+ def self.write_atomically(path, new_text)
346
+ target = path.to_s
347
+ backup = "#{target}.bak"
348
+ tmp = "#{target}.tmp~"
349
+ begin
350
+ FileUtils.cp(target, backup, preserve: true)
351
+ File.binwrite(tmp, new_text)
352
+ File.rename(tmp, target)
353
+ File.unlink(backup) if File.exist?(backup)
354
+ rescue SystemCallError, IOError => e
355
+ log(:warn, "Failed to update front matter in #{path}: #{e.message}")
356
+ File.unlink(tmp) if File.exist?(tmp)
357
+ return false
358
+ end
359
+ true
360
+ end
361
+ private_class_method :write_atomically
362
+
363
+ # The engine's shared logger lives in the `Zer0ImageGenerator::Logging`
364
+ # slice (info/warn/success/debug/step class methods, --verbose-gated debug).
365
+ # Route through it when present. Until it is loaded, warnings still have to
366
+ # reach a human — a silently-skipped front-matter write is the worst failure
367
+ # mode this file has — so fall back to stderr. `:debug` is dropped by the
368
+ # fallback because the shared logger gates it on --verbose (off by default).
369
+ def self.log(level, message)
370
+ logger = Zer0ImageGenerator.const_defined?(:Logging, false) ? Zer0ImageGenerator::Logging : nil
371
+ if logger.respond_to?(level)
372
+ logger.public_send(level, message)
373
+ elsif level != :debug
374
+ $stderr.puts("[#{level.to_s.upcase}] #{message}")
375
+ end
376
+ end
377
+ private_class_method :log
378
+ end
379
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pure-Ruby entry point for the image engine.
4
+ #
5
+ # Deliberately separate from `lib/zer0-image-generator.rb`: that file is the
6
+ # gem's Bundler entry point and conditionally pulls in Jekyll to register the
7
+ # `jekyll preview-images` command. This file loads the engine and nothing else,
8
+ # so the Rails app in `web/` (and any plain script) can drive the same code
9
+ # without dragging Jekyll in.
10
+ #
11
+ # Stdlib only, by policy — the gem stays dependency-light, so anything required
12
+ # here must ship with Ruby.
13
+
14
+ require_relative "version"
15
+ require_relative "support/py_random"
16
+
17
+ module Zer0ImageGenerator
18
+ # Raised for engine-level failures that callers are expected to surface to a
19
+ # human — a missing credential, an unreadable site, a provider refusal.
20
+ class Error < StandardError; end
21
+ end