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:
215
tools/docs_screenshots.sh
Executable file
215
tools/docs_screenshots.sh
Executable 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}"
|
||||
217
tools/screenshot.c
Normal file
217
tools/screenshot.c
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @file screenshot.c
|
||||
* @brief Run one BASIC program against an offscreen renderer and save a PNG.
|
||||
*
|
||||
* The documentation's graphics and sprite chapters describe what a verb draws,
|
||||
* and a sentence is a poor way to describe a picture. This is what turns the
|
||||
* listing in a chapter into the image beside it -- tools/docs_screenshots.sh
|
||||
* extracts the listing from the markdown and hands it here, so the picture is
|
||||
* generated from the exact program the reader is looking at rather than from a
|
||||
* copy that can drift away from it.
|
||||
*
|
||||
* **It is a host, like src/frontend_akgl.c**, and it is deliberately a separate
|
||||
* one: the frontend opens a real window, pumps events and never stops until the
|
||||
* script quits, none of which a build step wants. What this does instead is the
|
||||
* shortest path to pixels -- the dummy video driver, a software renderer, run to
|
||||
* completion, read the target back, write it out. deps/libakgl/tests/draw.c and
|
||||
* tests/akgl_backends.c set that pattern; this is the third user of it.
|
||||
*
|
||||
* **The text layer is deliberately not drawn.** A screenshot in the graphics
|
||||
* chapter is of the drawing, and a `READY` in the corner is noise in a figure
|
||||
* about `CIRCLE`. It also means no font has to be found, which keeps this
|
||||
* runnable from a build tree with no assets resolved.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
/*
|
||||
* game.h for the `renderer` and `camera` globals: akgl_actor_render() reads
|
||||
* both without taking either as an argument, so a host that never calls
|
||||
* akgl_game_init() -- which is every host here, because owning the game loop is
|
||||
* what goal 3 forbids -- populates them itself. Same three lines as
|
||||
* src/frontend_akgl.c and tests/akgl_backends.c, and each says so.
|
||||
*/
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
#include <akbasic/akgl.h>
|
||||
#include <akbasic/error.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
/** @brief The interpreter carries every pool it owns, so it does not fit on a stack. */
|
||||
static akbasic_Runtime RUNTIME;
|
||||
static akbasic_TextSink SINK;
|
||||
static akbasic_StdioSink SINKSTATE;
|
||||
static akbasic_GraphicsBackend GRAPHICS;
|
||||
static akbasic_AkglGraphics GRAPHICSSTATE;
|
||||
static akbasic_SpriteBackend SPRITES;
|
||||
static akbasic_AkglSprites SPRITESSTATE;
|
||||
/*
|
||||
* No `window` of our own: akgl/game.h declares one as an unprefixed extern --
|
||||
* libakgl's TODO calls that a defect and it is -- so a static here shadows it
|
||||
* and the file will not compile. Writing the global is what a host does anyway.
|
||||
*/
|
||||
/** @brief The whole program, read in one go. Longer than any figure's listing. */
|
||||
static char SOURCE[65536];
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: akbasic_screenshot <program.bas> <output.png> [width] [height]\n"
|
||||
"\n"
|
||||
" Runs the program against an offscreen renderer of the given size\n"
|
||||
" (default 320x200) and writes what it drew as a PNG.\n");
|
||||
}
|
||||
|
||||
/** @brief Slurp the program. A figure's listing is small; a partial read is not tolerated. */
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *read_program(const char *path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FILE *in = NULL;
|
||||
size_t got = 0;
|
||||
|
||||
memset(SOURCE, 0, sizeof(SOURCE));
|
||||
in = fopen(path, "r");
|
||||
FAIL_ZERO_RETURN(errctx, (in != NULL), AKERR_IO, "could not open %s", path);
|
||||
got = fread(SOURCE, 1, sizeof(SOURCE) - 1, in);
|
||||
fclose(in);
|
||||
FAIL_ZERO_RETURN(errctx, (got > 0), AKERR_IO, "%s is empty", path);
|
||||
FAIL_ZERO_RETURN(errctx, (got < sizeof(SOURCE) - 1), AKERR_OUTOFBOUNDS,
|
||||
"%s is longer than %zu bytes", path, sizeof(SOURCE) - 1);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Everything between SDL being up and the pixels being readable.
|
||||
*
|
||||
* Split out so the ATTEMPT in main() has one thing to CATCH and the SDL
|
||||
* teardown has one place to happen.
|
||||
*/
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *draw_program(const char *outpath, int w, int h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
|
||||
PASS(errctx, akgl_error_init());
|
||||
renderer = &_akgl_renderer;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s", SDL_GetError());
|
||||
FAIL_ZERO_RETURN(errctx,
|
||||
SDL_CreateWindowAndRenderer("net/aklabs/akbasic/screenshot", w, h, 0,
|
||||
&window, &renderer->sdl_renderer),
|
||||
AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
|
||||
PASS(errctx, akgl_render_bind2d(renderer));
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
camera = &_akgl_camera;
|
||||
camera->x = 0.0f;
|
||||
camera->y = 0.0f;
|
||||
camera->w = (float32_t)w;
|
||||
camera->h = (float32_t)h;
|
||||
|
||||
/*
|
||||
* Opaque black, so a figure has a defined background rather than whatever
|
||||
* the driver left in the buffer. GRAPHIC's own clear is a filled rectangle
|
||||
* over the output for a reason src/graphics_akgl.c explains -- clearing is
|
||||
* the host's prerogative -- and this is the host doing it.
|
||||
*/
|
||||
FAIL_ZERO_RETURN(errctx, SDL_SetRenderDrawColor(renderer->sdl_renderer, 0, 0, 0, 0xff),
|
||||
AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
FAIL_ZERO_RETURN(errctx, SDL_RenderClear(renderer->sdl_renderer),
|
||||
AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
/* stdout: a program that errors puts its "? line : CLASS message" there,
|
||||
which is what the caller shows when a figure comes out wrong. */
|
||||
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
|
||||
PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
|
||||
PASS(errctx, akbasic_graphics_init_akgl(&GRAPHICS, &GRAPHICSSTATE, renderer));
|
||||
PASS(errctx, akbasic_sprite_init_akgl(&SPRITES, &SPRITESSTATE, renderer, &GRAPHICSSTATE));
|
||||
PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL, &SPRITES));
|
||||
PASS(errctx, akbasic_runtime_load(&RUNTIME, SOURCE));
|
||||
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));
|
||||
PASS(errctx, akbasic_runtime_run(&RUNTIME, 0));
|
||||
|
||||
/*
|
||||
* The drawing verbs are immediate and are already on the target. Sprites are
|
||||
* not: they are libakgl actors and reach the target through a render pass,
|
||||
* which in the standalone frontend is part of a frame. There is no frame
|
||||
* here, so this is it.
|
||||
*/
|
||||
PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));
|
||||
|
||||
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, (shot != NULL), AKGL_ERR_SDL,
|
||||
"Couldn't read the target back: %s", SDL_GetError());
|
||||
if ( !IMG_SavePNG(shot, outpath) ) {
|
||||
SDL_DestroySurface(shot);
|
||||
FAIL_RETURN(errctx, AKGL_ERR_SDL, "Couldn't write %s: %s", outpath, SDL_GetError());
|
||||
}
|
||||
SDL_DestroySurface(shot);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int w = 320;
|
||||
int h = 200;
|
||||
|
||||
if ( argc < 3 ) {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
if ( argc > 3 ) {
|
||||
w = atoi(argv[3]);
|
||||
}
|
||||
if ( argc > 4 ) {
|
||||
h = atoi(argv[4]);
|
||||
}
|
||||
if ( w <= 0 || h <= 0 ) {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
/*
|
||||
* Set here rather than left to the environment so the tool answers the same
|
||||
* on a developer's desktop as it does in CI. A figure regenerated over a
|
||||
* real GPU could differ by a pixel of antialiasing and turn every rebuild
|
||||
* into a diff.
|
||||
*/
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, read_program(argv[1]));
|
||||
CATCH(errctx, draw_program(argv[2], w, h));
|
||||
} CLEANUP {
|
||||
if ( window != NULL ) {
|
||||
SDL_DestroyWindow(window);
|
||||
window = NULL;
|
||||
}
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "could not draw the screenshot");
|
||||
return 1;
|
||||
/*
|
||||
* FINISH_NORETURN rather than FINISH, matching src/main.c and
|
||||
* tests/akgl_backends.c: FINISH expands a `return __err_context` that
|
||||
* this int-returning function cannot compile even where the branch is
|
||||
* unreachable.
|
||||
*/
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user