commit ecc839244e71e9cdca0ef567073642552572a30e
parent 4eb0622a0f9f6572335a7e78289fcebfeca1669b
Author: David Eisinger <[email protected]>
Date: Sat, 10 Oct 2026 15:17:01 -0400
Update dither/alt text scripts
Diffstat:
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