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
+36
View File
@@ -197,6 +197,42 @@ def parse_pages(text, count):
return ",".join(parts)
def grayscale_command(source, target):
"""The command that writes SOURCE, in black and white, to TARGET.
Black and white is made here, before the preview, so the preview shows
it and CUPS is given the same file — 0.2.6.2 only told the driver, and
the preview stayed in colour while the paper came out grey. Measured in
the lab (Ghostscript 10.05.1) on a lesson page, a picture page, a
worksheet and a fillable form:
* the result is grey and nothing else, with the same pages, page
sizes and words, and the text still text;
* Ghostscript's own grey rendering of the colour original and of
this file give the same greys — within 1 level in 255 on every
filled area and picture; small text can land a fraction of a
pixel over. Elmer's driver is Ghostscript, so this is what Elmer
would have made of the colour file anyway — poppler's grey is
not, and differs on 7% of a coloured page;
* 0.05 s for a worksheet, 1.3 s for a full-page photograph, 2 s for
42 pages, and 4.4 s for one lesson page with eleven full-size
pictures, which is why the preview does it in the background.
Pages are never turned (pdfwrite otherwise rotates a page by its text)
and annotations — a form field a teacher filled in, a mark on a
worksheet — are drawn into the page as they print, so nothing is left
for a later filter to draw again in colour.
"""
return [
"gs", "-q", "-dSAFER", "-dBATCH", "-dNOPAUSE",
"-sDEVICE=pdfwrite",
"-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray",
"-dAutoRotatePages=/None",
"-dPreserveAnnots=false", "-dPrinted",
f"-sOutputFile={target}", "--", str(source),
]
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).
+8 -1
View File
@@ -4,7 +4,7 @@ Section: misc
Priority: optional
Architecture: amd64
Maintainer: Ekklesia
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, ghostscript, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
Description: Ekklesia managed classroom environment
@@ -48,6 +48,13 @@ Description: Ekklesia managed classroom environment
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
.
ghostscript is Wonder Ink's black and white. It was already on every
machine, because cups-filters needs it; it is named here because Wonder
Ink now runs it itself, to make the grey copy of a page that the print
preview shows and CUPS is given. Measured: it is the same engine Elmer's
driver prints with, and its grey differs from what Elmer would have
made of the colour page by at most 1 level in 255.
.
The fonts are what a Google Slides export asks for. The metric
compatible families cover the Microsoft names — Liberation for Arial,
Times New Roman and Courier New, Carlito for Calibri, Caladea for
+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
+56 -1
View File
@@ -230,6 +230,24 @@ check("every position is on a three by three grid",
len(printing.PICTURE_POSITIONS) == 9
and sorted(printing.PICTURE_POSITIONS.values()) == [(c, r) for c in range(3) for r in range(3)])
print()
print("4c. black and white, made before the preview")
command = printing.grayscale_command(work / "in.pdf", work / "out gray.pdf")
check("Ghostscript writes a PDF", command[0] == "gs" and "-sDEVICE=pdfwrite" in command, command)
check("in grey and nothing else",
"-sColorConversionStrategy=Gray" in command and "-dProcessColorModel=/DeviceGray" in command, command)
# pdfwrite otherwise turns a page to match its text; a landscape picture
# on a portrait sheet would come back turned.
check("never turning a page", "-dAutoRotatePages=/None" in command, command)
check("annotations drawn in as they print, not left for a filter to draw in colour",
"-dPreserveAnnots=false" in command and "-dPrinted" in command, command)
check("safely, and without stopping to ask",
"-dSAFER" in command and "-dBATCH" in command and "-dNOPAUSE" in command, command)
check("to the file asked for, even with a space in its name",
f"-sOutputFile={work / 'out gray.pdf'}" in command, command)
check("and the source after --, so no name can be read as an option",
command[-2:] == ["--", str(work / "in.pdf")], command)
print()
print("5. Wonder Ink prints through this, and only this")
ink = (root / "scripts/classroom-wonderink").read_text()
@@ -239,7 +257,10 @@ 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)
"def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):" in code
and "self._timer.start(timeout)" in code)
check("black and white has longer, since a page of photographs is slow, not stuck",
"GRAY_TIMEOUT_MS = 60_000" in code and " GRAY_TIMEOUT_MS,\n" 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)
@@ -255,6 +276,40 @@ check("the print interface says Color, not Colour",
'"Color"' in preview_code and '"Colour"' not in preview_code)
check("an exception in a handler is logged, not fatal",
"sys.excepthook = keep_going" in code)
# 0.2.6.3: black and white is shown as it prints.
check("black and white is made by classroom_printing.grayscale_command, in the background",
"classroom_printing.grayscale_command(" in preview_code and "QProcess(" in preview_code)
check("choosing Color or Black and white lays the preview out again",
"self._colour.currentIndexChanged.connect" in preview_code)
check("a failed conversion is judged by its exit and its pages, never printed in colour",
"process.exitCode() != 0" in preview_code
and "page_count(target) != expected" in preview_code)
check("the driver is still told ColorModel=Grayscale for the grey file",
'gray = self._options[printer]["gray"]' in preview_code)
check("and nothing prints that is not what is shown",
"(self._gray is not None and self._pdf == self._gray[1]) != (gray is not None)" in preview_code)
# 0.2.6.3: right-click says what it prints.
menu_code = code[code.index(" def contextMenuEvent"):code.index("\nclass WonderInk")]
check("right-click offers Print page always",
'"Print page…"' in menu_code and "print_view(self)" in menu_code)
check("and Print image only where the engine's hit test found a picture or a canvas",
'"Print image…"' in menu_code and "MediaTypeImage" in menu_code
and "MediaTypeCanvas" in menu_code and "print_image(self)" in menu_code)
check("the old single Print… is gone from the menu", '"Print…"' not in menu_code)
take_code = code[code.index(" def _take_picture"):code.index(" def _show_preview")]
check("the picture is the engine's own copy, not fetched again",
"WebAction.CopyImageToClipboard" in take_code
and "download(" not in take_code and "QNetwork" not in take_code)
check("what was on the clipboard is kept and put back",
"keep_clipboard(clipboard)" in take_code and "put_back()" in take_code)
check("a picture that never arrives is said so, not printed as the page",
"TAKE_PICTURE_MS" in take_code and "dialog.fail(" in take_code
and "print_view" not in take_code)
check("and it goes to the picture layout, on white",
"dialog.show_picture(printable_picture(image))" in take_code
and "Qt.GlobalColor.white" in code)
check("the module is packaged",
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())