A lesson site for the lab (session cookie, one-time addresses, a second origin, a request log) and real right-clicks on its words and on a JPEG, a PNG, a transparent PNG, a one-time picture, another site's, a blob:, a WebP, a canvas, a linked picture, a broken one, and Cancel. Each is judged on the job CUPS kept — where and how large, no other ink — and on the site log: nothing was fetched again. The clipboard is the teacher's afterwards. Black and white: a colourful lesson turned back and forth five times on page 2, a worksheet, a picture at 50% top left, a page full of pictures, back to Color, and Cancel. Every grey preview is grey pixel for pixel and the job is byte-for-byte the file shown, through pxlmono. The three 0.2.6.2 black-and-white scenarios now wait for the grey file before Print, as a teacher has to. 343 checks, none failing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
208 lines
8.3 KiB
Python
208 lines
8.3 KiB
Python
"""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
|
|
wonderink-picture-measure.py colour 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)
|
|
webp(directory)
|
|
|
|
|
|
def webp(directory):
|
|
"""ark-wide.png as WebP. Qt cannot write WebP, or read it; Chromium does both."""
|
|
import base64
|
|
|
|
from PyQt6.QtCore import QTimer, QUrl
|
|
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
|
app = QApplication.instance() or QApplication(sys.argv[:1])
|
|
source = base64.b64encode(open(f"{directory}/ark-wide.png", "rb").read()).decode()
|
|
view = QWebEngineView()
|
|
|
|
def made(answer):
|
|
if isinstance(answer, str) and answer.startswith("data:image/webp;base64,"):
|
|
with open(f"{directory}/ark-wide.webp", "wb") as handle:
|
|
handle.write(base64.b64decode(answer.split(",", 1)[1]))
|
|
app.quit()
|
|
|
|
def loaded(_ok):
|
|
view.page().runJavaScript("""var i = new Image(); i.onload = function(){
|
|
var c = document.createElement('canvas'); c.width = i.width; c.height = i.height;
|
|
c.getContext('2d').drawImage(i, 0, 0); window.__webp = c.toDataURL('image/webp', 0.95); };
|
|
i.src = 'data:image/png;base64,%s'; 0""" % source)
|
|
QTimer.singleShot(1500, lambda: view.page().runJavaScript("window.__webp", made))
|
|
|
|
view.loadFinished.connect(loaded)
|
|
view.setHtml("<html></html>")
|
|
QTimer.singleShot(20000, app.quit)
|
|
app.exec()
|
|
|
|
|
|
def page_pixels(pdf, page=1):
|
|
ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", str(page), "-l", str(page), "-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
|
|
|
|
# The corner mark, wherever it landed and whatever colour it came out:
|
|
# pink on a colour page, grey on a black-and-white one. Inked if most
|
|
# of the middle of where it should be is not paper.
|
|
mark_left = max(x + w * 0.07, MARGIN_POINTS)
|
|
mark_right = min(x + w * 0.13, width - MARGIN_POINTS)
|
|
mark_top = max(y + h * 0.82, MARGIN_POINTS)
|
|
mark_bottom = min(y + h * 0.88, height - MARGIN_POINTS)
|
|
mark = [
|
|
sum(rgb[(row * width + column) * 3:(row * width + column) * 3 + 3]) < 3 * 200
|
|
for row in range(int(mark_top), int(mark_bottom))
|
|
for column in range(int(mark_left), int(mark_right))
|
|
]
|
|
|
|
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,
|
|
"mark_inked": bool(mark) and sum(mark) > 0.8 * len(mark),
|
|
"colour_spread": spread(rgb),
|
|
}))
|
|
|
|
|
|
def spread(rgb):
|
|
"""The most any pixel's red, green and blue differ: 0 on a grey page."""
|
|
return max((max(rgb[i:i + 3]) - min(rgb[i:i + 3]) for i in range(0, len(rgb) - 2, 3)), default=0)
|
|
|
|
|
|
def colour(pdf):
|
|
"""Is every page grey? The colour spread of each page, and how many."""
|
|
pages = int(subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout
|
|
.split("Pages:")[1].split()[0])
|
|
spreads = [spread(page_pixels(pdf, n)[2]) for n in range(1, pages + 1)]
|
|
print(json.dumps({"pages": pages, "spreads": spreads, "gray": max(spreads) == 0}))
|
|
|
|
|
|
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])
|
|
elif sys.argv[1] == "colour":
|
|
colour(sys.argv[2])
|
|
else:
|
|
measure(*sys.argv[2:])
|