fix: a picture prints as the picture, where and as large as the teacher asks

The black bars above and below a printed picture were neither in the
picture nor in the layout. A PNG or JPEG in a tab of its own is shown by
the engine on a viewer page whose background is rgb(14, 14, 14), and Qt
prints page backgrounds by default; 0.2.6.1 printed that page.

A picture is now taken as itself — drawn onto white, capped at 300 dpi
across Letter — and laid out on the printer's paper by the preview, with
Size (as large as fits down to 25%), Position (nine squares), Paper
(automatic, upright, sideways) and Fill the whole page, which says it
cuts the edges off and is never the default. The preview is the PDF that
prints, laid out again after each change. Worksheet PDFs and lesson pages
get no picture controls and are unchanged.

Page backgrounds are no longer printed for lesson pages either, as in
Chrome; Blink darkens light text when it does that, so a white-on-black
page prints readable grey on white. "Colour" is "Color" in the print
interface and the manual.

Lab: 135 print checks, now including eight picture layouts measured on
the job file CUPS kept — byte-identical to the preview, within 1 point of
where asked, no ink anywhere else. Printed from the real session with the
pointer: 50% top left, Elmer-66, the same. Not yet on paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-23 21:41:15 -05:00
co-authored by Claude Opus 5.5
parent 6946bae455
commit 940f63e0e5
10 changed files with 662 additions and 13 deletions
+229 -6
View File
@@ -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
)