From 10433b850dafb960024fb11e5b5c152e4edeffb5 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 23 Sep 2026 18:36:29 -0500 Subject: [PATCH] fix: Wonder Ink prints without crashing, and prints worksheets at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/wonder-ink.md | 83 ++++- lib/classroom_printing.py | 211 +++++++++++ scripts/classroom-wonderink | 579 +++++++++++++++++++++++++++++-- tests/test-classroom-printing.sh | 204 +++++++++++ tests/test-deployment-support.sh | 13 +- tools/build-release.sh | 5 + tools/lab/wonderink-print-check | 152 ++++++++ tools/lab/wonderink-print-rig.py | 141 ++++++++ 8 files changed, 1356 insertions(+), 32 deletions(-) create mode 100644 lib/classroom_printing.py create mode 100755 tests/test-classroom-printing.sh create mode 100755 tools/lab/wonderink-print-check create mode 100644 tools/lab/wonderink-print-rig.py diff --git a/docs/wonder-ink.md b/docs/wonder-ink.md index 8c94d33..00d810a 100644 --- a/docs/wonder-ink.md +++ b/docs/wonder-ink.md @@ -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 diff --git a/lib/classroom_printing.py b/lib/classroom_printing.py new file mode 100644 index 0000000..d2c5b7c --- /dev/null +++ b/lib/classroom_printing.py @@ -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 diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index e9d2378..59a00ca 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -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") diff --git a/tests/test-classroom-printing.sh b/tests/test-classroom-printing.sh new file mode 100755 index 0000000..4d04345 --- /dev/null +++ b/tests/test-classroom-printing.sh @@ -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" <&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" </dev/null +EOF + +cat > "$WORK/bin/lp" < "$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 diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index fb8572c..10858c9 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -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") diff --git a/tools/build-release.sh b/tools/build-release.sh index 54d9964..58bcd30 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -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 diff --git a/tools/lab/wonderink-print-check b/tools/lab/wonderink-print-check new file mode 100755 index 0000000..345734a --- /dev/null +++ b/tools/lab/wonderink-print-check @@ -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 ]] diff --git a/tools/lab/wonderink-print-rig.py b/tools/lab/wonderink-print-rig.py new file mode 100644 index 0000000..9ba93b3 --- /dev/null +++ b/tools/lab/wonderink-print-rig.py @@ -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"{title}" + "".join( + f"

{title}, part {i}

{'Noah built an ark. ' * 80}

" for i in range(5) + ) + "", 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)