diff --git a/tools/lab/wonderink-lesson-site.py b/tools/lab/wonderink-lesson-site.py new file mode 100644 index 0000000..46cec25 --- /dev/null +++ b/tools/lab/wonderink-lesson-site.py @@ -0,0 +1,128 @@ +"""A signed-in lesson with pictures in it, for right-click printing. + + wonderink-lesson-site.py PICTURES PORT OTHER_PORT LOG + +Run inside the lab by tools/lab/wonderink-print-check. Stands in for +Wonder Ink's own site, which the lab has no account for, with every kind +of picture a lesson might put under a teacher's pointer: + + /login sets a session cookie (HttpOnly, SameSite=Lax), as a + sign-in does, and goes on to /lesson + /lesson the page; its pictures are all behind that cookie + /private/NAME a picture, 403 without the cookie + /once/N/NAME a picture that can be fetched exactly once — so a + print that fetches it again gets a 404, not a picture + OTHER_PORT the same pictures from another origin, with no CORS + +Every request is logged, one JSON line each, so a check can say what was +fetched while it printed. What was measured with this, and why Print +image fetches nothing, is in classroom-wonderink's _take_picture. +""" + +import http.server +import json +import sys +import threading +import time +import uuid +from pathlib import Path +from urllib.parse import urlparse + +PICTURES = Path(sys.argv[1]) +PORT, OTHER = int(sys.argv[2]), int(sys.argv[3]) +LOG = Path(sys.argv[4]) +TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp"} +fetched_once = set() +lock = threading.Lock() + +LESSON = """
Noah built an ark, and the animals came two by two. Colour the pictures below.
+
+
+
+
+
+
+
+
+"""
+
+
+class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *_):
+ pass
+
+ def answer(self, code, body=b"", kind="text/plain", headers=()):
+ self.send_response(code)
+ self.send_header("Content-Type", kind)
+ self.send_header("Content-Length", str(len(body)))
+ for name, value in headers:
+ self.send_header(name, value)
+ self.end_headers()
+ self.wfile.write(body)
+
+ def picture(self, name):
+ path = PICTURES / Path(name).name
+ if not path.is_file() or path.suffix not in TYPES:
+ return self.answer(404, b"no such picture")
+ return self.answer(200, path.read_bytes(), TYPES[path.suffix],
+ [("Cache-Control", "private, max-age=600")])
+
+ def do_GET(self):
+ path = urlparse(self.path).path
+ signed_in = "session=ok" in self.headers.get("Cookie", "")
+
+ with lock, open(LOG, "a") as log:
+ log.write(json.dumps({"t": time.time(), "port": self.server.server_port,
+ "path": path, "signed_in": signed_in}) + "\n")
+
+ if self.server.server_port == OTHER:
+ if path.startswith("/img/"):
+ return self.picture(path[5:])
+ return self.answer(404)
+
+ if path == "/login":
+ return self.answer(302, headers=[
+ ("Set-Cookie", "session=ok; Path=/; HttpOnly; SameSite=Lax"),
+ ("Location", "/lesson")])
+
+ if not signed_in:
+ return self.answer(403, b"sign in first")
+
+ if path == "/lesson":
+ page = LESSON.replace("NONCE", uuid.uuid4().hex).replace("OTHER", str(OTHER))
+ return self.answer(200, page.encode(), "text/html")
+
+ if path.startswith("/private/"):
+ return self.picture(path[9:])
+
+ if path.startswith("/once/"):
+ with lock:
+ if path in fetched_once:
+ return self.answer(404, b"this address has been used")
+ fetched_once.add(path)
+ return self.picture(path.rsplit("/", 1)[1])
+
+ return self.answer(404)
+
+
+for port in (OTHER, PORT):
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+
+threading.Event().wait()
diff --git a/tools/lab/wonderink-picture-measure.py b/tools/lab/wonderink-picture-measure.py
index 980831c..29c0402 100644
--- a/tools/lab/wonderink-picture-measure.py
+++ b/tools/lab/wonderink-picture-measure.py
@@ -3,6 +3,7 @@
wonderink-picture-measure.py make DIRECTORY
wonderink-picture-measure.py measure PDF WIDTHxHEIGHT SIZE POSITION PAPER FILL
wonderink-picture-measure.py darkness PDF
+ wonderink-picture-measure.py colour PDF
Run inside the lab by tools/lab/wonderink-print-check. The pictures are
white with a black border drawn on their own edges and a pink mark near
@@ -55,10 +56,42 @@ def make(directory):
# Transparent around the drawing: on paper that has to be white, not
# black, which is what a careless composite would make of it.
art(1200, 1200, "ark-clear.png", "Clear", transparent=True)
+ webp(directory)
-def page_pixels(pdf):
- ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", "1", "-l", "1", "-singlefile", pdf],
+def webp(directory):
+ """ark-wide.png as WebP. Qt cannot write WebP, or read it; Chromium does both."""
+ import base64
+
+ from PyQt6.QtCore import QTimer, QUrl
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
+ from PyQt6.QtWidgets import QApplication
+
+ app = QApplication.instance() or QApplication(sys.argv[:1])
+ source = base64.b64encode(open(f"{directory}/ark-wide.png", "rb").read()).decode()
+ view = QWebEngineView()
+
+ def made(answer):
+ if isinstance(answer, str) and answer.startswith("data:image/webp;base64,"):
+ with open(f"{directory}/ark-wide.webp", "wb") as handle:
+ handle.write(base64.b64decode(answer.split(",", 1)[1]))
+ app.quit()
+
+ def loaded(_ok):
+ view.page().runJavaScript("""var i = new Image(); i.onload = function(){
+ var c = document.createElement('canvas'); c.width = i.width; c.height = i.height;
+ c.getContext('2d').drawImage(i, 0, 0); window.__webp = c.toDataURL('image/webp', 0.95); };
+ i.src = 'data:image/png;base64,%s'; 0""" % source)
+ QTimer.singleShot(1500, lambda: view.page().runJavaScript("window.__webp", made))
+
+ view.loadFinished.connect(loaded)
+ view.setHtml("")
+ QTimer.singleShot(20000, app.quit)
+ app.exec()
+
+
+def page_pixels(pdf, page=1):
+ ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", str(page), "-l", str(page), "-singlefile", pdf],
capture_output=True, check=True).stdout
# P6 header: magic, width, height, maxval, then raw RGB.
fields, index = [], 0
@@ -106,6 +139,19 @@ def measure(pdf, picture, size, position, paper, fill):
if magenta:
pink += 1
+ # The corner mark, wherever it landed and whatever colour it came out:
+ # pink on a colour page, grey on a black-and-white one. Inked if most
+ # of the middle of where it should be is not paper.
+ mark_left = max(x + w * 0.07, MARGIN_POINTS)
+ mark_right = min(x + w * 0.13, width - MARGIN_POINTS)
+ mark_top = max(y + h * 0.82, MARGIN_POINTS)
+ mark_bottom = min(y + h * 0.88, height - MARGIN_POINTS)
+ mark = [
+ sum(rgb[(row * width + column) * 3:(row * width + column) * 3 + 3]) < 3 * 200
+ for row in range(int(mark_top), int(mark_bottom))
+ for column in range(int(mark_left), int(mark_right))
+ ]
+
found = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) if xs else None
expected = (round(left), round(top), round(right), round(bottom))
off = max(abs(a - b) for a, b in zip(found, expected)) if found else None
@@ -119,9 +165,24 @@ def measure(pdf, picture, size, position, paper, fill):
"off_by_points": off,
"ink_outside_picture": outside,
"pink_pixels": pink,
+ "mark_inked": bool(mark) and sum(mark) > 0.8 * len(mark),
+ "colour_spread": spread(rgb),
}))
+def spread(rgb):
+ """The most any pixel's red, green and blue differ: 0 on a grey page."""
+ return max((max(rgb[i:i + 3]) - min(rgb[i:i + 3]) for i in range(0, len(rgb) - 2, 3)), default=0)
+
+
+def colour(pdf):
+ """Is every page grey? The colour spread of each page, and how many."""
+ pages = int(subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout
+ .split("Pages:")[1].split()[0])
+ spreads = [spread(page_pixels(pdf, n)[2]) for n in range(1, pages + 1)]
+ print(json.dumps({"pages": pages, "spreads": spreads, "gray": max(spreads) == 0}))
+
+
def darkness(pdf):
"""How much of the first page is dark, 0 to 1."""
width, height, rgb = page_pixels(pdf)
@@ -140,5 +201,7 @@ if __name__ == "__main__":
make(sys.argv[2])
elif sys.argv[1] == "darkness":
darkness(sys.argv[2])
+ elif sys.argv[1] == "colour":
+ colour(sys.argv[2])
else:
measure(*sys.argv[2:])
diff --git a/tools/lab/wonderink-print-check b/tools/lab/wonderink-print-check
index a457cfa..4b599a8 100755
--- a/tools/lab/wonderink-print-check
+++ b/tools/lab/wonderink-print-check
@@ -27,6 +27,9 @@ lab 'mkdir -p /opt/wonderink-check'
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
MEASURE=/opt/wonderink-check/wonderink-picture-measure.py
docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null
+SITE=/opt/wonderink-check/wonderink-lesson-site.py
+SITE_LOG=/opt/wonderink-check/site.log
+docker cp "$ROOT/tools/lab/wonderink-lesson-site.py" "$NAME:$SITE" >/dev/null
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
echo "could not put the rig in the lab" >&2
exit 1
@@ -70,9 +73,10 @@ mkdir -p /opt/wonderink-check/www
cupsctl PreserveJobFiles=Yes
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
-lab "test -f /opt/wonderink-check/www/ark-clear.png || \
- QT_QPA_PLATFORM=offscreen python3 $MEASURE make /opt/wonderink-check/www; \
- chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-clear.png" || {
+lab "test -f /opt/wonderink-check/www/ark-wide.webp || \
+ QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' \
+ python3 $MEASURE make /opt/wonderink-check/www; \
+ chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-wide.webp" || {
echo "could not make the test pictures" >&2
exit 1
}
@@ -82,6 +86,15 @@ lab "test -f /opt/wonderink-check/www/ark-clear.png || \
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
+# The signed-in lesson with pictures in it, restarted each run so that its
+# one-time addresses start unused.
+lab "pkill -f '[w]onderink-lesson-site.py' ; : > $SITE_LOG; chmod a+rw $SITE_LOG"
+docker exec -d "$NAME" python3 "$SITE" /opt/wonderink-check/www 8094 8095 "$SITE_LOG"
+lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/login && exit 0; sleep 0.3; done; exit 1' || {
+ echo "the lesson site did not start" >&2
+ exit 1
+}
+
passed=0
failed=0
@@ -112,6 +125,48 @@ count() { wc -w <<< "$jobs"; }
# The file CUPS was handed for a job, kept by PreserveJobFiles.
spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; }
said() { grep -q -- "$1" <<< "$out"; }
+# Every line of the rig's output that starts with WORD.
+lines() { grep -o -- "$1.*" <<< "$out"; }
+# Is every page of a PDF in the lab grey? {"pages", "spreads", "gray"}
+colour_of() { lab "python3 $MEASURE colour $1"; }
+is_gray() { python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1])["gray"] else 1)' "$1"; }
+# What the lesson site was asked for after the right-click.
+fetched_since() {
+ local since
+ since=$(grep -o 'since=[0-9.]*' <<< "$out" | head -1 | cut -d= -f2)
+ lab "python3 -c 'import json,sys; print(sum(1 for l in open(\"$SITE_LOG\") if json.loads(l)[\"t\"] > float(sys.argv[1])))' ${since:-0}"
+}
+teacher_text_kept() { said "CLIPBOARD \"TEACHER'S TEXT, John 3:16\""; }
+# picture layout, measured on the page CUPS kept: judges its own lines.
+judge_layout() { # FILE_DIMS SIZE POSITION PAPER FILL COLOUR
+ local dims=$1 size=$2 position=$3 paper=$4 fill=$5 color=$6 spool result
+ spool=$(spool_file "$jobs")
+ judge "CUPS was given exactly the page the preview showed" \
+ "[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
+ result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
+ field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
+ judge "the paper is turned the way asked ($paper): $(field page)" \
+ '[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
+ judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
+ '[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
+ judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
+ '[[ "$(field ink_outside_picture)" == 0 ]]'
+ if [[ "$fill" == fill ]]; then
+ # The mark is in the picture's left fifth, which filling a
+ # portrait page with a wide picture cuts off. That is what the
+ # control says it does; the fit scenarios prove it happens only
+ # when asked.
+ judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
+ elif [[ "$color" == gray ]]; then
+ judge "the corner mark is on the paper, in grey — nothing cut off" '[[ "$(field mark_inked)" == True ]]'
+ judge "and the page is grey, as the preview showed (colour spread $(field colour_spread))" \
+ '(( $(field colour_spread) == 0 ))'
+ judge "and it went in black and white, by the driver's own option" \
+ '[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]'
+ else
+ judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
+ fi
+}
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
# Where a picture goes, measured on the page CUPS was given:
@@ -125,12 +180,36 @@ PICTURES=(
picture:ark-wide.png:100:centre:portrait:fill
picture:ark-wide.jpg:75:right:auto:fit:gray
picture:ark-clear.png:50:top:auto:fit
+ picture:ark-wide.png:50:top-left:auto:fit:gray
+)
+
+# Black and white shown as it prints, and Color again (0.2.6.3).
+COLOURS=(bw-web bw-pdf bw-colour-again bw-cancel)
+
+# Right-click in a signed-in lesson (0.2.6.3):
+# rclick-image:ELEMENT:SIZE:POSITION[:gray], the elements in
+# wonderink-lesson-site.py.
+RIGHT_CLICKS=(
+ rclick-page
+ rclick-page-gray
+ rclick-image:private-jpg:50:top-left
+ rclick-image:private-png:100:centre
+ rclick-image:clear:50:top-left
+ rclick-image:once:100:centre
+ rclick-image:cross:75:right
+ rclick-image:blob:100:centre
+ rclick-image:webp:100:centre
+ rclick-image:canvas:100:centre
+ rclick-image:linked:33:bottom-right
+ rclick-image:private-jpg:50:top-left:gray
+ rclick-broken
+ rclick-cancel
)
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat
- dark-page "${PICTURES[@]}")
+ dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}")
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
@@ -204,31 +283,98 @@ for scenario in "${scenarios[@]}"; do
IFS=: read -r _ file size position paper fill color <<< "$scenario"
judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'"
judge "the print interface says Color" "said \"labels='Color','Color'\""
- judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
- spool=$(spool_file "$jobs")
- judge "CUPS was given exactly the page the preview showed" \
- "[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
- dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
- result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
- field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
- judge "the paper is turned the way asked ($paper): $(field page)" \
- '[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
- judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
- '[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
- judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
- '[[ "$(field ink_outside_picture)" == 0 ]]'
- if [[ "$fill" == fill ]]; then
- # The mark is in the picture's left fifth, which filling a
- # portrait page with a wide picture cuts off. That is what the
- # control says it does; the fit scenarios prove it happens only
- # when asked.
- judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
- else
- judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
- fi
if [[ "$color" == gray ]]; then
- judge "and it went in black and white" '[[ "$(job_device $jobs)" == *pxlmono* ]]'
+ judge "the preview shows it in black and white" "said 'shown_gray=True'"
+ fi
+ judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
+ dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
+ judge_layout "$dims" "$size" "$position" "$paper" "$fill" "$color" ;;
+ bw-*)
+ judge "the preview starts in colour" "said 'COLOUR FIRST shown_gray=False'"
+ if [[ "$scenario" != bw-colour-again ]]; then
+ judge "Black and white shows a grey page, every time it is chosen" \
+ '! lines "want=gray" | grep -qv "shown_gray=True" && lines "want=gray" | grep -q .'
+ fi
+ if lines "want=colour" | grep -q .; then
+ judge "Color shows the colour page again, every time" '! lines "want=colour" | grep -qv "shown_gray=False"'
+ fi
+ case "$scenario" in
+ bw-web)
+ judge "turned over and over, it stays on page 2 of 3" '! lines TOGGLE | grep -qv "page=2/3"'
+ judge "black and white is made once, and the same file shown each time" \
+ '[[ $(lines "want=gray" | grep -o "pdf=[^ ]*" | sort -u | wc -l) -eq 1 ]]'
+ judge "and shown again at once (after the first: $(lines 'want=gray' | tail -n +2 | grep -o 'took=[0-9.]*' | tr '\n' ' '))" \
+ '! lines "want=gray" | tail -n +2 | grep -o "took=[0-9.]*" | cut -d= -f2 | grep -qv "^0\.[0-4]"'
+ judge "one job" '[[ $(count) -eq 1 ]]'
+ judge "page 2 only, two copies" \
+ '[[ "$(job_options $jobs)" == *page-ranges=2* && "$(job_options $jobs)" == *"argv[4]=\"2\""* ]]'
+ judge "in black and white, by the driver's own option" \
+ '[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
+ bw-pdf)
+ judge "one job" '[[ $(count) -eq 1 ]]'
+ judge "in black and white, by the driver's own option" \
+ '[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
+ bw-colour-again)
+ judge "one job" '[[ $(count) -eq 1 ]]'
+ judge "in colour, with no grey option" \
+ '[[ "$(job_options $jobs)" != *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlcolor* ]]' ;;
+ bw-cancel)
+ judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
+ judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
+ esac
+ if [[ $(count) -eq 1 ]]; then
+ spool=$(spool_file "$jobs")
+ judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
+ answer=$(colour_of "$spool")
+ if [[ "$scenario" == bw-colour-again ]]; then
+ judge "and it is in colour ($answer)" '! is_gray "$answer"'
+ else
+ judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
+ fi
fi ;;
+ rclick-page)
+ judge "right-click on the words offers Print page, and not Print image" \
+ "said \"MENU .*'Print page…'\" && ! said \"'Print image…'\""
+ judge "which prints the lesson page, not a picture" "said 'PREVIEW picture=False'"
+ judge "one job" '[[ $(count) -eq 1 ]]'
+ judge "CUPS was given exactly the page the preview showed" \
+ "lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'"
+ judge "the teacher's clipboard is untouched" teacher_text_kept ;;
+ rclick-page-gray)
+ judge "a lesson page full of pictures turns grey in the preview ($(lines 'GRAY PAGE' | grep -o 'took=[0-9.]*'))" \
+ "said 'GRAY PAGE .*shown_gray=True'"
+ judge "one job" '[[ $(count) -eq 1 ]]'
+ spool=$(spool_file "$jobs")
+ judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
+ answer=$(colour_of "$spool")
+ judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
+ judge "in black and white, by the driver's own option" \
+ '[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
+ rclick-image:*)
+ IFS=: read -r _ element size position color <<< "$scenario"
+ judge "right-click on the picture offers Print image and Print page" \
+ "said \"MENU .*'Print image…', 'Print page…'\""
+ judge "Print image opens the picture layout" "said 'PICTURE picture_box=True'"
+ judge "named for the lesson it came from" "said \"title='Picture from Picture lesson'\""
+ judge "nothing was fetched again to get it ($(fetched_since) requests)" '[[ $(fetched_since) -eq 0 ]]'
+ judge "the teacher's clipboard is theirs again" teacher_text_kept
+ if [[ "$color" == gray ]]; then
+ judge "the preview shows it in black and white" "said 'shown_gray=True'"
+ fi
+ judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
+ dims=$(grep -o 'image=[0-9]*x[0-9]*' <<< "$out" | head -1 | cut -d= -f2)
+ judge_layout "$dims" "$size" "$position" auto fit "$color" ;;
+ rclick-broken)
+ judge "a picture that never loaded is still offered as a picture" "said \"MENU .*'Print image…'\""
+ judge "and the teacher is told it could not be taken, with Print off" \
+ "said \"BROKEN status='This picture could not be taken from the page\" && said 'print_enabled=False pages=0 picture=False'"
+ judge "the whole page is not printed instead" '[[ $(count) -eq 0 ]]'
+ judge "the teacher's clipboard is untouched" teacher_text_kept ;;
+ rclick-cancel)
+ judge "the picture preview came up" "said 'PREVIEW picture=True'"
+ judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
+ judge "the teacher's clipboard is theirs again" teacher_text_kept
+ judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
diff --git a/tools/lab/wonderink-print-rig.py b/tools/lab/wonderink-print-rig.py
index 1562f0d..089bfe7 100644
--- a/tools/lab/wonderink-print-rig.py
+++ b/tools/lab/wonderink-print-rig.py
@@ -59,7 +59,53 @@ def when(cond, then, label, limit=30):
def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None
def describe(d):
- return f"picture={d._picture_box.isVisible()} shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
+ return f"picture={d._picture_box.isVisible()} shown={(d._pdf, d._page) in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
+
+def shown_gray():
+ """Is the page the preview is drawing grey, pixel for pixel?"""
+ pixmap = preview()._view._pixmap
+ return None if pixmap is None else pixmap.toImage().allGray()
+
+def keep_preview():
+ import shutil
+ out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
+ shutil.copy(preview()._pdf, out / "preview.pdf")
+
+def toggles(sequence, then):
+ """Color (0) and Black and white (1) in turn, as a teacher would, each shown before the next."""
+ def step(i):
+ if i == len(sequence): return then()
+ start = time.monotonic(); preview()._colour.setCurrentIndex(sequence[i])
+ def shown():
+ d = preview()
+ stamp(f"TOGGLE {i} want={'gray' if sequence[i] else 'colour'} took={time.monotonic() - start:.2f} "
+ f"pdf={d._pdf.name} source={d._source.name} page={d._page}/{d._pages} shown_gray={shown_gray()}")
+ step(i + 1)
+ when(ready, shown, f"toggle {i}")
+ step(0)
+
+def colour_lesson(view, title="Rainbow lesson"):
+ # Backgrounds do not print, so the colour is in the words and the drawings.
+ shapes = ("")
+ view.setHtml(f"{'God put a rainbow in the sky. ' * 60}
" for i in range(4) + ) + "", QUrl("https://app.wonderink.org/rainbow")) + +def colour_worksheet(name="Colour the Rainbow"): + from PyQt6.QtGui import QColor + d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True) + p = d / f"{name}.pdf" + w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300) + pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28)) + for page, colours in enumerate((("#e00000", "#ff9900"), ("#00a000", "#0050e0"))): + if page: w.newPage() + pa.setPen(QColor(colours[0])); pa.drawText(200, 400, f"WORKSHEET {name} page {page + 1}") + pa.fillRect(200, 600, 1000, 800, QColor(colours[1])) + pa.end() + return p def finish(code=0): stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}") @@ -76,7 +122,8 @@ actions = { "cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"), "options": lambda view: when(ready, lambda: ( preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"), - preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"), + preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), + when(ready, press_print, "black and white")), "preview"), "bad-range": lambda view: when(ready, lambda: ( preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(), stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"), @@ -100,7 +147,120 @@ actions = { QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"), } -if scenario.startswith("picture:"): +SITE = "http://127.0.0.1:8094" + +if scenario.startswith("bw-"): + # bw-web: a colourful lesson, turned to page 2, Color and Black and white + # back and forth, then page 2 twice in black and white. + # bw-pdf: a colourful worksheet, all of it, in black and white. + # bw-colour-again: black and white, then back to Color, then Print. + # bw-cancel: black and white, then Cancel. + if scenario == "bw-pdf": + window.open_pdf(colour_worksheet()); view = window.current() + else: + view = window.open_tab(); colour_lesson(view) + def colour_first(): + stamp(f"COLOUR FIRST shown_gray={shown_gray()} pdf={preview()._pdf.name}") + if scenario == "bw-web": + preview()._turn(1) + toggles([1, 0, 1, 0, 1], lambda: ( + preview()._which.setCurrentIndex(1), preview()._range.setText("2"), + preview()._copies.setValue(2), keep_preview(), press_print())) + elif scenario == "bw-pdf": + toggles([1], lambda: (keep_preview(), press_print())) + elif scenario == "bw-colour-again": + toggles([1, 0], lambda: (keep_preview(), press_print())) + else: + toggles([1], lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish())) + begin = lambda *_: (QTimer.singleShot(0, lambda: window.print_view(view)), when(ready, colour_first, "preview")) + if scenario == "bw-pdf": + QTimer.singleShot(2500, begin) + else: + view.loadFinished.connect(begin) +elif scenario.startswith("rclick"): + # A lesson behind a sign-in, with pictures in it; a real right-click. + # rclick-page on the words: the page prints + # rclick-image:ID:SIZE:POSITION[:gray] on a picture: that picture prints + # rclick-broken a picture that never loaded + # rclick-cancel a picture, then Cancel + from PyQt6.QtCore import QEvent, QPointF, Qt + from PyQt6.QtGui import QMouseEvent + from PyQt6.QtWidgets import QApplication as Application + parts = scenario.split(":") + target = {"rclick-page": "words", "rclick-page-gray": "words", "rclick-broken": "broken", + "rclick-cancel": "private-jpg"}.get(parts[0], parts[1] if len(parts) > 1 else "") + clipboard = Application.clipboard() + view = window.open_tab(); view.load(QUrl(f"{SITE}/login")) + page_ready = [] + def poll_ready(): + if view.url().path() == "/lesson": + view.page().runJavaScript("window.__ready === true", lambda ok: page_ready.append(1) if ok else None) + if not page_ready: QTimer.singleShot(200, poll_ready) + QTimer.singleShot(500, poll_ready) + def right_click(then): + js = ("(function(){var e=document.getElementById(%r);e.scrollIntoView({block:'center'});" + "var r=e.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()" % target) + def at(xy): + zoom = view.zoomFactor(); x, y = xy[0] * zoom, xy[1] * zoom + clipboard.setText("TEACHER'S TEXT, John 3:16") + stamp(f"RCLICK {target} at {x:.0f},{y:.0f} since={time.time():.3f}") + for kind, buttons in ((QEvent.Type.MouseButtonPress, Qt.MouseButton.RightButton), + (QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)): + Application.sendEvent(view.focusProxy(), QMouseEvent( + kind, QPointF(x, y), QPointF(view.mapToGlobal(QPointF(x, y))), + Qt.MouseButton.RightButton, buttons, Qt.KeyboardModifier.NoModifier)) + when(lambda: Application.activePopupWidget() is not None, + lambda: then(Application.activePopupWidget()), "menu") + view.page().runJavaScript(js, at) + def choose(menu, text, after): + items = [a.text() for a in menu.actions() if a.text()] + stamp(f"MENU {items}") + action = next((a for a in menu.actions() if a.text() == text), None) + menu.close() + if action is None: + stamp(f"NO {text!r} IN THE MENU"); return finish(5) + after() + QTimer.singleShot(0, action.trigger) + def picture_ready(): + d = preview(); return d is not None and d._picture is not None and d._pdf is not None + def clipboard_line(): + stamp(f"CLIPBOARD {clipboard.text()!r}") + if parts[0] == "rclick-page": + go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, lambda: ( + clipboard_line(), keep_preview(), press_print()), "page preview"))) + elif parts[0] == "rclick-page-gray": + # The page with all its pictures, in black and white: the slow case. + def gray_page(): + start = time.monotonic(); preview()._colour.setCurrentIndex(1) + when(ready, lambda: ( + stamp(f"GRAY PAGE took={time.monotonic() - start:.1f} shown_gray={shown_gray()} pages={preview()._pages}"), + keep_preview(), press_print()), "black and white", limit=70) + go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, gray_page, "page preview"))) + elif parts[0] == "rclick-broken": + go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when( + lambda: preview() is not None and "could not be taken" in preview()._status.text(), lambda: ( + stamp(f"BROKEN status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()} " + f"pages={preview()._pages} picture={preview()._picture is not None}"), + clipboard_line(), preview()._cancel.click(), finish()), "the picture failing", limit=15))) + elif parts[0] == "rclick-cancel": + go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lambda: ( + stamp("PREVIEW " + describe(preview())), clipboard_line(), preview()._cancel.click(), finish()), + "picture preview"))) + else: + _, _, size, position, *colour = parts + def lay_out(): + d = preview() + d._size.setCurrentIndex(d._size.findData(int(size))) + d._place.choose(position.replace("-", " ")) + if colour == ["gray"]: + d._colour.setCurrentIndex(1) + QTimer.singleShot(900, lambda: when(ready, lambda: ( + stamp(f"PICTURE picture_box={d._picture_box.isVisible()} image={d._picture.width()}x{d._picture.height()} " + f"title={d._title!r} shown_gray={shown_gray()}"), + clipboard_line(), keep_preview(), press_print()), "layout")) + go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lay_out, "picture preview"))) + when(lambda: bool(page_ready), go, "the lesson and its pictures", limit=30) +elif scenario.startswith("picture:"): # picture:FILE:SIZE:POSITION:PAPER:FILL[:color] # Opens a picture on its own, chooses the layout with the preview's own # controls, keeps the exact PDF the preview showed, then prints it. @@ -117,14 +277,16 @@ if scenario.startswith("picture:"): d._fill.setChecked(fill == "fill") if rest and rest[0] == "gray": d._colour.setCurrentIndex(1) - QTimer.singleShot(900, keep_and_print) + # The layout settles (120 ms), then black and white if asked for. + QTimer.singleShot(900, lambda: when(ready, keep_and_print, "layout")) def keep_and_print(): import shutil d = preview() out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True) shutil.copy(d._pdf, out / "preview.pdf") stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} " - f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r}") + f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r} " + f"shown_gray={shown_gray()}") press_print() QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)), when(ready_picture, choose, "picture preview"))) @@ -154,7 +316,8 @@ elif scenario in ("pdf", "pdf-options"): if scenario == "pdf-options": actions["pdf-options"] = lambda view: when(ready, lambda: ( preview()._which.setCurrentIndex(1), preview()._range.setText("2"), - preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview") + preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), + when(ready, press_print, "black and white")), "preview") QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)), actions[scenario if scenario == "pdf-options" else "web"](view))) elif scenario == "pdf-viewer-button":