fix: Wonder Ink prints without crashing, and prints worksheets at all
Little Waders, 0.2.2: SIGSEGV in QPrinter::resolution() every time a teacher confirmed a print job. QWebEngineView.print() keeps the raw QPrinter pointer and returns; Qt's didPrintPage() reads printer->resolution() once Chromium has rendered the page, and by then PyQt had deleted the printer, which print_view() held only as a local. Reproduced in the lab with the 0.2.2 code, same backtrace, no job. Keeping the printer alive fixed the crash and was measured, and Qt's path was still not a print path: a worksheet PDF tab printed as one blank page, a lesson went as 24.6 MB of 300 dpi pictures with no text, and a queue refusing jobs was reported as printed. Print is now: make a PDF (a worksheet already is one; a lesson page is printToPdf'd onto the printer's own paper; an inline PDF is downloaded and opened as a worksheet), show it in a preview with printer, pages, copies and colour, and hand it to CUPS with lp — Chrome's route in the same rooms. Nothing asynchronous holds a printer. Black and white uses the driver's ColorModel=Grayscale, because Elmer's generic PCL 6 driver ignores print-color-mode. An exception escaping any slot no longer aborts Wonder Ink: PyQt6's default is to kill the process, and it is now logged instead. tests/test-classroom-printing.sh: 50 checks. tools/lab/wonderink-print- check: 39 checks over 13 scenarios in the lab, against a queue built from Elmer's PPD, judged by what CUPS received. Not yet on paper. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
690ef95aff
commit
10433b850d
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/bin/bash
|
||||
# Wonder Ink's printing: which printers, which options, what is sent.
|
||||
#
|
||||
# CUPS is not on the development host, so lpstat, lpoptions, lp and
|
||||
# pdfinfo are stand-ins that answer with the output the real ones gave in
|
||||
# the lab — Elmer's own PPD options included. Whether a job really goes
|
||||
# through Elmer's filters is tools/lab/wonderink-print-check's question.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/bin"
|
||||
|
||||
cat > "$WORK/bin/lpstat" <<EOF
|
||||
#!/bin/bash
|
||||
[[ -f "$WORK/cups-down" ]] && { echo "lpstat: Scheduler is not running." >&2; exit 1; }
|
||||
case "\$1" in
|
||||
-e) cat "$WORK/printers" 2>/dev/null ;;
|
||||
-d) cat "$WORK/default" 2>/dev/null || echo "no system default destination" ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
# lpoptions -p NAME -l, as the real one printed it for Elmer's PPD.
|
||||
cat > "$WORK/bin/lpoptions" <<EOF
|
||||
#!/bin/bash
|
||||
cat "$WORK/options-\$2" 2>/dev/null
|
||||
EOF
|
||||
|
||||
cat > "$WORK/bin/lp" <<EOF
|
||||
#!/bin/bash
|
||||
printf '%s\n' "\$@" > "$WORK/lp-args"
|
||||
if [[ -f "$WORK/lp-refuses" ]]; then
|
||||
echo 'lp: Destination "Elmer" is not accepting jobs.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "request id is Elmer-42 (1 file(s))"
|
||||
EOF
|
||||
|
||||
cat > "$WORK/bin/pdfinfo" <<'EOF'
|
||||
#!/bin/bash
|
||||
[[ "$(head -c 5 "$1" 2>/dev/null)" == "%PDF-" ]] || { echo "Syntax Error" >&2; exit 1; }
|
||||
echo "Producer: test"
|
||||
echo "Pages: $(grep -c '^page' "$1")"
|
||||
EOF
|
||||
chmod +x "$WORK"/bin/*
|
||||
|
||||
printf 'Elmer\n' > "$WORK/printers"
|
||||
echo "system default destination: Elmer" > "$WORK/default"
|
||||
cat > "$WORK/options-Elmer" <<'EOF'
|
||||
PrintoutMode/Print Quality: Draft Draft.Gray *Normal Normal.Gray High High.Gray
|
||||
PageSize/Page Size: Custom.WIDTHxHEIGHT *Letter A4 11x17 A3 A5 B5 Env10 EnvC5 EnvDL EnvISOB5 EnvMonarch Executive Legal
|
||||
Duplex/Double-Sided Printing: DuplexNoTumble DuplexTumble *None
|
||||
ColorModel/Color Mode: FromPrintoutMode *Color Grayscale
|
||||
PrinterResolution/Resolution: *FromPrintoutMode 300x300dpi 600x600dpi 1200x600dpi 1200x1200dpi
|
||||
EOF
|
||||
cat > "$WORK/options-Office" <<'EOF'
|
||||
PageSize/Media Size: Letter *A4 Legal
|
||||
EOF
|
||||
|
||||
PATH="$WORK/bin:$PATH" WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import classroom_printing as printing
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
work = Path(os.environ["WORK"])
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" ok {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {label}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
def lp_args():
|
||||
return (work / "lp-args").read_text().splitlines()
|
||||
|
||||
|
||||
print("1. which printers a teacher is offered")
|
||||
check("Elmer, and Elmer is the default", printing.printers() == (["Elmer"], "Elmer"))
|
||||
(work / "printers").write_text("Elmer\nOffice\n")
|
||||
(work / "default").write_text("system default destination: Office\n")
|
||||
check("the system default is preselected, whichever it is",
|
||||
printing.printers() == (["Elmer", "Office"], "Office"))
|
||||
(work / "default").write_text("no system default destination\n")
|
||||
check("with no default, the first printer",
|
||||
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||
(work / "default").write_text("system default destination: Gone\n")
|
||||
check("a default that is not in the list is not offered",
|
||||
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||
(work / "printers").write_text("")
|
||||
check("no printers at all is an empty list, not an error",
|
||||
printing.printers() == ([], None))
|
||||
(work / "cups-down").touch()
|
||||
check("and so is CUPS not running", printing.printers() == ([], None))
|
||||
(work / "cups-down").unlink()
|
||||
(work / "printers").write_text("Elmer\n")
|
||||
(work / "default").write_text("system default destination: Elmer\n")
|
||||
|
||||
print()
|
||||
print("2. what each printer's driver offers")
|
||||
elmer = printing.options("Elmer")
|
||||
check("Elmer's paper is Letter", elmer["paper"] == "Letter", elmer)
|
||||
# Measured: this driver ignores print-color-mode and prints in colour.
|
||||
check("black and white is asked for in the driver's own terms",
|
||||
elmer["gray"] == ("ColorModel", "Grayscale"), elmer)
|
||||
office = printing.options("Office")
|
||||
check("an A4 printer is A4", office["paper"] == "A4", office)
|
||||
check("a printer with no grey choice offers none", office["gray"] is None, office)
|
||||
unknown = printing.options("Nobody")
|
||||
check("a printer that says nothing is Letter, colour only",
|
||||
unknown == {"paper": "Letter", "gray": None}, unknown)
|
||||
|
||||
print()
|
||||
print("3. a teacher's page list")
|
||||
for text, expected in (("", None), ("all", None), (" ", None), ("2", "2"),
|
||||
("1-3", "1-3"), ("1, 3, 5-6", "1,3,5-6"), ("3-3", "3"),
|
||||
("1 - 2", "1-2"), ("6", "6")):
|
||||
try:
|
||||
got = printing.parse_pages(text, 6)
|
||||
except ValueError as error:
|
||||
got = f"refused: {error}"
|
||||
check(f"{text!r} is {expected!r}", got == expected, got)
|
||||
|
||||
for text in ("7", "0", "3-1", "1-9", "one", "1;lp -d x", "-2", "1--2", "2,", "1.5"):
|
||||
try:
|
||||
printing.parse_pages(text, 6)
|
||||
check(f"{text!r} is refused", False)
|
||||
except ValueError as error:
|
||||
check(f"{text!r} is refused, and says why: {error}", bool(str(error)))
|
||||
|
||||
print()
|
||||
print("4. what is handed to CUPS")
|
||||
pdf = work / "Colour the Ark.pdf"
|
||||
pdf.write_text("%PDF-1.4\npage\npage\n")
|
||||
check("a PDF with two pages has two", printing.page_count(pdf) == 2)
|
||||
check("something that is not a PDF has none",
|
||||
printing.page_count(work / "printers") == 0)
|
||||
|
||||
ok, answer = printing.submit(pdf, "Elmer")
|
||||
args = lp_args()
|
||||
check("a job is accepted, with its id", (ok, answer) == (True, "Elmer-42"), answer)
|
||||
check("to Elmer, one copy, named for the lesson",
|
||||
args[:6] == ["-d", "Elmer", "-n", "1", "-t", "Wonder Ink"], args)
|
||||
check("the file comes after --, so no name can be read as an option",
|
||||
args[-2:] == ["--", str(pdf)], args)
|
||||
check("and nothing else is asked for", "-P" not in args and "-o" not in args, args)
|
||||
|
||||
printing.submit(pdf, "Elmer", copies=3, pages="2-3", gray=("ColorModel", "Grayscale"),
|
||||
title="Noah")
|
||||
args = lp_args()
|
||||
check("copies, pages and black and white arrive as lp options",
|
||||
args[args.index("-n") + 1] == "3"
|
||||
and args[args.index("-P") + 1] == "2-3"
|
||||
and args[args.index("-o") + 1] == "ColorModel=Grayscale"
|
||||
and args[args.index("-t") + 1] == "Noah", args)
|
||||
|
||||
printing.submit(pdf, "Elmer", copies=500)
|
||||
check(f"copies are capped at {printing.MAX_COPIES}",
|
||||
lp_args()[lp_args().index("-n") + 1] == str(printing.MAX_COPIES))
|
||||
printing.submit(pdf, "Elmer", copies=0)
|
||||
check("and never fewer than one", lp_args()[lp_args().index("-n") + 1] == "1")
|
||||
|
||||
(work / "lp-refuses").touch()
|
||||
ok, answer = printing.submit(pdf, "Elmer")
|
||||
check("a refused job is a failure, in CUPS's own words",
|
||||
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
|
||||
|
||||
print()
|
||||
print("5. Wonder Ink prints through this, and only this")
|
||||
ink = (root / "scripts/classroom-wonderink").read_text()
|
||||
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
|
||||
# The 0.2.2 crash, and the blank worksheet, both came from Qt's print path.
|
||||
check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
|
||||
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
|
||||
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
|
||||
check("which is given up on if the engine never answers",
|
||||
"PREPARE_TIMEOUT_MS" in code and "self._timer.start(PREPARE_TIMEOUT_MS)" in code)
|
||||
check("the preview submits with classroom_printing.submit",
|
||||
"classroom_printing.submit(" in code)
|
||||
check("there is no file chooser in it", "QFileDialog" not in code)
|
||||
check("a worksheet tab is printed from its file",
|
||||
"self._pdf_views[view] = resolved" in code)
|
||||
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
|
||||
check("an exception in a handler is logged, not fatal",
|
||||
"sys.excepthook = keep_going" in code)
|
||||
check("the module is packaged",
|
||||
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -342,8 +342,11 @@ check("local documents may not read other local documents",
|
||||
"LocalContentCanAccessFileUrls, False" in wonderink)
|
||||
check("nor reach the network",
|
||||
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
||||
check("there is no file chooser behind the print dialog",
|
||||
"PrintDialogOption.PrintToFile, False" in wonderink)
|
||||
# Qt's print dialog has a Print to File path with a "..." chooser behind
|
||||
# it. Wonder Ink no longer uses that dialog: its preview offers CUPS
|
||||
# printers and nothing else, and no file dialog exists anywhere in it.
|
||||
check("there is no file chooser behind printing",
|
||||
"QFileDialog" not in wonderink and "QPrintDialog" not in wonderink)
|
||||
check("nothing downloaded is executed",
|
||||
"subprocess" not in wonderink.split("def notify")[0]
|
||||
and "QDesktopServices" not in wonderink
|
||||
@@ -374,8 +377,12 @@ check("the Present-view autoplay fix is still there",
|
||||
"PlaybackRequiresUserGesture, False" in wonderink)
|
||||
check("fullscreen still returns to a maximised window",
|
||||
"WindowFullScreen" in wonderink)
|
||||
# One text box exists, and it is the print preview's page range. Anywhere
|
||||
# else a text box in this window is an address bar.
|
||||
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
|
||||
check("still no new-tab button or address bar",
|
||||
"setCornerWidget" not in wonderink and "QLineEdit" not in wonderink)
|
||||
"setCornerWidget" not in wonderink
|
||||
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
|
||||
|
||||
print()
|
||||
print("7b. the disc player is decided before a session starts")
|
||||
|
||||
Reference in New Issue
Block a user