blob: 5ee7f73206fc913dd24bc59cdd9130182166949d [file]
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Render an authored terminal transcript to a static SVG.
#
# The family screenshots are written, not captured. A capture needs a
# terminal, a scratch project and a human, and it needs all three again the
# next time any output moves — which is what left nine family recordings
# showing the same thing for months. A transcript is a text file: it reviews
# as a diff, a contributor can fix a line without a recording session, and it
# costs a fraction of a PNG set in every source release.
#
# What a capture gives that authoring does not is a guarantee that the picture
# matches the program. This script buys back the half of that guarantee it can:
# rendering is deterministic — same .txt, same .svg, byte for byte — so
# check-quickstart-recording.py can prove every committed .svg still matches
# its source. The other half, that the .txt still matches what the skill
# prints, is a human's judgement and assets/quickstart/README.md says so.
#
# Nothing here shells out to Node, and nothing in this repository does any more:
# the recorder that needed svg-term-cli is retired. This runs on a bare clone
# and in CI.
#
# Usage:
# tools/dev/render-screenshot.sh <path.txt> # write the sibling .svg
# tools/dev/render-screenshot.sh --all # render every transcript
# tools/dev/render-screenshot.sh --check # fail if any .svg is stale
set -euo pipefail
# Every authored transcript lives under here: the per-family screenshots and
# the first-run walkthrough. The one real recording, magpie-setup.svg, sits at
# the top of this tree with no transcript -- it is captured, not written.
SHOTS_DIR="assets/quickstart"
RECORDING="assets/quickstart/magpie-setup.svg"
# Generated by render-wizard.py from frontmatter, not from a transcript.
WIZARD_DIR="assets/quickstart/wizard"
# The recorder's palette, so the authored set and the one real recording read
# as one thing rather than two.
BG="#1d1f21"
BAR="#2b2e31"
DOT="#3f4448"
FG="#c5c8c6"
MUTED="#8a8f94"
CMD="#8abeb7"
OK="#7f9f7f"
WARN="#d8a657"
BAD="#cc6666"
# 18px in a monospace face advances 0.6em per cell. Everything below is
# integer arithmetic in tenths so the geometry is identical on every machine —
# a float formatted differently by a different awk would change the bytes.
FONT_SIZE=18
ADVANCE_TENTHS=108
LINE_HEIGHT=26
PAD_X=28
PAD_TOP=30
BAR_H=34
MIN_WIDTH=640
usage() {
cat <<'USAGE'
Usage:
tools/dev/render-screenshot.sh <path.txt> # write the sibling .svg
tools/dev/render-screenshot.sh --all # render every transcript
tools/dev/render-screenshot.sh --check # fail if any .svg is stale
USAGE
}
licence_header() {
cat <<'HEADER'
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
GENERATED FILE — do not edit.
Rendered from the .txt beside it by tools/dev/render-screenshot.sh.
Edit the transcript and re-render; check-quickstart-recording.py fails
the build when this file no longer matches its source.
-->
HEADER
}
render() {
local txt="$1"
if [[ ! -f "${txt}" ]]; then
echo "error: ${txt}: no such transcript" >&2
return 1
fi
if [[ ! -s "${txt}" ]]; then
echo "error: ${txt}: transcript is empty" >&2
return 1
fi
licence_header
BG="${BG}" BAR="${BAR}" DOT="${DOT}" FG="${FG}" MUTED="${MUTED}" \
CMD="${CMD}" OK="${OK}" WARN="${WARN}" BAD="${BAD}" \
FONT_SIZE="${FONT_SIZE}" ADVANCE_TENTHS="${ADVANCE_TENTHS}" \
LINE_HEIGHT="${LINE_HEIGHT}" PAD_X="${PAD_X}" PAD_TOP="${PAD_TOP}" \
BAR_H="${BAR_H}" MIN_WIDTH="${MIN_WIDTH}" TITLE="$(basename "${txt}" .txt)" \
LC_ALL=C awk '
function esc(s) {
gsub(/&/, "\\&amp;", s)
gsub(/</, "\\&lt;", s)
gsub(/>/, "\\&gt;", s)
return s
}
# Colour is a function of the line, never of a marker the transcript has
# to carry: a transcript stays something you can read as a terminal.
function colour(s, t) {
t = s
sub(/^[ \t]+/, "", t)
if (t ~ /^> /) return ENVIRON["CMD"]
if (t ~ /^\342\234\223/) return ENVIRON["OK"] # U+2713 check
if (t ~ /^\342\232\240/) return ENVIRON["WARN"] # U+26A0 warning
if (t ~ /^\342\234\227/) return ENVIRON["BAD"] # U+2717 ballot X
if (t ~ /^[A-Z][A-Z0-9 _-]*$/ && length(t) > 1) return ENVIRON["WARN"]
return ENVIRON["FG"]
}
# Width is measured in BYTES, under a forced LC_ALL=C, because length() in
# awk is locale-dependent: measuring characters would make the output
# depend on the locale of the machine that rendered it, and a renderer
# whose bytes move between machines cannot be checked for staleness at all.
# The cost is that a line carrying a multi-byte glyph measures a little
# wide and the frame gains a few pixels of right-hand padding. Padding is
# invisible; a false staleness failure is not.
# A transcript is authored source, so it carries the ASF licence header
# like every other authored file here -- as a leading block of # comments,
# which is stripped before anything is drawn. Without this the header would
# be rendered into the picture; with it, RAT is satisfied and no transcript
# needs a .rat-excludes entry. The cost is that a transcript cannot open
# with a literal # line, which no terminal transcript in this repository
# does.
BEGIN { inhdr = 1 }
inhdr && /^#/ { next }
inhdr && /^[ \t]*$/ { next }
{ inhdr = 0; n++; line[n] = $0; if (length($0) > cols) cols = length($0) }
END {
if (n == 0) exit 1
pad_x = ENVIRON["PAD_X"] + 0
bar_h = ENVIRON["BAR_H"] + 0
pad_top = ENVIRON["PAD_TOP"] + 0
lh = ENVIRON["LINE_HEIGHT"] + 0
adv = ENVIRON["ADVANCE_TENTHS"] + 0
w = 2 * pad_x + int((cols * adv + 9) / 10)
if (w < ENVIRON["MIN_WIDTH"] + 0) w = ENVIRON["MIN_WIDTH"] + 0
h = bar_h + pad_top + n * lh + pad_x
printf "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 %d %d\"\n", w, h
printf " width=\"%d\" height=\"%d\" role=\"img\"\n", w, h
printf " data-magpie-rendered=\"render-screenshot\"\n"
printf " aria-label=\"Terminal transcript: %s\">\n", ENVIRON["TITLE"]
printf " <title>%s</title>\n", ENVIRON["TITLE"]
printf " <rect width=\"%d\" height=\"%d\" rx=\"10\" fill=\"%s\"/>\n", w, h, ENVIRON["BG"]
printf " <rect x=\"0\" y=\"0\" width=\"%d\" height=\"%d\" rx=\"10\" fill=\"%s\"/>\n", w, bar_h, ENVIRON["BAR"]
printf " <rect x=\"0\" y=\"%d\" width=\"%d\" height=\"10\" fill=\"%s\"/>\n", bar_h - 10, w, ENVIRON["BAR"]
printf " <circle cx=\"22\" cy=\"17\" r=\"6\" fill=\"%s\"/>\n", ENVIRON["DOT"]
printf " <circle cx=\"44\" cy=\"17\" r=\"6\" fill=\"%s\"/>\n", ENVIRON["DOT"]
printf " <circle cx=\"66\" cy=\"17\" r=\"6\" fill=\"%s\"/>\n", ENVIRON["DOT"]
printf " <g font-family=\"ui-monospace,SFMono-Regular,Menlo,Consolas,monospace\""
printf " font-size=\"%s\" xml:space=\"preserve\">\n", ENVIRON["FONT_SIZE"]
for (i = 1; i <= n; i++) {
y = bar_h + pad_top + i * lh - 6
if (line[i] ~ /^[ \t]*$/) continue
printf " <text x=\"%d\" y=\"%d\" fill=\"%s\">%s</text>\n", \
pad_x, y, colour(line[i]), esc(line[i])
}
print " </g>"
print "</svg>"
}
' "${txt}"
}
transcripts() {
find "${SHOTS_DIR}" -name '*.txt' -type f | LC_ALL=C sort
}
write_one() {
local txt="$1" svg="${1%.txt}.svg"
render "${txt}" > "${svg}"
echo "${svg}"
}
check_one() {
local txt="$1" svg="${1%.txt}.svg"
if [[ ! -f "${svg}" ]]; then
echo "${svg}: missing — run tools/dev/render-screenshot.sh ${txt}" >&2
return 1
fi
# Render to a file rather than comparing against a pipe: a sandbox that
# denies the agent stdin turns `diff -` into a failure that reads exactly
# like drift, and a staleness check nobody trusts is worse than none.
local tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/render-screenshot.XXXXXX")"
render "${txt}" > "${tmp}"
if ! cmp -s "${tmp}" "${svg}"; then
rm -f "${tmp}"
echo "${svg}: stale — does not match ${txt}; re-render it" >&2
return 1
fi
rm -f "${tmp}"
}
main() {
case "${1:-}" in
"" | -h | --help)
usage
[[ -z "${1:-}" ]] && return 2 || return 0
;;
--all)
local txt
while IFS= read -r txt; do write_one "${txt}"; done < <(transcripts)
;;
--check)
local txt rc=0
while IFS= read -r txt; do check_one "${txt}" || rc=1; done < <(transcripts)
# An .svg with no .txt beside it is not this script's to render,
# but it is exactly the state --check exists to refuse.
local svg
while IFS= read -r svg; do
# Everything render-wizard.py generates: the top-level
# animations and the per-family wizard directory. Those derive
# from frontmatter, not from a transcript beside them.
[[ "${svg}" != */families/* && "${svg}" != */walkthrough/* ]] && continue
[[ -f "${svg%.svg}.txt" ]] || { echo "${svg}: no transcript beside it" >&2; rc=1; }
done < <(find "${SHOTS_DIR}" -name '*.svg' -type f | LC_ALL=C sort)
return "${rc}"
;;
-*)
echo "error: unknown option ${1}" >&2
usage >&2
return 2
;;
*)
write_one "$1"
;;
esac
}
main "$@"