← Portfolio / rebuild log Feb 2026 → Oct 2026 rewritten no framework

「禁日」 — from a Preview puppet show to a real reader

The first version of this flipbook was two AppleScript applets that carried 75 photographs of pages inside their own bundles and then asked Preview — via the Accessibility API — to go full screen. This is the same book rebuilt as a web reader: one page at a time, every page at its original pixel size, with the page-turn, thumbnails, deep links and resume state the original never had.

Open the flipbook → Start at volume 2 Back to portfolio
Pages
75 36 + 39
Source weight
41.0 MB
Served weight
9.7 MB
Page geometries
67 distinct
Re-framed
65 of 75
Console errors
0 3 viewports
Volume 1, page 1 thumbnail
v1 · p1 · 451×563
Volume 1, page 18 thumbnail
v1 · p18 · 465×581
Volume 2, page 1 thumbnail
v2 · p1 · 850×1336
Volume 2, page 39 thumbnail
v2 · p39 · 1168×1452
The one-line history

The original app's entire "flipbook" was three keystrokes sent to Preview — ⌘2, ⌥⌘1, then View ▸ Enter Full Screen — wrapped in a try block that silently swallowed the failure when macOS refused the Accessibility request.

01 · What was actually in the old app

Both bundles live in /Applications/Installed Applications/. They are Script Editor applets, not compiled apps: same 15,094-byte main.scpt in each, same bundle identifier com.apple.ScriptEditor.id.test-flipbook, same displayed name "test flipbook". The only real difference is the folder of photographs each one carries.

BundlePagesFormatPixel sizesPayload
「禁日」.app36JPEG378–465 × 6402.5 MB
「禁日」 后续.app39PNG413–1204 × 640–173438.5 MB

Filename order was the reading order, and the filenames were unpadded — page 1.jpg, page 2.jpg … page 36.jpg. That survives Finder's numeric sort, but it is one sort call away from serving page 10 before page 2.

02 · The 15 KB that did all the work

1

Photos were copied into the app

On first run the script asked for image files and copied them into Contents/Resources/FlipbookPhotos/, then told you to double-click the app again. The bundle was the library: editing a page meant re-running that import.

2

Preview was reconfigured, globally

defaults write com.apple.Preview PVImageOpeningMode 1 — a persistent, machine-wide preference so that every future image you open in Preview arrives in a single window, whether or not you are looking at the flipbook.

3

Every page was opened at once

No lazy loading and no window management: 36 or 39 full images decoded up front, so the first paint waited on the heaviest page in the set.

4

System Events pressed the buttons

Three UI-scripting calls: ⌘2 (single page), ⌥⌘1 (hide sidebar), View ▸ Enter Full Screen. Everything the reader did was done by another app's menu, so anything that changed Preview's menus broke the flipbook.

03 · Why it degraded instead of failing

error "-25211" — System Events got an error: osascript is not allowed assistive access. what the script swallowed

The full-screen step sat inside try … end try with a fallback that simply zoomed the Preview window. So when Accessibility permission was missing — after an OS update, a new machine, a reset privacy database — the app still opened, just not full screen. Nothing reported a problem. That is the worst failure mode there is: it looks like the app half-worked.

What the old design cost

  • Machine state: reads and writes a global Preview preference
  • Permission debt: needs Accessibility, checked only by crashing quietly
  • Two copies: the same 15 KB script shipped twice, with the pages duplicated inside them
  • No navigation: no page number, no jump, no thumbnail, no resume
  • No sharing: you cannot link someone to page 20

What the rebuild needs

  • One static folder: index.html + 75 JPEGs + thumbnails
  • No permissions, no other app, no OS settings touched
  • Deep links: #v1p12, #v2p20
  • Resume: last page per volume, remembered locally
  • Native size: one click for 1:1 pixels, four for fit

04 · The geometry problem nobody warns you about

These are photographs of pages, so they are not one size. After the framing pass (section 07) volume 1 has 34 distinct sizes across 36 pages (378–465 wide, 539–640 tall), and volume 2 has 36 distinct sizes across 39 pages (413–1188 wide, 547–1684 tall). Aspect ratios run 0.49 → 0.82.

That single fact decides the layout. A classic two-page spread pairs leaves across a gutter, which only works if paired pages share a height; here a 378 px leaf would face a 1204 px one. So the reader shows one page at a time, sizes the stage to that page's own aspect ratio, and lets the page turn be the thing that reads as a book. No stretching and no letterbox inside a fake frame — and the one crop that does happen (section 07) removes dead black frame, never a resampled pixel.

VolumePagesDistinct sizesAspect rangeServed
禁日36340.591 – 0.8042.31 MB
禁日 · 后续39360.490 – 0.8165.85 MB
Thumbnails75220 px wide—1.45 MB

The 38.5 MB PNG volume was re-encoded to JPEG at quality 84 at the same pixel dimensions — 5.85 MB, no resampling. Total served: 9.7 MB including thumbnails. A build script asserts every output dimension against its source and stops if they ever disagree.

05 · How the turn works

Two stacked layers. The front layer holds the page you are on; the back layer holds whatever is being revealed. On a forward turn the back layer is loaded with the next page and the front rotates -104° around its left edge inside a 2400 px perspective — one moving layer, one compositor-friendly transform. Going back, the incoming page starts at -104° with transitions disabled, then animates to 0°, which reads as a page sweeping back into place.

The reset is the fiddly part: after the animation ends the front layer must be restored to rotateY(0) without transitioning, or the new page visibly swings back into frame. The commit step sets transition:none, swaps the image, forces a reflow, then clears the inline style. The verification below asserts the resting transform is literally none.

InputAction
→ Space PageDownNext page (rolls into volume 2 at the end of volume 1)
← PageUpPrevious page (rolls back into volume 1)
Home EndFirst / last page of the current volume
TThumbnail drawer (75 pages, both volumes)
FFull screen
0Toggle original pixel size ↔ fit
EscClose drawer → leave 1:1 → back to cover
Swipe / click edgesSame as arrows

06 · Verification, in a real browser

Headless Chromium, three viewports, driving the keyboard and reading the DOM back — not a screenshot review. Every row below is a measured value from the run, not an intention.

Check1440×900768×1024390×844
Page 1 decoded451×563 ok451×563 ok451×563 ok
Horizontal overflow0 px0 px0 px
Top bar fitsyesyesyes (wraps to 2 rows)
Stage size1440×803768×927390×706
Page drawn at543×771631×895358×508
→ / ←2/36 → 1/362/36 → 1/362/36 → 1/36
Rest transformnonenonenone
Thumbnail buttons757575
End of v1 + →1/391/391/39
1:1 mode451×563451×563451×563
Console errors000
Failed requests000

Separate pass for the states: a fresh visit hides Continue reading; reading to page 3 and reloading shows Continue page 3 and lands on 3/36; aborting one image request surfaces the error panel with the exact filename (pages/v1-05.jpg) and a working retry; with prefers-reduced-motion the turn becomes a 130 ms fade and navigation still works. Two bugs were caught this way and fixed before deploy — a Continue page 1 button that was visible on a fresh visit because .cta{display:inline-flex} outranks the hidden attribute, and a top bar that scrolled sideways at 390 px.

07 · The white edges

Every page arrived still framed by its photograph: a black caption bar across the top (75–89 px on the 640-tall pages, 207–240 px on the large ones — about 13% of the page height), the photo's black surround down the sides, and a bright white strip where the sheet's edge caught the light. On a dark stage that reads as a pale border on some pages and a black border on others, which is exactly how it looked.

The build now takes the dead framing off. For each side it walks in while the line is still dark (mean ≤ 120), accepts the band only if the band really is black (mean ≤ 45), then removes the pure-white rim on top of that. It then refuses its own work: if more than 1.5% of the page's ink would be lost, the page is left alone. 65 of 75 pages were re-framed, median 13.9% of the frame, maximum 17.6%, and no page lost more than 0.52% of its ink. Nothing is resampled — the crop only moves the rectangles.

PageWasNowFrame removedInk lost
v1 · p1451×640451×56312.0%0.01%
v1 · p18465×640465×5819.2%0.00%
v2 · p151196×17121166×144717.6%0.10%
v2 · p391192×16861168×145215.6%0.22%
Volume 2 page 15 before and after the framing pass
v2 · p15, before and after. The black caption bar goes; the sheet's own printed header (「9月14日 星期六 天氣:雨」) stays, so the date is not lost with the bar. Every handwriting stroke is intact.
Where it declined

10 pages were left exactly as they were. Five are dark pages — the black title cards (10月25日 - 到訪學校, 家庭訪問日) and the night scenes — where the black is the artwork. The other five are pages whose only trim would have clipped a drawing that runs off the sheet, so the ink test vetoed it. The first version of the detector did try to re-frame v1-32 by 58.7% of its frame; that is the failure the ink test exists to stop.

08 · What this still doesn't do

Honest limits

  • The source is small. Page 1 is 451 px wide; at 1440 the reader draws it 543 px wide, so it is enlarged about 1.2× and looks it. 1:1 mode is there for exactly this.
  • The text is paint, not text. These are photographs of pages: nothing is selectable, searchable or indexed, and this Mac has no CJK OCR data installed, so I could not transcribe a single line.
  • No audio, no bookmarks, no per-page notes — none of that was in the original to preserve.

Reading decisions I made

  • One continuous book. The two volumes are chained (36 → 1) rather than kept apart, because the second bundle carries the same script and a later page set.
  • Original page files kept. The .app bundles were never modified; the build script only reads them.
  • Filenames preserved as provenance in the manifest ("src":"page 7.jpg") next to the normalised web name.

09 · Rebuild it

# 1. stage 75 pages out of the two applets, native pixel sizes, plus thumbnails + manifest
python3 ~/scripts/build-kinjitsu.py
# → v1: 36 pages, v2: 39 pages, 41.0 MB source → 8.2 MB web

# 2. take the dead framing off (black caption bar + photo surround), then re-index
python3 ~/scripts/trim-kinjitsu-pages.py --apply   # 65 of 75 pages, median 13.9% of the frame
python3 ~/scripts/reindex-kinjitsu.py              # rewrite manifest + thumbs at the new sizes

# 2. grade it in Chromium at 390 / 768 / 1440
python3 ~/scripts/verify-kinjitsu.py        # ends with "FAILURES: none"

# 3. states: resume, error, reduced motion
python3 ~/scripts/verify-kinjitsu-states.py

The reader is one HTML file with no dependencies — no framework, no build step, no network calls beyond its own images. The manifest ships as a classic <script> rather than JSON fetched over fetch(), so the book also opens correctly straight off the filesystem.