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
+144
View File
@@ -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:])
+68 -1
View File
@@ -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/^/ /'; }
+46 -5
View File
@@ -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("<html><head><title>Night sky</title></head><body style='background:#000;color:#fff'>"
+ "<h1>Stars</h1>" + "<p>God made the stars. </p>" * 40 + "</body></html>",
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()