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:
co-authored by
Claude Opus 5.5
parent
6946bae455
commit
940f63e0e5
+229
-6
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user