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.
183 lines
8.5 KiB
Markdown
183 lines
8.5 KiB
Markdown
# 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.
|