Align bitonal scans of Japanese vertical-writing (縦書き) novel pages onto a
fixed paper-size canvas. Every scan of the same book produces slightly different
page sizes, text-block positions and rotations; register removes that jitter
so every cleaned page sits identically, ready to be bound back into a uniform PDF.
It shares the Leptonica/FFM foundation with its sibling
despeckle:
pdfimages → despeckle (noise removal) → register (alignment) → re-pack to PDF
For each page, in two passes over the directory:
- Detect the main text column. A projection profile (per-row then per-column ink counts) finds the densest band; the running title (柱), footnote indices and other marginalia sit outside it and are excluded.
- Derive a reference layout. Per parity (recto / verso), the median main-column box across the whole book becomes the target — robust to the odd badly scanned page.
- Register each page onto a fresh paper-size canvas: deskew, scale its
column to the reference height, and place it onto the corpus type-area grid
(the fixed rectangle the book is set in). With
--anchor top_right(default) the page anchors, per axis, whichever content edge sits on the grid: a body page by its top (head margin, where every column starts) and right (reading origin of vertical RTL text); an opener by its bottom (head dropped) or left (rightmost columns blank). Margin overflowing the canvas is cropped, so the text block — and the page number riding with it — lands at the same canvas position on every page of a parity.--anchor centerbalances each page's margins without cropping, losing that cross-page consistency. Pages whose column is far smaller than the reference (sparse or blank) are passed through, centered.
Input and output are directories of bitonal PBM / PNG / TIFF images; PDFs are never touched (that is the re-packing step's job).
just run scans/book out/book # defaults: shiroku paper, 400 dpi
just run scans/book out/book --paper b6 --dpi 600 --force
just run scans/book out/book --no-scale --no-deskew --format png| option | default | meaning |
|---|---|---|
--paper |
auto |
auto, a standard name (shiroku/a4/a5/a6/b5/b6/shinsho), or a custom WxH mm (e.g. 127x188). auto snaps the median scanned page to the nearest standard book size — robust to a book scanned a little under nominal (binding trim / paper shrink) and over nominal (loose crop / scan margin, which it treats as croppable and so does not let pull the choice up a size) — falling back to the exact measured size when no standard is close |
--dpi |
inherit scan | output resolution; fixes the canvas pixel size. Default: inherit the input scan's own resolution (falls back to 400 for untagged inputs such as raw PBM) |
--format |
same |
same/pbm/png/tiff/webp |
--glob |
*.{pbm,png,tiff,tif} |
input file-name glob (matched case-insensitively) |
-j/--jobs |
CPUs | worker threads |
--no-deskew |
off | turn off straightening each page before detection (on by default) |
--no-scale |
off | turn off scaling each column to the reference height (on by default) |
--outlier-ratio |
0.5 |
a column smaller than this fraction of the reference is centered, not registered |
--anchor |
top_right |
top_right (pin column top-right to the reference, cropping margin overflow — same text-block position on every page) or center (balance each page's margins, no cropping) |
--force |
off | overwrite a non-empty output directory |
--diag |
off | write diagnostic artifacts to this directory (see below) |
--flipbook |
off | with --diag, also assemble an animated WebP flip-book of the registered pages (needs libwebp's img2webp) |
-v/--verbose |
off | enable verbose (DEBUG) logging |
-h/--help |
— | show help and exit |
-V/--version |
— | print version and exit |
--completion <bash|zsh|fish> |
— | print a shell completion script and exit |
--man |
— | print a man page (troff) and exit |
Enum values (--format, --anchor) are case-insensitive. A bare register prints
help and exits 0.
register pipeline <in.pdf> <out.pdf> registers a scanned PDF onto a fixed canvas
and repacks it as lossless-JBIG2 in one step (a directory batches every top-level
*.pdf). Pass - for <in.pdf> to read one PDF from stdin and/or - for
<out.pdf> to write it to stdout.
CLI exit codes follow the shared error model: 0
success, 2 usage/parse error, 64 bad value, 65 unreadable image, 66 input
not found, 70 internal / native-tool failure, 73 output exists (pass
--force), 137 out of memory. Errors print as Error[KIND]: …; the KIND token
is the language-neutral error vocabulary — the CLI renders it in English, the web
UI in Japanese, neither sharing a message string.
--diag writes artifacts showing what the alignment did, without touching the
bilevel output:
NNNN-<page>.diag.webp— one per page: the page with its detected column (green), the band it came from (blue), the reference box (orange), the projection profiles and the numeric placement.corpus-overlay.webp— for each parity, every registered page's detected column before (raw page space — the scan-jitter cloud) and after (canvas space). A tight after stack around the orange median grid box where the before cloud was wide shows the jitter collapsed onto the grid.residuals.webp— per-page distance from each placed column edge to the grid edge, plotted by page index (recto ●, verso ▲), one panel per axis. Low and flat means tight registration; a spike names the page that drifted. (Same numbers assummary.txt.)pages.jsonl/summary.txt— machine-readable per-page log and aggregate run summary.flipbook.webp(with--flipbook) — the registered pages as an animated WebP. Built via libwebp'simg2webp(from thePATHor-Dp4suta.img2webp.path; the legacy-Dregister.img2webp.pathstill works); if the tool is missing the flip-book is skipped and the rest is unaffected.
Five Gradle modules under io.github.p4suta.register, a hexagonal (ports & adapters) graph in
which a layer violation does not compile — the inter-module dependency edges put the offending class
off the classpath. ArchUnit (in :app) pins the intra-module rules the module graph cannot express
(the FFM island, PDFBox / ProcessBuilder / System.out / filesystem confinement, the single
JSpecify nullness vocabulary). Shared build conventions live in build-logic/ as the
register.{java,test,quality}-conventions plugins.
:app ──▶ :application ──▶ :port ──▶ :domain (pure; no project or third-party runtime deps)
└────▶ :infrastructure ──▶ :port, :domain (PDFBox / Leptonica(FFM) / external binaries only here)
The Throwable → exit-code mapping and the CLI's fatal uncaught-exception handler come from the
cross-app :shared:observability + :shared:cli modules.
| module | role |
|---|---|
:domain |
the pure kernel — no project dependency, no third-party runtime: the value types (Box, Transform, Canvas, PaperSize, Anchor, Parity, OutputFormat, RegisterOptions, the diagnostic records) and the framework-free algorithms (ProjectionProfile, the per-parity Reference, TransformPlanner) |
:port |
the interfaces the application drives and the infrastructure implements — PageRegistrar, PdfImageExtractor, Jbig2Assembler, Reporter/ReporterFactory — speaking only domain types and file paths (no Pix, no PDFBox crosses the boundary) |
:application |
orchestration: the corpus RegistrationService (directory walk, fixed thread pool, the two-pass analyze → reference → render) and the register pipeline drivers (PdfPipelineService, PdfBatchService). Sees only :domain + :port |
:infrastructure |
the adapters: Leptonica (the one FFM binding island) + Pix (RAII handle) + the pixel-pushing LeptonicaPageRegistrar / MainColumnDetector; the PDFBox + pdfimages/jbig2 PDF wrappers; the opt-in --diag renderers; the native-tool/process helpers. The one module that uses PDFBox and crosses the FFM/process boundary; publishes the TestImages fixture |
:app |
the composition root and runnable artifact: Main, the Apache Commons CLI front end (RegisterCommand/PipelineCommand, shared parsing in CliSupport), and the self-contained jpackage app-image distribution (jlink + shadow + staged natives) |
The project-wide JaCoCo coverage aggregation (just coverage) lives in the root build, not a module.
:domain performs no directory or thread work and never touches Leptonica. The detection /
planning logic (ProjectionProfile, Reference, TransformPlanner, PaperSize) is pure Java;
the Leptonica adapter does the pixel work (pixCountPixelsByRow/Column, pixDeskew,
pixScaleToSize, pixRasterop) behind the PageRegistrar port.
- Leptonica (
liblept.so) at run time — the dev image installslibleptonica-dev. Override the resolved path with-Dp4suta.leptonica.path=/path/to/liblept.soif needed (the legacy-Dregister.leptonica.pathis still honored). See Distribution & packaging for the full property scheme. - JDK 25 (FFM is final since JDK 22; the build pins the 25 toolchain).
- Run with
--enable-native-access=ALL-UNNAMED—:app'srun/testand the bundled launcher already pass it (it is set in the conventions andgradle.properties).
./gradlew build (or just build) runs the full gate, mirrored by CI:
- Spotless + google-java-format (AOSP, 100 columns)
- Error Prone on every compile,
-Werror - NullAway (JSpecify,
@NullMarked) at ERROR on both main and test sources - SpotBugs at max effort
- Javadoc doclint (
-Xdoclint:all,-missing,-Werror) — a broken{@link}fails the build - ArchUnit — the hexagonal layering, FFM /
MethodHandleconfinement, PDFBox /ProcessBuilder/ filesystem / standard-stream limits, Commons CLI pinned to the shell, no package cycles, JSpecify-only nullness - JaCoCo coverage floors on the unit-testable modules (
jacocoTestCoverageVerification);:applicationand:app, which are exercised cross-module, are tracked via the aggregated coverage report (just coverage) instead - JUnit — projection-profile and planner unit tests, an FFM smoke test, and a directory end-to-end run, plus the ArchUnit architecture suite
Out of the build gate, run on demand:
just coverage— project-wide aggregated coverage: the rootjacoco-report-aggregationmerges every module's JaCoCo data into one report, so cross-module end-to-end coverage is credited (e.g.:application, which is unit-light on its own, is covered by:app's E2E suites)just mutation— pitest mutation testing for the pure-logic:domainjust rewrite— an advisory OpenRewrite pass (never blocking)
Dual-licensed under either of
- MIT license (LICENSE-MIT)
- Apache License 2.0 (LICENSE-APACHE)
at your option.