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 = """Picture lesson +

Noah's Ark

+

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"{title}" + "".join( + f"

{title}, part {i}

{shapes}" + 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":