# Wonder Ink
Wonder Ink is a lesson application, not a browser: its own window, its own
profile, and no way from it into the rest of the web.
Since 0.2.1 it has tabs.
## Why it stopped being Chrome
It used to be `google-chrome --app=https://app.wonderink.org/`. App mode
gives exactly the window a classroom wants — no tab strip, no address bar,
reads as a program — and teacher feedback then found the thing app mode
takes away is the thing they need. A lesson is prepared across several
tabs: the lesson, its video, the activity.
Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs. A
normal window has tabs and an address bar, and no policy, flag or
extension removes the address bar from it. Tabbed web apps come closest,
need a `display_override` in a manifest on a site that is not ours, and
push off-origin links out into a normal browser window — which is the
whole of what this design exists to prevent.
So the tab strip is the classroom's own, in `classroom-wonderink`, and the
engine is Chromium either way.
## Why QtWebEngine
`python3-pyqt6.qtwebengine`, from Debian, updated with the rest of the
system.
The usual and correct objection is codecs, so it was measured before it
was chosen. On Debian 13, QtWebEngine 6.8.2 (Chromium 122):
| | |
| --- | --- |
| `canPlayType` H.264, AAC, MP3, VP8, VP9 | all "probably" |
| `MediaSource.isTypeSupported` avc1, mp4a, vp9 | all true |
| a 720p H.264/AAC file | loaded, played to the end, 1280×720 |
| `app.wonderink.org` | loads; the sign-in is an ordinary email and password form, not a Google flow that would mind an embedded browser |
Two further things the shell does for the same reason:
* **The user agent** has QtWebEngine's own token stripped, leaving a plain
Chrome string. The engine really is that Chromium, and a site gating on
the UA should not see something it has never heard of.
* **Widevine** is handed to it from Chrome's own bundle when Chrome is
installed, which on a classroom machine it always is. A lesson whose
video service wants DRM is not stopped by the engine change. Absent, the
flag is not passed and nothing else is affected.
## What a teacher gets
[ Lesson ] [ Video ] [ Activity ]
The strip appears only when there is more than one tab, so a room with one
lesson open looks exactly like the application this replaced. Each tab has
a close button. Closing the last one closes Wonder Ink; closing any other
leaves the rest alone.
A tab can only come from the lesson: a link the site opens in a new
window, a middle click, a Ctrl+click, "open in a new tab" in the menu.
There is no address bar, no search, and no button that makes a blank tab.
That is the seam the whole design turns on — Wonder Ink can take a teacher
anywhere it needs to, including a video CDN nobody told us about, and a
teacher cannot take themselves anywhere.
**There is deliberately no allowlist of domains.** A list would have to be
right about every service a lesson might ever embed, and the day it is
wrong a teacher is standing in front of a class.
The right-click menu is built rather than filtered, so a future Qt cannot
grow view-source or save-page back into it without somebody noticing in
the code: back, forward, reload, open in a new tab when there is a link,
cut/copy/paste in a text field, print image when there is a picture under
the pointer, print page.
Keys: Ctrl+W closes a tab, Alt+Left and Alt+Right navigate, Ctrl+P prints,
Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound.
## Worksheets
Some of Wonder Ink's lesson links are a download rather than a page: the
server sends the PDF as an attachment, so there is nothing for a tab to
show and the file simply lands on disk.
In this room that was the same as it not arriving at all. There is no file
manager, no application launcher and no downloads bar, on purpose, and a
teacher is not going to open a maintenance terminal to print a Christmas
tree. 0.2.1 downloaded the worksheet, said so in a notification, and left
it somewhere unreachable.
So a worksheet that finishes downloading is opened in a tab:
1. a teacher clicks a worksheet link;
2. Wonder Ink downloads it into `~/Downloads/Wonder Ink`;
3. when the download **completes**, and the file really is a PDF, it
opens as another tab, named after the file;
4. the teacher reads it, scrolls it, prints it to the room's printer;
5. closing that tab leaves the lesson exactly where it was.
Rendering is the engine's own PDF viewer — the same PDFium that Chrome
uses — so there is no second rendering library in the room and no
separate application. `QtWebEngineSettings.PdfViewerEnabled` was already
on for remote PDFs; what 0.2.2 adds is opening a *local* one, which was
verified against a real completed file rather than assumed from the
remote case.
### What is refused, and why
A completed download is not the same as a usable worksheet, and each of
these was a real case in the lab:
| | |
| --- | --- |
| an interrupted download | the notification says it did not finish and to try again. Nothing opens |
| HTML that the server called `.pdf` | refused on its first five bytes, not on its name. Without this the teacher gets a blank tab and no reason |
| an empty or unreadable file | the same |
| anything that is not a PDF | saved, and said so, exactly as before. Nothing is opened and nothing is executed |
The terminal state is read from `stateChanged`, not `isFinishedChanged`.
Measured: an interrupted download never emits `isFinishedChanged` at all,
so the first version of this said nothing whatsoever about a worksheet
that failed to arrive — which is the one case a teacher most needs told
about.
### Which local files Wonder Ink will open
Exactly the ones it opened itself, and nothing else.
Opening a worksheet adds that **one resolved path** to a list. The page's
`acceptNavigationRequest` refuses every `file:` URL that is not on that
list, so a lesson cannot navigate to one, a link cannot point at one and a
redirect cannot reach one. Chromium already refuses to let a remote page
reach `file:` at all — verified in the lab, where a link, a
`location.href` and a link to a worksheet that *had* been downloaded but
not opened by the shell were all refused — so this is the second lock
rather than the only one. It is there because the first one is somebody
else's.
Alongside it, two Qt defaults are turned off rather than left implicit:
`LocalContentCanAccessFileUrls` (on by default, and would let a local
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
Printing has no **Print to File** and no file chooser of any kind: the
preview (below) offers the room's CUPS printers and nothing else.
### What a teacher still cannot reach
**Anything that is not a PDF.** A lesson handing out a Word document, a
spreadsheet, a zip of images or a PowerPoint still downloads, still says
so, and still lands somewhere a teacher has no way to open. This release
deliberately fixes only the worksheet case, because that is the one the
room actually hit and because "open whatever arrives" is how an appliance
stops being one.
The obvious small follow-ups, in the order they are worth doing:
1. **Presentations.** Already planned separately, and the larger of the
two jobs — a PowerPoint needs a renderer, not just a viewer.
2. **A worksheets list.** Not a file manager: one Wonder Ink tab, showing
the PDFs already in `~/Downloads/Wonder Ink`, each opening in a tab the
same way a fresh download does. It reads one directory, offers no
navigation out of it, and needs no new permission — the paths it offers
are ones the shell would open anyway. That covers "a teacher closed the
worksheet tab and wants it back", which is the next thing that will be
asked for.
Neither is in this release.
### Where Wonder Ink's downloads end, and Chrome's begin
They never meet, and not because anything inspects a filename.
The download handler is connected to **Wonder Ink's own profile**, so it
only ever sees downloads that started in a Wonder Ink tab. Chrome is a
separate application with a separate profile and its own download
handling, and nothing here changes, watches or intercepts it. There is no
directory watcher anywhere in the classroom: a PDF that Chrome downloads
is Chrome's business and stays that way.
`~/Downloads/Wonder Ink` is a directory of its own inside the ordinary
Downloads folder. That is partly tidiness and mostly the security
argument: it is the only place a worksheet can come from.
## Printing
### The crash (0.2.2, Little Waders)
Wonder Ink closed completely every time a teacher confirmed a print job:
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
print-completion path.
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
Chromium has rendered the page, `didPrintPage()` calls
`currentPrinter->resolution()` and hands the printer to a worker thread.
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
it when the function returned and Qt read freed memory. Reproduced in the
lab with the 0.2.2 code: `QPrinter::resolution()` ←
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
the Little Waders backtrace, and no job in CUPS.
Keeping the printer alive until `printFinished` stops the crash — that was
built and measured first — and it is still not a working print path:
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
| --- | --- | --- |
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
Qt's path also mishandles three things its own source makes plain: a
second `print()` on a page that is still printing is refused with no
`printFinished`, the worker keeps the printer after the tab has gone, and
quitting destroys the printer under that worker.
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
worksheet already is one; a lesson page is rendered by the engine onto the
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
break a job: once `lp` returns, the job is CUPS's.
### The preview
One dialog, for lessons and worksheets alike:
* the document as it will print, a whole page at a time, ‹ › to turn;
* **Printer** — the CUPS destinations, the system default preselected
(Elmer, in every room);
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
document with the reason given if wrong;
* **Copies** — 1 to 50;
* **Color** — colour or black and white, only where the printer's driver
has a grey mode, and asked for in that driver's own terms. Since 0.2.6.3
the preview turns grey too (below);
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
the job, and a notification says where it went. A refused job keeps the
preview open with CUPS's reason.
Choosing a printer with different paper lays a lesson page out again. A
page that closes its own tab while the preview is open is reported, not
printed. Print pressed again while a preview is open does nothing, and so
does a page calling `window.print()` meanwhile. The PDF viewer's own print
button opens the same preview.
### Pictures (0.2.6.2)
A picture a lesson opens in a tab of its own — a colouring page as a PNG
or JPEG — printed in 0.2.6.1 with **black bars** above and below it.
Measured: the bars were neither in the picture nor in the page layout.
The engine shows a lone picture on a viewer page whose background is
rgb(14, 14, 14), scaled to the window, and Qt prints page backgrounds by
default; the dark page went onto the paper around the artwork.
So a picture is no longer printed as that page. The preview takes the
picture itself — drawn onto white, which is what transparent parts are on
paper, at up to 3300 pixels (300 dpi across Letter) — and lays it out on
the printer's own paper, and the teacher chooses:
* **Size** — as large as fits, 75%, 50%, 33% or 25% of that;
* **Position** — nine squares, like the page itself;
* **Paper** — automatic (landscape for a wide picture), upright or
sideways;
* **Fill the whole page** — covers the printable area and says it cuts the
edges off. Nothing is cropped unless this is ticked.
The preview is the PDF that will print, laid out again after every change.
`tools/lab/wonderink-print-check` measures it on the file CUPS actually
kept (`PreserveJobFiles`), which is byte-for-byte the preview's: the
picture within 1 point of where it was asked for, the paper turned as
asked, and no ink anywhere else on the page. Worksheet PDFs and lesson
pages have no picture controls; nothing about them changed.
Page backgrounds are also no longer printed for lesson pages, as in
Chrome, where "Background graphics" is off unless asked for. A white-on-
black page prints dark grey on white: Blink darkens light text when the
background is left off, measured.
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
when the preview closes.
### Black and white is shown as it prints (0.2.6.3)
Printing through Elmer found that **Black and white** came out grey on
paper while the preview stayed in colour. 0.2.6.2 only told the driver:
the preview was the colour PDF, CUPS was given the colour PDF, and
`ColorModel=Grayscale` turned it grey inside Elmer's filters.
So black and white is now made before the preview, not after it. When it
is chosen, the preview makes a grey copy of the document with
Ghostscript, shows that copy, and Print hands CUPS that same file — still
with `ColorModel=Grayscale`, so Elmer's generic PCL 6 driver prints it
with `pxlmono` as before rather than as a colour job that happens to be
grey. Choosing **Color** shows and prints the colour document again.
Measured before building it (Ghostscript 10.05.1, in the lab):
| | |
| --- | --- |
| a lesson page, a picture page, a leader guide, a fillable form | grey and only grey; same pages, page sizes and words; text still text; a filled-in form field and a drawn mark kept, in grey |
| against what Elmer would have printed | Ghostscript's grey rendering of the colour original and of the grey copy 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 — on a real lesson page, 12 of 32,754 8×8 blocks, all on one line of body text, in pairs of opposite sign. Elmer's driver *is* Ghostscript. (poppler's own grey differs on 7% of a coloured page, which is why the copy is not made with poppler) |
| how long | 0.05 s for a worksheet, 0.2 s for a text lesson page, 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, under the real Plasma session. `-dUseFastColor` saves 40% and changes the greys, so it is not used |
| a PDF Ghostscript cannot read | exits 1 and **still leaves a file behind** |
From those:
* the copy is made in the background, the preview says *Preparing black
and white…* and Print waits for it — four seconds is long enough to
look like a frozen window. It has a minute before it is given up on,
not the 20 s a page render has: a lesson of photographs on a slower
laptop is slow, not stuck. Cancel stops it at once;
* it is made once per document and kept, and each drawn page is kept, so
turning Color and Black and white back and forth is instant after the
first time, and stays on the page the teacher was looking at;
* a conversion is judged by its exit status and its page count, never by
whether a file appeared. If it fails the preview says so and offers
Color. It **never prints the colour file in its place**: the teacher
asked for black and white;
* pages are never turned (pdfwrite otherwise rotates a page to match its
text), and annotations are drawn into the page as they print, so no
later filter can draw them again in colour;
* a printer whose driver has no grey mode is not offered black and white,
and choosing one returns the preview to colour.
`tools/lab/wonderink-print-check` shows a colourful lesson in colour,
turns it to black and white and back five times on page 2, and prints
page 2 twice: every grey preview is grey pixel for pixel, every colour
one is not, the grey copy is made once, and the job CUPS kept is
**byte-for-byte the file the preview showed**, grey on every page, sent
with `ColorModel=Grayscale` through `pxlmono`. The same for a worksheet,
a 50% top-left picture, a return to Color (which prints in colour through
`pxlcolor`), and Cancel.
### Print image: a picture from inside a lesson (0.2.6.3)
Right-clicking a picture in a lesson and choosing Print printed the whole
page around it. Some of Wonder Ink's pictures cannot be opened on their
own through the site at all, so a teacher had no way to give that
picture the picture layout 0.2.6.2 introduced.
The right-click menu now says what it will print. It asks the engine's
own hit test what is under the pointer:
| Under the pointer | Measured `mediaType` | The menu offers |
| --- | --- | --- |
| a picture (`
`, also inside a link, a frame, from `blob:` or `data:`) | Image | **Print image…** and **Print page…** |
| a `