diff --git a/docs/manual/50-wonderink.md b/docs/manual/50-wonderink.md index 8e930f5..61e6a0c 100644 --- a/docs/manual/50-wonderink.md +++ b/docs/manual/50-wonderink.md @@ -36,7 +36,14 @@ and choose **Print…**. You will see the pages exactly as they will print. * Choose **Only some pages** to print, for example, `2` or `1-3`. * Set **Copies** for the number of children. -* Choose **Black and white** to save colour ink. +* Choose **Black and white** to save color ink. + +**Printing a picture.** When the tab is a picture on its own, the +preview also shows **Picture**: choose its **Size** (for example 50%), +click one of the nine **Position** squares to put it in a corner or the +middle, and choose **Paper** upright or sideways. The preview changes as +you choose, and shows exactly what will print. **Fill the whole page** +makes it cover the paper but cuts off the edges. Click **Print**. Wonder Ink says when the printer has it and goes back to the lesson. **Cancel** prints nothing. diff --git a/docs/screenshots/pic-preview-default.png b/docs/screenshots/pic-preview-default.png new file mode 100644 index 0000000..9524074 Binary files /dev/null and b/docs/screenshots/pic-preview-default.png differ diff --git a/docs/screenshots/pic-preview-half-topleft.png b/docs/screenshots/pic-preview-half-topleft.png new file mode 100644 index 0000000..0200229 Binary files /dev/null and b/docs/screenshots/pic-preview-half-topleft.png differ diff --git a/docs/wonder-ink.md b/docs/wonder-ink.md index b89121c..4dc224d 100644 --- a/docs/wonder-ink.md +++ b/docs/wonder-ink.md @@ -244,6 +244,39 @@ printed. Print pressed again while a preview is open does nothing, and so does a page calling `window.print()` meanwhile. The PDF viewer's own print button opens the same preview. +### Pictures (0.2.6.2) + +A picture a lesson opens in a tab of its own — a colouring page as a PNG +or JPEG — printed in 0.2.6.1 with **black bars** above and below it. +Measured: the bars were neither in the picture nor in the page layout. +The engine shows a lone picture on a viewer page whose background is +rgb(14, 14, 14), scaled to the window, and Qt prints page backgrounds by +default; the dark page went onto the paper around the artwork. + +So a picture is no longer printed as that page. The preview takes the +picture itself — drawn onto white, which is what transparent parts are on +paper, at up to 3300 pixels (300 dpi across Letter) — and lays it out on +the printer's own paper, and the teacher chooses: + +* **Size** — as large as fits, 75%, 50%, 33% or 25% of that; +* **Position** — nine squares, like the page itself; +* **Paper** — automatic (landscape for a wide picture), upright or + sideways; +* **Fill the whole page** — covers the printable area and says it cuts the + edges off. Nothing is cropped unless this is ticked. + +The preview is the PDF that will print, laid out again after every change. +`tools/lab/wonderink-print-check` measures it on the file CUPS actually +kept (`PreserveJobFiles`), which is byte-for-byte the preview's: the +picture within 1 point of where it was asked for, the paper turned as +asked, and no ink anywhere else on the page. Worksheet PDFs and lesson +pages have no picture controls; nothing about them changed. + +Page backgrounds are also no longer printed for lesson pages, as in +Chrome, where "Background graphics" is off unless asked for. A white-on- +black page prints dark grey on white: Blink darkens light text when the +background is left off, measured. + Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed when the preview closes. diff --git a/lib/classroom_printing.py b/lib/classroom_printing.py index 11f497e..7014480 100644 --- a/lib/classroom_printing.py +++ b/lib/classroom_printing.py @@ -225,3 +225,77 @@ def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"): # without the program's name in front of it. message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job." return False, message + + +# ---------------------------------------------------------------- pictures +# +# A picture a lesson opens in a tab of its own is not a page: the engine +# shows it on a near-black viewer page (body background rgb(14, 14, 14), +# measured) scaled to the window, and printing that page printed the black +# around the picture — the bars teachers saw above and below the artwork. +# So a picture is laid out on the paper here instead, from the picture +# itself, and the teacher chooses how large and where. + +# How large, as a share of the largest size that fits the page. +PICTURE_SIZES = (100, 75, 50, 33, 25) + +# Where, as (column, row): 0 left or top, 1 centre, 2 right or bottom. +PICTURE_POSITIONS = { + "top left": (0, 0), "top": (1, 0), "top right": (2, 0), + "left": (0, 1), "centre": (1, 1), "right": (2, 1), + "bottom left": (0, 2), "bottom": (1, 2), "bottom right": (2, 2), +} + +# Portrait or landscape: "auto" follows the picture's own shape. +ORIENTATIONS = ("auto", "portrait", "landscape") + + +def picture_landscape(orientation, width, height): + """Should the page be landscape for this picture?""" + if orientation == "landscape": + return True + + if orientation == "portrait": + return False + + return width > height + + +def picture_rect(page, margin, picture, size=100, position="centre", fill=False): + """Where a picture goes on a page, as (x, y, width, height). + + PAGE is (width, height) and MARGIN the blank edge, both in the same + units; PICTURE is the picture's (width, height) in pixels. Never + stretched: the picture keeps its own proportions. + + Fit (the default) is the largest size that shows ALL of the picture + inside the margins, times SIZE percent, placed at POSITION. Fill covers + the whole printable area and so cuts off whatever overhangs — which a + teacher asks for by name, never by default; SIZE and POSITION do not + apply to it. + """ + page_width, page_height = page + width, height = picture + + if width <= 0 or height <= 0: + raise ValueError("the picture has no size") + + area_x, area_y = margin, margin + area_width = max(1.0, page_width - 2 * margin) + area_height = max(1.0, page_height - 2 * margin) + + if fill: + scale = max(area_width / width, area_height / height) + drawn_width, drawn_height = width * scale, height * scale + return (area_x + (area_width - drawn_width) / 2, + area_y + (area_height - drawn_height) / 2, + drawn_width, drawn_height) + + size = max(1, min(100, int(size))) + scale = min(area_width / width, area_height / height) * size / 100 + drawn_width, drawn_height = width * scale, height * scale + column, row = PICTURE_POSITIONS.get(position, (1, 1)) + + return (area_x + (area_width - drawn_width) * column / 2, + area_y + (area_height - drawn_height) * row / 2, + drawn_width, drawn_height) diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 7074998..31eab8d 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -167,6 +167,7 @@ from PyQt6.QtCore import ( # noqa: E402 QEvent, QKeyCombination, QMarginsF, + QRectF, Qt, QTimer, QUrl, @@ -177,8 +178,11 @@ from PyQt6.QtGui import ( # noqa: E402 QGuiApplication, QIcon, QKeySequence, + QImage, QPageLayout, QPageSize, + QPainter, + QPdfWriter, QPixmap, ) from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 @@ -191,9 +195,12 @@ from PyQt6.QtWebEngineCore import ( # noqa: E402 from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402 from PyQt6.QtWidgets import ( # noqa: E402 QApplication, + QButtonGroup, + QCheckBox, QComboBox, QDialog, QFormLayout, + QGridLayout, QHBoxLayout, QLabel, QLineEdit, @@ -221,6 +228,36 @@ PRINT_MARGIN_MM = 10 # and says so. Measured: a long lesson page takes well under a second. PREPARE_TIMEOUT_MS = 20_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 + +# 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 +# near-black viewer page, scaled to the window. Printed as a page, that is +# the black bars above and below the artwork that teachers saw. So the +# picture is taken as it is — drawn onto white, which is what transparent +# parts become on paper — and laid out on the paper by the preview. +# Capped at 3300 pixels on its longest side, which is 300 dpi across a +# sheet of Letter; nothing a printer can show is lost. An SVG is a +# document, not a picture on a viewer page, and prints as a page. +PICTURE = """(function(){ + var type = document.contentType || ''; + 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 canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(img.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(img.naturalHeight * scale)); + var g = canvas.getContext('2d'); + g.fillStyle = '#ffffff'; + 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; } +})()""" + # How much of a page title fits on a tab before it stops being a label # and starts being a paragraph. TAB_TITLE = 22 @@ -936,8 +973,31 @@ class WonderInk(QMainWindow): self._preview = None def _render(self, view, dialog, paper): - """Ask the engine for this page as a PDF on PAPER, for DIALOG.""" + """Ask the engine for this page as a PDF on PAPER, for DIALOG. + + Unless the tab is a picture on its own, in which case the picture + itself is handed to the preview to be laid out on the paper. + """ attempt = dialog.preparing() + + def picture(answer): + if not attempt.current(): + return + + if isinstance(answer, str) and answer.startswith("data:image/png;base64,"): + import base64 + + image = QImage.fromData(base64.b64decode(answer.split(",", 1)[1])) + + if not image.isNull(): + dialog.show_picture(image) + return + + self._render_page(view, dialog, paper, attempt) + + view.page().runJavaScript(PICTURE, picture) + + def _render_page(self, view, dialog, paper, attempt): size = QPageSize(getattr(QPageSize.PageSizeId, paper)) layout = QPageLayout( size, QPageLayout.Orientation.Portrait, @@ -1173,6 +1233,14 @@ QDialog#preview QPushButton#print {{ QDialog#preview QPushButton#print:disabled {{ background: {c['line']}; color: {c['dim']}; }} QDialog#preview QPushButton#turn {{ min-width: 44px; padding: 4px 8px; font-size: 18px; }} QDialog#preview QLabel#status[error="true"] {{ color: {c['negative']}; }} +QDialog#preview QLabel#heading {{ font-weight: 600; font-size: 16px; }} +QDialog#preview QToolButton#place {{ + border: 1px solid {c['dim']}; + border-radius: 4px; + background: {c['view']}; +}} +QDialog#preview QToolButton#place:checked {{ background: {c['accent']}; border-color: {c['accent']}; }} +QDialog#preview QToolButton#place:disabled {{ border-color: {c['line']}; background: {c['window']}; }} """ @@ -1191,6 +1259,45 @@ class _Attempt: return self.live +class PositionGrid(QWidget): + """Where on the paper: nine squares, one chosen, like the page itself.""" + + changed = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + grid = QGridLayout(self) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(4) + self._group = QButtonGroup(self) + self._names = {} + + for name, (column, row) in classroom_printing.PICTURE_POSITIONS.items(): + button = QToolButton(self) + button.setCheckable(True) + button.setObjectName("place") + button.setToolTip(name.capitalize()) + button.setAccessibleName(name.capitalize()) + button.setFixedSize(30, 24) + grid.addWidget(button, row, column) + self._group.addButton(button) + self._names[button] = name + button.setChecked(name == "centre") + + self._group.buttonToggled.connect( + lambda button, checked: self.changed.emit() if checked else None + ) + + def position(self): + checked = self._group.checkedButton() + return self._names.get(checked, "centre") + + def choose(self, name): + for button, known in self._names.items(): + if known == name: + button.setChecked(True) + + class PageView(QWidget): """One page of the document, whole, as it will come out of the printer.""" @@ -1241,8 +1348,9 @@ class PageView(QWidget): class PrintPreview(QDialog): """The document as it will print, and the few choices a teacher has. - Printer, which pages, how many copies, and colour or black and white - where the printer's driver can do both. No paper tray, no scaling, no + Printer, which pages, how many copies, and color or black and white + where the printer's driver can do both — and, for a picture, how large + 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. """ @@ -1269,6 +1377,7 @@ class PrintPreview(QDialog): self._attempt = None self._paper = None self._options = {} + self._serial = iter(range(1, 1 << 30)) self._timer = QTimer(self) self._timer.setSingleShot(True) @@ -1309,8 +1418,8 @@ class PrintPreview(QDialog): self._copies = QSpinBox(self) self._copies.setRange(1, classroom_printing.MAX_COPIES) self._colour = QComboBox(self) - self._colour.addItems(["Colour", "Black and white"]) - self._colour_label = QLabel("Colour", self) + self._colour.addItems(["Color", "Black and white"]) + self._colour_label = QLabel("Color", self) form = QFormLayout() form.addRow("Printer", self._printer) @@ -1319,6 +1428,40 @@ class PrintPreview(QDialog): form.addRow("Copies", self._copies) form.addRow(self._colour_label, self._colour) + # -- a picture: how large, and where --------------------------------- + self._picture = None + self._compose_timer = QTimer(self) + self._compose_timer.setSingleShot(True) + self._compose_timer.timeout.connect(self._compose) + + self._size = QComboBox(self) + for percent in classroom_printing.PICTURE_SIZES: + self._size.addItem( + "As large as fits (100%)" if percent == 100 else f"{percent}%", percent + ) + self._place = PositionGrid(self) + self._turned = QComboBox(self) + self._turned.addItem("Automatic", "auto") + self._turned.addItem("Upright (portrait)", "portrait") + self._turned.addItem("Sideways (landscape)", "landscape") + self._fill = QCheckBox("Fill the whole page\n(the edges are cut off)", self) + + for signal in (self._size.currentIndexChanged, self._turned.currentIndexChanged, + self._place.changed, self._fill.toggled): + signal.connect(self._picture_changed) + + self._picture_box = QWidget(self) + picture_form = QFormLayout(self._picture_box) + picture_form.setContentsMargins(0, 12, 0, 0) + heading = QLabel("Picture", self) + heading.setObjectName("heading") + picture_form.addRow(heading) + picture_form.addRow("Size", self._size) + picture_form.addRow("Position", self._place) + picture_form.addRow("Paper", self._turned) + picture_form.addRow("", self._fill) + self._picture_box.setVisible(False) + self._status = QLabel(self) self._status.setObjectName("status") self._status.setWordWrap(True) @@ -1337,6 +1480,7 @@ class PrintPreview(QDialog): right = QVBoxLayout() right.addLayout(form) + right.addWidget(self._picture_box) right.addStretch(1) right.addWidget(self._status) right.addLayout(buttons) @@ -1410,6 +1554,74 @@ class PrintPreview(QDialog): path.write_bytes(data) self.show_document(path) + def show_picture(self, image): + """A picture on its own, to be laid out on the paper here.""" + self._timer.stop() + + if self._attempt is not None: + self._attempt.live = False + + self._picture = image + self._picture_box.setVisible(True) + self._compose() + + def _picture_changed(self, *_): + filled = self._fill.isChecked() + self._size.setEnabled(not filled) + self._place.setEnabled(not filled) + + if self._picture is not None: + # A moment's pause, so turning through the sizes does not lay + # the page out once for every step. + self._compose_timer.start(120) + + def _compose(self): + """Lay the picture out on one page, as the PDF that will print.""" + import tempfile + + 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" + + writer = QPdfWriter(str(path)) + writer.setTitle(self._title) + writer.setResolution(PICTURE_DPI) + writer.setPageLayout(QPageLayout( + QPageSize(getattr(QPageSize.PageSizeId, self.paper())), + QPageLayout.Orientation.Landscape if landscape else QPageLayout.Orientation.Portrait, + QMarginsF(0, 0, 0, 0), QPageLayout.Unit.Millimeter, + )) + + page = (writer.width(), writer.height()) + margin = PRINT_MARGIN_MM / 25.4 * PICTURE_DPI + x, y, width, height = classroom_printing.picture_rect( + page, margin, (image.width(), image.height()), + self._size.currentData(), self._place.position(), self._fill.isChecked(), + ) + + painter = QPainter(writer) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + # Filling overhangs the margins by design; nothing is drawn in them. + painter.setClipRect(QRectF(margin, margin, page[0] - 2 * margin, page[1] - 2 * margin)) + painter.drawImage(QRectF(x, y, width, height), image) + painter.end() + + previous = self._pdf + 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): self._pdf = Path(pdf) self._pages = classroom_printing.page_count(self._pdf) @@ -1482,7 +1694,12 @@ class PrintPreview(QDialog): if found["paper"] != self._paper: self._paper = found["paper"] - if not initial: + if initial: + return + + if self._picture is not None: + self._compose() + else: self.paper_wanted.emit(self._paper) def _turn(self, step): @@ -1683,6 +1900,12 @@ def build_profile(app, name="wonderink"): # loads file: URLs at all, a default is not a good enough answer. # Both are named here so that the answer is in the file rather than in # a Qt release note. + # Page backgrounds are not printed, as in Chrome, where "Background + # graphics" is off unless a teacher asks for it. Qt's default is the + # opposite, and a dark page background printed as solid black. + settings.setAttribute( + QWebEngineSettings.WebAttribute.PrintElementBackgrounds, False + ) settings.setAttribute( QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False ) diff --git a/tests/test-classroom-printing.sh b/tests/test-classroom-printing.sh index 4d04345..05f730d 100755 --- a/tests/test-classroom-printing.sh +++ b/tests/test-classroom-printing.sh @@ -177,6 +177,59 @@ ok, answer = printing.submit(pdf, "Elmer") check("a refused job is a failure, in CUPS's own words", not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer) +print() +print("4b. where a picture goes on the paper") +LETTER, MARGIN = (612, 792), 28.35 +area = (MARGIN, MARGIN, 612 - 2 * MARGIN, 792 - 2 * MARGIN) +close = lambda a, b: all(abs(x - y) < 0.01 for x, y in zip(a, b)) + +x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000)) +check("a wide picture fits the width and keeps its shape", + abs(w - area[2]) < 0.01 and abs(w / h - 1.6) < 0.001, (x, y, w, h)) +check("and is centred on the page", abs(y + h / 2 - 396) < 0.01) +check("nothing of it falls outside the margins", + x >= MARGIN - 0.01 and y >= MARGIN - 0.01 + and x + w <= 612 - MARGIN + 0.01 and y + h <= 792 - MARGIN + 0.01) + +x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "top left") +check("50% in the top left is half the fitted size, at the margin", + close((x, y, w), (MARGIN, MARGIN, area[2] / 2)), (x, y, w, h)) +x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "bottom right") +check("25% in the bottom right ends at the margin", + close((x + w, y + h), (612 - MARGIN, 792 - MARGIN)), (x, y, w, h)) +x, y, w, h = printing.picture_rect(LETTER, MARGIN, (900, 1400), 100, "right") +check("a tall picture fits the height and sits at the right", + abs(h - area[3]) < 0.01 and abs(x + w - (612 - MARGIN)) < 0.01, (x, y, w, h)) + +x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True) +check("fill covers the whole printable area", + x <= MARGIN and y <= MARGIN + 0.01 and x + w >= 612 - MARGIN and y + h >= 792 - MARGIN - 0.01) +check("and keeps the picture's shape, cutting off what overhangs", + abs(w / h - 1.6) < 0.001 and w > area[2]) +check("fill ignores size and position", + printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "top left", True) + == printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True)) +check("an unknown position is the centre", + printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "upside down") + == printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "centre")) +try: + printing.picture_rect(LETTER, MARGIN, (0, 10)) + check("a picture with no size is refused", False) +except ValueError: + check("a picture with no size is refused", True) + +check("automatic paper is landscape for a wide picture", + printing.picture_landscape("auto", 1600, 1000)) +check("and portrait for a tall or square one", + not printing.picture_landscape("auto", 900, 1400) + and not printing.picture_landscape("auto", 1000, 1000)) +check("and the teacher's choice overrides it", + not printing.picture_landscape("portrait", 1600, 1000) + and printing.picture_landscape("landscape", 900, 1400)) +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("5. Wonder Ink prints through this, and only this") ink = (root / "scripts/classroom-wonderink").read_text() @@ -193,6 +246,13 @@ check("there is no file chooser in it", "QFileDialog" not in code) check("a worksheet tab is printed from its file", "self._pdf_views[view] = resolved" in code) check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code) +check("a page's background is not printed, as in Chrome", + "PrintElementBackgrounds, False" in code) +check("a picture on its own is laid out, not printed as the viewer page", + "dialog.show_picture(image)" in code and "document.contentType" in ink) +preview_code = code[code.index("class PrintPreview"):code.index("\ndef notify")] +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) check("the module is packaged", diff --git a/tools/lab/wonderink-picture-measure.py b/tools/lab/wonderink-picture-measure.py new file mode 100644 index 0000000..980831c --- /dev/null +++ b/tools/lab/wonderink-picture-measure.py @@ -0,0 +1,144 @@ +"""Make the test pictures, and measure where a printed picture really is. + + wonderink-picture-measure.py make DIRECTORY + wonderink-picture-measure.py measure PDF WIDTHxHEIGHT SIZE POSITION PAPER FILL + wonderink-picture-measure.py darkness PDF + +Run inside the lab by tools/lab/wonderink-print-check. The pictures are +white with a black border drawn on their own edges and a pink mark near +one corner, so the ink on a printed page says exactly where the picture +landed — and any other dark ink on the page is something that was added, +which is what the black bars were. + +The page is rendered at 72 dpi, so one pixel is one PostScript point, and +compared against classroom_printing.picture_rect: the same arithmetic +Wonder Ink lays the page out with, asked independently of the preview. +""" + +import json +import subprocess +import sys + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") + +import classroom_printing # noqa: E402 + +MARGIN_POINTS = 10 / 25.4 * 72 + + +def make(directory): + from PyQt6.QtCore import QRectF, Qt + from PyQt6.QtGui import QColor, QFont, QImage, QPainter, QPen + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv[:1]) # noqa: F841 — kept, or Qt has no application + + def art(width, height, name, label, transparent=False): + image = QImage(width, height, QImage.Format.Format_ARGB32) + image.fill(QColor(0, 0, 0, 0) if transparent else QColor("white")) + painter = QPainter(image) + painter.setPen(QPen(QColor("black"), 8)) + painter.drawRect(QRectF(4, 4, width - 8, height - 8)) + painter.setPen(QPen(QColor("black"), 6)) + painter.drawEllipse(QRectF(width * 0.2, height * 0.25, width * 0.6, height * 0.5)) + painter.setFont(QFont("DejaVu Sans", int(min(width, height) * 0.05))) + painter.drawText(QRectF(0, height * 0.05, width, height * 0.15), + Qt.AlignmentFlag.AlignCenter, label) + painter.fillRect(QRectF(width * 0.05, height * 0.8, width * 0.1, height * 0.1), + QColor("#e0006c")) + painter.end() + image.save(f"{directory}/{name}") + + art(1600, 1000, "ark-wide.png", "Colour the Ark") + art(900, 1400, "ark-tall.png", "Noah") + art(1600, 1000, "ark-wide.jpg", "Colour the Ark") + # Transparent around the drawing: on paper that has to be white, not + # black, which is what a careless composite would make of it. + art(1200, 1200, "ark-clear.png", "Clear", transparent=True) + + +def page_pixels(pdf): + ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", "1", "-l", "1", "-singlefile", pdf], + capture_output=True, check=True).stdout + # P6 header: magic, width, height, maxval, then raw RGB. + fields, index = [], 0 + while len(fields) < 4: + while ppm[index:index + 1].isspace(): + index += 1 + start = index + while not ppm[index:index + 1].isspace(): + index += 1 + fields.append(ppm[start:index]) + width, height = int(fields[1]), int(fields[2]) + return width, height, ppm[index + 1:] + + +def measure(pdf, picture, size, position, paper, fill): + width, height, rgb = page_pixels(pdf) + picture_w, picture_h = (int(n) for n in picture.split("x")) + landscape = classroom_printing.picture_landscape(paper, picture_w, picture_h) + x, y, w, h = classroom_printing.picture_rect( + (width, height), MARGIN_POINTS, (picture_w, picture_h), + int(size), position.replace("-", " "), fill == "fill") + + # The expected ink, clipped to the printable area as the page is. + left = max(x, MARGIN_POINTS) + top = max(y, MARGIN_POINTS) + right = min(x + w, width - MARGIN_POINTS) + bottom = min(y + h, height - MARGIN_POINTS) + + xs, ys, outside, pink = [], [], 0, 0 + + for row in range(height): + for column in range(width): + i = (row * width + column) * 3 + r, g, b = rgb[i], rgb[i + 1], rgb[i + 2] + dark = r + g + b < 3 * 110 + magenta = r > 150 and g < 90 and b > 60 + + if dark or magenta: + xs.append(column) + ys.append(row) + + if not (left - 2 <= column <= right + 2 and top - 2 <= row <= bottom + 2): + outside += 1 + + if magenta: + pink += 1 + + found = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) if xs else None + expected = (round(left), round(top), round(right), round(bottom)) + off = max(abs(a - b) for a, b in zip(found, expected)) if found else None + + print(json.dumps({ + "page": [width, height], + "page_landscape": width > height, + "wanted_landscape": landscape, + "found": found, + "expected": expected, + "off_by_points": off, + "ink_outside_picture": outside, + "pink_pixels": pink, + })) + + +def darkness(pdf): + """How much of the first page is dark, 0 to 1.""" + width, height, rgb = page_pixels(pdf) + sums = [rgb[i] + rgb[i + 1] + rgb[i + 2] for i in range(0, len(rgb) - 2, 3)] + dark = sum(1 for total in sums if total < 3 * 110) + # Anything printed at all. Blink darkens light text when a page's + # background is not printed, so white-on-black lesson text comes out + # grey on white rather than vanishing; this is what says it did. + ink = sum(1 for total in sums if total < 3 * 200) + print(json.dumps({"dark_fraction": round(dark / (width * height), 4), + "ink_fraction": round(ink / (width * height), 4)})) + + +if __name__ == "__main__": + if sys.argv[1] == "make": + make(sys.argv[2]) + elif sys.argv[1] == "darkness": + darkness(sys.argv[2]) + else: + measure(*sys.argv[2:]) diff --git a/tools/lab/wonderink-print-check b/tools/lab/wonderink-print-check index 1fe0ad9..a457cfa 100755 --- a/tools/lab/wonderink-print-check +++ b/tools/lab/wonderink-print-check @@ -25,6 +25,8 @@ lab() { docker exec -i "$NAME" bash -c "$1"; } RIG=/opt/wonderink-check/wonderink-print-rig.py lab 'mkdir -p /opt/wonderink-check' docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null +MEASURE=/opt/wonderink-check/wonderink-picture-measure.py +docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || { echo "could not put the rig in the lab" >&2 exit 1 @@ -63,8 +65,18 @@ install -d -o teacher -m 700 /tmp/teacher-rt # An inline PDF, served the way a site shows one rather than sends it. Its # own port and directory: a lab often has another test server on 8088. mkdir -p /opt/wonderink-check/www +# CUPS keeps what it was given, so a check can compare the submitted +# file with the one the preview showed, byte for byte. +cupsctl PreserveJobFiles=Yes ' || { echo "could not prepare the lab's CUPS" >&2; exit 1; } +lab "test -f /opt/wonderink-check/www/ark-clear.png || \ + QT_QPA_PLATFORM=offscreen python3 $MEASURE make /opt/wonderink-check/www; \ + chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-clear.png" || { + echo "could not make the test pictures" >&2 + exit 1 +} + # Started detached. A background process started inside a docker exec # dies with it in the Plasma lab, whose PID 1 is systemd. lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \ @@ -97,12 +109,28 @@ run() { } count() { wc -w <<< "$jobs"; } +# The file CUPS was handed for a job, kept by PreserveJobFiles. +spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; } said() { grep -q -- "$1" <<< "$out"; } exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; } +# Where a picture goes, measured on the page CUPS was given: +# picture:FILE:SIZE:POSITION:PAPER:FILL[:gray] +PICTURES=( + picture:ark-wide.png:100:centre:auto:fit + picture:ark-wide.png:100:centre:portrait:fit + picture:ark-wide.png:50:top-left:auto:fit + picture:ark-tall.png:100:centre:auto:fit + picture:ark-tall.png:33:bottom-right:portrait:fit + picture:ark-wide.png:100:centre:portrait:fill + picture:ark-wide.jpg:75:right:auto:fit:gray + picture:ark-clear.png:50:top:auto:fit +) + scenarios=("$@") [[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline - options bad-range broken cancel page-closes-itself close-window page-prints-again repeat) + options bad-range broken cancel page-closes-itself close-window page-prints-again repeat + dark-page "${PICTURES[@]}") # The inline scenario needs a PDF to serve; the worksheet scenario writes one. lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; } @@ -162,6 +190,45 @@ for scenario in "${scenarios[@]}"; do judge "one job" '[[ $(count) -eq 1 ]]' ;; repeat) judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;; + dark-page) + judge "one job" '[[ $(count) -eq 1 ]]' + spool=$(spool_file "$jobs") + answer=$(lab "python3 $MEASURE darkness $spool") + dark=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["dark_fraction"])' "$answer") + ink=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["ink_fraction"])' "$answer") + judge "a black page background does not print (dark: $dark of the page)" \ + "python3 -c 'import sys; sys.exit(0 if $dark < 0.05 else 1)'" + judge "and its white text still does, darkened ($ink of the page)" \ + "python3 -c 'import sys; sys.exit(0 if $ink > 0.005 else 1)'" ;; + picture:*) + IFS=: read -r _ file size position paper fill color <<< "$scenario" + judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'" + judge "the print interface says Color" "said \"labels='Color','Color'\"" + judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' + spool=$(spool_file "$jobs") + judge "CUPS was given exactly the page the preview showed" \ + "[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'" + dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null) + result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill") + field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; } + judge "the paper is turned the way asked ($paper): $(field page)" \ + '[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]' + judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \ + '[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))' + judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \ + '[[ "$(field ink_outside_picture)" == 0 ]]' + if [[ "$fill" == fill ]]; then + # The mark is in the picture's left fifth, which filling a + # portrait page with a wide picture cuts off. That is what the + # control says it does; the fit scenarios prove it happens only + # when asked. + judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))' + else + judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))' + fi + if [[ "$color" == gray ]]; then + judge "and it went in black and white" '[[ "$(job_device $jobs)" == *pxlmono* ]]' + fi ;; esac [[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; } diff --git a/tools/lab/wonderink-print-rig.py b/tools/lab/wonderink-print-rig.py index 376ce0f..1562f0d 100644 --- a/tools/lab/wonderink-print-rig.py +++ b/tools/lab/wonderink-print-rig.py @@ -23,9 +23,8 @@ from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings notes = [] wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True)) app = QApplication(sys.argv) -profile = QWebEngineProfile("rig", app) -for a in ("PdfViewerEnabled", "PluginsEnabled"): - profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True) +# Wonder Ink's own profile, settings and all — not one of the rig's. +profile = wi.build_profile(app, "rig") window = wi.WonderInk(profile) profile.downloadRequested.connect(window.on_download) window.resize(1280, 800); window.show() @@ -60,7 +59,7 @@ def when(cond, then, label, limit=30): def preview(): return window._preview def ready(): return preview() is not None and preview()._pdf is not None def describe(d): - return f"shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}" + return f"picture={d._picture_box.isVisible()} shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}" def finish(code=0): stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}") @@ -101,7 +100,49 @@ actions = { QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"), } -if scenario == "repeat": +if scenario.startswith("picture:"): + # picture:FILE:SIZE:POSITION:PAPER:FILL[:color] + # Opens a picture on its own, chooses the layout with the preview's own + # controls, keeps the exact PDF the preview showed, then prints it. + _, name, size, position, paper, fill, *rest = scenario.split(":") + view = window.open_tab(); view.load(QUrl(f"http://127.0.0.1:8089/{name}")) + def ready_picture(): + d = preview() + return d is not None and d._picture is not None and d._pdf is not None + def choose(): + d = preview() + d._size.setCurrentIndex(d._size.findData(int(size))) + d._place.choose(position.replace("-", " ")) + d._turned.setCurrentIndex(d._turned.findData(paper)) + d._fill.setChecked(fill == "fill") + if rest and rest[0] == "gray": + d._colour.setCurrentIndex(1) + QTimer.singleShot(900, keep_and_print) + def keep_and_print(): + import shutil + d = preview() + out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True) + shutil.copy(d._pdf, out / "preview.pdf") + stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} " + f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r}") + press_print() + QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)), + when(ready_picture, choose, "picture preview"))) +elif scenario == "dark-page": + # A lesson page with a black background. Chrome does not print page + # backgrounds unless asked, and neither may Wonder Ink. + view = window.open_tab() + view.setHtml("
God made the stars.
" * 40 + "", + QUrl("https://app.wonderink.org/night")) + def keep_dark(): + import shutil + out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True) + shutil.copy(preview()._pdf, out / "preview.pdf") + press_print() + view.loadFinished.connect(lambda ok: (QTimer.singleShot(0, lambda: window.print_view(view)), + when(ready, keep_dark, "dark preview"))) +elif scenario == "repeat": view = window.open_tab(); lesson(view); n = [0] def once(): if n[0] == 5: return finish()