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:
ekklesia
2026-09-23 18:36:29 -05:00
co-authored by Claude Opus 5.5
parent 690ef95aff
commit 10433b850d
8 changed files with 1356 additions and 32 deletions
+80 -3
View File
@@ -140,9 +140,8 @@ Alongside it, two Qt defaults are turned off rather than left implicit:
`LocalContentCanAccessFileUrls` (on by default, and would let a local
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
The print dialog's **Print to File** option is also removed. Choosing it
enables an output path with a "..." button, which is a file chooser, in a
room whose whole design is that there is no file chooser.
Printing has no **Print to File** and no file chooser of any kind: the
preview (below) offers the room's CUPS printers and nothing else.
### What a teacher still cannot reach
@@ -182,6 +181,84 @@ is Chrome's business and stays that way.
Downloads folder. That is partly tidiness and mostly the security
argument: it is the only place a worksheet can come from.
## Printing
### The crash (0.2.2, Little Waders)
Wonder Ink closed completely every time a teacher confirmed a print job:
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
print-completion path.
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
Chromium has rendered the page, `didPrintPage()` calls
`currentPrinter->resolution()` and hands the printer to a worker thread.
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
it when the function returned and Qt read freed memory. Reproduced in the
lab with the 0.2.2 code: `QPrinter::resolution()` ←
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
the Little Waders backtrace, and no job in CUPS.
Keeping the printer alive until `printFinished` stops the crash — that was
built and measured first — and it is still not a working print path:
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
| --- | --- | --- |
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
Qt's path also mishandles three things its own source makes plain: a
second `print()` on a page that is still printing is refused with no
`printFinished`, the worker keeps the printer after the tab has gone, and
quitting destroys the printer under that worker.
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
worksheet already is one; a lesson page is rendered by the engine onto the
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
break a job: once `lp` returns, the job is CUPS's.
### The preview
One dialog, for lessons and worksheets alike:
* the document as it will print, a whole page at a time, ‹ › to turn;
* **Printer** — the CUPS destinations, the system default preselected
(Elmer, in every room);
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
document with the reason given if wrong;
* **Copies** — 1 to 50;
* **Colour** — colour or black and white, only where the printer's driver
has a grey mode, and asked for in that driver's own terms;
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
the job, and a notification says where it went. A refused job keeps the
preview open with CUPS's reason.
Choosing a printer with different paper lays a lesson page out again. A
page that closes its own tab while the preview is open is reported, not
printed. Print pressed again while a preview is open does nothing, and so
does a page calling `window.print()` meanwhile. The PDF viewer's own print
button opens the same preview.
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
when the preview closes.
`tools/lab/wonderink-print-check` runs all of this against the installed
command in the lab, and judges every scenario by what CUPS actually
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
build. **Neither is paper.** The first print through the real Elmer is a
job for somebody standing next to it.
### Any other error
An exception escaping any Qt handler makes PyQt6 abort the whole process —
measured: a mistake in one slot and the window vanishes. Wonder Ink now
logs it to the journal and carries on.
## What stayed the same
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
+211
View File
@@ -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
+553 -26
View File
@@ -120,14 +120,24 @@ if WIDEVINE.exists():
f"{flags} --widevine-path={WIDEVINE}".strip()
)
from PyQt6.QtCore import QEvent, Qt, QUrl # noqa: E402
from PyQt6.QtGui import QAction, QGuiApplication, QKeySequence # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtPrintSupport import ( # noqa: E402
QAbstractPrintDialog,
QPrintDialog,
QPrinter,
import classroom_printing # noqa: E402
from PyQt6.QtCore import ( # noqa: E402
QEvent,
QMarginsF,
Qt,
QTimer,
QUrl,
pyqtSignal,
)
from PyQt6.QtGui import ( # noqa: E402
QAction,
QGuiApplication,
QKeySequence,
QPageLayout,
QPageSize,
QPixmap,
)
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWebEngineCore import ( # noqa: E402
QWebEngineDownloadRequest,
QWebEnginePage,
@@ -137,9 +147,20 @@ from PyQt6.QtWebEngineCore import ( # noqa: E402
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QTabWidget,
QVBoxLayout,
QWidget,
)
# One running Wonder Ink per session. A second QtWebEngine process on the
@@ -147,6 +168,14 @@ from PyQt6.QtWidgets import ( # noqa: E402
# the panel gives a teacher a button that is easy to press twice.
SOCKET = "ekklesia-wonderink"
# Chrome's default margins, near enough, so a lesson prints as it would
# have from the ordinary browser.
PRINT_MARGIN_MM = 10
# How long a lesson page has to become a PDF before the preview gives up
# and says so. Measured: a long lesson page takes well under a second.
PREPARE_TIMEOUT_MS = 20_000
# How much of a page title fits on a tab before it stops being a label
# and starts being a paragraph.
TAB_TITLE = 22
@@ -280,6 +309,13 @@ class WonderInk(QMainWindow):
# from the file, because a PDF has no document title to take one
# from, and their downloads are not re-opened as further tabs.
self._pdf_views = {}
# Printing: see print_view. The preview on screen, if any; pages
# whose inline PDF is being fetched so that it can be printed; and
# what each tab was last answered with, which is how a PDF shown
# inline is told from a page.
self._preview = None
self._print_when_downloaded = set()
self._mime = {}
self.setWindowTitle("Wonder Ink")
@@ -313,6 +349,15 @@ class WonderInk(QMainWindow):
# concludes the printer is broken. Bound to the page that asked
# rather than to whichever tab is in front.
view.page().printRequested.connect(lambda v=view: self.print_view(v))
# A window.print() from inside a frame: Qt 6.8 reports it apart
# from the main frame's, and it is still this tab being printed.
if hasattr(view.page(), "printRequestedByFrame"):
view.page().printRequestedByFrame.connect(
lambda _frame, v=view: self.print_view(v)
)
view.page().loadingChanged.connect(
lambda info, v=view: self._loading_changed(v, info)
)
index = self._tabs.addTab(view, "…")
@@ -340,6 +385,7 @@ class WonderInk(QMainWindow):
self._tabs.removeTab(index)
self._pdf_views.pop(view, None)
self._mime.pop(view, None)
view.page().deleteLater()
view.deleteLater()
self._show_tab_bar()
@@ -361,7 +407,8 @@ class WonderInk(QMainWindow):
# viewer leaves document.title empty, so left alone every PDF tab
# would be called "Wonder Ink" and two of them would be
# indistinguishable.
title = self._pdf_views.get(view) or (title or "Wonder Ink")
pdf = self._pdf_views.get(view)
title = pdf.stem if pdf is not None else (title or "Wonder Ink")
title = title.strip()
short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…"
self._tabs.setTabText(index, short)
@@ -472,6 +519,10 @@ class WonderInk(QMainWindow):
from_worksheet = download.page() in [
view.page() for view in self._pdf_views
]
# Fetched by print_view, for a PDF the site showed inline.
url = download.url().toString()
print_after = url in self._print_when_downloaded
self._print_when_downloaded.discard(url)
# stateChanged, not isFinishedChanged.
#
@@ -507,6 +558,8 @@ class WonderInk(QMainWindow):
path = DOWNLOADS / name
if not from_worksheet and self.open_worksheet(path, mime):
if print_after and self.current() in self._pdf_views:
QTimer.singleShot(0, self.print_current)
return
notify("Saved", f"{name} is in {DOWNLOADS}")
@@ -550,40 +603,128 @@ class WonderInk(QMainWindow):
self._openable.add(resolved)
view = self.open_tab()
self._pdf_views[view] = path.stem
# The file itself, not only its name: a worksheet is printed from
# the PDF on disk, because the engine cannot print its own viewer.
self._pdf_views[view] = resolved
self._retitle(view, path.stem)
view.load(QUrl.fromLocalFile(str(resolved)))
return view
# ---- printing -------------------------------------------------------
# WHAT PRINT DOES, AND WHY IT IS NOT QT'S PRINT DIALOG ANY MORE.
#
# Little Waders, 0.2.2: Wonder Ink disappeared every time a teacher
# pressed Print in the dialog, SIGSEGV in QPrinter::resolution().
# QWebEngineView.print() is asynchronous: it keeps the raw QPrinter
# pointer and returns at once, and when Chromium has rendered the page
# Qt's didPrintPage() reads printer->resolution() and hands the
# printer to a worker thread. The printer was a local variable here,
# so PyQt had deleted it by then. Reproduced in the lab with the 0.2.2
# code — the same backtrace, frame for frame — and no job reached CUPS.
#
# Keeping the printer alive fixed the crash, and was measured, and was
# still the wrong answer: that same path sent a worksheet tab as one
# blank page, sent a lesson as 24 MB of pictures, and called a job
# the printer had refused a success. See lib/classroom_printing.py.
#
# So Print now makes a PDF — a worksheet already is one; a lesson page
# is rendered by the engine onto the printer's own paper — shows the
# teacher that PDF, and hands it to CUPS with lp, which is how Chrome
# prints in the same room. Nothing asynchronous holds a printer, so
# closing a tab or Wonder Ink afterwards has nothing left to break.
def print_current(self):
self.print_view(self.current())
def print_view(self, view):
if view is None:
if view is None or self._preview is not None:
return
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
dialog = QPrintDialog(printer, self)
title = self._tabs.tabToolTip(self._tabs.indexOf(view)) or "Wonder Ink"
pdf = self._pdf_views.get(view)
# No "Print to File".
#
# Qt offers it in every print dialog, and choosing it enables an
# output path with a "..." button beside it — which is a file
# chooser, in a room whose whole design is that there is no file
# chooser. Nothing in a classroom wants a worksheet written to a
# PDF nobody can then open; the room has a printer, and that is
# what this dialog is for.
dialog.setOption(
QAbstractPrintDialog.PrintDialogOption.PrintToFile, False
if pdf is None and self._shows_pdf(view):
# A PDF the site showed inline rather than sending as a
# download. Measured: the engine cannot render the PDF viewer
# for printing at all — printToPdf never answers — so it is
# fetched as the file it is, opened as a worksheet like any
# other, and printed from there.
self._print_when_downloaded.add(view.url().toString())
view.page().download(view.url())
return
dialog = PrintPreview(self, title)
self._preview = dialog
if pdf is not None:
dialog.show_document(pdf)
else:
dialog.paper_wanted.connect(
lambda paper, v=view, d=dialog: self._render(v, d, paper)
)
self._render(view, dialog, dialog.paper())
# A page may close its own tab while the teacher is looking at the
# preview. The preview says so rather than printing nothing.
watch = view.destroyed.connect(
lambda *_: dialog.fail("The page was closed.")
)
if dialog.exec() != QPrintDialog.DialogCode.Accepted:
try:
dialog.exec()
finally:
try:
view.destroyed.disconnect(watch)
except (RuntimeError, TypeError):
pass
dialog.finish()
dialog.deleteLater()
self._preview = None
def _render(self, view, dialog, paper):
"""Ask the engine for this page as a PDF on PAPER, for DIALOG."""
attempt = dialog.preparing()
size = QPageSize(getattr(QPageSize.PageSizeId, paper))
layout = QPageLayout(
size, QPageLayout.Orientation.Portrait,
QMarginsF(*(PRINT_MARGIN_MM,) * 4), QPageLayout.Unit.Millimeter,
)
# The callback may arrive after the dialog has gone, or never:
# attempt is a plain Python object, so asking it is always safe,
# and the dialog's own timer gives up on an answer that does not
# come.
def rendered(data):
if attempt.current():
dialog.show_rendered(bytes(data))
view.page().printToPdf(rendered, layout)
def _shows_pdf(self, view):
"""Is this tab the engine's PDF viewer showing a remote PDF?"""
if self._mime.get(view) == "application/pdf":
return True
return view.url().path().lower().endswith(".pdf")
def _loading_changed(self, view, info):
if info.status() != info.LoadStatus.LoadSucceededStatus:
return
# Qt's own dialog talks to CUPS, so a lesson prints to the room's
# queue the same way everything else in the classroom does.
view.print(printer)
# responseHeaders() is Qt 6.6 and later; Debian 13 has 6.8. PyQt
# gives it as a dict of lists — it is a multi-map in C++.
headers = getattr(info, "responseHeaders", dict)()
mime = ""
for key, values in headers.items():
if key.data().decode("latin-1").lower() != "content-type":
continue
for value in values if isinstance(values, list) else [values]:
mime = value.data().decode("latin-1").split(";")[0].strip().lower()
self._mime[view] = mime
# ---- full screen ----------------------------------------------------
@@ -633,6 +774,375 @@ class WonderInk(QMainWindow):
self._show_tab_bar()
class _Attempt:
"""One request for a PDF. Stale once another is made or the dialog ends.
A plain Python object on purpose: the engine's callback can arrive
after the dialog has been deleted, and asking this is always safe
where asking a deleted Qt object is not.
"""
def __init__(self):
self.live = True
def current(self):
return self.live
class PageView(QWidget):
"""One page of the document, whole, as it will come out of the printer."""
def __init__(self, parent=None):
super().__init__(parent)
self._pixmap = None
self._message = ""
self.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.setMinimumSize(320, 400)
def show_pixmap(self, pixmap):
self._pixmap, self._message = pixmap, ""
self.update()
def show_message(self, message):
self._pixmap, self._message = None, message
self.update()
def paintEvent(self, event):
from PyQt6.QtGui import QColor, QPainter
painter = QPainter(self)
painter.fillRect(self.rect(), self.palette().window().color().darker(115))
if self._pixmap is None:
painter.setPen(self.palette().windowText().color())
painter.drawText(
self.rect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap,
self._message,
)
return
# The page's own shape, as large as fits. Never stretched: a
# preview that changes the proportions is not showing the print.
area = self.rect().adjusted(16, 16, -16, -16)
scaled = self._pixmap.size().scaled(
area.size(), Qt.AspectRatioMode.KeepAspectRatio
)
x = area.x() + (area.width() - scaled.width()) // 2
y = area.y() + (area.height() - scaled.height()) // 2
painter.fillRect(x + 3, y + 3, scaled.width(), scaled.height(), QColor(0, 0, 0, 60))
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(x, y, scaled.width(), scaled.height(), self._pixmap)
class PrintPreview(QDialog):
"""The document as it will print, and the few choices a teacher has.
Printer, which pages, how many copies, and colour or black and white
where the printer's driver can do both. No paper tray, no scaling, no
"print to file" — there is no file chooser anywhere in this room, and
a print dialog is the usual way one gets in.
"""
# A lesson page is laid out for a paper size; choosing a printer with
# different paper asks for it again.
paper_wanted = pyqtSignal(str)
# Wide enough to stay sharp on a television at twice scale.
RENDER_WIDTH = 1200
def __init__(self, window, title):
super().__init__(window)
self.setWindowTitle(f"Print — {title}")
self.setModal(True)
self._title = title
self._pdf = None
self._scratch = None
self._pages = 0
self._page = 1
self._images = {}
self._attempt = None
self._paper = None
self._options = {}
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(
lambda: self.fail("The page took too long to prepare. Close this and try again.")
)
# -- the page ------------------------------------------------------
self._view = PageView(self)
self._previous = QPushButton("‹", self)
self._next = QPushButton("›", self)
self._where = QLabel(self)
self._where.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._previous.clicked.connect(lambda: self._turn(-1))
self._next.clicked.connect(lambda: self._turn(1))
pager = QHBoxLayout()
pager.addStretch(1)
pager.addWidget(self._previous)
pager.addWidget(self._where)
pager.addWidget(self._next)
pager.addStretch(1)
left = QVBoxLayout()
left.addWidget(self._view, 1)
left.addLayout(pager)
# -- the choices ---------------------------------------------------
self._printer = QComboBox(self)
self._which = QComboBox(self)
self._which.addItems(["All pages", "Only some pages"])
self._range = QLineEdit(self)
self._range.setPlaceholderText("for example 1-3, 5")
self._range.setVisible(False)
self._which.currentIndexChanged.connect(self._which_changed)
self._copies = QSpinBox(self)
self._copies.setRange(1, classroom_printing.MAX_COPIES)
self._colour = QComboBox(self)
self._colour.addItems(["Colour", "Black and white"])
self._colour_label = QLabel("Colour", self)
form = QFormLayout()
form.addRow("Printer", self._printer)
form.addRow("Pages", self._which)
form.addRow("", self._range)
form.addRow("Copies", self._copies)
form.addRow(self._colour_label, self._colour)
self._status = QLabel(self)
self._status.setWordWrap(True)
self._cancel = QPushButton("Cancel", self)
self._print = QPushButton("Print", self)
self._print.setDefault(True)
self._cancel.clicked.connect(self.reject)
self._print.clicked.connect(self._submit)
buttons = QHBoxLayout()
buttons.addStretch(1)
buttons.addWidget(self._cancel)
buttons.addWidget(self._print)
right = QVBoxLayout()
right.addLayout(form)
right.addStretch(1)
right.addWidget(self._status)
right.addLayout(buttons)
side = QWidget(self)
side.setLayout(right)
side.setMinimumWidth(300)
side.setMaximumWidth(420)
layout = QHBoxLayout(self)
layout.addLayout(left, 1)
layout.addWidget(side)
names, default = classroom_printing.printers()
self._printer.addItems(names)
if default:
self._printer.setCurrentText(default)
self._printer.currentTextChanged.connect(self._printer_changed)
self._printer_changed(self._printer.currentText(), initial=True)
screen = window.screen().availableGeometry() if window.screen() else None
if screen is not None:
self.resize(int(screen.width() * 0.8), int(screen.height() * 0.85))
self._show_page()
if not names:
self.fail("No printer is set up on this computer. Ask for help.")
# -- what the window tells it -----------------------------------------
def paper(self):
return self._paper or "Letter"
def preparing(self):
"""A new PDF is on its way; anything older is stale."""
if self._attempt is not None:
self._attempt.live = False
self._attempt = _Attempt()
self._clear()
self._say("Preparing the preview…")
self._view.show_message("Preparing the preview…")
self._timer.start(PREPARE_TIMEOUT_MS)
return self._attempt
def show_rendered(self, data):
"""The engine's PDF of a lesson page, as bytes."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
if not data.startswith(PDF_MAGIC):
self.fail("This page could not be prepared for printing.")
return
import tempfile
if self._scratch is None:
# The teacher's own runtime directory: private to them, in
# memory, and gone at logout even if this is never cleaned.
self._scratch = tempfile.mkdtemp(
prefix="wonderink-print-", dir=os.environ.get("XDG_RUNTIME_DIR")
)
path = Path(self._scratch) / f"page-{id(data)}.pdf"
path.write_bytes(data)
self.show_document(path)
def show_document(self, pdf):
self._pdf = Path(pdf)
self._pages = classroom_printing.page_count(self._pdf)
if self._pages < 1:
self.fail("This document could not be read.")
return
self._page = 1
self._images = {}
self._say("")
self._print.setEnabled(bool(self._printer.count()))
self._show_page()
def fail(self, message):
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._clear()
self._view.show_message(message)
self._say(message, error=True)
def finish(self):
"""Everything this preview made, gone."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
if self._scratch is not None:
import shutil
shutil.rmtree(self._scratch, ignore_errors=True)
self._scratch = None
# -- its own behaviour ------------------------------------------------
def _clear(self):
self._pdf = None
self._pages = 0
self._images = {}
self._print.setEnabled(False)
self._show_page()
def _say(self, message, error=False):
self._status.setText(message)
self._status.setStyleSheet("color: #d9534f;" if error else "")
def _which_changed(self, index):
self._range.setVisible(index == 1)
if index == 1:
self._range.setFocus()
def _printer_changed(self, name, initial=False):
if not name:
return
if name not in self._options:
self._options[name] = classroom_printing.options(name)
found = self._options[name]
self._colour.setVisible(found["gray"] is not None)
self._colour_label.setVisible(found["gray"] is not None)
if found["paper"] != self._paper:
self._paper = found["paper"]
if not initial:
self.paper_wanted.emit(self._paper)
def _turn(self, step):
self._page = max(1, min(self._pages, self._page + step))
self._show_page()
def _show_page(self):
showing = self._pdf is not None and self._pages > 0
self._previous.setEnabled(showing and self._page > 1)
self._next.setEnabled(showing and self._page < self._pages)
self._where.setText(f"Page {self._page} of {self._pages}" if showing else "")
if not showing:
return
if self._page not in self._images:
data = classroom_printing.render_page(self._pdf, self._page, self.RENDER_WIDTH)
pixmap = QPixmap()
if data is None or not pixmap.loadFromData(data):
self._view.show_message(f"Page {self._page} could not be shown.")
return
self._images[self._page] = pixmap
self._view.show_pixmap(self._images[self._page])
def _submit(self):
if self._pdf is None:
return
try:
pages = classroom_printing.parse_pages(
self._range.text() if self._which.currentIndex() == 1 else "",
self._pages,
)
except ValueError as error:
self._say(str(error), error=True)
self._range.setFocus()
return
printer = self._printer.currentText()
gray = None
if self._colour.isVisible() and self._colour.currentIndex() == 1:
gray = self._options[printer]["gray"]
self._say(f"Sending to {printer}…")
self._print.setEnabled(False)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
ok, answer = classroom_printing.submit(
self._pdf, printer, self._copies.value(), pages, gray, self._title
)
finally:
QApplication.restoreOverrideCursor()
if not ok:
say(f"print to {printer} refused: {answer}")
self._say(f"{printer} did not take the job: {answer}", error=True)
self._print.setEnabled(True)
return
say(f"printed {self._title!r} to {printer} as {answer}")
notify("Sent to the printer", f"{self._title} — {printer}")
self.accept()
def notify(summary, body):
import subprocess
@@ -676,6 +1186,21 @@ def raise_running_instance():
return True
def keep_going(kind, value, trace):
"""An error in Wonder Ink's own code is logged, and the lesson stays.
PyQt6's answer to an exception escaping any slot — a download
finishing, a title changing, a button in the print preview — is to
abort the whole process, measured in the lab: one mistake in one
handler and the window a teacher is presenting from vanishes. The
journal is the right place for that mistake, not the classroom.
"""
import traceback
say("unexpected error, carrying on:")
traceback.print_exception(kind, value, trace, file=sys.stderr)
def main():
config = read_device_config()
@@ -693,6 +1218,8 @@ def main():
if raise_running_instance():
return 0
sys.excepthook = keep_going
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
app = QApplication(sys.argv)
app.setApplicationName("Wonder Ink")
+204
View File
@@ -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
+10 -3
View File
@@ -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")
+5
View File
@@ -383,6 +383,11 @@ install -Dm755 \
"$ROOT/scripts/classroom-wonderink" \
"$STAGE/usr/local/bin/classroom-wonderink"
# Wonder Ink's printing: a PDF, shown, then handed to CUPS with lp.
install -Dm644 \
"$ROOT/lib/classroom_printing.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py"
# PRESENTATIONS.
#
# The library a teacher sees, and the watcher that fills it from their
+152
View File
@@ -0,0 +1,152 @@
#!/bin/bash
# Does Wonder Ink print — and does a job really reach CUPS?
#
# The lab's CUPS gets an "Elmer" built from Elmer's own PPD and options,
# writing to a file instead of socket://10.10.0.155:9100, so every job
# goes through the same filters the real printer's jobs do and none can
# reach a printer. A second queue refuses jobs, to show a teacher is told.
#
# Each scenario runs the INSTALLED classroom-wonderink under a real
# QtWebEngine (offscreen), presses the preview's buttons, and is then
# judged by CUPS's own record of what it was given.
#
# tools/lab/wonderink-print-check [SCENARIO...]
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
lab() { docker exec -i "$NAME" bash -c "$1"; }
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:/tmp/wonderink-print-rig.py" >/dev/null
lab 'chmod a+r /tmp/wonderink-print-rig.py'
# The church's printer network is fenced off inside the lab. Checked, not
# assumed: a lab job must never be able to reach the real Elmer.
lab 'ip route replace blackhole 10.10.0.0/16 2>/dev/null
! timeout 3 bash -c "exec 3<>/dev/tcp/10.10.0.155/9100" 2>/dev/null' || {
echo "REFUSING TO CONTINUE: the real Elmer is reachable from the lab" >&2
exit 1
}
lab '
set -e
export DEBIAN_FRONTEND=noninteractive
command -v lpadmin >/dev/null && command -v pdftoppm >/dev/null || \
apt-get install -y -qq cups cups-client poppler-utils >/dev/null 2>&1
dpkg -s foomatic-db-compressed-ppds >/dev/null 2>&1 || \
apt-get install -y -qq foomatic-db-compressed-ppds cups-filters ghostscript >/dev/null 2>&1
grep -q "^FileDevice Yes" /etc/cups/cups-files.conf || echo "FileDevice Yes" >> /etc/cups/cups-files.conf
pkill -x cupsd || true; sleep 1; /usr/sbin/cupsd; sleep 1
cupsctl --debug-logging
: > /var/log/cups/error_log
mkdir -p /var/lab-print && chown lp /var/lab-print
driver="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd"
for q in $(lpstat -e 2>/dev/null); do lpadmin -x "$q"; done
lpadmin -p Elmer -E -v file:///var/lab-print/elmer.pxl -m "$driver" \
-D "LAB STAND-IN for Elmer: its PPD, a file for a printer" \
-o PrintoutMode=Normal -o ColorModel=Color 2>/dev/null
lpadmin -d Elmer
lpadmin -p Refusing -E -v file:///dev/null -m "$driver" -D "LAB: refuses jobs" 2>/dev/null
cupsreject Refusing
id teacher >/dev/null 2>&1 || useradd -m teacher
install -d -o teacher -m 700 /tmp/teacher-rt
# An inline PDF, served the way a site shows one rather than sends it.
mkdir -p /tmp/www
pgrep -f "http.server 8088" >/dev/null || (cd /tmp/www && setsid python3 -m http.server 8088 >/dev/null 2>&1 &)
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
passed=0
failed=0
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# Runs one scenario; leaves its output in $out and the jobs it caused in $jobs.
run() {
local before
before=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
out=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" \
timeout 120 python3 /tmp/wonderink-print-rig.py $COMMAND $1 2>&1'; echo \"exit=\$?\"")
sleep 3
jobs=$(lab "lpstat -W all -o | tail -n +$((before + 1)) | awk '{print \$1}' | tr '\n' ' '")
job_options() { lab "grep -E '\\[Job ${1#*-}\\] argv\\[(4|5)\\]' /var/log/cups/error_log"; }
job_device() { lab "grep -E '\\[Job ${1#*-}\\]' /var/log/cups/error_log | grep -oE 'sDEVICE=[a-z]+' | sort -u"; }
}
count() { wc -w <<< "$jobs"; }
said() { grep -q -- "$1" <<< "$out"; }
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat)
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /tmp/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /tmp/www/ark.pdf; chmod a+r /tmp/www/ark.pdf'; }
for scenario in "${scenarios[@]}"; do
echo "$scenario"
run "$scenario"
judge "Wonder Ink did not crash, and exited by itself" exited_cleanly
case "$scenario" in
web)
judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \
"said \"pages=3 printers=\\['Elmer', 'Refusing'\\] paper=Letter\""
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
judge "Elmer's filters printed it in colour" '[[ "$(job_device $jobs)" == *pxlcolor* ]]'
judge "and nothing was left behind" "said 'scratch_left=\\[\\]'" ;;
pdf|pdf-viewer-button)
judge "the preview showed the worksheet's own 2 pages" "said 'pages=2'"
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
pdf-options)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "page 2 only, three copies" \
'[[ "$(job_options $jobs)" == *"argv[4]=\"3\""* && "$(job_options $jobs)" == *page-ranges=2* ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
inline)
judge "the inline PDF was recognised" "said \"mime='application/pdf'\""
judge "and opened as a worksheet tab" "said 'current_is_pdf=True'"
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
options)
judge "pages 2-3, two copies, black and white" \
'[[ "$(job_options $jobs)" == *page-ranges=2-3* && "$(job_options $jobs)" == *"argv[4]=\"2\""* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bad-range)
judge "a page that does not exist is explained, not printed" "said 'This document has 3 pages'"
judge "and once corrected, one job" '[[ $(count) -eq 1 ]]' ;;
broken)
judge "a refusing printer is reported in CUPS's words" "said 'Refusing did not take the job'"
judge "and no job is claimed" '[[ $(count) -eq 0 ]] && ! said "NOTIFY Sent"' ;;
cancel)
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]' ;;
page-closes-itself)
judge "the preview says the page closed, and cannot print" \
"said \"status='The page was closed.' print_enabled=False\""
judge "and sends nothing" '[[ $(count) -eq 0 ]]' ;;
close-window)
judge "closing Wonder Ink with the preview open sends nothing" '[[ $(count) -eq 0 ]]' ;;
page-prints-again)
judge "a second print request does not open a second preview" "said 'still one preview=True'"
judge "one job" '[[ $(count) -eq 1 ]]' ;;
repeat)
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
esac
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
done
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+141
View File
@@ -0,0 +1,141 @@
"""Drive Wonder Ink's print preview through one scenario, headless.
usage: wonderink-print-rig.py WONDERINK_COMMAND SCENARIO
Run by tools/lab/wonderink-print-check inside the lab, against the
command the package installed. It presses the preview's own buttons the
way a teacher would, and prints what happened; the check around it asks
CUPS what actually arrived.
"""
import importlib.machinery, importlib.util, sys, time, faulthandler, gc, os
faulthandler.enable()
path, scenario = sys.argv[1], sys.argv[2]
loader = importlib.machinery.SourceFileLoader("wonderink", path)
spec = importlib.util.spec_from_loader("wonderink", loader)
wi = importlib.util.module_from_spec(spec); loader.exec_module(wi)
from pathlib import Path
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtGui import QPdfWriter, QPainter, QFont, QPageSize
from PyQt6.QtWidgets import QApplication
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
notes = []
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
app = QApplication(sys.argv)
profile = QWebEngineProfile("rig", app)
for a in ("PdfViewerEnabled", "PluginsEnabled"):
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download)
window.resize(1280, 800); window.show()
t0 = time.monotonic()
def stamp(m): print(f"[{time.monotonic()-t0:6.2f}s] {m}", flush=True)
def lesson(view, title="Noahs Ark lesson"):
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
f"<h1>{title}, part {i}</h1><p>{'Noah built an ark. ' * 80}</p>" for i in range(5)
) + "</body></html>", QUrl("https://app.wonderink.org/lesson"))
def worksheet(name="Colour the Ark"):
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
p = d / f"{name}.pdf"
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
pa.drawText(200, 400, f"WORKSHEET {name} page one"); pa.drawRect(200, 600, 2000, 1500)
w.newPage(); pa.drawText(200, 400, f"WORKSHEET {name} page two"); pa.end()
return p
def when(cond, then, label, limit=30):
start = time.monotonic()
def poll():
try: ok = cond()
except Exception as e: ok = False
if ok: return then()
if time.monotonic() - start > limit:
stamp(f"GAVE UP waiting for {label}"); app.exit(4); return
QTimer.singleShot(100, poll)
QTimer.singleShot(100, poll)
def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None
def describe(d):
return f"pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
def finish(code=0):
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
QTimer.singleShot(1500, lambda: app.exit(code))
def press_print(then=None):
d = preview(); stamp("PREVIEW " + describe(d))
d._print.click()
stamp(f"after Print: open={preview() is not None} status={d._status.text() if preview() else ''!r}")
(then or finish)()
actions = {
"web": lambda view: when(ready, press_print, "preview"),
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
"options": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"),
"bad-range": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
preview()._range.setText("1"), press_print()), "preview"),
"broken": lambda view: when(ready, lambda: (
preview()._printer.setCurrentText("Refusing"),
stamp(f"switched to Refusing: paper={preview().paper()} status={preview()._status.text()!r}"),
when(ready, lambda: (
stamp(f"re-rendered on {preview().paper()}: pages={preview()._pages}"),
preview()._print.click(),
stamp(f"broken: open={preview() is not None} status={preview()._status.text()!r}"),
preview()._cancel.click(), finish()), "re-render")), "preview"),
"page-closes-itself": lambda view: when(ready, lambda: (
view.page().runJavaScript("window.close()"),
when(lambda: preview() and "closed" in preview()._status.text(), lambda: (
stamp(f"after page closed: status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()}"),
preview()._cancel.click(), finish()), "closed message")), "preview"),
"close-window": lambda view: when(ready, lambda: (stamp("closing window with preview open"), window.close()), "preview"),
"page-prints-again": lambda view: when(ready, lambda: (
view.page().runJavaScript("window.print()"),
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
}
if scenario == "repeat":
view = window.open_tab(); lesson(view); n = [0]
def once():
if n[0] == 5: return finish()
n[0] += 1; QTimer.singleShot(0, lambda: window.print_view(view))
when(ready, lambda: press_print(then=lambda: QTimer.singleShot(200, once)), "preview")
view.loadFinished.connect(lambda ok: once() if n[0] == 0 else None)
elif scenario in ("pdf", "pdf-options"):
window.open_pdf(worksheet()); view = window.current()
if scenario == "pdf-options":
actions["pdf-options"] = lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview")
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
actions[scenario if scenario == "pdf-options" else "web"](view)))
elif scenario == "pdf-viewer-button":
# The viewer's own print button reaches the page as a print request.
window.open_pdf(worksheet()); view = window.current()
QTimer.singleShot(2500, lambda: (actions["web"](view), view.page().printRequested.emit()))
elif scenario == "inline":
view = window.open_tab(); view.load(QUrl("http://127.0.0.1:8088/ark.pdf"))
QTimer.singleShot(4000, lambda: (stamp(f"inline mime={window._mime.get(view)!r} shows_pdf={window._shows_pdf(view)}"),
QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready, lambda: (stamp(f"worksheet tab opened: tabs={window._tabs.count()} current_is_pdf={window.current() in window._pdf_views}"), press_print()), "inline preview")))
else:
if scenario == "page-closes-itself":
lesson(window.open_tab(), "The other lesson")
view = window.open_tab(); lesson(view)
fired = []
def go(ok):
if fired: return
fired.append(1); QTimer.singleShot(0, lambda: window.print_view(view)); actions[scenario](view)
view.loadFinished.connect(go)
QTimer.singleShot(90000, lambda: (stamp("TIMEOUT"), app.exit(3)))
rc = app.exec()
stamp(f"EXIT {rc} scratch_left={sorted(p.name for p in Path(os.environ.get('XDG_RUNTIME_DIR','/tmp')).glob('wonderink-print-*'))}")
sys.exit(rc)