Files
PageScribe/README.md
PageScribe 5b64d268f0 Add UmbraTools-branded app icon
PageScribe joins the UmbraTools suite, so give it an icon in the shared
"umbra" identity: an open bronze book bound along its gutter by a glowing
cyan weave, held inside a broken reticle ring on a midnight field.

- Full adaptive icon: background + foreground + themed monochrome layer,
  with the mark sized to the 66dp safe circle so circle, squircle and
  rounded-square launcher masks all crop it cleanly.
- Legacy ic_launcher / ic_launcher_round bitmaps at all five densities.
- 1024 master lockup, Play Store 512 icon and the rounded-square mark in
  brand/, next to the generator and design notes.
- brand/generate_icons.py rebuilds every asset deterministically (SVG via
  cairosvg for the gradients and glow, Montserrat for the wordmark);
  brand/README.md documents the palette, what each element means, and how
  to regenerate.

The old flat vector drawable/ic_launcher.xml is replaced; the manifest now
points at @mipmap/ic_launcher with a roundIcon.
2026-10-15 12:16:25 -07:00

183 lines
8.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PageScribe
Photograph the open spread of a book, page by page. PageScribe reads the text
off each photo in the background, puts the pages back in order by their printed
page numbers, and writes them into a single `.txt` file named after the book.
The photos themselves are shrunk down and kept alongside the text.
You never have to wait for recognition: shoot the next spread while the previous
one is still being read.
## What it does
- **One photo = one two-page spread.** The camera screen shows the spread and a
shutter; each shot is dropped straight into the book's queue.
- **Background OCR.** A WorkManager chain drains the queue, so you can keep
shooting. Progress is shown on the shelf, in the book, and on the camera
screen, and as a notification.
- **A text file per book.** Pages are ordered by the folio (printed page
number) they carry, not by the order you happened to photograph them. The
finished file is `<book title>.txt`.
- **Coverage you can see.** Because the folios are read anyway, the app knows
which pages have been captured. The book screen shows `Pages 42–51 · 7 of 10
captured · 3 to shoot`, listing every folio in order — the ones already read
with their opening lines, the ones never photographed as *not captured yet*,
and the ones whose photo came out blank as *no readable text — photograph this
page again*. The page numbers steer the process but are never written into
the text.
- **Figures and captions are dropped.** Isolated short lines that sit under
picture content (with their one or two credit lines) are filtered out, while a
short line over blank paper is kept.
- **Small archived photos.** Each source JPEG is scaled so the smallest text
line on the page is about 20 px tall, converted to greyscale, and encoded at
the lowest quality on a ladder that still keeps the text region close to the
original. The smaller of JPEG and WebP wins. A ~350 KB source becomes a
~35 KB archive.
Everything lives in app-private storage (`files/books/<title>/`); the **Export
text** button hands the assembled `.txt` to the system share sheet, from where
it can go to Drive, email, or the Files app.
## Screens
- **Shelf** (`MainActivity`) — one row per book, plus a `+` to create one.
- **Capture** (`CaptureActivity`) — CameraX preview, flash toggle, shutter, and
a jump to the book. Every accepted shot is confirmed with a short shutter
click (a bundled sample played through `SoundPool`), so a batch of spreads can
be shot by feel. A tap that the camera refuses — still busy, not ready —
stays silent, so the click always means "taken".
- **Book** (`BookActivity`) — the pages read so far, the log, **Export text**,
**Capture**, and **Delete book**.
## How the pipeline works
Each queued photo goes through `SpreadProcessor`:
1. Decode the JPEG (EXIF rotation honoured), longest edge `OCR_MAX_EDGE`.
2. Find the **gutter** between the two pages as a tilted line, so a slightly
skewed shot still splits cleanly. Falls back to the image centre.
3. Run ML Kit text recognition over the whole spread (bundled Latin model, so
it works offline).
4. Split the recognised lines by the gutter and read each half's **folio** — the
bare page number near the outer edge, top or bottom. A page whose body failed
to OCR often leaves only its folio readable, so even a near-empty half is
searched for one.
5. Drop **captions**: short, narrow lines isolated below picture content.
6. **Reflow** the body into paragraphs, gluing hyphenated line breaks and
trimming running heads.
7. Write `pages/%04d.txt` (folio = sort key) and archive the image.
At the end, `assembleTextFile` sorts the page files by folio, joins each page to
the next when a sentence runs across the spread, and writes `<title>.txt`.
### Tracking coverage and knowing what to re-shoot
Every stored page file is named after its folio, so the set of folios in
`pages/` is a record of which pages have been seen. `PageCoverage` turns that
into, for the span `[low, high]` of folios seen so far:
- **captured** — a page file with body text;
- **needs re-capture** — a page file that exists but is blank (the folio was
read, the body was not), written by `SpreadProcessor` as the one-byte file
`"\n"`;
- **missing** — a folio in the span with no page file at all.
The book screen lists every folio in that span in order and marks the two kinds
of gap, so a page that was never shot and a page that came out blank are both
pointed at by number. A book can never reveal how long it really is, so pages
beyond the highest folio seen are unknown rather than missing. A photo that
contains no text at all has no folio to attribute, so it is parked in `failed/`
and listed separately as *could not be read*.
### Storage layout
```
files/books/<sanitized title>/
meta.properties book title, created-at
queue/ photos waiting to be read (claimed by the worker)
work/ in-flight photos
pages/ %04d.txt, one per recognised page
images/ the archived (shrunk) photos, same numbering
failed/ photos given up on after MAX_JOB_ATTEMPTS
log.txt human-readable progress log
failures.properties why each failed photo failed
<title>.txt the assembled book
```
The queue is crash-safe: `PageScribeApp.onCreate` reclaims photos orphaned in
`work/` older than two minutes and re-arms the worker, and failed photos are
retried up to `MAX_JOB_ATTEMPTS` before being parked in `failed/`.
## Icon and branding
PageScribe belongs to the **UmbraTools** suite, so its launcher icon follows the
UmbraTools UX brand: a dark, luminous "umbra" identity built from interwoven,
glowing forms. The mark is an open **book** bound along its gutter by a glowing
**weave**, held inside a broken **reticle** ring — a midnight field
(`#0A0F1F`), Electric Cyan (`#00BFFF`) glow, a bronze (`#A67C52`) book, and Ice
Blue (`#CFE9FF`) ruled lines.
The icon ships as a full adaptive icon (background + foreground + themed
`monochrome` layer) sized to the 66 dp safe circle, so circle, squircle and
rounded-square launcher masks all crop it cleanly, plus legacy bitmaps for older
launchers. The 1024 master lockup and Play Store icon live in `brand/`, together
with the deterministic generator and the design notes — see
[`brand/README.md`](brand/README.md).
## Building
```bash
./gradlew :app:assembleDebug # APK at app/build/outputs/apk/debug/
./gradlew :app:testDebugUnitTest # 98 unit tests (JVM + Robolectric)
```
Install on a connected device:
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
Release builds read signing credentials from `keystore.properties` at the repo
root (git-ignored); without it a release APK is produced unsigned.
## Tech
- Kotlin, Material Components, `minSdk 26`, `targetSdk 36`.
- CameraX `1.6.2` for preview and capture.
- ML Kit `text-recognition` `16.0.1` (bundled Latin model — no Play services,
no network).
- WorkManager `2.12.0` for the durable OCR queue.
Note: from `targetSdk 35` on, Android enforces edge-to-edge and the built-in
AppCompat action bar is no longer inset correctly, so each screen uses a
`MaterialToolbar` in an `AppBarLayout` and applies the system-bar insets itself
(`util/EdgeToEdge.kt`).
## Tests
`app/src/test/` holds 98 JVM/Robolectric tests covering the parts that do not
need a camera:
| Suite | What it covers |
| --- | --- |
| `PageCoverageTest` | folio spans, missing pages vs pages that need re-shooting |
| `GutterDetectorTest` | tilted-gutter fit and centre fallback |
| `PageNumberDetectorTest` | folio geometry, margin isolation, sparse pages |
| `PageNumberResolverTest` | choosing the right folio per half |
| `CaptionFilterTest` | dropping captions under figures, keeping orphans |
| `ParagraphReflowTest` | hyphen gluing, running heads, across-page join |
| `BitmapsTest` | EXIF-aware downsample and scaling |
| `ImageArchiverTest` | archive scale and JPEG/WebP quality ladder |
| `BookRepositoryTest` | create/list/delete and navigation |
| `BookTest` | queue claim/fail/complete, page files, text assembly, coverage |
## Known limitations
- The archive size is driven by the *smallest* text on the page, so a page with
a tiny folio or caption archives a little larger than a body-text floor would
suggest.
- A figure that interrupts a paragraph produces a paragraph break (treated as a
section break).
- Two-page spreads whose text runs past the gutter (an illustration, or a very
wide table) can only be split at the fitted gutter line.