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]>
205 lines
8.0 KiB
Bash
Executable File
205 lines
8.0 KiB
Bash
Executable File
#!/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
|