fix: black and white is shown as it prints, and right-click prints the picture

Printing through Elmer found two things. Black and white came out grey
while the preview stayed in colour: 0.2.6.2 only told the driver. And
right-clicking a picture in a lesson and choosing Print printed the whole
page around it, where some pictures cannot be opened on their own at all.

Black and white is now made before the preview. Ghostscript makes a grey
copy, the preview shows it, and CUPS is given that same file, still with
ColorModel=Grayscale so Elmer's PCL 6 driver prints with pxlmono. Measured
first: Ghostscript's own grey of the colour original and of the copy give
the same greys (Elmer's driver is Ghostscript); poppler's do not. It takes
up to 4.4 s for a lesson page of pictures, so it runs in the background
with a minute to finish, is made once and kept, and a failure is said so
and never printed in colour instead. Ghostscript is now a named dependency;
cups-filters already brought it.

Right-click asks the engine's hit test: on a picture or a canvas it offers
Print image and Print page, elsewhere only Print page. The picture comes
from the engine's own copy image — the only one of five ways measured that
worked for a signed-in picture, a hotlink-protected one, a one-time
address, blob:, data:, another site's, a canvas and WebP — and it fetches
nothing, so the teacher's sign-in is not needed again. It goes through the
clipboard, which is kept and put back; measured under KWin with another
program owning it. A picture that never arrives is said so; the page is
not printed in its place. It then gets the 0.2.6.2 picture layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-26 16:34:38 -05:00
co-authored by Claude Opus 5.5
parent 550c0bf126
commit 594fbd810e
4 changed files with 514 additions and 44 deletions
+414 -42
View File
@@ -167,6 +167,8 @@ from PyQt6.QtCore import ( # noqa: E402
QEvent,
QKeyCombination,
QMarginsF,
QMimeData,
QProcess,
QRectF,
Qt,
QTimer,
@@ -187,6 +189,7 @@ from PyQt6.QtGui import ( # noqa: E402
)
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWebEngineCore import ( # noqa: E402
QWebEngineContextMenuRequest,
QWebEngineDownloadRequest,
QWebEnginePage,
QWebEngineProfile,
@@ -228,10 +231,27 @@ PRINT_MARGIN_MM = 10
# and says so. Measured: a long lesson page takes well under a second.
PREPARE_TIMEOUT_MS = 20_000
# How long black and white may take before the preview gives up on it.
# Not the same as a page: measured under the real Plasma session, one
# ordinary lesson page with eleven full-size pictures on it took 4.4 s to
# turn grey on an idle machine, and a lesson of photographs on a slower
# laptop could pass 20 s while working perfectly well. The preview says
# what it is doing all the while, and Cancel stops it.
GRAY_TIMEOUT_MS = 60_000
# A picture's page is drawn at printer resolution: 300 dpi is what the
# picture itself is capped at, so nothing it has is thrown away.
PICTURE_DPI = 300
# 300 dpi across a sheet of Letter: the most of a picture a printer can use.
PICTURE_LARGEST = 3300
# How long a picture a teacher right-clicked has to arrive. Measured: 0.01
# to 0.05 s in the lab, 0.016 s under the real Plasma session. A broken
# picture never arrives at all, and this is how long the teacher waits to
# be told so.
TAKE_PICTURE_MS = 3000
# Is this tab a picture on its own, and if so, the picture.
#
# A PNG or JPEG a lesson opens by itself is shown by the engine on a
@@ -247,7 +267,7 @@ PICTURE = """(function(){
if (type.indexOf('image/') !== 0 || type === 'image/svg+xml') return null;
var img = document.images[0];
if (!img || !img.naturalWidth || document.images.length !== 1) return null;
var scale = Math.min(1, 3300 / Math.max(img.naturalWidth, img.naturalHeight));
var scale = Math.min(1, %d / Math.max(img.naturalWidth, img.naturalHeight));
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
@@ -256,7 +276,7 @@ PICTURE = """(function(){
g.fillRect(0, 0, canvas.width, canvas.height);
g.drawImage(img, 0, 0, canvas.width, canvas.height);
try { return canvas.toDataURL('image/png'); } catch (e) { return null; }
})()"""
})()""" % PICTURE_LARGEST
# How much of a page title fits on a tab before it stops being a label
# and starts being a paragraph.
@@ -425,9 +445,25 @@ class LessonView(QWebEngineView):
for action in (bigger, smaller, normal):
menu.addAction(action)
# Print says which: the picture under the pointer, or the page.
# 0.2.6.2 had one Print, and on a picture it printed the whole
# page around it. The engine's own hit test is the judge: a
# picture (<img>, including one inside a link or a frame) or a
# canvas is offered as a picture; words, space and a CSS
# background are the page. Page is always there, so a teacher who
# wanted the page from a picture still has it.
menu.addSeparator()
printing = QAction("Print…", menu)
printing.triggered.connect(self._window.print_current)
if request is not None and request.mediaType() in (
QWebEngineContextMenuRequest.MediaType.MediaTypeImage,
QWebEngineContextMenuRequest.MediaType.MediaTypeCanvas,
):
picture = QAction("Print image…", menu)
picture.triggered.connect(lambda: self._window.print_image(self))
menu.addAction(picture)
printing = QAction("Print page…", menu)
printing.triggered.connect(lambda: self._window.print_view(self))
menu.addAction(printing)
menu.popup(event.globalPos())
@@ -957,10 +993,123 @@ class WonderInk(QMainWindow):
# 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.")
self._show_preview(view, dialog, lambda: dialog.fail("The page was closed."))
def print_image(self, view):
"""The picture a teacher right-clicked, laid out as a picture.
The same preview a picture in a tab of its own gets (0.2.6.2) —
Size, Position, Paper, Fill the whole page, Color — for a picture
inside a lesson page, which a teacher often cannot open on its own
at all.
"""
if view is None or self._preview is not None:
return
page = self._tabs.tabToolTip(self._tabs.indexOf(view)) or "Wonder Ink"
dialog = PrintPreview(self, f"Picture from {page}")
self._preview = dialog
taken = self._take_picture(view, dialog)
# Once the picture has been taken the page is not needed; before,
# a page that closes itself has nothing left to take it from.
self._show_preview(
view, dialog,
lambda: None if taken() else dialog.fail("The page was closed."),
)
def _take_picture(self, view, dialog):
"""The picture under the teacher's last right-click, into DIALOG.
WHY THROUGH THE CLIPBOARD.
The engine's own "copy image" is the only way it hands over the
picture it is showing. Measured in the lab, QtWebEngine 6.8.2, on a
page with every kind of picture a lesson might have:
download the image fetches it again: a one-time address
is gone (404), and a canvas gives
nothing at all
fetch it from the page refused for another site's picture
(CORS)
draw it on a canvas refused for another site's picture
(a tainted canvas)
copy image every one of them: a signed-in
picture, hotlink-protected, one-time,
blob:, data:, another site's, a
canvas, WebP (which Qt itself cannot
read), SVG, GIF, transparency kept
Copy image fetches nothing — the server saw no request at all — so
a picture behind the teacher's sign-in needs no sign-in again: it
is the picture the page already has, as large as it really is. In
0.01 to 0.05 s; 0.016 s under the real Plasma session.
What was on the clipboard is kept and put back straight after,
measured under KWin with another program owning it: saved in
0.002 s, and that program could paste its own text again. A
picture that never arrives — one that failed to load — is said so
after TAKE_PICTURE_MS, and nothing prints.
Returns a function saying whether the picture has arrived.
"""
attempt = dialog.preparing("Taking the picture from the page…")
clipboard = QGuiApplication.clipboard()
put_back = keep_clipboard(clipboard)
# [True] once the picture is here, [False] once it has been given up on.
ended = []
def stop_listening():
try:
clipboard.dataChanged.disconnect(arrived)
except (RuntimeError, TypeError):
pass
def arrived():
image = clipboard.image()
if image.isNull():
return
# Whenever it comes — even after the teacher has cancelled, or
# been told it did not come — the clipboard is put back.
stop_listening()
put_back()
if ended:
return
ended.append(True)
if attempt.current():
dialog.show_picture(printable_picture(image))
def gave_up():
if ended:
return
ended.append(False)
# Still listening for a while, so a picture that turns up late
# does not stay on the teacher's clipboard.
QTimer.singleShot(10 * TAKE_PICTURE_MS, stop_listening)
if attempt.current():
say(f"a right-clicked picture did not arrive from {view.url().host()}")
dialog.fail(
"This picture could not be taken from the page — it may not "
"have finished loading. Nothing was printed. Close this and "
"try again, or choose Print page to print the whole page."
)
clipboard.dataChanged.connect(arrived)
view.page().triggerAction(QWebEnginePage.WebAction.CopyImageToClipboard)
QTimer.singleShot(TAKE_PICTURE_MS, gave_up)
return lambda: ended == [True]
def _show_preview(self, view, dialog, closed):
"""DIALOG, until the teacher is done with it; CLOSED if VIEW goes."""
watch = view.destroyed.connect(lambda *_: closed())
try:
dialog.exec()
finally:
@@ -1353,6 +1502,12 @@ class PrintPreview(QDialog):
and where on the paper. 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.
What is shown is the file that prints. There are two documents: the
source, in colour — a worksheet, a lesson page the engine rendered, a
picture laid out — and, when Black and white is chosen, a grey copy of
it made here. The preview shows whichever of the two is chosen and
Print hands that same file to CUPS.
"""
# A lesson page is laid out for a paper size; choosing a printer with
@@ -1369,7 +1524,17 @@ class PrintPreview(QDialog):
self.setModal(True)
self._title = title
# What is shown and what prints: the source, or its grey copy.
self._pdf = None
# The document in colour, once it has arrived; None while one is
# on its way.
self._source = None
# (source, its grey copy), made once and kept, so turning Color
# and Black and white back and forth does not make it again.
self._gray = None
self._converter = None
self._conversion = None
self._too_long = ""
self._scratch = None
self._pages = 0
self._page = 1
@@ -1381,9 +1546,7 @@ class PrintPreview(QDialog):
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.")
)
self._timer.timeout.connect(self._timed_out)
# -- the page ------------------------------------------------------
self._view = PageView(self)
@@ -1420,6 +1583,10 @@ class PrintPreview(QDialog):
self._colour = QComboBox(self)
self._colour.addItems(["Color", "Black and white"])
self._colour_label = QLabel("Color", self)
# The preview changes with it: the same page, in the other colour.
self._colour.currentIndexChanged.connect(
lambda *_: self._present(keep_page=True)
)
form = QFormLayout()
form.addRow("Printer", self._printer)
@@ -1518,18 +1685,26 @@ class PrintPreview(QDialog):
def paper(self):
return self._paper or "Letter"
def preparing(self):
"""A new PDF is on its way; anything older is stale."""
def preparing(self, message="Preparing the preview…"):
"""A new document 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)
self._stop_converting()
self._source = None
self._forget_gray()
self._images = {}
self._wait(message, "The page took too long to prepare. Close this and try again.")
return self._attempt
def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):
self._clear()
self._say(message)
self._view.show_message(message)
self._too_long = too_long
self._timer.start(timeout)
def show_rendered(self, data):
"""The engine's PDF of a lesson page, as bytes."""
self._timer.stop()
@@ -1541,16 +1716,7 @@ class PrintPreview(QDialog):
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 = self._scratch_dir() / f"page-{next(self._serial)}.pdf"
path.write_bytes(data)
self.show_document(path)
@@ -1576,22 +1742,15 @@ class PrintPreview(QDialog):
self._compose_timer.start(120)
def _compose(self):
"""Lay the picture out on one page, as the PDF that will print."""
import tempfile
"""Lay the picture out on one page, in colour, as the source."""
if self._picture is None:
return
if self._scratch is None:
self._scratch = tempfile.mkdtemp(
prefix="wonderink-print-", dir=os.environ.get("XDG_RUNTIME_DIR")
)
image = self._picture
landscape = classroom_printing.picture_landscape(
self._turned.currentData(), image.width(), image.height()
)
path = Path(self._scratch) / f"picture-{next(self._serial)}.pdf"
path = self._scratch_dir() / f"picture-{next(self._serial)}.pdf"
writer = QPdfWriter(str(path))
writer.setTitle(self._title)
@@ -1616,13 +1775,150 @@ class PrintPreview(QDialog):
painter.drawImage(QRectF(x, y, width, height), image)
painter.end()
previous = self._pdf
previous = self._source
self.show_document(path)
if previous is not None and previous != path and previous.parent == path.parent:
previous.unlink(missing_ok=True)
def show_document(self, pdf):
"""The document in colour: a worksheet, a lesson page, a picture laid out."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._stop_converting()
self._forget_gray()
self._images = {}
self._source = Path(pdf)
self._present()
# -- color, or black and white ------------------------------------------
def _wants_gray(self):
"""Black and white chosen, on a printer whose driver can do it."""
found = self._options.get(self._printer.currentText())
return (
self._colour.currentIndex() == 1
and found is not None
and found["gray"] is not None
)
def _present(self, keep_page=False):
"""Show the source in the colour chosen, making the grey copy if needed."""
self._stop_converting()
if self._source is None:
# A document is on its way, and is shown in the colour chosen
# by then when it arrives.
return
if not self._wants_gray():
self._display(self._source, keep_page)
elif self._gray is not None and self._gray[0] == self._source:
self._display(self._gray[1], keep_page)
else:
self._convert(keep_page)
def _convert(self, keep_page):
"""The source in black and white, made in the background.
Measured: 0.05 s for a worksheet, 2 s for 42 pages of text, and
4.4 s for one lesson page with eleven full-size pictures — long
enough that the preview says what it is doing, and Print waits,
rather than the window freezing.
"""
source = self._source
target = self._scratch_dir() / f"gray-{next(self._serial)}.pdf"
expected = classroom_printing.page_count(source)
conversion = self._conversion = _Attempt()
self._wait(
"Preparing black and white…",
"Black and white took too long to prepare. Choose Color, or close this and try again.",
GRAY_TIMEOUT_MS,
)
process = QProcess(self)
self._converter = process
def done(*_):
if self._converter is process:
self._converter = None
process.deleteLater()
if not conversion.current():
target.unlink(missing_ok=True)
return
conversion.live = False
self._timer.stop()
# Judged by the exit status and the pages, not by the file.
# Measured: Ghostscript given a PDF it cannot read exits 1 and
# still leaves a file behind.
if (
process.exitStatus() != QProcess.ExitStatus.NormalExit
or process.exitCode() != 0
or classroom_printing.page_count(target) != expected
):
error = bytes(process.readAllStandardError()).decode(errors="replace")
say(f"black and white failed for {source}: {error.strip()[-300:]}")
target.unlink(missing_ok=True)
# Never the colour file in its place: the teacher asked for
# black and white, and would be charged for colour. The
# source is kept, so choosing Color shows it again.
self._complain(
"Black and white could not be prepared. "
"Choose Color, or close this and try again."
)
return
self._gray = (source, target)
self._display(target, keep_page)
def not_started(error):
# A program that cannot start never finishes: errorOccurred is
# all there is.
if error == QProcess.ProcessError.FailedToStart:
done()
process.finished.connect(done)
process.errorOccurred.connect(not_started)
command = classroom_printing.grayscale_command(source, target)
process.start(command[0], command[1:])
def _stop_converting(self):
if self._conversion is not None:
self._conversion.live = False
process, self._converter = self._converter, None
if process is not None and process.state() != QProcess.ProcessState.NotRunning:
process.kill()
process.waitForFinished(2000)
def _forget_gray(self):
if self._gray is not None:
self._gray[1].unlink(missing_ok=True)
self._gray = None
def _scratch_dir(self):
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")
)
return Path(self._scratch)
def _display(self, pdf, keep_page=False):
self._timer.stop()
page = self._page
self._pdf = Path(pdf)
self._pages = classroom_printing.page_count(self._pdf)
@@ -1630,18 +1926,35 @@ class PrintPreview(QDialog):
self.fail("This document could not be read.")
return
self._page = 1
self._images = {}
# Turning Color and Black and white over stays on the page the
# teacher was looking at.
self._page = min(page, self._pages) if keep_page else 1
self._say("")
self._print.setEnabled(bool(self._printer.count()))
self._show_page()
def fail(self, message):
"""Nothing here can be printed any more."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._stop_converting()
self._source = None
self._complain(message)
def _timed_out(self):
if self._conversion is not None and self._conversion.current():
# Only black and white is late; the document itself is fine,
# and Color still shows it.
self._stop_converting()
self._complain(self._too_long)
else:
self.fail(self._too_long)
def _complain(self, message):
self._timer.stop()
self._clear()
self._view.show_message(message)
self._say(message, error=True)
@@ -1653,6 +1966,8 @@ class PrintPreview(QDialog):
if self._attempt is not None:
self._attempt.live = False
self._stop_converting()
if self._scratch is not None:
import shutil
@@ -1664,7 +1979,6 @@ class PrintPreview(QDialog):
def _clear(self):
self._pdf = None
self._pages = 0
self._images = {}
self._print.setEnabled(False)
self._show_page()
@@ -1691,6 +2005,11 @@ class PrintPreview(QDialog):
self._colour.setVisible(found["gray"] is not None)
self._colour_label.setVisible(found["gray"] is not None)
if found["gray"] is None and self._colour.currentIndex() != 0:
# A printer that cannot print black and white is not shown a
# black-and-white preview.
self._colour.setCurrentIndex(0)
if found["paper"] != self._paper:
self._paper = found["paper"]
@@ -1715,7 +2034,11 @@ class PrintPreview(QDialog):
if not showing:
return
if self._page not in self._images:
# Kept for each file, colour and grey alike, so turning Color and
# Black and white back and forth shows a page drawn once already.
key = (self._pdf, self._page)
if key not in self._images:
data = classroom_printing.render_page(self._pdf, self._page, self.RENDER_WIDTH)
pixmap = QPixmap()
@@ -1723,9 +2046,9 @@ class PrintPreview(QDialog):
self._view.show_message(f"Page {self._page} could not be shown.")
return
self._images[self._page] = pixmap
self._images[key] = pixmap
self._view.show_pixmap(self._images[self._page])
self._view.show_pixmap(self._images[key])
def _submit(self):
if self._pdf is None:
@@ -1744,9 +2067,17 @@ class PrintPreview(QDialog):
printer = self._printer.currentText()
gray = None
if self._colour.isVisible() and self._colour.currentIndex() == 1:
if self._wants_gray():
# The grey file, and still the driver's own grey mode. Elmer's
# generic PCL 6 driver is told ColorModel=Grayscale as before,
# so it prints with pxlmono rather than a colour job that
# happens to be grey.
gray = self._options[printer]["gray"]
if (self._gray is not None and self._pdf == self._gray[1]) != (gray is not None):
# Never print something other than what is shown.
return
self._say(f"Sending to {printer}…")
self._print.setEnabled(False)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
@@ -1769,6 +2100,47 @@ class PrintPreview(QDialog):
self.accept()
def keep_clipboard(clipboard):
"""What is on the clipboard now, and a function that puts it back."""
data = clipboard.mimeData()
kept = QMimeData()
if data is not None:
if data.hasImage():
kept.setImageData(data.imageData())
for name in data.formats():
if name != "application/x-qt-image":
kept.setData(name, data.data(name))
def put_back():
if kept.formats():
clipboard.setMimeData(kept)
else:
clipboard.clear()
return put_back
def printable_picture(image):
"""IMAGE as a picture is printed: on white, at most PICTURE_LARGEST.
What PICTURE does in the page for a picture in a tab of its own, for a
picture taken from inside one: transparent parts become the white of
the paper, not black.
"""
scale = min(1.0, PICTURE_LARGEST / max(image.width(), image.height(), 1))
width = max(1, round(image.width() * scale))
height = max(1, round(image.height() * scale))
picture = QImage(width, height, QImage.Format.Format_RGB32)
picture.fill(Qt.GlobalColor.white)
painter = QPainter(picture)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawImage(QRectF(0, 0, width, height), image)
painter.end()
return picture
def notify(summary, body):
import subprocess