Konubinix' opinionated web of thoughts

Pwa to Create Printable Thumbnails

Fleeting

Why this note

Vignettes prints photos small. The need comes down to five gestures:

  1. pick photos from the phone’s gallery;
  2. decide the size of the long side;
  3. see the sheet that packs them best — each photo whole, never cropped, in its original orientation;
  4. clone some of them, to have several copies;
  5. download the PDF, ready to print.

Same build-free discipline as memories and the frieze — an import map pulls everything from esm.sh, no compilation step. But a fresh rendering tech, on purpose (one per app): the frieze is in Preact, memories in Solid, tally in Alpine; this one tries Mithril — the m(...) hyperscript and a redraw cycle, behavior locality kept by handlers set right on the element. And unlike those connected apps, no data leaves the phone: no server, no account, the photos stay local files — kept in the phone’s own store, so the sheet is still there next time — and the PDF is built on the spot with pdf-lib.

One idea carries the whole app: the layout is computed once, in millimetres on the sheet; the preview renders it in % of the page, the PDF in points.

Same discipline as memories: each need is a chapter — the prose, a Playwright test, the code, the CSS — short blocks, rewrite rather than patch.

It boots

First prove the build-free stack loads: the import map resolves Mithril from esm.sh, the component renders its title — the visible h1 “Vignettes” the tests wait on. And the sheet is there from the very first open — a blank, empty A4 page with exact proportions — because it is on it that every chapter that follows plays out.

@testcase
def test_boots_with_an_empty_sheet(page):
    """The app boots: the title, and an empty A4 sheet with A4 proportions."""
    open_app(page)
    expect(heading(page)).to_have_text("Vignettes")
    expect(sheet_pages(page)).to_have_count(1)
    b = box(sheet_pages(page).first)
    assert abs(ratio(b) - 210 / 297) < 0.01, f"page ratio {ratio(b):.3f}"
    expect(tiles(page)).to_have_count(0)
    print("  PASS: boots with an empty sheet")

import m from 'mithril';
import { PDFDocument } from 'pdf-lib';

The app is a single Mithril component, written as a closure: the state, the gestures and the view live in the same function — locality taken to its limit. The state holds in a few variables: the language, the page format, the long-side size (50 mm to start, to tune with use), the thumbnails, the selection, the number of copies to make when cloning (one to start), a flag while the PDF is built, and an id counter. Mithril repaints the view after each event; for what comes from the asynchronous side (loading the photos, generating the PDF) an explicit m.redraw() reflects the change. Each gesture, each piece of view — and the state each one needs — arrives in the chapter that motivates it; the view first freezes the current sheet (paper), which all rendering reads. Two moments belong to the component itself: on start it brings back the sheet left from last time, and after each repaint it writes the sheet down again, so that closing the app loses nothing (the sheet survives the phone).

function App(){
  let lang = <<lang-initial>>;
  let format = 'A4';
  let size = 50;
  let vignettes = [];
  const selected = new Set();
  let copies = 1;
  let building = false;
  let nextId = 1;

  <<app-methods>>

  return {
    oninit: restore,
    onupdate: save,
    view: () => {
      const paper = FORMATS[format];
      return [
        m('h1', 'Vignettes'),
        m('.bar', [
          <<ui-bar>>
        ]),
        <<ui-pages>>
      ];
    },
  };
}

A thumbnail carries an id that follows it through the clones; byId finds it in a given list — a pure function, outside the component.

const byId = (vignettes, id) => vignettes.find(v => v.id === id);

At startup, we mount the component in the page; Mithril renders it synchronously, so the flag is raised right after.

m.mount(document.getElementById('app'), App);

:root{ --bg:#20232f; --fg:#e8e8f0; --accent:#7cb3e8; }
body{ background:var(--bg); color:var(--fg); font-family:system-ui,sans-serif; margin:0; padding:12px; }
h1{ font-size:18px; margin:0 0 12px; }
.bar{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:12px; font-size:14px; }
.bar button, .pick, .bar select{ background:#2d3142; color:var(--fg); border:1px solid #444a63;
                                 border-radius:6px; padding:8px 12px; font-size:14px; }
.bar button:disabled{ opacity:.4; }
.bar label{ display:flex; gap:6px; align-items:center; }
.bar input[type=number]{ width:4em; background:#2d3142; color:var(--fg);
                         border:1px solid #444a63; border-radius:6px; padding:6px; }

In the phone’s language

The app must speak the language of whoever opens it, with no gesture to make. The labels therefore do not live hardcoded in the view but in a per-language dictionary, read by t(key) according to the lang state — change lang and Mithril repaints everything in the other language.

const MESSAGES = {
  en: { choose: 'choose photos', longSide: 'long side (mm)', format: 'page format',
        copies: 'copies', clone: 'clone', download: 'download the pdf', generating: 'generating…',
        empty: 'empty the sheet', lang: 'FR' },
  fr: { choose: 'choisir des photos', longSide: 'grand côté (mm)', format: 'format de page',
        copies: 'exemplaires', clone: 'cloner', download: 'télécharger le pdf', generating: 'génération…',
        empty: 'vider la feuille', lang: 'EN' },
};

const t = k => MESSAGES[lang][k];

It remains to choose lang at the very first opening, when nothing yet says what the reader wants. The one signal available then is the phone’s own setting, which the browser exposes as navigator.language: a device set to French announces fr there, everything else falls back to English.

(navigator.language || '').startsWith('fr') ? 'fr' : 'en'

A French phone thus opens the app in French, at first glance, without touching anything.

@testcase
def test_language_follows_the_phone(page):
    """A browser set to French opens the app in French, without a click."""
    fr = page.context.browser.new_context(viewport=PHONE_VIEWPORT, locale="fr-FR")
    try:
        p = fr.new_page()
        open_app(p)
        expect(p.get_by_label("choisir des photos")).to_be_visible()
    finally:
        fr.close()
    print("  PASS: language follows the phone")

The phone’s setting is only a guess — a borrowed device, set in a language one cannot read — so a button forces it, and the app holds on to that choice (the sheet survives the phone). Since it is almost never touched, it fades away: no frame, no background, a plain grey letter set back from the main controls, showing the language it switches to — FR in English, EN in French.

@testcase
def test_language_can_be_forced(page):
    """The test context is in English; the button forces French."""
    open_app(page)
    expect(page.get_by_label("choose photos")).to_be_visible()
    page.get_by_role("button", name="FR").click()
    expect(page.get_by_label("choisir des photos")).to_be_visible()
    print("  PASS: language can be forced")

m('button.lang', { onclick: () => { lang = lang === 'en' ? 'fr' : 'en'; } }, t('lang')),

.bar button.lang{ background:none; border:0; padding:4px; color:#5b6080; font-size:12px; }

Picking photos from the gallery

On Android, an <input type“file” accept=“image/*” multiple>= opens the gallery picker: access to the photos is granted by the browser, nothing to build. Each chosen file must end up on the sheet.

@testcase
def test_picked_photos_land_on_the_sheet(page):
    """Two chosen photos appear on the sheet."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500), img("portrait.jpg", 500, 800))
    expect(tiles(page)).to_have_count(2)
    expect(tile(page, "paysage.jpg").get_by_role("img")).to_be_visible()
    expect(tile(page, "portrait.jpg").get_by_role("img")).to_be_visible()
    print("  PASS: picked photos land on the sheet")

A photo placed on the sheet is whole — never cropped — and in its original orientation. Phone shots often carry their rotation in EXIF; the photo is therefore decoded into an ImageBitmap with imageOrientation:'from-image', so the dimensions read are the ones the eye expects: a portrait shot in portrait mode stays a portrait.

@testcase
def test_photos_keep_their_ratio_and_orientation(page):
    """A photo keeps its ratio, and the EXIF orientation is applied."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500), img("tournee.jpg", 800, 500, orientation=6))
    expect(tiles(page)).to_have_count(2)
    assert abs(ratio(box(tile(page, "paysage.jpg"))) - 800 / 500) < 0.05
    # EXIF orientation 6 = 90°: the encoded 800×500 displays as 500×800
    assert abs(ratio(box(tile(page, "tournee.jpg"))) - 500 / 800) < 0.05
    print("  PASS: photos keep their ratio and orientation")

Decoding is what turns a file into something the sheet can show: the bytes it was read from, an object URL for the preview, the bitmap (the PDF will redraw it), and the oriented dimensions from which the ratio derives.

async function decode(blob){
  const bmp = await createImageBitmap(blob, { imageOrientation: 'from-image' });
  return { blob, url: URL.createObjectURL(blob),
           w: bmp.width, h: bmp.height, bitmap: bmp };
}

A holiday is sixty photos, and decoding them in one go leaves the screen still for a dozen seconds — nothing tells that apart from a freeze. So the files are taken one at a time: each lands on the sheet as soon as it is decoded, under a bar that counts them off, with the id that will follow it through the clones. The sheet filling up while the count climbs is the whole answer to the freeze.

The bar has left by the time an assertion could look at it, so the test watches the page as it changes and keeps, at each step, what the bar said and how many thumbnails were up.

@testcase
def test_the_sheet_fills_as_the_photos_arrive(page):
    """Eight photos: the bar counts them, thumbnails land while it climbs, then it goes."""
    open_app(page)
    watch_import(page)
    pick(page, *[img(f"s{i}.jpg", 800, 500) for i in range(8)])
    expect(tiles(page)).to_have_count(8)
    seen = import_seen(page)
    assert seen, "no bar during the import"
    assert all(total == 8 for _, total, _ in seen), f"the bar must count the eight photos: {seen}"
    assert max(done for done, _, _ in seen) > 0, f"the bar never advanced: {seen}"
    assert any(0 < shown < 8 for _, _, shown in seen), \
        f"the sheet stayed empty until the end of the import: {seen}"
    expect(page.get_by_role("progressbar")).to_have_count(0)
    print("  PASS: the sheet fills as the photos arrive")

A gallery also hands over files the browser will not read — a truncated download, a format it does not know. Such a file is left out and the ones after it still come in; the count keeps climbing, and the bar leaves with the last file whatever happened on the way.

@testcase
def test_a_file_that_will_not_open_is_left_out(page):
    """Among three files, the one that is not an image is skipped; the bar still goes."""
    open_app(page)
    pick(page, img("bonne.jpg", 800, 500), not_an_image("cassee.jpg"),
         img("autre.jpg", 500, 800))
    expect(tiles(page)).to_have_count(2)
    expect(tile(page, "bonne.jpg")).to_have_count(1)
    expect(tile(page, "autre.jpg")).to_have_count(1)
    expect(page.get_by_role("progressbar")).to_have_count(0)
    print("  PASS: a file that will not open is left out")

In practice the repaint is forced at each photo (m.redraw.sync()): Mithril batches its redraws to the next frame, and a loop this tight would collapse into a single one, showing the finished sheet and nothing of the wait.

let progress = null;

async function pick(e){
  const files = [...e.target.files];
  e.target.value = '';        // re-picking the same file re-triggers the event
  progress = { done: 0, total: files.length };
  try {
    for(const f of files){
      try {
        vignettes = [...vignettes, { id: nextId++, name: f.name, ...await decode(f) }];
      } catch(err){
        console.warn('photo left out:', f.name, err);
      }
      progress.done++;
      m.redraw.sync();
    }
  } finally {
    progress = null;
    m.redraw();
  }
}

What we touch is the label dressed as a button; the input itself is hidden but stays labelled, hence reachable — by a screen reader as by the test. While the photos are coming in it is held: picking more would scramble the count.

m('label.pick', [t('choose'),
  m('input[type=file]', { accept: 'image/*', multiple: true, onchange: pick,
                          disabled: !!progress })]),

.pick{ cursor:pointer; }
.pick:has(input:disabled){ opacity:.4; }
.pick input{ position:absolute; width:1px; height:1px; opacity:0;
             overflow:hidden; clip:rect(0 0 0 0); }

The bar is the browser’s own progress element, which already announces itself as such; the count beside it says the same thing in figures, in any language.

progress && m('.loading', [
  m('progress', { value: progress.done, max: progress.total }),
  `${progress.done} / ${progress.total}`,
]),

.loading{ display:flex; gap:6px; align-items:center; color:#9aa0bf; }
.loading progress{ width:96px; }

The long-side size

A thumbnail is set by a single quantity: its long side, in millimetres. A landscape takes that size as width, a portrait as height, the other side follows the ratio — it is the scaling rule for all the rest of the note.

@testcase
def test_long_side_is_the_chosen_size(page):
    """At 50 mm, the landscape is 50/210 of the page width, the portrait 50/297 of its height."""
    open_app(page)
    expect(size_box(page)).to_have_value("50")
    pick(page, img("paysage.jpg", 800, 500), img("portrait.jpg", 500, 800))
    expect(tiles(page)).to_have_count(2)
    p = box(sheet_pages(page).first)
    assert abs(box(tile(page, "paysage.jpg"))["width"] - p["width"] * 50 / 210) < 2
    assert abs(box(tile(page, "portrait.jpg"))["height"] - p["height"] * 50 / 297) < 2
    print("  PASS: long side is the chosen size")

function scaled(v, size){
  const r = v.w / v.h;
  return r >= 1 ? { w: size, h: size / r } : { w: size * r, h: size };
}

The setting is a numeric input; each change re-packs the whole sheet. Its upper bound follows the sheet — the usable area of its long side.

@testcase
def test_long_side_adjustable(page):
    """Switching to 80 mm enlarges the thumbnail accordingly."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500))
    expect(tiles(page)).to_have_count(1)
    p = box(sheet_pages(page).first)
    size_box(page).fill("80")
    wait_until(page, lambda: abs(box(tile(page, "paysage.jpg"))["width"]
                                 - p["width"] * 80 / 210) < 2,
               label="the thumbnail follows the new size")
    print("  PASS: long side adjustable")

m('label', [t('longSide'),
  m('input[type=number]', { min: 10, max: paper.w - 2 * MARGIN, value: size,
    oninput: e => { const n = e.target.valueAsNumber; if(Number.isFinite(n)) size = n; } })]),

The sheet, packing space best

The printer does not reach the edge: 10 mm of margin all around. Between the thumbnails, a 2 mm gutter — enough to run the scissors without nicking the neighbour. On the remaining area, packing best is a classic strip packing, solved by FFDH: the thumbnails sorted from tallest to shortest settle into shelves — each joins the first shelf where its width fits, otherwise opens a new one below the others. The sort is the optimization: close heights share a shelf, which then wastes little height. The expected result is visible to the eye: nothing overlaps, nothing spills past the margins.

@testcase
def test_sheet_packs_without_overlap_within_margins(page):
    """Six mixed photos: all within the margins, none on top of another."""
    open_app(page)
    pick(page, *[img(f"p{i}.jpg", 800, 500) if i % 2 else img(f"p{i}.jpg", 500, 800)
                 for i in range(6)])
    expect(tiles(page)).to_have_count(6)
    p = box(sheet_pages(page).first)
    m = p["width"] * 10 / 210                      # the margin, in rendered px
    printable = {"x": p["x"] + m, "y": p["y"] + m,
                 "width": p["width"] - 2 * m, "height": p["height"] - 2 * m}
    bs = [box(tiles(page).nth(i)) for i in range(6)]
    for i, b in enumerate(bs):
        assert inside(b, printable), f"thumbnail {i} outside the margins: {b}"
    for i in range(6):
        for j in range(i + 1, 6):
            assert not overlap(bs[i], bs[j]), f"thumbnails {i} and {j} overlap"
    print("  PASS: sheet packs without overlap within margins")

When the sheet is full, a page is added — the final PDF will have as many.

@testcase
def test_overflow_opens_a_second_page(page):
    """Twelve landscapes at 90 mm do not fit on one page: a second one opens."""
    open_app(page)
    pick(page, *[img(f"q{i}.jpg", 800, 500) for i in range(12)])
    expect(tiles(page)).to_have_count(12)
    size_box(page).fill("90")
    expect(sheet_pages(page)).to_have_count(2)
    for k in range(2):
        pg = sheet_pages(page).nth(k)
        n = pg.get_by_role("button").count()
        assert n > 0, f"page {k+1} empty"
        for i in range(n):
            assert inside(box(pg.get_by_role("button").nth(i)), box(pg)), \
                f"page {k+1}, thumbnail {i} spills over"
    print("  PASS: overflow opens a second page")

Everything is computed in millimetres on the current format. The requested size is bounded here, at the only place that consumes it: never wider than the usable area (the long side minus the margins), never below 10 mm (typing “80” passes through “8” — a transient typing state, not an intent).

const MARGIN = 10, GAP = 2;   // mm
const pc = (v, total) => (v / total * 100).toFixed(3) + '%';

function layout(vignettes, sizeMm, paper){
  const usable = { w: paper.w - 2 * MARGIN, h: paper.h - 2 * MARGIN };
  const size = Math.min(Math.max(sizeMm, 10), usable.w);
  const items = vignettes.map(v => ({ id: v.id, ...scaled(v, size) }))
                         .sort((a, b) => b.h - a.h);
  const pages = [{ placed: [], shelves: [] }];
  for(const it of items){
    let page = pages[pages.length - 1];
    let shelf = page.shelves.find(s => s.x + it.w <= usable.w);
    if(!shelf){
      const last = page.shelves[page.shelves.length - 1];
      let y = last ? last.y + last.h + GAP : 0;
      if(y + it.h > usable.h){ page = { placed: [], shelves: [] }; pages.push(page); y = 0; }
      shelf = { y, h: it.h, x: 0 };
      page.shelves.push(shelf);
    }
    page.placed.push({ id: it.id, x: MARGIN + shelf.x, y: MARGIN + shelf.y, w: it.w, h: it.h });
    shelf.x += it.w + GAP;
  }
  return pages.map(p => p.placed);
}

The format itself is a choice: A4 by default, but also A5, A6 or Letter — each with its millimetres, the rest (layout, preview, PDF) follows. Changing format re-fits the whole sheet and, if it is smaller, opens more pages.

@testcase
def test_page_format_configurable(page):
    """The sheet follows the chosen format: switching to Letter changes its ratio."""
    open_app(page)
    assert abs(ratio(box(sheet_pages(page).first)) - 210 / 297) < 0.01   # A4 by default
    format_select(page).select_option("Letter")
    wait_until(page, lambda: abs(ratio(box(sheet_pages(page).first)) - 216 / 279) < 0.01,
               label="the sheet switches to the Letter ratio")
    print("  PASS: page format configurable")

const FORMATS = { A4: { w: 210, h: 297 }, A5: { w: 148, h: 210 },
                  A6: { w: 105, h: 148 }, Letter: { w: 216, h: 279 } };   // mm

m('label', [t('format'),
  m('select', { onchange: e => format = e.target.value },
    Object.keys(FORMATS).map(f => m('option', { value: f, selected: f === format }, f)))]),

On screen, each page is a section at the sheet’s ratio and each placement a tile positioned in % of the page — the same millimetres will serve as-is for the PDF. The tile itself (ui-tile) arrives in the next chapter, with the gesture that motivates it.

m('.pages', layout(vignettes, size, paper).map((placed, i) =>
  m('section.page', { 'aria-label': `page ${i + 1}`, style: `aspect-ratio:${paper.w}/${paper.h}` },
    placed.map(pl =>
      m('button.tile', {
        key: pl.id,
        class: selected.has(pl.id) ? 'selected' : '',
        'aria-pressed': String(selected.has(pl.id)),
        onclick: () => toggle(pl.id),
        style: `left:${pc(pl.x, paper.w)}; top:${pc(pl.y, paper.h)};
                width:${pc(pl.w, paper.w)}; height:${pc(pl.h, paper.h)}`,
      }, m('img', { src: byId(vignettes, pl.id).url, alt: byId(vignettes, pl.id).name }))
    ))))

.pages{ display:flex; flex-direction:column; gap:16px; max-width:480px; }
.page{ position:relative; width:100%; background:#fff;
       border-radius:2px; box-shadow:0 2px 12px rgba(0,0,0,.5); }
.tile{ position:absolute; margin:0; padding:0; border:0; background:none; cursor:pointer; }
.tile img{ width:100%; height:100%; object-fit:contain; display:block; }

Cloning thumbnails

Some photos must end up in several copies on the sheet. So one must first designate: the tile is a button — a tap marks it selected (an outline, and the state spoken aloud by aria-pressed), a second releases it.

@testcase
def test_tap_selects_and_unselects(page):
    """A tap selects the thumbnail, a second deselects it."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500))
    t = tile(page, "paysage.jpg")
    expect(t).to_have_attribute("aria-pressed", "false")
    t.click()
    expect(t).to_have_attribute("aria-pressed", "true")
    t.click()
    expect(t).to_have_attribute("aria-pressed", "false")
    print("  PASS: tap selects and unselects")

The tap also parks the attention on that photo: the tile takes the focus, which a screen reader announces and a keyboard carries on from. Photos keep arriving meanwhile, each one re-packing the sheet, and the photo being held must stay the one being held — never slide onto the newcomer that took its place.

@testcase
def test_the_photo_you_are_on_stays_yours(page):
    """A photo is tapped, a taller one takes its spot: the focus is still on the first."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500))
    expect(tiles(page)).to_have_count(1)
    tile(page, "paysage.jpg").click()
    pick(page, img("portrait.jpg", 500, 800))
    expect(tiles(page)).to_have_count(2)
    assert focused_photo(page) == "paysage.jpg", \
        f"the focus slid onto another photo: {focused_photo(page)}"
    expect(tile(page, "paysage.jpg")).to_have_attribute("aria-pressed", "true")
    print("  PASS: the photo you are on stays yours")

This is what the tile’s key buys. The sheet re-packs at every arrival, and without a key Mithril leaves the nodes where they are and swaps their contents: the tile that was holding the focus would be handed the newcomer, focus and voice included. Keyed on the thumbnail it shows, the node follows its photo instead.

m('button.tile', {
  key: pl.id,
  class: selected.has(pl.id) ? 'selected' : '',
  'aria-pressed': String(selected.has(pl.id)),
  onclick: () => toggle(pl.id),
  style: `left:${pc(pl.x, paper.w)}; top:${pc(pl.y, paper.h)};
          width:${pc(pl.w, paper.w)}; height:${pc(pl.h, paper.h)}`,
}, m('img', { src: byId(vignettes, pl.id).url, alt: byId(vignettes, pl.id).name }))

function toggle(id){
  selected.has(id) ? selected.delete(id) : selected.add(id);   // Mithril repaints after the click
}

.tile.selected{ outline:3px solid var(--accent); outline-offset:1px; }

It remains to say how many copies: a numeric field proposes it — one by default, as many as you want. “Clone” then adds that number of copies of each selected thumbnail — same file, same bitmap, only the id changes — and the layout re-packs it all.

@testcase
def test_clone_makes_the_requested_number_of_copies(page):
    """Two copies requested for two selected thumbnails: each gains two copies, same images."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500), img("portrait.jpg", 500, 800))
    expect(clone_button(page)).to_be_disabled()
    tile(page, "paysage.jpg").click()
    tile(page, "portrait.jpg").click()
    copies_box(page).fill("2")
    clone_button(page).click()
    expect(tiles(page)).to_have_count(6)
    expect(tile(page, "paysage.jpg")).to_have_count(3)
    expect(tile(page, "portrait.jpg")).to_have_count(3)
    srcs = tile(page, "paysage.jpg").get_by_role("img").evaluate_all("els => els.map(e => e.src)")
    assert len(set(srcs)) == 1, "the clones must show the same image"
    print("  PASS: clone makes the requested number of copies")

function clone(){
  const picked = vignettes.filter(v => selected.has(v.id));
  const made = [];
  for(let i = 0; i < copies; i++) made.push(...picked.map(v => ({ ...v, id: nextId++ })));
  vignettes = [...vignettes, ...made];
}

m('label', [t('copies'),
  m('input[type=number]', { min: 1, value: copies,
    oninput: e => { const n = e.target.valueAsNumber; if(Number.isFinite(n)) copies = Math.max(1, n); } })]),
m('button', { onclick: clone, disabled: !selected.size }, t('clone')),

The print-ready PDF

The sheet on screen is only a preview; what goes to the printer is a PDF in the chosen format — the same layout, converted from millimetres to points (72 per inch, i.e. × 72/25.4).

@testcase
def test_pdf_ready_to_print(page):
    """The downloaded PDF: one A4 page, one draw per thumbnail, one image stream shared by the clone."""
    open_app(page)
    pick(page, img("paysage.jpg", 800, 500), img("portrait.jpg", 500, 800))
    tile(page, "paysage.jpg").click()
    clone_button(page).click()
    expect(tiles(page)).to_have_count(3)
    with page.expect_download(timeout=30000) as dl:
        pdf_button(page).click()
    assert dl.value.suggested_filename == "vignettes.pdf"
    path = os.path.join(TMP, "vignettes.pdf")
    dl.value.save_as(path)
    r = PdfReader(path)
    assert len(r.pages) == 1
    mb = r.pages[0].mediabox
    assert abs(float(mb.width) - 595.28) < 0.5 and abs(float(mb.height) - 841.89) < 0.5, \
        f"not A4: {mb}"
    content = r.pages[0].get_contents().get_data()
    assert len(re.findall(rb"/\S+\s+Do", content)) == 3, "three thumbnails drawn"
    # 3 draws, but the clone shares the embedded stream: 2 unique streams, not 3
    xobj = r.pages[0]["/Resources"]["/XObject"]
    streams = {x.indirect_reference.idnum for x in xobj.values()}
    assert len(streams) == 2, f"the clone must reuse the embedded stream: {len(streams)} streams"
    print("  PASS: pdf ready to print")

Building the PDF takes several seconds when there are many photos — each is re-encoded then embedded. A click with no feedback would look like a bug, so the button switches to “generating…” and disables itself while the work runs.

@testcase
def test_pdf_pages_match_the_preview(page):
    """Two pages on screen → two pages in the PDF, and the button signals the generation."""
    open_app(page)
    pick(page, *[img(f"r{i}.jpg", 800, 500) for i in range(12)])
    expect(tiles(page)).to_have_count(12)
    size_box(page).fill("90")
    expect(sheet_pages(page)).to_have_count(2)
    with page.expect_download(timeout=30000) as dl:
        pdf_button(page).click()
        expect(page.get_by_role("button", name="generating…")).to_be_visible()
    path = os.path.join(TMP, "two-pages.pdf")
    dl.value.save_as(path)
    assert len(PdfReader(path).pages) == 2
    print("  PASS: pdf pages match the preview")

Each photo is re-encoded only once: drawn onto a canvas capped at 300 dpi for its printed size, output as JPEG. This detour through the canvas does three things at once — it bounds the PDF’s weight (a 12 Mpx shot does not enter it whole), it absorbs the formats pdf-lib cannot embed (HEIC, WebP… the bitmap, for its part, is already decoded), and it bakes the EXIF orientation into the pixels.

const DPI = 300, MM = 72 / 25.4;
async function asJpeg(v, sizeMm){
  const px = sizeMm / 25.4 * DPI;
  const k = Math.min(1, px / Math.max(v.w, v.h));   // never enlarged
  const c = new OffscreenCanvas(Math.round(v.w * k), Math.round(v.h * k));
  c.getContext('2d').drawImage(v.bitmap, 0, 0, c.width, c.height);
  const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
  return new Uint8Array(await blob.arrayBuffer());
}

The clones reuse the embedded object: a photo enters the file only once, whatever the number of copies drawn. The PDF’s coordinate origin starts at the bottom of the page, hence the flip of y.

async function buildPdf(vignettes, size, paper){
  const pdf = await PDFDocument.create();
  const embedded = new Map();                       // one photo → a single object, clones included
  for(const placed of layout(vignettes, size, paper)){
    const page = pdf.addPage([paper.w * MM, paper.h * MM]);
    for(const t of placed){
      const v = byId(vignettes, t.id);
      if(!embedded.has(v.url)) embedded.set(v.url, await pdf.embedJpg(await asJpeg(v, size)));
      page.drawImage(embedded.get(v.url), { x: t.x * MM, y: (paper.h - t.y - t.h) * MM,
                                            width: t.w * MM, height: t.h * MM });
    }
  }
  return pdf.save();
}

The download goes through an ephemeral link — the browser’s standard gesture, which on Android files it into downloads, ready to head to the printer. The building flag holds the button at “generating…” until the bytes are ready.

async function download(){
  building = true; m.redraw();
  try {
    const bytes = await buildPdf(vignettes, size, FORMATS[format]);
    const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
    const a = Object.assign(document.createElement('a'), { href: url, download: 'vignettes.pdf' });
    a.click();
    URL.revokeObjectURL(url);
  } finally {
    building = false; m.redraw();
  }
}

m('button', { onclick: download, disabled: !vignettes.length || building },
  building ? t('generating') : t('download')),

Installable on the phone (PWA)

The flow starts from the phone’s gallery: the app must live there — installed on the home screen, launched in its own window. Like tally, a manifest (display:standalone, icons) makes it installable, and the shared service workernetwork-first on navigation, cache-first on assets — makes it start fast and stay up to date after a deploy. We neither rewrite nor re-test this worker: we wire it up and check what this app brings — a linked manifest declaring standalone and icons.

@testcase
def test_pwa_installable(page):
    """The app exposes a standalone manifest with icons."""
    open_app(page)
    assert page.locator("link[rel='manifest']").get_attribute("href"), "no linked manifest"
    man = page.evaluate("() => fetch('manifest.json').then(r => r.json())")
    assert man["display"] == "standalone", f"display is {man.get('display')!r}"
    assert man["icons"], "manifest with no icon"
    print("  PASS: pwa installable")

A browser offers a real install (WebAPK) only if the manifest provides raster icons at 192 and 512 px it can download; the icon.svg alone degrades the install to a mere shortcut. The manifest therefore announces icon-192.png and icon-512.png — rendered from the icon.svg — and lists the SVG as a resolution-independent variant. The detail and the failure modes are in how to make a PWA install as a WebAPK.

{
  "name": "Vignettes",
  "short_name": "Vignettes",
  "description": "Imprimer des photos en vignettes",
  "start_url": ".",
  "scope": ".",
  "display": "standalone",
  "background_color": "#20232f",
  "theme_color": "#20232f",
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
    { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
  ]
}

The icon: four coloured thumbnails on a white sheet.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#20232f"/>
  <rect x="136" y="86" width="240" height="340" rx="10" fill="#f5f5f7"/>
  <rect x="156" y="110" width="96" height="70" fill="#7cb3e8"/>
  <rect x="260" y="110" width="96" height="70" fill="#e8a87c"/>
  <rect x="156" y="188" width="96" height="140" fill="#8fd18c"/>
  <rect x="260" y="188" width="96" height="70" fill="#d17c9e"/>
</svg>

The worker declares its single cache and the shell to preload, then reuses the shared handlers.

const CACHES = [{ name: 'vignettes-v1' }];
const ASSETS = ['./', './index.html'];
nil

The sheet survives the phone

Once the app lives on the home screen it is used like any other: a call comes in, Android reclaims the memory of what sits in the background, and coming back reloads the page. Sixty photos picked one by one, gone. The sheet must therefore be kept outside the page, in the phone’s own store: IndexedDB is the one that takes blobs the size of a photo, and idb-keyval reduces it to the three gestures needed here — read a key, write a key, empty the lot.

import { get as dbGet, set as dbSet, clear as dbClear } from 'idb-keyval';

What must come back is the sheet as it was left: the photos, whole and in their orientation, at the size and on the format chosen. Nothing on screen says that the sheet has reached the store — that is rather the point — so the test waits for the record to be there before it reloads.

open_app(page)
pick(page, img("paysage.jpg", 800, 500), img("tournee.jpg", 800, 500, orientation=6))
expect(tiles(page)).to_have_count(2)
size_box(page).fill("70")
format_select(page).select_option("A5")
wait_until(page, lambda: (stored_sheet(page) or {}).get("format") == "A5",
           label="the sheet reaches the store")
page.reload(wait_until="commit")
expect(tiles(page)).to_have_count(2)
expect(size_box(page)).to_have_value("70")
expect(format_select(page)).to_have_value("A5")
assert abs(ratio(box(tile(page, "tournee.jpg"))) - 500 / 800) < 0.05

The store holds two kinds of things, and they do not move at the same rhythm. A photo’s bytes are heavy and never change: the thumbnail is still holding them, so they go in once, under a key of their own — and the clones of a photo, sharing its bytes, share that key. The rest — which thumbnail shows which photo, at what size, on what format, in which language the app is being read — is light and changes at every keystroke: rewritten whole, in a single record, whenever anything moves.

const photoKey = new Map();      // a photo's bytes → the key they are filed under
let nextKey = 1;

async function persist(){
  for(const v of vignettes){
    if(photoKey.has(v.blob)) continue;
    const key = nextKey++;
    photoKey.set(v.blob, key);
    await dbSet('photo:' + key, v.blob);
  }
  await dbSet('sheet', { lang, size, format, nextId, nextKey,
                         items: vignettes.map(v => ({ id: v.id, name: v.name,
                                                      key: photoKey.get(v.blob) })) });
}

Coming back is the same walk in reverse: the record says what was left, each photo is decoded again — orientation included, since what was stored is the original file — and the thumbnails land one by one behind the bar that counted the import, the wait having the same cause.

async function readBack(){
  const sheet = await dbGet('sheet');
  if(!sheet) return;
  ({ lang, size, format, nextId, nextKey } = sheet);
  progress = { done: 0, total: sheet.items.length };
  try {
    const photos = new Map();
    for(const it of sheet.items){
      if(!photos.has(it.key)){
        const photo = await decode(await dbGet('photo:' + it.key));
        photos.set(it.key, photo);
        photoKey.set(photo.blob, it.key);
      }
      vignettes = [...vignettes, { id: it.id, name: it.name, ...photos.get(it.key) }];
      progress.done++;
      m.redraw.sync();
    }
  } finally {
    progress = null;
    m.redraw();
  }
}

Reading and writing must not tread on each other: each thumbnail landing during the read-back triggers a repaint, and the write that follows would file a half-restored sheet over the very record being read. So every storage job passes through the same queue, one at a time — a write held behind the read-back describes the whole sheet by the time it runs. A store that refuses — no room left on the phone — costs the memory of the sheet, never the sheet itself.

let queue = Promise.resolve();
const enqueue = job => { queue = queue.then(job).catch(e => console.warn('storage', e)); };
const save = () => enqueue(persist);
const restore = () => enqueue(readBack);

A sheet kept for good must also be dismissable: without a way to start over, the next printing session would open on the photos of the last one. A discreet button empties everything — the thumbnails, the selection made on them, and the bytes in the store, where nothing must be left but an empty sheet. Like the picker, it is held while photos are coming in: emptying a sheet still on its way would only watch it arrive.

tile(page, "paysage.jpg").click()
expect(clone_button(page)).to_be_enabled()
empty_button(page).click()
expect(tiles(page)).to_have_count(0)
expect(clone_button(page)).to_be_disabled()
wait_until(page, lambda: stored_keys(page) == ["sheet"]
                         and (stored_sheet(page) or {}).get("items") == [],
           label="the store is left with nothing but an empty sheet")
page.reload(wait_until="commit")
expect(heading(page)).to_have_text("Vignettes")
expect(tiles(page)).to_have_count(0)

function empty(){
  vignettes = [];
  selected.clear();
  photoKey.clear();
  enqueue(dbClear);
}

m('button.empty', { onclick: empty, disabled: !vignettes.length || !!progress }, t('empty')),

.bar button.empty{ background:none; border:0; color:#8a8faf; }

The language is part of what was left too. A phone borrowed in a script one cannot read has the language button pressed once, and that choice must hold: the next opening owes the reader the language chosen, not the phone’s guess again.

page.get_by_role("button", name="FR").click()
expect(page.get_by_label("choisir des photos")).to_be_visible()
wait_until(page, lambda: (stored_sheet(page) or {}).get("lang") == "fr",
           label="the language reaches the store")
page.reload(wait_until="commit")
expect(page.get_by_label("choisir des photos")).to_be_visible()

Two photos show the mechanism; a holiday is what the sheet is made for. Sixty shots of a phone, twelve megapixels apiece, fill it as they are decoded, reach the store, and come back from it in full — behind the same bar, since the read-back is a decoding too.

@testcase
def test_a_whole_holiday_comes_back(page):
    """Sixty phone photos land on the sheet, reach the store, and all come back."""
    open_app(page)
    watch_import(page)
    holiday = [img(f"h{i:02d}.jpg", *((4000, 3000) if i % 3 else (3000, 4000)))
               for i in range(60)]
    pick(page, *holiday)
    expect(tiles(page)).to_have_count(60, timeout=120000)
    assert any(0 < shown < 60 for _, _, shown in import_seen(page)), \
        "the sheet stayed empty until the end of the import"
    wait_until(page, lambda: len((stored_sheet(page) or {}).get("items", [])) == 60,
               timeout=120000, label="the sixty photos reach the store")
    watch_import_from_boot(page)
    page.reload(wait_until="commit")
    expect(tiles(page)).to_have_count(60, timeout=120000)
    assert import_seen(page), "no bar while the sheet was read back"
    print("  PASS: a whole holiday comes back")

Notes linking here