fix: Wonder Ink prints without crashing, and prints worksheets at all
Little Waders, 0.2.2: SIGSEGV in QPrinter::resolution() every time a teacher confirmed a print job. QWebEngineView.print() keeps the raw QPrinter pointer and returns; Qt's didPrintPage() reads printer->resolution() once Chromium has rendered the page, and by then PyQt had deleted the printer, which print_view() held only as a local. Reproduced in the lab with the 0.2.2 code, same backtrace, no job. Keeping the printer alive fixed the crash and was measured, and Qt's path was still not a print path: a worksheet PDF tab printed as one blank page, a lesson went as 24.6 MB of 300 dpi pictures with no text, and a queue refusing jobs was reported as printed. Print is now: make a PDF (a worksheet already is one; a lesson page is printToPdf'd onto the printer's own paper; an inline PDF is downloaded and opened as a worksheet), show it in a preview with printer, pages, copies and colour, and hand it to CUPS with lp — Chrome's route in the same rooms. Nothing asynchronous holds a printer. Black and white uses the driver's ColorModel=Grayscale, because Elmer's generic PCL 6 driver ignores print-color-mode. An exception escaping any slot no longer aborts Wonder Ink: PyQt6's default is to kill the process, and it is now logged instead. tests/test-classroom-printing.sh: 50 checks. tools/lab/wonderink-print- check: 39 checks over 13 scenarios in the lab, against a queue built from Elmer's PPD, judged by what CUPS received. Not yet on paper. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
690ef95aff
commit
10433b850d
+80
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Executable
+204
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+152
@@ -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 ]]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user