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
|
`LocalContentCanAccessFileUrls` (on by default, and would let a local
|
||||||
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
|
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
|
||||||
|
|
||||||
The print dialog's **Print to File** option is also removed. Choosing it
|
Printing has no **Print to File** and no file chooser of any kind: the
|
||||||
enables an output path with a "..." button, which is a file chooser, in a
|
preview (below) offers the room's CUPS printers and nothing else.
|
||||||
room whose whole design is that there is no file chooser.
|
|
||||||
|
|
||||||
### What a teacher still cannot reach
|
### 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
|
Downloads folder. That is partly tidiness and mostly the security
|
||||||
argument: it is the only place a worksheet can come from.
|
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
|
## What stayed the same
|
||||||
|
|
||||||
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
|
* **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()
|
f"{flags} --widevine-path={WIDEVINE}".strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
from PyQt6.QtCore import QEvent, Qt, QUrl # noqa: E402
|
import classroom_printing # noqa: E402
|
||||||
from PyQt6.QtGui import QAction, QGuiApplication, QKeySequence # noqa: E402
|
from PyQt6.QtCore import ( # noqa: E402
|
||||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
QEvent,
|
||||||
from PyQt6.QtPrintSupport import ( # noqa: E402
|
QMarginsF,
|
||||||
QAbstractPrintDialog,
|
Qt,
|
||||||
QPrintDialog,
|
QTimer,
|
||||||
QPrinter,
|
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
|
from PyQt6.QtWebEngineCore import ( # noqa: E402
|
||||||
QWebEngineDownloadRequest,
|
QWebEngineDownloadRequest,
|
||||||
QWebEnginePage,
|
QWebEnginePage,
|
||||||
@@ -137,9 +147,20 @@ from PyQt6.QtWebEngineCore import ( # noqa: E402
|
|||||||
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMenu,
|
QMenu,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
# One running Wonder Ink per session. A second QtWebEngine process on the
|
# 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.
|
# the panel gives a teacher a button that is easy to press twice.
|
||||||
SOCKET = "ekklesia-wonderink"
|
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
|
# How much of a page title fits on a tab before it stops being a label
|
||||||
# and starts being a paragraph.
|
# and starts being a paragraph.
|
||||||
TAB_TITLE = 22
|
TAB_TITLE = 22
|
||||||
@@ -280,6 +309,13 @@ class WonderInk(QMainWindow):
|
|||||||
# from the file, because a PDF has no document title to take one
|
# from the file, because a PDF has no document title to take one
|
||||||
# from, and their downloads are not re-opened as further tabs.
|
# from, and their downloads are not re-opened as further tabs.
|
||||||
self._pdf_views = {}
|
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")
|
self.setWindowTitle("Wonder Ink")
|
||||||
|
|
||||||
@@ -313,6 +349,15 @@ class WonderInk(QMainWindow):
|
|||||||
# concludes the printer is broken. Bound to the page that asked
|
# concludes the printer is broken. Bound to the page that asked
|
||||||
# rather than to whichever tab is in front.
|
# rather than to whichever tab is in front.
|
||||||
view.page().printRequested.connect(lambda v=view: self.print_view(v))
|
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, "…")
|
index = self._tabs.addTab(view, "…")
|
||||||
|
|
||||||
@@ -340,6 +385,7 @@ class WonderInk(QMainWindow):
|
|||||||
|
|
||||||
self._tabs.removeTab(index)
|
self._tabs.removeTab(index)
|
||||||
self._pdf_views.pop(view, None)
|
self._pdf_views.pop(view, None)
|
||||||
|
self._mime.pop(view, None)
|
||||||
view.page().deleteLater()
|
view.page().deleteLater()
|
||||||
view.deleteLater()
|
view.deleteLater()
|
||||||
self._show_tab_bar()
|
self._show_tab_bar()
|
||||||
@@ -361,7 +407,8 @@ class WonderInk(QMainWindow):
|
|||||||
# viewer leaves document.title empty, so left alone every PDF tab
|
# viewer leaves document.title empty, so left alone every PDF tab
|
||||||
# would be called "Wonder Ink" and two of them would be
|
# would be called "Wonder Ink" and two of them would be
|
||||||
# indistinguishable.
|
# 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()
|
title = title.strip()
|
||||||
short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…"
|
short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…"
|
||||||
self._tabs.setTabText(index, short)
|
self._tabs.setTabText(index, short)
|
||||||
@@ -472,6 +519,10 @@ class WonderInk(QMainWindow):
|
|||||||
from_worksheet = download.page() in [
|
from_worksheet = download.page() in [
|
||||||
view.page() for view in self._pdf_views
|
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.
|
# stateChanged, not isFinishedChanged.
|
||||||
#
|
#
|
||||||
@@ -507,6 +558,8 @@ class WonderInk(QMainWindow):
|
|||||||
path = DOWNLOADS / name
|
path = DOWNLOADS / name
|
||||||
|
|
||||||
if not from_worksheet and self.open_worksheet(path, mime):
|
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
|
return
|
||||||
|
|
||||||
notify("Saved", f"{name} is in {DOWNLOADS}")
|
notify("Saved", f"{name} is in {DOWNLOADS}")
|
||||||
@@ -550,40 +603,128 @@ class WonderInk(QMainWindow):
|
|||||||
self._openable.add(resolved)
|
self._openable.add(resolved)
|
||||||
|
|
||||||
view = self.open_tab()
|
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)
|
self._retitle(view, path.stem)
|
||||||
view.load(QUrl.fromLocalFile(str(resolved)))
|
view.load(QUrl.fromLocalFile(str(resolved)))
|
||||||
|
return view
|
||||||
|
|
||||||
# ---- printing -------------------------------------------------------
|
# ---- 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):
|
def print_current(self):
|
||||||
self.print_view(self.current())
|
self.print_view(self.current())
|
||||||
|
|
||||||
def print_view(self, view):
|
def print_view(self, view):
|
||||||
if view is None:
|
if view is None or self._preview is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
title = self._tabs.tabToolTip(self._tabs.indexOf(view)) or "Wonder Ink"
|
||||||
dialog = QPrintDialog(printer, self)
|
pdf = self._pdf_views.get(view)
|
||||||
|
|
||||||
# No "Print to File".
|
if pdf is None and self._shows_pdf(view):
|
||||||
#
|
# A PDF the site showed inline rather than sending as a
|
||||||
# Qt offers it in every print dialog, and choosing it enables an
|
# download. Measured: the engine cannot render the PDF viewer
|
||||||
# output path with a "..." button beside it — which is a file
|
# for printing at all — printToPdf never answers — so it is
|
||||||
# chooser, in a room whose whole design is that there is no file
|
# fetched as the file it is, opened as a worksheet like any
|
||||||
# chooser. Nothing in a classroom wants a worksheet written to a
|
# other, and printed from there.
|
||||||
# PDF nobody can then open; the room has a printer, and that is
|
self._print_when_downloaded.add(view.url().toString())
|
||||||
# what this dialog is for.
|
view.page().download(view.url())
|
||||||
dialog.setOption(
|
return
|
||||||
QAbstractPrintDialog.PrintDialogOption.PrintToFile, False
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
# Qt's own dialog talks to CUPS, so a lesson prints to the room's
|
# responseHeaders() is Qt 6.6 and later; Debian 13 has 6.8. PyQt
|
||||||
# queue the same way everything else in the classroom does.
|
# gives it as a dict of lists — it is a multi-map in C++.
|
||||||
view.print(printer)
|
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 ----------------------------------------------------
|
# ---- full screen ----------------------------------------------------
|
||||||
|
|
||||||
@@ -633,6 +774,375 @@ class WonderInk(QMainWindow):
|
|||||||
self._show_tab_bar()
|
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):
|
def notify(summary, body):
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -676,6 +1186,21 @@ def raise_running_instance():
|
|||||||
return True
|
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():
|
def main():
|
||||||
config = read_device_config()
|
config = read_device_config()
|
||||||
|
|
||||||
@@ -693,6 +1218,8 @@ def main():
|
|||||||
if raise_running_instance():
|
if raise_running_instance():
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
sys.excepthook = keep_going
|
||||||
|
|
||||||
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
|
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Wonder Ink")
|
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)
|
"LocalContentCanAccessFileUrls, False" in wonderink)
|
||||||
check("nor reach the network",
|
check("nor reach the network",
|
||||||
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
||||||
check("there is no file chooser behind the print dialog",
|
# Qt's print dialog has a Print to File path with a "..." chooser behind
|
||||||
"PrintDialogOption.PrintToFile, False" in wonderink)
|
# 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",
|
check("nothing downloaded is executed",
|
||||||
"subprocess" not in wonderink.split("def notify")[0]
|
"subprocess" not in wonderink.split("def notify")[0]
|
||||||
and "QDesktopServices" not in wonderink
|
and "QDesktopServices" not in wonderink
|
||||||
@@ -374,8 +377,12 @@ check("the Present-view autoplay fix is still there",
|
|||||||
"PlaybackRequiresUserGesture, False" in wonderink)
|
"PlaybackRequiresUserGesture, False" in wonderink)
|
||||||
check("fullscreen still returns to a maximised window",
|
check("fullscreen still returns to a maximised window",
|
||||||
"WindowFullScreen" in wonderink)
|
"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",
|
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()
|
||||||
print("7b. the disc player is decided before a session starts")
|
print("7b. the disc player is decided before a session starts")
|
||||||
|
|||||||
@@ -383,6 +383,11 @@ install -Dm755 \
|
|||||||
"$ROOT/scripts/classroom-wonderink" \
|
"$ROOT/scripts/classroom-wonderink" \
|
||||||
"$STAGE/usr/local/bin/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.
|
# PRESENTATIONS.
|
||||||
#
|
#
|
||||||
# The library a teacher sees, and the watcher that fills it from their
|
# 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