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
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.
| Function | Use it for | Returns |
|---|---|---|
convert() | One input file, one output file | ConversionResult |
convert_batch() | Many input files (globs, mixed extensions, PDFs), one output per input, or merged | BatchResult |
convert_merge() | Several images combined into one multi-page PDF | ConversionResult |
All three, plus configure(), configure_from_file(), get_settings(),
and load_config_file(), import straight from the top-level package:
from scanlayer import convert, convert_batch, convert_merge, configure
convert()
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
| Parameter | Type | Default | Meaning |
|---|---|---|---|
input_path | str | required | Path to the source image (jpg, png, tiff…) or a native .pdf. |
output_path | str | None | Path 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. |
lang | str | config.DEFAULT_OCR_LANG ("fra+eng") | Tesseract language(s), e.g. "eng", "fra+eng". |
dpi | int | config.DEFAULT_DPI (300) | Used only if the image has no usable DPI metadata. |
jpeg_quality | int | config.PDF_JPEG_QUALITY (82) | JPEG quality for the PDF background, when JPEG encoding is chosen. |
char_whitelist | str | None | Characters Tesseract is allowed to recognize. |
char_blacklist | str | None | Characters Tesseract should ignore. |
pdf_metadata | dict | None | Overrides for config.PDF_METADATA. Keys: title, author, subject, creator, keywords. |
force | bool | False | Build output anyway if the page looks blank, instead of raising BlankPageDetectedError. |
orientation | None | "none" | float | None | See orientation modes below. |
output_format | str | "pdf" | One of "pdf", "txt", "json", "tsv", "hocr". |
debug_image | bool | False | Also 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) viaminAreaRecton the text pixels."none": disables all of that. No EXIF transpose, no OSD, no deskew. The image is used exactly as loaded.- a number (
intorfloat, 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
@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
| Field | Meaning |
|---|---|
output_path | Absolute path written. |
words_count | Words kept after the confidence threshold. |
mean_confidence | Average confidence across kept words. |
best_psm | The PSM that won auto-selection, or the forced one if --psm/psm_candidates pinned it. |
early_exited | Whether PSM auto-selection stopped early on a high-confidence pass. |
pdf_size_bytes | Size of the written file in bytes, regardless of format despite the name. |
elapsed_ms | Total time for the call, in milliseconds. |
language_used | Tesseract language(s) actually used. |
output_format | Echoes the format written. |
debug_image_path | Path to the debug overlay, if debug_image=True. |
column_count | Detected 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).
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 you get a background-only PDF (or an empty result for other formats), not a fabricated text layer.
convert_batch()
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
inputsaccepts 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 forconvert()to raise on if it doesn't exist. Any.pdfinput is rasterized to one image per page first.outputis a folder (created if it doesn't exist): one output file per input, named after the input's stem plus theoutput_formatextension. If omitted, each output file is written next to its own input instead, same stem and extension rule, no shared folder needed. Withmerge=True,outputis instead a single file path for the combined PDF, andoutput_formatmust stay"pdf";outputis 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/quietset the log level for the whole call, equivalent toconfigure(log_level="DEBUG"/"ERROR"). Mutually exclusive: passing both raisesValueError.
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
@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
.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()
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.
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
| Exception | Raised when |
|---|---|
ValidationError | A prerequisite wasn't met, before any real work started. |
InputFileError | Source file missing, unreadable, or corrupted. |
OutputPathError | Destination path invalid (missing parent directory, not writable, points at a directory). |
TesseractEnvironmentError | Tesseract not found, or its configured tessdata_dir is missing. |
DependencyError | A required package or binary is missing, most commonly poppler for .pdf input. |
BlankPageDetectedError | The page looks blank (near-uniform grayscale) and force=False. |
PipelineError | A stage started but failed for an operational reason, after every precondition already passed. |
OCRProcessingError | Every configured PSM candidate failed for a real reason (timeout, crash, corrupted language data). A page with genuinely no text is not an error. |
PDFBuildError | The PDF could not be written (disk full, permissions, corrupted background image, ReportLab failure). |
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.
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()
from scanlayer.preprocessing.enhance import preprocess
result = preprocess(image, dpi=300, orientation=None)
Takes a PIL Image and returns a PreprocessResult:
| Field | Type | Meaning |
|---|---|---|
background | PIL.Image | Straightened image (orientation + skew corrected), colors and resolution otherwise unchanged. This is what gets drawn as the PDF page. |
ocr_image | np.ndarray | Grayscale, illumination-corrected, denoised, contrast-enhanced, upscaled/downscaled copy for Tesseract. Never displayed. |
ocr_scale | float | ocr_image.width / background.width, used to map OCR coordinates back onto background. |
effective_dpi | int | DPI actually passed to Tesseract, after any upscale. |
exif_orientation_applied | int | The PIL EXIF orientation tag value that was applied (1 = none). |
gross_rotation_applied | int | 0/90/180/270, from Tesseract OSD. |
deskew_angle_applied | float | Fine-deskew angle in degrees. |
manual_rotation_applied | float | Set when orientation was passed as a numeric angle. |
orientation_correction_skipped | bool | Set when orientation="none". |
source_std | float | Grayscale standard deviation of the input, the blank-page signal. |
likely_blank | bool | source_std < config.DESKEW_MIN_STD. |
preprocessing_warnings | tuple[str, ...] | Non-fatal diagnostics, e.g. a downscale warning on a very large image. |
ocr.engine.extract_words()
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).
| Field | Type | Meaning |
|---|---|---|
words | list[Word] | Coordinates already mapped back to the background coordinate system. |
best_psm | int | The winning PSM. |
best_confidence | float | That PSM's mean confidence. |
attempts | list[PsmAttempt] | One entry per PSM candidate that actually ran, for diagnostics. |
early_exited | bool | Whether remaining candidates were skipped. |
language_used | str | The Tesseract language string used. |
@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
words list.
layout.columns.reorder_reading_order()
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
from scanlayer.ocr.export import to_text, to_json, to_tsv, to_hocr, export_words, FORMATS
| Function | Signature | Returns |
|---|---|---|
to_text(words) | list[Word] -> str | Plain text, one line per detected Tesseract text line. |
to_json(words, mean_confidence, best_psm, language_used, image_width, image_height) | keyword args required | JSON string, see Output Formats for the schema. |
to_tsv(words) | list[Word] -> str | Tab-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 args | Minimal hOCR document (ocr_page/ocr_line/ocrx_word). |
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
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:
@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
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
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()
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()
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.
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.