scanlayer
Docs / Reference

Library API

Three top-level functions cover almost everything: convert() for one file, convert_batch() for many, convert_merge() to combine several images into one PDF. Further down, every lower-level function they're built from, for programs that want to run just one pipeline stage.

Quick start

pythonquickstart.py
import scanlayer

result = scanlayer.convert("invoice.jpg", "invoice.pdf")
print(result.words_count, result.mean_confidence)

That's the whole surface most callers need. Everything below is detail on that call, its two siblings, and what's underneath them.

FunctionUse it forReturns
convert()One input file, one output fileConversionResult
convert_batch()Many input files (globs, mixed extensions, PDFs), one output per input, or mergedBatchResult
convert_merge()Several images combined into one multi-page PDFConversionResult

All three, plus configure(), configure_from_file(), get_settings(), and load_config_file(), import straight from the top-level package:

python
from scanlayer import convert, convert_batch, convert_merge, configure

convert()

python
def convert(
    input_path: str,
    output_path: Optional[str] = None,
    lang: Optional[str] = None,
    dpi: Optional[int] = None,
    jpeg_quality: Optional[int] = None,
    char_whitelist: Optional[str] = None,
    char_blacklist: Optional[str] = None,
    pdf_metadata: Optional[dict] = None,
    force: bool = False,
    orientation: "str | float | None" = None,
    output_format: str = "pdf",
    debug_image: bool = False,
) -> ConversionResult

Parameters

ParameterTypeDefaultMeaning
input_pathstrrequiredPath to the source image (jpg, png, tiff…) or a native .pdf.
output_pathstrNonePath to write the output to. If omitted, defaults to the input file's own directory and stem, with the extension for output_format, e.g. invoice.jpg becomes invoice.pdf. Lets debug_image=True be used without naming an output file.
langstrconfig.DEFAULT_OCR_LANG ("fra+eng")Tesseract language(s), e.g. "eng", "fra+eng".
dpiintconfig.DEFAULT_DPI (300)Used only if the image has no usable DPI metadata.
jpeg_qualityintconfig.PDF_JPEG_QUALITY (82)JPEG quality for the PDF background, when JPEG encoding is chosen.
char_whiteliststrNoneCharacters Tesseract is allowed to recognize.
char_blackliststrNoneCharacters Tesseract should ignore.
pdf_metadatadictNoneOverrides for config.PDF_METADATA. Keys: title, author, subject, creator, keywords.
forceboolFalseBuild output anyway if the page looks blank, instead of raising BlankPageDetectedError.
orientationNone | "none" | floatNoneSee orientation modes below.
output_formatstr"pdf"One of "pdf", "txt", "json", "tsv", "hocr".
debug_imageboolFalseAlso write <output_path stem>_debug.png.

Orientation modes

orientation controls how the page is straightened before OCR:

  • None (default): fully automatic: EXIF orientation tag, then Tesseract's orientation/script detection (OSD) for 0/90/180/270 rotation, then a fine deskew (a few degrees) via minAreaRect on the text pixels.
  • "none": disables all of that. No EXIF transpose, no OSD, no deskew. The image is used exactly as loaded.
  • a number (int or float, degrees, clockwise positive): skips auto-detection entirely and applies exactly that rotation. Useful for deterministic, reproducible output across a batch of identically-scanned pages.

Return value: ConversionResult

python
@dataclass
class ConversionResult:
    output_path: str
    words_count: int
    mean_confidence: float
    best_psm: Optional[int]
    early_exited: bool
    pdf_size_bytes: int
    elapsed_ms: float
    language_used: str
    output_format: str = "pdf"
    debug_image_path: Optional[str] = None
    column_count: int = 1
FieldMeaning
output_pathAbsolute path written.
words_countWords kept after the confidence threshold.
mean_confidenceAverage confidence across kept words.
best_psmThe PSM that won auto-selection, or the forced one if --psm/psm_candidates pinned it.
early_exitedWhether PSM auto-selection stopped early on a high-confidence pass.
pdf_size_bytesSize of the written file in bytes, regardless of format despite the name.
elapsed_msTotal time for the call, in milliseconds.
language_usedTesseract language(s) actually used.
output_formatEchoes the format written.
debug_image_pathPath to the debug overlay, if debug_image=True.
column_countDetected column count. 1 if single-column or detection didn't trigger.

The blank-page gate

Before OCR runs at all, scanlayer checks whether the source image is near-uniform (grayscale standard deviation under config.DESKEW_MIN_STD, 5.0 by default). If it is, convert() raises BlankPageDetectedError instead of quietly producing an empty PDF, since a blank page is usually unexpected (wrong file, a scanner that fired on nothing).

python
try:
    result = scanlayer.convert("maybe_blank.jpg", "out.pdf")
except scanlayer.utils.errors.BlankPageDetectedError:
    result = scanlayer.convert("maybe_blank.jpg", "out.pdf", force=True)
force=True still skips OCR Running OCR on a page already identified as blank only produces noise misread as text. With force=True you get a background-only PDF (or an empty result for other formats), not a fabricated text layer.

convert_batch()

python
def convert_batch(
    inputs: "str | list[str]",
    output: Optional[str] = None,
    *,
    merge: bool = False,
    lang: Optional[str] = None,
    dpi: Optional[int] = None,
    jpeg_quality: Optional[int] = None,
    char_whitelist: Optional[str] = None,
    char_blacklist: Optional[str] = None,
    pdf_metadata: Optional[dict] = None,
    force: bool = False,
    orientation: "str | float | None" = None,
    output_format: str = "pdf",
    debug_image: bool = False,
    verbose: bool = False,
    quiet: bool = False,
) -> BatchResult
  • inputs accepts a single pattern/path or a list, mixed extensions welcome: ["*.png", "*.jpg", "*.pdf", "specific.tiff"]. Each entry is glob-expanded; a literal path that doesn't match a glob passes through and is left for convert() to raise on if it doesn't exist. Any .pdf input is rasterized to one image per page first.
  • output is a folder (created if it doesn't exist): one output file per input, named after the input's stem plus the output_format extension. If omitted, each output file is written next to its own input instead, same stem and extension rule, no shared folder needed. With merge=True, output is instead a single file path for the combined PDF, and output_format must stay "pdf"; output is required in that case, since there is no single input to derive a shared name from.
  • Every other keyword argument is the same as convert(), applied to every file.
  • verbose/quiet set the log level for the whole call, equivalent to configure(log_level="DEBUG"/"ERROR"). Mutually exclusive: passing both raises ValueError.
python
result = scanlayer.convert_batch(
    ["*.png", "*.jpg", "*.pdf"], "./converted/",
    lang="fra", force=True, quiet=True,
)
print(f"{result.succeeded} ok, {result.failed} failed")
for path, err in result.failures:
    print(f"  {path}: {err}")

Return value: BatchResult

python
@dataclass
class BatchResult:
    results: list[ConversionResult]
    failures: list[tuple[str, str]]

    @property
    def succeeded(self) -> int: ...
    @property
    def failed(self) -> int: ...
    @property
    def ok(self) -> bool: ...   # True if failures is empty
Never raises for a per-file failure One bad file does not abort the rest of the batch; check .results, .failures, .succeeded, .failed, and .ok after the call instead. It only raises for a call-level mistake (e.g. verbose=True, quiet=True, or merge=True, output_format="json"). With merge=True, even a total merge failure is recorded in .failures as ("<merge>", message) rather than raised.

convert_merge()

python
def convert_merge(
    input_paths: list[str],
    output_path: str,
    lang: Optional[str] = None,
    dpi: Optional[int] = None,
    jpeg_quality: Optional[int] = None,
    char_whitelist: Optional[str] = None,
    char_blacklist: Optional[str] = None,
    pdf_metadata: Optional[dict] = None,
    force: bool = False,
    orientation: "str | float | None" = None,
) -> ConversionResult

Combines several source images into one multi-page searchable PDF, in argument order. PDF-only: there's no sensible schema for merging into txt/json/tsv/hocr; use convert() once per page, or a plain convert_batch() run without merge=True, if you need one of those formats for a multi-page source.

python
result = scanlayer.convert_merge(["a.jpg", "b.jpg", "c.jpg"], "combined.pdf")
print(result.words_count, "words across", result.column_count, "max columns")

Raises BlankPageDetectedError if any page is blank and force=False, checked per page. In the returned ConversionResult, best_psm is always None (no single meaningful PSM across a multi-page merge), and mean_confidence is word-count-weighted across every page.

Exceptions

ExceptionRaised when
ValidationErrorA prerequisite wasn't met, before any real work started.
InputFileErrorSource file missing, unreadable, or corrupted.
OutputPathErrorDestination path invalid (missing parent directory, not writable, points at a directory).
TesseractEnvironmentErrorTesseract not found, or its configured tessdata_dir is missing.
DependencyErrorA required package or binary is missing, most commonly poppler for .pdf input.
BlankPageDetectedErrorThe page looks blank (near-uniform grayscale) and force=False.
PipelineErrorA stage started but failed for an operational reason, after every precondition already passed.
OCRProcessingErrorEvery configured PSM candidate failed for a real reason (timeout, crash, corrupted language data). A page with genuinely no text is not an error.
PDFBuildErrorThe PDF could not be written (disk full, permissions, corrupted background image, ReportLab failure).
python
from scanlayer.utils.validators import ValidationError
from scanlayer.utils.errors import PipelineError

try:
    result = scanlayer.convert("invoice.jpg", "invoice.pdf")
except ValidationError as exc:
    print(f"Bad input or environment: {exc}")
except PipelineError as exc:
    print(f"Conversion failed mid-run: {exc}")

Low-level API

Everything above wraps a fixed pipeline: validate, preprocess, OCR, order columns, build output. Most programs should stay at that level. The functions below are what convert() calls internally, exposed for programs that want to run just one stage.

text
image path
    |
    v
validators.validate_all()              (file exists, Tesseract found, output path OK)
    |
    v
preprocessing.enhance.preprocess()     -> PreprocessResult (background + ocr_image)
    |
    v
ocr.engine.extract_words()             -> OcrResult (Word list + stats)
    |
    v
layout.columns.reorder_reading_order() -> reordered Word list, column count
    |
    v
pdf.builder.build_searchable_pdf()     -> PDF on disk
  or
ocr.export.export_words()              -> txt / json / tsv / hocr string

preprocessing.enhance.preprocess()

python
from scanlayer.preprocessing.enhance import preprocess

result = preprocess(image, dpi=300, orientation=None)

Takes a PIL Image and returns a PreprocessResult:

FieldTypeMeaning
backgroundPIL.ImageStraightened image (orientation + skew corrected), colors and resolution otherwise unchanged. This is what gets drawn as the PDF page.
ocr_imagenp.ndarrayGrayscale, illumination-corrected, denoised, contrast-enhanced, upscaled/downscaled copy for Tesseract. Never displayed.
ocr_scalefloatocr_image.width / background.width, used to map OCR coordinates back onto background.
effective_dpiintDPI actually passed to Tesseract, after any upscale.
exif_orientation_appliedintThe PIL EXIF orientation tag value that was applied (1 = none).
gross_rotation_appliedint0/90/180/270, from Tesseract OSD.
deskew_angle_appliedfloatFine-deskew angle in degrees.
manual_rotation_appliedfloatSet when orientation was passed as a numeric angle.
orientation_correction_skippedboolSet when orientation="none".
source_stdfloatGrayscale standard deviation of the input, the blank-page signal.
likely_blankboolsource_std < config.DESKEW_MIN_STD.
preprocessing_warningstuple[str, ...]Non-fatal diagnostics, e.g. a downscale warning on a very large image.
Never raises preprocess() always returns a result, even in degraded mode, rather than raising, so a single failed sub-step (OSD failing on a text-sparse page, for instance) does not abort the call.

ocr.engine.extract_words()

python
from scanlayer.ocr.engine import extract_words

ocr_result = extract_words(
    result.ocr_image, result.ocr_scale,
    effective_dpi=result.effective_dpi, lang="eng",
)

Runs Tesseract across config.TESSERACT_PSM_CANDIDATES ([3, 4, 6, 11] by default), in parallel threads by default, and keeps the candidate with the highest mean confidence. Stops early once a candidate clears config.PSM_EARLY_EXIT_CONFIDENCE (80% by default).

FieldTypeMeaning
wordslist[Word]Coordinates already mapped back to the background coordinate system.
best_psmintThe winning PSM.
best_confidencefloatThat PSM's mean confidence.
attemptslist[PsmAttempt]One entry per PSM candidate that actually ran, for diagnostics.
early_exitedboolWhether remaining candidates were skipped.
language_usedstrThe Tesseract language string used.
python
@dataclass
class Word:
    text: str
    x: float            # top-left corner, background image coordinates (pixels)
    y: float
    width: float
    height: float
    confidence: float
    line_id: int = -1   # combined block/paragraph/line index from Tesseract
OCRProcessingError only on real failure Raised only if every PSM candidate fails for a real reason (timeout, crash, corrupted language data). A page that legitimately has no text is not an error: every candidate succeeds, just with an empty words list.

layout.columns.reorder_reading_order()

python
from scanlayer.layout.columns import reorder_reading_order

ordered_words, column_count = reorder_reading_order(
    ocr_result.words, page_width=result.background.width,
)

Re-derives left-to-right, top-to-bottom reading order for genuine multi-column pages geometrically (from word positions), rather than trusting Tesseract's own block/line numbering, which frequently interleaves columns line by line. Tuned for prose-style layouts (articles, letters, reports), not tables: an invoice's line-item table has narrow per-row gutters that don't look like a page-wide column break, so this deliberately won't touch it.

Returns the words unchanged (and column_count=1) if config.MULTI_COLUMN_DETECTION is False, there isn't enough text to detect columns confidently, no gutter wide enough to trust is found, or the reorder would drop or duplicate a word (a safety fallback).

This is the same function convert() and convert_merge() call internally, before both PDF drawing and text export.

ocr.export: format-specific exporters

python
from scanlayer.ocr.export import to_text, to_json, to_tsv, to_hocr, export_words, FORMATS
FunctionSignatureReturns
to_text(words)list[Word] -> strPlain text, one line per detected Tesseract text line.
to_json(words, mean_confidence, best_psm, language_used, image_width, image_height)keyword args requiredJSON string, see Output Formats for the schema.
to_tsv(words)list[Word] -> strTab-separated text/confidence/x/y/width/height/line_id, a simple flat schema, not Tesseract's own TSV layout.
to_hocr(words, image_width, image_height, source_name="image")keyword argsMinimal hOCR document (ocr_page/ocr_line/ocrx_word).
Reading order in these exporters is NOT column-aware Word order and line grouping here follow Tesseract's own line numbering (Word.line_id), not the geometric multi-column reordering above; that reordering is applied by convert()/convert_merge() before calling into ocr.export, not inside it. Call reorder_reading_order() yourself first if you use these directly on raw extract_words() output and the source is multi-column.

pdf.builder: building a PDF from words you already have

python
from scanlayer.pdf.builder import build_searchable_pdf, build_searchable_pdf_multipage, PageInput

build_searchable_pdf(background, words, output_path, dpi, jpeg_quality=None, metadata=None, lang=None) -> int, builds a single-page searchable PDF: draws background as the page image, then overlays each Word as invisible text (PDF render mode 3), horizontally stretched to match its detected bounding box. Returns the written file's size in bytes. Raises PDFBuildError on a write failure.

build_searchable_pdf_multipage(pages, output_path, jpeg_quality=None, metadata=None) -> int, the same thing for several already-preprocessed-and-OCR'd pages, combined into one document:

python
@dataclass
class PageInput:
    background: Image.Image
    words: list[Word]
    dpi: int
    lang: Optional[str] = None

Each page can have different pixel dimensions, DPI, and OCR language (useful for a batch-scanned mixed-language document). metadata applies to the document as a whole. Raises PDFBuildError if pages is empty.

Validators, called directly

python
from scanlayer.utils.validators import (
    validate_all, validate_input_file, validate_output_path,
    validate_tesseract_environment, validate_image_readable,
)

convert() calls validate_all(input_path, output_path, output_format) up front, which runs, in order: validate_input_file (exists, readable, recognized extension, non-empty), validate_tesseract_environment (binary found and executable, tessdata_dir sane if configured), validate_output_path (parent directory exists and is writable), then validate_image_readable (Pillow can actually open it). Each is callable on its own, useful for pre-flighting a batch of files before running any OCR, to report every bad file up front instead of one at a time as the batch runs.

Full exception hierarchy

text
ValidationError (scanlayer.utils.validators)
├── InputFileError
├── OutputPathError
├── TesseractEnvironmentError
└── DependencyError

PipelineError (scanlayer.utils.errors)
├── OCRProcessingError
├── PDFBuildError
└── BlankPageDetectedError

ValidationError subclasses mean a precondition failed before any real work started. PipelineError subclasses mean a stage began and failed partway through, for an operational reason, not a bug. The CLI maps each one to a specific exit code, see CLI Reference.

utils.debug_image.build_debug_image()

python
from scanlayer.utils.debug_image import build_debug_image

overlay = build_debug_image(
    image,
    words,               # list of ocr.engine.Word
    best_psm=3,
    mean_confidence=92.4,
    language_used="fra+eng",
    exif_orientation=1,
    gross_rotation=0,
    rotation_note="",
)

Returns a new PIL image: a copy of image with per-word bounding boxes, each word’s confidence drawn above it (colored green/orange/red by threshold), and a run-summary header banner at the top. The input image is not modified. words expects the Word objects produced by extract_words() (each exposing x, y, width, height, and confidence). This is the function behind Debug Visualization and the --debug-image flag.

pdf.fonts.resolve_font()

python
from scanlayer.pdf.fonts import resolve_font

font_name = resolve_font("fra+eng")   # -> str, a registered reportlab font name

Returns the reportlab font name used for the invisible PDF text layer. If config.FONT_PATH is set it registers and returns that TTF, otherwise it auto-selects: a CJK CID font (chi_sim, chi_tra, jpn, kor), the bundled DejaVu Sans for Latin/Cyrillic/Greek/Vietnamese, or a Helvetica fallback. Fonts are registered once and reused, and a failed registration falls back gracefully instead of raising.

Putting the low-level pieces together

Roughly what convert() does internally, written out explicitly. A starting point if you need to insert your own step in the middle, see also Examples → Advanced.

python
from PIL import Image
from scanlayer.utils.validators import validate_all
from scanlayer.preprocessing.enhance import preprocess
from scanlayer.ocr.engine import extract_words
from scanlayer.layout.columns import reorder_reading_order
from scanlayer.pdf.builder import build_searchable_pdf
from scanlayer.utils.errors import BlankPageDetectedError

input_abs, output_abs = validate_all("invoice.jpg", "invoice.pdf", "pdf")

with Image.open(input_abs) as img:
    image = img.copy()

result = preprocess(image, dpi=300, orientation=None)

if result.likely_blank:
    raise BlankPageDetectedError("page looks blank, pass force logic here if needed")

ocr_result = extract_words(
    result.ocr_image, result.ocr_scale,
    effective_dpi=result.effective_dpi, lang="fra+eng",
)

words, column_count = reorder_reading_order(
    ocr_result.words, page_width=result.background.width,
)

build_searchable_pdf(result.background, words, output_abs, dpi=300)

For anything that doesn't need this level of control, scanlayer.convert() does all of the above, plus validation edge cases, logging, and blank-page/force logic, in one call.