The tab strip is drawn from the classroom's own palette, the one the panel, Parent Call and Presentations read: larger titles, an accent under the active tab, worksheets marked with a document icon. Its one corner widget is the lesson's size and Print — not a new-tab button. With one tab the strip stays hidden, as before; a change of size shows a moment's badge instead, and the right-click menu and keys still work. The window is named "Lesson — Wonder Ink" and carries its own icon. The print preview showed "Page 1 could not be shown" in the real session while printing correctly: poppler 25.03 does not write to stdout when asked for "-" — it writes a file called "-.png" into the current directory, which for Wonder Ink is the teacher's home. Pages are now rendered into a directory of their own, and the lab print check fails any preview that does not draw its page. The teacher's manual has lesson size and printing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
228 lines
7.8 KiB
Python
228 lines
7.8 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.
|
|
|
|
Rendered into a directory of its own and read back. Measured on
|
|
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
|
|
stdout — it writes a file called "-.png" (or "--1.png") into whatever
|
|
directory it was started in, which for Wonder Ink is the teacher's
|
|
home, and the preview showed nothing at all.
|
|
"""
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
|
|
root = Path(directory) / "page"
|
|
|
|
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), str(root),
|
|
],
|
|
capture_output=True,
|
|
timeout=QUERY_TIMEOUT,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
|
|
image = root.with_suffix(".png")
|
|
|
|
if result.returncode != 0 or not image.is_file():
|
|
return None
|
|
|
|
data = image.read_bytes()
|
|
return data or None
|
|
|
|
|
|
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
|