Add package skeleton, source classification and raster loading

The pdf module is the whole of M1 except the debug overlay, which needs
detection results to draw.

Source type is detected per PDF by looking for a page-covering image,
and is always reported for confirmation rather than applied silently
(ADR 0004). Rasters are extracted via Pixmap(doc, xref) rather than by
decoding extract_image() bytes, because MuPDF handles JBIG2 and CCITT
scans that no image library will.

Scanned pages are extracted at the embedded image's native resolution;
only vector pages are rendered, at 600 DPI. Verified against the corpus:
Elaman nalka (vector) renders 4959x7017, Ketun joululaulu (scan) loads
1653x2332, Engel (scan) 2552x3504 — and Engel's page 2 is 2480 wide
where page 1 is 2552, so scan width varies within one PDF.

tests/test_pdf.py builds its own PDFs so the check runs without corpus
files, which are copyrighted and gitignored.

Closes #1, #2, #3
This commit is contained in:
Esa Kataja
2026-07-28 22:33:26 +03:00
parent 01227b3382
commit e2f3e8fbdc
6 changed files with 402 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+45
View File
@@ -0,0 +1,45 @@
"""Command line entry point."""
from __future__ import annotations
import argparse
import sys
from . import __version__
from .pdf import SourceType, open_source, page_raster
def _info(args: argparse.Namespace) -> int:
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
note = f" (detected {source.detected.value}, overridden)" if source.overridden else ""
print(f"{source.path.name}: {source.type.value}{note}, {len(source)} pages")
for i in range(len(source)):
h, w = page_raster(source, i).shape
print(f" p{i + 1:<3} {w}x{h}")
source.close()
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="noteman-slicer",
description="Cut score PDFs into noteman's slice images and markers.",
)
parser.add_argument("--version", action="version", version=__version__)
sub = parser.add_subparsers(dest="command", required=True)
info = sub.add_parser("info", help="classify a PDF and report its page rasters")
info.add_argument("pdf")
info.add_argument(
"--type",
choices=[t.value for t in SourceType],
help="override source-type detection",
)
info.set_defaults(func=_info)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
"""PDF input: classify a score source and hand back page rasters.
Two source types, never mixed within one PDF (docs/spec.md):
raster — a scan; every page carries one full-page image, and *that image
is the scan*. It is extracted at its native resolution rather
than re-rendered: real scans in this corpus run ~200 DPI, and
re-rendering at 600 would triple the pixel count for no detail.
vector — an engraving; nothing to extract, so the page is rendered.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
import numpy as np
import pymupdf
VECTOR_RENDER_DPI = 600
# An image covering at least this fraction of the page is the page's scan
# rather than an illustration sitting on an engraving.
_FULL_PAGE_AREA = 0.5
class SourceType(Enum):
RASTER = "raster"
VECTOR = "vector"
@dataclass
class Source:
path: Path
doc: pymupdf.Document
type: SourceType
detected: SourceType
render_dpi: int = VECTOR_RENDER_DPI
@property
def overridden(self) -> bool:
"""True when the user's choice disagrees with detection."""
return self.type is not self.detected
def __len__(self) -> int:
return len(self.doc)
def close(self) -> None:
self.doc.close()
def _full_page_image(page: pymupdf.Page) -> int | None:
"""xref of the image covering this page, or None."""
page_area = abs(page.rect.get_area())
if page_area <= 0:
return None
# full=True is required, or get_image_bbox rejects the item.
for item in page.get_images(full=True):
try:
bbox = pymupdf.Rect(page.get_image_bbox(item))
except ValueError:
continue
if abs(bbox.get_area()) >= page_area * _FULL_PAGE_AREA:
return item[0]
return None
def classify(doc: pymupdf.Document) -> SourceType:
"""Detection only — the caller confirms with the user (ADR 0004)."""
scanned = sum(_full_page_image(page) is not None for page in doc)
return SourceType.RASTER if scanned * 2 > len(doc) else SourceType.VECTOR
def open_source(path: str | Path, source_type: SourceType | None = None) -> Source:
"""Open a PDF. `source_type` overrides detection; it never silently wins."""
path = Path(path)
doc = pymupdf.open(path)
detected = classify(doc)
return Source(path=path, doc=doc, type=source_type or detected, detected=detected)
def page_raster(source: Source, index: int) -> np.ndarray:
"""One page as a grayscale array, at the resolution the pipeline should work at."""
page = source.doc[index]
if source.type is SourceType.RASTER:
xref = _full_page_image(page)
if xref is not None:
# Pixmap(doc, xref) rather than decoding extract_image() bytes:
# MuPDF handles JBIG2 and CCITT, which no image library will.
pix = pymupdf.Pixmap(source.doc, xref)
return _to_gray(pix)
# A scanned PDF whose page has no embedded image (a blank, or a
# cover typeset in vector). Rendering is the only option left.
return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY))
def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray:
if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1:
pix = pymupdf.Pixmap(pymupdf.csGRAY, pix)
return np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width)