davideisinger.com

My personal website
Log | Files | Refs | README

commit ecc839244e71e9cdca0ef567073642552572a30e
parent 4eb0622a0f9f6572335a7e78289fcebfeca1669b
Author: David Eisinger <[email protected]>
Date:   Sat, 10 Oct 2026 15:17:01 -0400

Update dither/alt text scripts

Diffstat:
MREADME.md | 23+++++++++++++++++++++++
Abin/check-dither-alt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbin/encrypt | 20++++++--------------
Abin/lib/image_alt_text.rb | 27+++++++++++++++++++++++++++
Abin/refresh-dither-alt | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 240 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md @@ -2,6 +2,29 @@ [1]: https://davideisinger.com +## Image alt text + +`bin/encrypt IMAGE` encrypts an image and uses Codex to write plain, descriptive +accessibility alt text for the copied `dither` shortcode. + +Regenerate existing, nonempty `dither` alt text with: + +```sh +bin/refresh-dither-alt --dry-run +bin/refresh-dither-alt content/journal/dispatch-44-october-2026/index.md +bin/refresh-dither-alt +``` + +With no paths, the script scans all Markdown and HTML files under `content`. +`--dry-run` checks image paths and lists matches without calling Codex or changing +anything. A real run requires `openssl`, an authenticated `codex` CLI, and the +repository's `secret.key`. Each image is decrypted into a temporary directory +that is removed after generation, including on failure or interruption. Original +images and encrypted files are left alone. Each post is saved only after all its +alt text has been generated successfully; failures are reported and other posts +continue. Completed posts remain updated if the run is interrupted. Review the +resulting Git diff before publishing. Rerunning regenerates all selected alt text. + ## Publishing Dispatches Enable the repository's Git hooks once per clone: diff --git a/bin/check-dither-alt b/bin/check-dither-alt @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Exercise migration and cleanup with fake external commands, without AI calls.""" + +import os +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent + +with tempfile.TemporaryDirectory(prefix="check-dither-alt-") as directory: + root = Path(directory) + commands = root / "commands" + commands.mkdir() + temporary = root / "temporary" + temporary.mkdir() + post = root / "index.md" + image = root / "photo with spaces.jpg.enc" + image.write_text("encrypted fixture") + plaintext = root / "photo with spaces.jpg" + plaintext.write_text("original image must survive") + log = root / "calls" + + def command(name, source): + path = commands / name + path.write_text("#!/usr/bin/env python3\n" + source) + path.chmod(0o755) + + command("openssl", """ +import os, sys +from pathlib import Path +args = sys.argv[1:] +assert args[:2] == ['aes-256-cbc', '-d'] +assert args[args.index('-iter') + 1] == '1000000' +assert Path(args[args.index('-in') + 1]).read_text() == 'encrypted fixture' +Path(args[args.index('-out') + 1]).write_text('decrypted fixture') +if os.environ.get('FAIL_DECRYPT'): + sys.exit(1) +""") + command("codex", """ +import os, sys +from pathlib import Path +args = sys.argv[1:] +assert 'accessibility alt text' in args[-1] +assert 'Avoid jokes' in args[-1] +assert args[args.index('--sandbox') + 1] == 'read-only' +assert Path(args[args.index('--image') + 1]).read_text() == 'decrypted fixture' +log = Path(os.environ['CALL_LOG']) +log.write_text(log.read_text() + 'call\\n' if log.exists() else 'call\\n') +if os.environ.get('FAIL_CODEX') and len(log.read_text().splitlines()) == 2: + sys.exit(1) +result = '' if os.environ.get('EMPTY_CODEX') else 'A child in a blue coat.\\nStanding beside a bicycle.' +Path(args[args.index('--output-last-message') + 1]).write_text(result) +print('Diagnostic output that must not become alt text') +""") + env = dict(os.environ, PATH=f"{commands}:{os.environ['PATH']}", + TMPDIR=str(temporary), CALL_LOG=str(log)) + + def run(*args, **overrides): + result = subprocess.run( + [str(ROOT / "bin/refresh-dither-alt"), *args, str(post)], + env=dict(env, **overrides), capture_output=True, text=True) + leaked = [path for path in temporary.iterdir() + if path.name.startswith(("dither-alt-", "image-alt-text-"))] + assert not leaked, f"Temporary files leaked: {leaked}; {result.stderr}" + assert plaintext.read_text() == "original image must survive" + if image.exists(): + assert image.read_text() == "encrypted fixture" + return result + + original = '''Before. +{{<dither unused.jpg "20x20" />}} +{{<dither "photo with spaces.jpg" "782x600">}}Cheeky caption.{{</dither>}} +Between. +{{% dither `photo with spaces.jpg` %}}Multiline +caption.{{% /dither %}} +{{<dither no-alt.jpg>}} {{</dither>}} +After. +''' + post.write_text(original) + result = run("--dry-run") + assert result.returncode == 0, result.stderr + assert 'Found 2 shortcode(s)' in result.stdout + assert post.read_text() == original and not log.exists() + + result = run() + assert result.returncode == 0, result.stderr + alt = "A child in a blue coat. Standing beside a bicycle." + assert post.read_text() == original.replace("Cheeky caption.", alt).replace("Multiline\ncaption.", alt) + + for failure in ("FAIL_CODEX", "FAIL_DECRYPT", "EMPTY_CODEX"): + log.unlink(missing_ok=True) + post.write_text(original) + result = run(**{failure: "1"}) + assert result.returncode == 1, result.stdout + assert post.read_text() == original, "Failure partially rewrote a post" + + image.unlink() + result = run() + assert result.returncode == 1 + assert post.read_text() == original + +print("Dither alt-text migration checks passed.") diff --git a/bin/encrypt b/bin/encrypt @@ -1,6 +1,7 @@ #!/usr/bin/env ruby require "open3" +require_relative "lib/image_alt_text" file = ARGV.pop || raise("please supply a filename") @@ -15,22 +16,13 @@ raise("file '#{file}' does not exist") unless File.exist?(file) -iter 1000000 ) -caption_cmd = ["codex", "exec", "caption this image: #{file}"] -caption_output, status = Open3.capture2e(*caption_cmd) - -unless status.success? - warn "Caption generation failed:\n#{caption_output}" - exit 1 -end - -caption = caption_output.split("\n").last.to_s.strip - -if caption.empty? - warn "Caption output was empty." - exit 1 +begin + alt = ImageAltText.generate(file) +rescue StandardError => error + abort error.message end -output = %[{{<dither #{ File.basename(file) } "782x600">}}#{ caption }{{</dither>}}] +output = %[{{<dither #{ File.basename(file) } "782x600">}}#{ alt }{{</dither>}}] IO.popen("pbcopy", "w") { |pb| pb.write(output) } diff --git a/bin/lib/image_alt_text.rb b/bin/lib/image_alt_text.rb @@ -0,0 +1,27 @@ +require "open3" +require "tmpdir" + +module ImageAltText + PROMPT = "Write concise accessibility alt text for this image. " \ + "Describe the relevant visible content plainly and objectively. " \ + "Avoid jokes, wordplay, commentary, and unsupported assumptions. " \ + "Return only the alt text, as plain text in a single paragraph, without quotation marks or Markdown." + + def self.generate(image) + Dir.mktmpdir("image-alt-text-") do |directory| + response = File.join(directory, "response.txt") + output, status = Open3.capture2e( + "codex", "exec", "--sandbox", "read-only", "--ephemeral", + "--output-last-message", response, "--image", File.expand_path(image), "--", PROMPT + ) + raise "Alt text generation failed:\n#{output}" unless status.success? + raise "Alt text output was missing." unless File.file?(response) + + alt = File.read(response).strip.gsub(/\s+/, " ") + raise "Alt text output was empty." if alt.empty? + raise "Alt text contained shortcode delimiters." if alt.include?("{{") || alt.include?("}}") + + alt + end + end +end diff --git a/bin/refresh-dither-alt b/bin/refresh-dither-alt @@ -0,0 +1,81 @@ +#!/usr/bin/env ruby + +require "optparse" +require "shellwords" +require "tempfile" +require_relative "lib/image_alt_text" + +ROOT = File.expand_path("..", __dir__) +# Match paired shortcodes, including quoted filenames and multiline alt text. +PAIRED_SHORTCODE = /(?<opening>\{\{(?<kind>[<%])\s*dither\s+(?![^{}]*\/\s*[>%]\}\})(?<args>(?:"[^"]*"|'[^']*'|`[^`]*`|[^"'`{}])*?)\s*[>%]\}\})(?<alt>.*?)(?<closing>\{\{\k<kind>\s*\/dither\s*[>%]\}\})/m + +dry_run = false +options = OptionParser.new do |parser| + parser.banner = "Usage: bin/refresh-dither-alt [--dry-run] [POST_OR_DIRECTORY ...]" + parser.on("--dry-run", "List images without decrypting, calling Codex, or editing posts") { dry_run = true } + parser.on("-h", "--help", "Show this help") { puts parser; exit } +end + +begin + options.parse! + inputs = ARGV.empty? ? [File.join(ROOT, "content")] : ARGV + files = inputs.flat_map do |path| + raise "Path not found: #{path}" unless File.exist?(path) + File.directory?(path) ? Dir.glob(File.join(path, "**", "*.{md,markdown,html}")) : [path] + end.map { |path| File.expand_path(path) }.uniq.sort + + count = 0 + failures = 0 + files.each do |post| + original = File.read(post) + post_count = 0 + begin + updated = original.gsub(PAIRED_SHORTCODE) do |shortcode| + match = Regexp.last_match + next shortcode if match[:alt].strip.empty? + next shortcode if match[:args].rstrip.end_with?("/") + + arguments = match[:args].gsub(/`([^`]*)`/) { Shellwords.escape(Regexp.last_match(1)) } + filename = Shellwords.split(arguments).first + raise "Missing image filename" if filename.nil? || filename.empty? + encrypted = File.expand_path("#{filename}.enc", File.dirname(post)) + raise "Encrypted image not found: #{encrypted}" unless File.file?(encrypted) + + puts "#{dry_run ? 'Would update' : 'Updating'} #{post}: #{filename}" + post_count += 1 + next shortcode if dry_run + + alt = nil + Dir.mktmpdir("dither-alt-") do |directory| + decrypted = File.join(directory, "image#{File.extname(filename)}") + output, status = Open3.capture2e( + "openssl", "aes-256-cbc", "-d", "-in", encrypted, "-out", decrypted, + "-pass", "file:#{File.join(ROOT, 'secret.key')}", "-iter", "1000000" + ) + raise "Decryption failed for #{filename}: #{output}" unless status.success? + alt = ImageAltText.generate(decrypted) + end + "#{match[:opening]}#{alt}#{match[:closing]}" + end + + if updated != original + # Finish each post before replacing it; failed generations leave it intact. + Tempfile.create([".dither-alt-", ".tmp"], File.dirname(post)) do |temp| + temp.write(updated) + temp.flush + File.chmod(File.stat(post).mode & 0o777, temp.path) + raise "Post changed during generation: #{post}" unless File.read(post) == original + File.rename(temp.path, post) + end + end + count += post_count + rescue StandardError => error + warn "#{post}: #{error.message} (post left unchanged)" + failures += 1 + end + end + puts "#{dry_run ? 'Found' : 'Processed'} #{count} shortcode(s); #{failures} failed post(s)." + exit(failures.zero? ? 0 : 1) +rescue OptionParser::ParseError, StandardError => error + abort error.message +end