lumbda/whitepaper/embed-images.py
russell@unturf.com c91d7a3362 whitepaper HTML: inline every image as a data: URI
Previously the HTML whitepaper referenced diagrams via relative paths
(src="diagrams/X.png"), which 404'd because the docroot does not carry
a mirror of whitepaper/diagrams/. The "HTML whitepaper" thus showed
broken image stubs instead of the architecture figures.

Fix: a post-processor (whitepaper/embed-images.py, stdlib Python) runs
after rst2html5. Every <img> with a relative src gets base64-inlined
as a data: URI with inferred MIME. Any .svg reference would splice in
as an inline <svg> element, surfacing alt text as <title>; the current
RST only uses the .png variant of gnu-logo so no SVGs inline for now,
but the path works when we switch.

Makefile's whitepaper-html target chains the embed step after the
existing sed passes for the uncloseai.js script tag and the meaningful
<title>. Title tweaked to "feedback as a primitive" to match the
homepage tagline.

Result: whitepaper/lumbda-whitepaper.html grows from 180 KB to 4.6 MB
(base64 inflation on ~2 MB of diagrams), and it now opens offline as
a single file — no network fetches for figures, no docroot mirror
needed.
2026-04-19 17:09:11 -04:00

95 lines
3 KiB
Python

#!/usr/bin/env python3
# Embed every <img src="..."> in an HTML file as an inline asset so a
# single-file whitepaper travels without a server. SVGs splice in as
# inline <svg>. Raster images (png/jpg/etc.) base64-encode into a
# data: URI. Absolute URLs (http://, https://, data:) pass through.
#
# Usage: embed-images.py <html-file>
import base64
import mimetypes
import os
import re
import sys
def _is_absolute(src):
return bool(re.match(r"^(https?:)?//|^data:", src))
def _svg_inline(path, attrs):
"""Read a .svg file and return an <svg> element ready to splice in,
preserving any alt text from the <img> tag via <title>."""
with open(path, "r") as f:
svg = f.read()
# Strip the XML prolog and the <!DOCTYPE svg ...> declaration;
# keeping them inside an HTML5 document is harmless but messy.
svg = re.sub(r"^\s*<\?xml[^>]*\?>\s*", "", svg)
svg = re.sub(r"^\s*<!DOCTYPE[^>]*>\s*", "", svg, flags=re.IGNORECASE)
# If the original <img> carried alt="..." surface it as <title> so
# hover-text and a11y tools still get the description.
alt = attrs.get("alt", "").strip()
if alt and "<title" not in svg[:512]:
svg = re.sub(r"(<svg\b[^>]*>)", r"\1<title>" + alt + "</title>", svg, count=1)
return svg
def _data_uri(path):
mime, _ = mimetypes.guess_type(path)
if not mime:
mime = "application/octet-stream"
with open(path, "rb") as f:
payload = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{payload}"
_IMG_RE = re.compile(r"<img\b([^>]*)>", re.IGNORECASE)
_ATTR_RE = re.compile(r'([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*"([^"]*)"')
def _parse_attrs(blob):
return dict(_ATTR_RE.findall(blob))
def _render_attrs(attrs):
return " " + " ".join(f'{k}="{v}"' for k, v in attrs.items()) if attrs else ""
def embed(html, base_dir):
def replace(match):
attr_blob = match.group(1)
attrs = _parse_attrs(attr_blob)
src = attrs.get("src", "")
if not src or _is_absolute(src):
return match.group(0)
fs_path = os.path.join(base_dir, src)
if not os.path.isfile(fs_path):
sys.stderr.write(f"embed-images: missing {src}\n")
return match.group(0)
ext = os.path.splitext(src)[1].lower()
if ext == ".svg":
# Inline the SVG in place of the <img>. Drops the img entirely.
return _svg_inline(fs_path, attrs)
attrs["src"] = _data_uri(fs_path)
return f"<img{_render_attrs(attrs)}>"
return _IMG_RE.sub(replace, html)
def main(argv):
if len(argv) != 2:
sys.stderr.write("usage: embed-images.py <html-file>\n")
return 2
path = argv[1]
base_dir = os.path.dirname(os.path.abspath(path))
with open(path, "r") as f:
html = f.read()
out = embed(html, base_dir)
with open(path, "w") as f:
f.write(out)
print(f"embed-images: rewrote {path} ({len(out)} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))