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
@@ -0,0 +1,211 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user