From 594fbd810e96505aa61c5b06a41db945f8844dff Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 26 Sep 2026 16:34:38 -0500 Subject: [PATCH] fix: black and white is shown as it prints, and right-click prints the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/classroom_printing.py | 36 +++ packaging/debian/control.template | 9 +- scripts/classroom-wonderink | 456 +++++++++++++++++++++++++++--- tests/test-classroom-printing.sh | 57 +++- 4 files changed, 514 insertions(+), 44 deletions(-) diff --git a/lib/classroom_printing.py b/lib/classroom_printing.py index 7014480..f3831d6 100644 --- a/lib/classroom_printing.py +++ b/lib/classroom_printing.py @@ -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). diff --git a/packaging/debian/control.template b/packaging/debian/control.template index 996d36d..d33c052 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -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 diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 31eab8d..87ce221 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -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 (, 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 diff --git a/tests/test-classroom-printing.sh b/tests/test-classroom-printing.sh index 05f730d..aeaf4a5 100755 --- a/tests/test-classroom-printing.sh +++ b/tests/test-classroom-printing.sh @@ -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())