Generate the documentation's figures from the listings they illustrate

Chapters 6 and 8 described what a verb draws in prose. Eight figures now show
it, and each one is produced by running the BASIC listing printed immediately
above it -- so a picture cannot drift away from the code beside it, which is the
way a screenshot goes wrong and the way nothing notices.

tools/screenshot.c is a second SDL host, much smaller than the frontend: dummy
video driver, software renderer, run to completion, read the target back, write
a PNG. It draws no text layer on purpose, so a READY in the corner is not noise
in a figure about BOX and no font has to be resolved.

tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out
of the markdown. size=WxH is the second tag, and SCALE's figure uses it: the
point being made is a 320x200 listing filling a larger window, which cannot be
made on a 320x200 surface.

Two gates, answering different questions. docs_examples fails a tagged block
with no image, in both configurations, so a figure cannot be added and
forgotten. docs_screenshots -- a CTest, AKGL build only -- re-renders every
figure and compares byte for byte, so a listing edited without regenerating
fails. Only the second catches a stale picture.

The PNGs are checked in because a reader on the forge has no build tree, and
docs/images/README.md says loudly that they are generated. Regenerating is never
part of a build: the target is run deliberately, so a make cannot put eight
binary diffs in front of whoever ran it.

Drawing the BOX figure caught a defect in TODO.md itself. Deviation 16 claimed
in bold that BOX fills on a negative angle while its own paragraph said the fill
was filed rather than implemented. BOX cannot fill, and filled_rect is reached
by no verb as a result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 07:37:04 -04:00
parent 737fdc760f
commit 16b38c1138
18 changed files with 759 additions and 40 deletions

215
tools/docs_screenshots.sh Executable file
View File

@@ -0,0 +1,215 @@
#!/bin/bash
#
# Regenerate every figure in the documentation from the listing shown beside it.
#
# A chapter that says "CIRCLE draws ellipses" and shows a listing wants a picture
# of what that listing draws, and the failure mode of a picture is that it stops
# being of the code next to it. Nothing tells you: a screenshot taken by hand in
# 2026 still looks like a screenshot in 2027, long after the verb it illustrates
# has changed.
#
# So the figure is not an asset, it is *output*. A block tagged
#
# ```basic requires=akgl screenshot=circle
#
# is run by tools/screenshot.c and written to docs/images/circle.png, and the
# chapter shows that file. Regenerate and the picture follows the code. The
# generated PNGs are checked in on purpose -- a reader on the forge has no build
# tree -- and tests/docs_examples.sh fails if a tagged block has no image, so a
# new figure cannot be forgotten.
#
# **This is not run by the build.** `cmake --build build-akgl --target
# docs_screenshots` is deliberate; see the target's comment in CMakeLists.txt.
#
# Exit status is the number of figures that failed, the house convention.
set -u
ROOT=""
TOOL=""
CHECK=0
FAILURES=0
usage()
{
cat >&2 <<'EOF'
usage: docs_screenshots.sh --root DIR --tool PATH [--check] [FILE...]
--root DIR repository root; images are written to DIR/docs/images
--tool PATH the built akbasic_screenshot
--check render to a scratch directory and compare, changing nothing
FILE... which documents to regenerate (default: docs/*.md)
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--root) ROOT="$2"; shift 2 ;;
--tool) TOOL="$2"; shift 2 ;;
--check) CHECK=1; shift ;;
--help|-h) usage ;;
--*) echo "unknown option $1" >&2; usage ;;
*) break ;;
esac
done
[ -n "${ROOT}" ] || usage
[ -n "${TOOL}" ] || usage
[ -x "${TOOL}" ] || { echo "FAIL: no screenshot tool at ${TOOL}" >&2; exit 2; }
# Absolute, because the loop below cd's into a sandbox to run each program --
# a listing that loads an asset resolves it relative to its own directory.
case "${TOOL}" in
/*) ;;
*) TOOL="${PWD}/${TOOL}" ;;
esac
cd "${ROOT}" || exit 2
ROOT="${PWD}"
DOCS=("$@")
if [ ${#DOCS[@]} -eq 0 ]; then
DOCS=(docs/*.md)
fi
IMAGES="${ROOT}/docs/images"
mkdir -p "${IMAGES}" || exit 2
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
# --check renders somewhere else entirely and compares, so a run that finds a
# stale figure does not also fix it. A test that repairs what it is measuring
# passes the second time for the wrong reason.
if [ "${CHECK}" -eq 1 ]; then
OUTDIR="${WORK}/rendered"
mkdir -p "${OUTDIR}" || exit 2
else
OUTDIR="${IMAGES}"
fi
# The value of attribute $1 in an info string $2, or empty. Same shape as the
# `attr` in tests/docs_examples.sh, and deliberately so -- the two read the same
# fence tags and a second dialect of them would be a bug waiting to happen.
attr()
{
local name="$1" info="$2" word
for word in ${info}; do
case "${word}" in
"${name}"=*) echo "${word#*=}"; return 0 ;;
esac
done
echo ""
}
# One figure: write the listing to a file, run it, keep the PNG.
render()
{
local name="$1" body="$2" size="$3" where="$4" setup="$5"
local w=320 h=200 out="${OUTDIR}/${name}.png" log=""
if [ -n "${size}" ]; then
w="${size%x*}"
h="${size#*x}"
fi
# The same setup scripts tests/docs_examples.sh uses, run in the same place
# relative to the program. A listing that loads `ship.png` needs one whether
# it is being checked or being photographed.
if [ -n "${setup}" ]; then
if [ ! -x "${ROOT}/tests/docs_setups/${setup}.sh" ]; then
echo "FAIL ${where}: no setup script ${setup}.sh" >&2
FAILURES=$((FAILURES + 1))
return
fi
( cd "${WORK}" && "${ROOT}/tests/docs_setups/${setup}.sh" ) || {
echo "FAIL ${where}: setup ${setup} failed" >&2
FAILURES=$((FAILURES + 1))
return
}
fi
printf '%s' "${body}" > "${WORK}/${name}.bas"
# **stdout only.** The tool puts the interpreter's sink on stdout and libakgl
# logs its registry chatter -- "Actor akbasic:actor:1 initialized" -- to
# stderr, so merging the two would make every sprite figure look like a
# failing program. stderr is shown when the tool itself refuses, which is
# when it is worth reading.
if ! log="$(cd "${WORK}" && "${TOOL}" "${name}.bas" "${out}" "${w}" "${h}" \
2>"${WORK}/${name}.err")"; then
echo "FAIL ${where}: ${name} did not render" >&2
cat "${WORK}/${name}.err" >&2
FAILURES=$((FAILURES + 1))
return
fi
# A program that raised prints its error line and still exits zero, because
# a BASIC error is the script's and not the host's. For a figure that is
# still a failure: an image of a blank screen is worse than no image.
if [ -n "${log}" ]; then
echo "FAIL ${where}: ${name} rendered, but the program reported:" >&2
echo "${log}" >&2
FAILURES=$((FAILURES + 1))
return
fi
if [ "${CHECK}" -eq 1 ]; then
if [ ! -r "${IMAGES}/${name}.png" ]; then
echo "FAIL ${where}: docs/images/${name}.png does not exist" >&2
FAILURES=$((FAILURES + 1))
return
fi
if ! cmp -s "${out}" "${IMAGES}/${name}.png"; then
echo "FAIL ${where}: docs/images/${name}.png is not what that listing draws" >&2
echo " regenerate with: cmake --build <akgl build> --target docs_screenshots" >&2
FAILURES=$((FAILURES + 1))
return
fi
echo " ${name}.png matches"
return
fi
echo " ${name}.png (${w}x${h})"
}
for DOC in "${DOCS[@]}"; do
[ -r "${DOC}" ] || { echo "FAIL: no document at \"${DOC}\"" >&2; exit 2; }
# Walk the file collecting fenced blocks. Only `screenshot=` ones are kept;
# everything else is somebody else's problem, and docs_examples.sh is that
# somebody.
IN_BLOCK=0
INFO=""
BODY=""
START=0
LINENO=0
while IFS= read -r LINE; do
LINENO=$((LINENO + 1))
case "${LINE}" in
'```'*)
if [ "${IN_BLOCK}" -eq 0 ]; then
IN_BLOCK=1
INFO="${LINE#'```'}"
BODY=""
START="${LINENO}"
else
IN_BLOCK=0
NAME="$(attr screenshot "${INFO}")"
if [ -n "${NAME}" ]; then
render "${NAME}" "${BODY}" "$(attr size "${INFO}")" \
"${DOC}:${START}" "$(attr setup "${INFO}")"
fi
fi
;;
*)
if [ "${IN_BLOCK}" -eq 1 ]; then
BODY="${BODY}${LINE}"$'\n'
fi
;;
esac
done < "${DOC}"
done
if [ "${FAILURES}" -ne 0 ]; then
echo "${FAILURES} figure(s) failed" >&2
fi
exit "${FAILURES}"