A collection of command-line tools and scripts for asset optimisation, static web building, and environment maintenance. Each script is short enough to read in full, and the code below is exactly what was run and tested. Requirements are listed with each tool.
1-Bit Dither Pass (dither-pass.py)
Converts a directory of images to 1-bit Atkinson-dithered PNGs, flattening any transparency onto white. ImageMagick has no Atkinson option, so the algorithm is implemented directly (the same kernel is explained on the dithering page). Requires Python 3.8 or later and Pillow (pip install pillow).
#!/usr/bin/env python3
"""dither-pass.py - Convert images to 1-bit Atkinson-dithered PNGs.
Requires: Python 3.8+ and Pillow (pip install pillow).
Usage: python3 dither-pass.py <input_dir> <output_dir> [max_width]
"""
import sys
from pathlib import Path
from PIL import Image
EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp"}
# Atkinson kernel: each of these six neighbours receives 1/8 of the error.
KERNEL = [(1, 0), (2, 0), (-1, 1), (0, 1), (1, 1), (0, 2)]
def to_grey(img):
"""Greyscale, with any transparency flattened onto white."""
if img.mode in ("RGBA", "LA") or "transparency" in img.info:
img = img.convert("RGBA")
flat = Image.new("RGBA", img.size, (255, 255, 255, 255))
flat.alpha_composite(img)
img = flat
return img.convert("L")
def atkinson(img):
w, h = img.size
buf = [float(v) for v in img.tobytes()]
for y in range(h):
for x in range(w):
i = y * w + x
old = buf[i]
new = 255.0 if old >= 128.0 else 0.0
buf[i] = new
err = (old - new) / 8.0
for dx, dy in KERNEL:
nx, ny = x + dx, y + dy
if 0 <= nx < w and ny < h:
buf[ny * w + nx] += err
# Every value is now exactly 0 or 255, so the 1-bit conversion is lossless.
return Image.frombytes("L", (w, h), bytes(int(v) for v in buf)).convert("1")
def main():
if len(sys.argv) < 3:
print("Usage: dither-pass.py <input_dir> <output_dir> [max_width]")
return 1
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
max_w = int(sys.argv[3]) if len(sys.argv) > 3 else None
if not src.is_dir():
print(f"Error: input directory '{src}' does not exist.", file=sys.stderr)
return 1
dst.mkdir(parents=True, exist_ok=True)
files = sorted(p for p in src.iterdir() if p.suffix.lower() in EXTS and p.is_file())
print(f"dither-pass: processing {len(files)} files from {src}")
done = 0
for path in files:
try:
img = to_grey(Image.open(path))
if max_w and img.width > max_w:
img = img.resize((max_w, round(img.height * max_w / img.width)), Image.LANCZOS)
out = dst / f"{path.stem}-1bit.png"
atkinson(img).save(out, optimize=True)
except OSError as e:
print(f" {path.name}: skipped ({e})", file=sys.stderr)
continue
print(f" {path.name}: {path.stat().st_size}B -> {out.stat().st_size}B")
done += 1
print(f"dither-pass: complete. {done} of {len(files)} files written to {dst}")
return 0
if __name__ == "__main__":
sys.exit(main())
Usage: python3 dither-pass.py ./raw-images ./output 120 (the last argument, a maximum width in pixels, is optional)
HTML Lint & Size Check (html-lite-check.py)
Checks plain-HTML pages for a doctype, charset, viewport, language, title and landmarks, for images without alt, and for scripts or external requests. It also checks the size of the HTML file against the 14.6 KB budget from our System Load Time specification. Requires Python 3.6 or later and nothing else.
#!/usr/bin/env python3
"""html-lite-check.py - lint plain-HTML pages and check a size budget.
Checks: doctype, charset, viewport, lang, title, header/nav/main/footer
landmarks, img alt/width/height, scripts and external requests, and the
size of the HTML file itself (default budget 14.6 KB).
Usage: python3 html-lite-check.py [--budget KB] file.html [file.html ...]
Exit status is 1 if any file fails.
"""
import argparse
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
REQUIRED = [
("DOCTYPE", r"<!doctype html>"),
("lang attribute", r'<html[^>]*\slang="'),
("meta charset", r'<meta\s+charset="utf-8"'),
("meta viewport", r'<meta\s+name="viewport"'),
("title", r"<title>[^<]+</title>"),
("header", r"<header[\s>]"),
("nav", r"<nav[\s>]"),
("main", r"<main[\s>]"),
("footer", r"<footer[\s>]"),
]
OBSOLETE = [(r"<font[\s>]", "<font>"), (r"<center[\s>]", "<center>"), (r"\sbgcolor=", "bgcolor")]
class Tags(HTMLParser):
def __init__(self):
super().__init__()
self.imgs, self.scripts, self.external = [], 0, []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "img":
self.imgs.append(a)
elif tag == "script":
self.scripts += 1
for key in ("src", "href"):
v = a.get(key) or ""
if v.startswith(("http://", "https://")) and tag in ("script", "img", "link"):
self.external.append(v)
def check(path, budget):
p = Path(path)
if not p.is_file():
print(f"{path}: ERROR file not found")
return False
text = p.read_text(encoding="utf-8")
size = p.stat().st_size
errors, warns = [], []
for name, pat in REQUIRED:
if not re.search(pat, text, re.I):
errors.append(f"missing {name}")
if size > budget:
errors.append(f"{size} bytes exceeds budget of {budget} bytes")
t = Tags()
t.feed(text)
for img in t.imgs:
if "alt" not in img:
errors.append(f"img without alt: {img.get('src')}")
if "width" not in img or "height" not in img:
warns.append(f"img without width/height: {img.get('src')}")
if t.scripts:
warns.append(f"{t.scripts} <script> element(s)")
for url in t.external:
warns.append(f"external request: {url}")
for pat, name in OBSOLETE:
if re.search(pat, text, re.I):
warns.append(f"obsolete presentational markup: {name}")
print(f"{path}: {size} bytes ({size / budget * 100:.0f}% of budget)")
for w in warns:
print(f" WARN: {w}")
for e in errors:
print(f" FAIL: {e}")
return not errors
def main():
ap = argparse.ArgumentParser(description="Lint plain-HTML pages.")
ap.add_argument("files", nargs="+")
ap.add_argument("--budget", type=float, default=14.6, help="size budget in KB")
args = ap.parse_args()
budget = int(args.budget * 1000)
results = [check(f, budget) for f in args.files]
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())
Usage: python3 html-lite-check.py index.html doc/*.html or python3 html-lite-check.py --budget 20 index.html
Static Index Generator (mkindex.sh)
POSIX shell script that lists a directory as a plain HTML page, for static servers without directory indexes. File names are HTML-escaped and URL-encoded, and the output file is not listed in itself.
#!/bin/sh
# mkindex.sh - Generate a plain HTML directory index
# Usage: ./mkindex.sh <directory> [output_file]
# The output defaults to <directory>/index.html and is not listed in itself.
# File names containing newlines are not supported.
set -eu
TARGET_DIR="${1:-.}"
OUTPUT="${2:-$TARGET_DIR/index.html}"
if [ ! -d "$TARGET_DIR" ]; then
echo "Error: '$TARGET_DIR' is not a directory." >&2
exit 1
fi
# Escape text for HTML, and percent-encode names for use in an href.
esc() {
printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g'
}
enc() {
printf '%s' "$1" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/#/%23/g' -e 's/?/%3F/g' \
-e 's/"/%22/g' -e 's/</%3C/g' -e 's/>/%3E/g'
}
DIR_NAME=$(basename "$(cd "$TARGET_DIR" && pwd)")
OUT_NAME=$(basename "$OUTPUT")
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
{
cat <<ENDOFFILE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Index of $(esc "$DIR_NAME")</title>
</head>
<body>
<h1>Index of $(esc "$DIR_NAME")</h1>
<hr>
<ul>
ENDOFFILE
find "$TARGET_DIR" -maxdepth 1 -type f -name "*.html" ! -name "$OUT_NAME" | sort |
while IFS= read -r FILE; do
NAME=$(basename "$FILE")
printf '<li><a href="%s">%s</a></li>\n' "$(enc "$NAME")" "$(esc "$NAME")"
done
find "$TARGET_DIR" -maxdepth 1 -type f ! -name "*.html" ! -name "$OUT_NAME" | sort |
while IFS= read -r FILE; do
NAME=$(basename "$FILE")
SIZE=$(wc -c < "$FILE" | tr -d ' ')
printf '<li>%s (%s bytes)</li>\n' "$(esc "$NAME")" "$SIZE"
done
cat <<ENDOFFILE
</ul>
<hr>
<footer>
<address>Generated by mkindex.sh</address>
</footer>
</body>
</html>
ENDOFFILE
} > "$TMP"
mv "$TMP" "$OUTPUT"
chmod 644 "$OUTPUT"
trap - EXIT
echo "mkindex: wrote $OUTPUT ($(wc -c < "$OUTPUT" | tr -d ' ') bytes)"
Usage: chmod +x mkindex.sh && ./mkindex.sh /var/www/html
Navigation & Repository Links:
Developer Utility Scripts