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())