Printing through Elmer found two things. Black and white came out grey while the preview stayed in colour: 0.2.6.2 only told the driver. And right-clicking a picture in a lesson and choosing Print printed the whole page around it, where some pictures cannot be opened on their own at all. Black and white is now made before the preview. Ghostscript makes a grey copy, the preview shows it, and CUPS is given that same file, still with ColorModel=Grayscale so Elmer's PCL 6 driver prints with pxlmono. Measured first: Ghostscript's own grey of the colour original and of the copy give the same greys (Elmer's driver is Ghostscript); poppler's do not. It takes up to 4.4 s for a lesson page of pictures, so it runs in the background with a minute to finish, is made once and kept, and a failure is said so and never printed in colour instead. Ghostscript is now a named dependency; cups-filters already brought it. Right-click asks the engine's hit test: on a picture or a canvas it offers Print image and Print page, elsewhere only Print page. The picture comes from the engine's own copy image — the only one of five ways measured that worked for a signed-in picture, a hotlink-protected one, a one-time address, blob:, data:, another site's, a canvas and WebP — and it fetches nothing, so the teacher's sign-in is not needed again. It goes through the clipboard, which is kept and put back; measured under KWin with another program owning it. A picture that never arrives is said so; the page is not printed in its place. It then gets the 0.2.6.2 picture layout. Co-Authored-By: Claude Opus 5.5 <[email protected]>
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""Printing a PDF to the room's printer, and showing what will be printed.
|
|
|
|
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
|
|
Ink prints is a PDF by the time it gets here — a worksheet already is
|
|
one, and a lesson page is turned into one by the engine — so what is left
|
|
is the part the rest of the classroom already trusts: CUPS.
|
|
|
|
WHY NOT QPRINTER.
|
|
|
|
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
|
|
built from Elmer's own PPD with a file in place of the printer:
|
|
|
|
QWebEngineView.print() a lesson page arrived as 24.6 MB of
|
|
300 dpi pictures, no text left in it;
|
|
a worksheet PDF tab arrived as ONE BLANK
|
|
PAGE; a queue rejecting jobs was reported
|
|
as printed successfully; and on 0.2.2 it
|
|
crashed, because the printer was deleted
|
|
before Qt had finished with it.
|
|
|
|
printToPdf() + lp the same lesson was an 18 KB PDF with its
|
|
text, on Letter, through Elmer's filters
|
|
in 0.43 s; a rejected job is an error
|
|
with CUPS's own words in it.
|
|
|
|
The second is what Chrome does, and Chrome is what the rooms already
|
|
print from without trouble.
|
|
"""
|
|
|
|
import re
|
|
import subprocess
|
|
|
|
# lp hands the file to the scheduler and returns; it does not wait for
|
|
# the printer. A scheduler that has not answered in this long is not
|
|
# going to, and a teacher is standing at the dialog.
|
|
SUBMIT_TIMEOUT = 30
|
|
QUERY_TIMEOUT = 10
|
|
|
|
# What a worksheet can reasonably be asked for. A spin box that goes to
|
|
# 999 is one slip of a finger away from a ream of paper.
|
|
MAX_COPIES = 50
|
|
|
|
# Paper names a driver uses, in the terms Qt's page sizes are named by.
|
|
# Anything else is Letter, which is what the church's printer is.
|
|
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
|
|
|
|
# The driver's own words for "no colour", in the order they are tried.
|
|
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
|
|
# altogether — measured, it logs "Unknown option" and prints in colour —
|
|
# so black and white has to be asked for in the PPD's terms.
|
|
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
|
|
|
|
|
|
def _run(command, timeout=QUERY_TIMEOUT):
|
|
try:
|
|
return subprocess.run(
|
|
command, capture_output=True, text=True, timeout=timeout
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
return subprocess.CompletedProcess(command, 1, "", str(error))
|
|
|
|
|
|
def printers():
|
|
"""(names, default) for every printer CUPS will accept a job for.
|
|
|
|
lpstat -e is the list a teacher can print to: in a classroom that is
|
|
Elmer and nothing else, because discovery is switched off.
|
|
"""
|
|
names = [
|
|
line.strip()
|
|
for line in _run(["lpstat", "-e"]).stdout.splitlines()
|
|
if line.strip()
|
|
]
|
|
|
|
default = None
|
|
match = re.search(
|
|
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
|
|
)
|
|
|
|
if match and match.group(1) in names:
|
|
default = match.group(1)
|
|
elif names:
|
|
default = names[0]
|
|
|
|
return names, default
|
|
|
|
|
|
def options(printer):
|
|
"""What this printer's driver offers, parsed from lpoptions -l.
|
|
|
|
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
|
|
"""
|
|
found = {"paper": "Letter", "gray": None}
|
|
|
|
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
|
|
if ":" not in line:
|
|
continue
|
|
|
|
key, choices = line.split(":", 1)
|
|
key = key.split("/", 1)[0].strip()
|
|
choices = choices.split()
|
|
default = next((c[1:] for c in choices if c.startswith("*")), None)
|
|
plain = [c.lstrip("*") for c in choices]
|
|
|
|
if key == "PageSize" and default in PAPER:
|
|
found["paper"] = PAPER[default]
|
|
|
|
if key in ("ColorModel", "ColorMode", "OutputMode"):
|
|
gray = next((c for c in GRAY_CHOICES if c in plain), None)
|
|
|
|
if gray is not None and found["gray"] is None:
|
|
found["gray"] = (key, gray)
|
|
|
|
return found
|
|
|
|
|
|
def page_count(pdf):
|
|
"""How many pages the PDF has, or 0 if it cannot be read."""
|
|
result = _run(["pdfinfo", str(pdf)])
|
|
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
|
|
return int(match.group(1)) if match else 0
|
|
|
|
|
|
def render_page(pdf, number, width):
|
|
"""PNG bytes of one page, WIDTH pixels wide, or None.
|
|
|
|
Rendered into a directory of its own and read back. Measured on
|
|
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
|
|
stdout — it writes a file called "-.png" (or "--1.png") into whatever
|
|
directory it was started in, which for Wonder Ink is the teacher's
|
|
home, and the preview showed nothing at all.
|
|
"""
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
|
|
root = Path(directory) / "page"
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"pdftoppm", "-f", str(number), "-l", str(number),
|
|
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
|
|
"-png", "-singlefile", "--", str(pdf), str(root),
|
|
],
|
|
capture_output=True,
|
|
timeout=QUERY_TIMEOUT,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
|
|
image = root.with_suffix(".png")
|
|
|
|
if result.returncode != 0 or not image.is_file():
|
|
return None
|
|
|
|
data = image.read_bytes()
|
|
return data or None
|
|
|
|
|
|
def parse_pages(text, count):
|
|
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
|
|
|
|
"" or "all" is every page (None). Otherwise numbers and ranges
|
|
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
|
|
document. Returned normalised: "1-3,5".
|
|
"""
|
|
text = (text or "").strip().lower()
|
|
|
|
if text in ("", "all"):
|
|
return None
|
|
|
|
parts = []
|
|
|
|
for piece in re.split(r"\s*,\s*", text):
|
|
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
|
|
|
|
if not match:
|
|
raise ValueError(
|
|
f"“{piece}” is not a page number. "
|
|
"Use numbers like 2 or 1-3, separated by commas."
|
|
)
|
|
|
|
first = int(match.group(1))
|
|
last = int(match.group(2) or first)
|
|
|
|
if first < 1 or last < first:
|
|
raise ValueError(f"“{piece}” is not a range of pages.")
|
|
|
|
if last > count:
|
|
raise ValueError(
|
|
f"This document has {count} page{'s' if count != 1 else ''}."
|
|
)
|
|
|
|
parts.append(str(first) if first == last else f"{first}-{last}")
|
|
|
|
return ",".join(parts)
|
|
|
|
|
|
def grayscale_command(source, target):
|
|
"""The command that writes SOURCE, in black and white, to TARGET.
|
|
|
|
Black and white is made here, before the preview, so the preview shows
|
|
it and CUPS is given the same file — 0.2.6.2 only told the driver, and
|
|
the preview stayed in colour while the paper came out grey. Measured in
|
|
the lab (Ghostscript 10.05.1) on a lesson page, a picture page, a
|
|
worksheet and a fillable form:
|
|
|
|
* the result is grey and nothing else, with the same pages, page
|
|
sizes and words, and the text still text;
|
|
* Ghostscript's own grey rendering of the colour original and of
|
|
this file give the same greys — within 1 level in 255 on every
|
|
filled area and picture; small text can land a fraction of a
|
|
pixel over. Elmer's driver is Ghostscript, so this is what Elmer
|
|
would have made of the colour file anyway — poppler's grey is
|
|
not, and differs on 7% of a coloured page;
|
|
* 0.05 s for a worksheet, 1.3 s for a full-page photograph, 2 s for
|
|
42 pages, and 4.4 s for one lesson page with eleven full-size
|
|
pictures, which is why the preview does it in the background.
|
|
|
|
Pages are never turned (pdfwrite otherwise rotates a page by its text)
|
|
and annotations — a form field a teacher filled in, a mark on a
|
|
worksheet — are drawn into the page as they print, so nothing is left
|
|
for a later filter to draw again in colour.
|
|
"""
|
|
return [
|
|
"gs", "-q", "-dSAFER", "-dBATCH", "-dNOPAUSE",
|
|
"-sDEVICE=pdfwrite",
|
|
"-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray",
|
|
"-dAutoRotatePages=/None",
|
|
"-dPreserveAnnots=false", "-dPrinted",
|
|
f"-sOutputFile={target}", "--", str(source),
|
|
]
|
|
|
|
|
|
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
|
|
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
|
|
|
|
Success means the scheduler has the job and its id — which is the
|
|
thing Qt's own print path could not say: it reported a rejected job
|
|
as printed.
|
|
"""
|
|
copies = max(1, min(int(copies), MAX_COPIES))
|
|
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
|
|
|
|
if pages:
|
|
command += ["-P", pages]
|
|
|
|
if gray:
|
|
command += ["-o", f"{gray[0]}={gray[1]}"]
|
|
|
|
command += ["--", str(pdf)]
|
|
result = _run(command, timeout=SUBMIT_TIMEOUT)
|
|
match = re.search(r"request id is (\S+)", result.stdout)
|
|
|
|
if result.returncode == 0 and match:
|
|
return True, match.group(1)
|
|
|
|
message = (result.stderr or result.stdout).strip()
|
|
# "lp: The printer or class is not accepting jobs." reads better
|
|
# 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)
|