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
+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")