Files
Classroom_Desktop/lib/classroom_printing.py
T
ekklesiaandClaude Opus 5.5 10433b850d 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]>
2026-09-23 18:36:29 -05:00

212 lines
7.1 KiB
Python

"""Printing a PDF to the room's printer, and showing what will be printed.
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
Ink prints is a PDF by the time it gets here — a worksheet already is
one, and a lesson page is turned into one by the engine — so what is left
is the part the rest of the classroom already trusts: CUPS.
WHY NOT QPRINTER.
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
built from Elmer's own PPD with a file in place of the printer:
QWebEngineView.print() a lesson page arrived as 24.6 MB of
300 dpi pictures, no text left in it;
a worksheet PDF tab arrived as ONE BLANK
PAGE; a queue rejecting jobs was reported
as printed successfully; and on 0.2.2 it
crashed, because the printer was deleted
before Qt had finished with it.
printToPdf() + lp the same lesson was an 18 KB PDF with its
text, on Letter, through Elmer's filters
in 0.43 s; a rejected job is an error
with CUPS's own words in it.
The second is what Chrome does, and Chrome is what the rooms already
print from without trouble.
"""
import re
import subprocess
# lp hands the file to the scheduler and returns; it does not wait for
# the printer. A scheduler that has not answered in this long is not
# going to, and a teacher is standing at the dialog.
SUBMIT_TIMEOUT = 30
QUERY_TIMEOUT = 10
# What a worksheet can reasonably be asked for. A spin box that goes to
# 999 is one slip of a finger away from a ream of paper.
MAX_COPIES = 50
# Paper names a driver uses, in the terms Qt's page sizes are named by.
# Anything else is Letter, which is what the church's printer is.
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
# The driver's own words for "no colour", in the order they are tried.
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
# altogether — measured, it logs "Unknown option" and prints in colour —
# so black and white has to be asked for in the PPD's terms.
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
def _run(command, timeout=QUERY_TIMEOUT):
try:
return subprocess.run(
command, capture_output=True, text=True, timeout=timeout
)
except (OSError, subprocess.TimeoutExpired) as error:
return subprocess.CompletedProcess(command, 1, "", str(error))
def printers():
"""(names, default) for every printer CUPS will accept a job for.
lpstat -e is the list a teacher can print to: in a classroom that is
Elmer and nothing else, because discovery is switched off.
"""
names = [
line.strip()
for line in _run(["lpstat", "-e"]).stdout.splitlines()
if line.strip()
]
default = None
match = re.search(
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
)
if match and match.group(1) in names:
default = match.group(1)
elif names:
default = names[0]
return names, default
def options(printer):
"""What this printer's driver offers, parsed from lpoptions -l.
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
"""
found = {"paper": "Letter", "gray": None}
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
if ":" not in line:
continue
key, choices = line.split(":", 1)
key = key.split("/", 1)[0].strip()
choices = choices.split()
default = next((c[1:] for c in choices if c.startswith("*")), None)
plain = [c.lstrip("*") for c in choices]
if key == "PageSize" and default in PAPER:
found["paper"] = PAPER[default]
if key in ("ColorModel", "ColorMode", "OutputMode"):
gray = next((c for c in GRAY_CHOICES if c in plain), None)
if gray is not None and found["gray"] is None:
found["gray"] = (key, gray)
return found
def page_count(pdf):
"""How many pages the PDF has, or 0 if it cannot be read."""
result = _run(["pdfinfo", str(pdf)])
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
return int(match.group(1)) if match else 0
def render_page(pdf, number, width):
"""PNG bytes of one page, WIDTH pixels wide, or None."""
try:
result = subprocess.run(
[
"pdftoppm", "-f", str(number), "-l", str(number),
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
"-png", "-singlefile", str(pdf), "-",
],
capture_output=True,
timeout=QUERY_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0 or not result.stdout:
return None
return result.stdout
def parse_pages(text, count):
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
"" or "all" is every page (None). Otherwise numbers and ranges
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
document. Returned normalised: "1-3,5".
"""
text = (text or "").strip().lower()
if text in ("", "all"):
return None
parts = []
for piece in re.split(r"\s*,\s*", text):
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
if not match:
raise ValueError(
f"“{piece}” is not a page number. "
"Use numbers like 2 or 1-3, separated by commas."
)
first = int(match.group(1))
last = int(match.group(2) or first)
if first < 1 or last < first:
raise ValueError(f"“{piece}” is not a range of pages.")
if last > count:
raise ValueError(
f"This document has {count} page{'s' if count != 1 else ''}."
)
parts.append(str(first) if first == last else f"{first}-{last}")
return ",".join(parts)
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
Success means the scheduler has the job and its id — which is the
thing Qt's own print path could not say: it reported a rejected job
as printed.
"""
copies = max(1, min(int(copies), MAX_COPIES))
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
if pages:
command += ["-P", pages]
if gray:
command += ["-o", f"{gray[0]}={gray[1]}"]
command += ["--", str(pdf)]
result = _run(command, timeout=SUBMIT_TIMEOUT)
match = re.search(r"request id is (\S+)", result.stdout)
if result.returncode == 0 and match:
return True, match.group(1)
message = (result.stderr or result.stdout).strip()
# "lp: The printer or class is not accepting jobs." reads better
# without the program's name in front of it.
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
return False, message