avvertix / liteparse-php
PHP FFI bindings for LiteParse, fast local PDF and document parsing with spatial text extraction
Requires
- php: ^8.3
- composer-runtime-api: ^2.0
- ext-ffi: *
- symfony/console: ^6.4|^7.0|^8.0|^8.1
Requires (Dev)
- laravel/pint: ^1.29
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-25 17:04:57 UTC
README
Native PHP bindings for LiteParse, local PDF and document parsing with spatial text extraction powered by Rust and PDFium.
Given a PDF this package extracts text with bounding boxes and renders it as structured JSON, plain text, or layout-aware Markdown (headings, lists, tables, figure references). The parsing happens directly from PHP, via a compiled Rust cdylib loaded through PHP's FFI extension.
Note
Only PDF and image files are supported. Support for Office documents (DOCX/XLSX/PPTX) is untested and not planned.
Requirements
- PHP 8.3+ with
ext-ffienabled (ffi.enable=Onin php.ini; CLI defaults to on) - A compiled
liteparse_phpnative library for your platform (see Installation) - Optional, for OCR: an HTTP OCR server implementing
LiteParse OCR_API_SPEC.md(reference servers for EasyOCR/PaddleOCR/SuryaOCR ship in the LiteParse repo). This binding does not bundle the Tesseract engine as would increase the complexity of the build and distribution pipeline, pointConfig::$ocrServerUrlat a server instead.
Installation
Get the package via Composer and install the native library for your platform.
composer require avvertix/liteparse-php vendor/bin/liteparse-php install
install downloads the compiled liteparse_php library and its PDFium dependency for your platform from the package's GitHub Releases into vendor/avvertix/liteparse-php/lib/. The specific installed versions are recorded in a natives.lock file in the root of your project, commit this alongside composer.lock to install the same version of the compiled binary. Run vendor/bin/liteparse-php update after upgrading the package to fetch the matching native library.
Upgrading from an earlier version? See UPGRADE.md for what changed and what needs attention.
Quick start
use LiteParse\Config; use LiteParse\OutputFormat; use LiteParse\LiteParse; $parser = new LiteParse(new Config(outputFormat: OutputFormat::Markdown)); $result = $parser->parseFile('/path/to/document.pdf'); echo $result->pageCount(); // int echo $result->text(); // plain text, "--- Page N ---" headers echo $result->markdown(); // headings/lists/tables/figure refs reconstructed from layout echo json_encode($result->json()); // structured per-page text items: bbox + font size + fill/stroke color, per item echo json_encode($result->lines()); // structured per-page projected lines: merged bbox + style + column geometry
json() returns liteparse's full per-item TextItem — not the lean {text, x, y, width, height} shape the upstream lit CLI's own --format json produces. Each item carries its bounding box together with font_size and fill_color/stroke_color (ARGB hex, e.g. "ff000000") on the same record, plus rotation, links, strikethrough, and OCR confidence where applicable. Font/color fields are only populated for native PDF text; OCR-derived items carry null there instead.
lines() sits between json() and markdown(): each entry is a merged visual line (one or more TextItems sharing a baseline) carrying its own bounding box, dominant font/style, and region_path — the xy-cut column/region position liteparse uses internally to group paragraphs and tables. Each line's spans field keeps the original TextItems that merged into it, so per-run font/color survives even where the line's own text concatenates multiple items. Unlike markdown(), nothing here is reformatted or dropped when the heuristic table/heading detection misfires — you get the raw geometry and can reconstruct rows/columns/headings yourself from region_path and bbox positions. There is no heading/paragraph/list "role" label at this layer.
With Config::$extractDocumentMetadata and Config::$continueOnPageError on, json()'s top level also carries doc_meta (dates, encryption, signatures, incremental-save markers, raw XMP) and page_errors (page-level extraction failures that didn't abort the parse); total_pages (source page count before maxPages/targetPages truncation) is always present.
Every ParseResult accessor (text(), markdown(), json(), lines(), blocks(), flatBlocks(), images()) renders on demand from the same underlying parsed pages.
Building your own document model: blocks() over markdown()
markdown() reconstructs headings/lists/tables/figure references as rendered text — meant for humans, LLM prompts, or diffing, not for parsing back into a structure. Re-parsing it loses the bounding boxes liteparse already computed, and stands a text convention (like a ----- thematic break for page breaks) in for real document structure.
If you're building your own page/block/document model — the actual reason most integrations touch markdown() — start from Config::$extractBlocks and ParseResult::blocks()/flatBlocks() instead:
use LiteParse\Config; use LiteParse\LiteParse; use LiteParse\Layout\BlockKind; $parser = new LiteParse(new Config(extractBlocks: true)); $result = $parser->parseFile('/path/to/document.pdf'); // Grouped by page — the default, familiar shape: foreach ($result->blocks() as $page) { foreach ($page['blocks'] as $block) { if ($block->kind === BlockKind::Heading) { echo str_repeat('#', $block->level ?? 1)." {$block->text}\n"; } } } // Or flattened across the whole document, each block still tagged with its own page: foreach ($result->flatBlocks() as $block) { printf("p%d %s: %s\n", $block->pageNumber, $block->kind->value, $block->text ?? ''); }
Each Block carries a bbox (the union of every source line that fed it — including a bbox on every table cell), reading order matching what markdown() renders, and kind-specific fields (level/ordered/marker for lists, header/rows for tables, id/format for figures — join a figure block's id against ParseResult::images() to get its extracted file). See Block for the full field list. Independent of outputFormat; enabling extractBlocks never changes the rendered Markdown.
With Config::$extractImages (and optionally imageOutputDir) on, ParseResult::images() returns each embedded image's metadata — id, path (when written to disk), bbox, dimensions, format, and duplicateOf for repeated images (e.g. a logo reused across pages) — never pixel bytes.
Images as input, and OCR
parseFile()/parseBytes() accept a plain image (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg) directly, not just PDFs — it's converted to a one-page PDF natively in Rust first (no ImageMagick or other external tool needed). A bare image carries no embedded text, though, so that PDF comes back empty until OCR fills it in. This binding ships without the bundled Tesseract engine (it would add real weight and platform-specific complexity to the build/distribution pipeline), so OCR needs Config::$ocrServerUrl pointed at an HTTP OCR server implementing OCR_API_SPEC.md — Config::$ocrEnabled defaults to null and is inferred true automatically once a server URL is set; pass false explicitly to keep a configured URL around without OCR actually running. liteparse ships ready-to-use reference servers for EasyOCR/PaddleOCR/SuryaOCR in its own repo — see its OCR guide for how to build and run one (not shipped or committed in this repo; bring your own):
$parser = new LiteParse(new Config( ocrServerUrl: 'http://localhost:8828/ocr', // implies ocrEnabled: true )); $result = $parser->parseFile('scan.png'); // or a scanned/photographed PDF page echo $result->text();
OCR also kicks in automatically on scanned/text-sparse pages and embedded images inside an otherwise-normal PDF — the same config applies, no separate code path. OCR-derived text items carry a confidence score and font_name === 'OCR' in place of real font metadata (json()'s text_items), since OCR reports no font metrics. See examples/ocr/ for a full worked example, including the "OCR off on an image" failure mode (silently empty text, not an error) it's easy to trip over.
Visual citations: showing where an answer came from
ParseResult::search() gives you a phrase match's bounding box; a page screenshot gives you the pixels to draw it on. Both are in scope from the same parse, so highlighting exactly where a search hit (or an agent's cited answer) sits on the page needs no extra rendering pass:
$parser = new LiteParse(new Config(dpi: 150.0)); // keep parse and screenshot DPI in sync $result = $parser->parseFile('report.pdf'); $matches = $result->search('quarterly revenue'); $screenshots = $parser->screenshotFile('report.pdf', pageNumbers: array_column($matches, 'page_number'));
A match's x/y/width/height are in the same 72-DPI point space as every other bbox in this package, not the screenshot's own pixel space — scale by dpi / 72 (the DPI the screenshot was rendered at) before drawing. See examples/visual-citations/ for the full overlay-drawing example.
Features
LiteParse::parseFile()/parseBytes()— parse from a file path or an in-memory buffer (e.g. a PDF downloaded over the network).LiteParse::isComplexFile()/isComplexBytes()— a cheap per-page pre-check (no OCR, no rendering) reporting whether each page looks scanned, sparse, garbled, or image-heavy — useful for deciding whether a document needs OCR before committing to a full parse.LiteParse::screenshotFile()/screenshotBytes()— render selected pages (or the whole document) to PNG bytes. WithConfig::$extractScreenshotson,ParseResult::screenshots()returns the same pages' PNGs from the parse that already happened, instead of rendering a second time. EveryScreenshotalso carriesisSolidFill(blank-page detection, always computed) andrects(solid rectangles/lines detected in the raster, needsConfig::$detectScreenshotRects).ParseResult::search()— search already-parsed text for phrase matches, with bounding boxes, merged across text items that were split mid-phrase.
// Complexity pre-check $stats = $parser->isComplexFile('scan.pdf'); $needsOcr = array_filter($stats, fn ($page) => $page['needs_ocr']); // Screenshots $screenshots = $parser->screenshotFile('doc.pdf', pageNumbers: [1, 2]); // null = all pages foreach ($screenshots as $shot) { file_put_contents("page-{$shot->pageNumber}.png", $shot->bytes); } // Search $result = $parser->parseFile('doc.pdf'); foreach ($result->search('quarterly revenue') as $match) { printf("page %d at (%.0f, %.0f)\n", $match['page_number'], $match['x'], $match['y']); }
See examples/ for runnable scripts.
Configuration
Config mirrors liteparse's Rust LiteParseConfig field-for-field:
| Field | Default | Notes |
|---|---|---|
ocrLanguage |
'eng' |
Tesseract-format language code |
ocrEnabled |
null |
This binding has no built-in OCR engine. null infers from ocrServerUrl (set = on, unset = off); pass true/false to override |
ocrServerUrl |
null |
HTTP OCR server URL |
ocrServerHeaders |
[] |
[[name, value], ...] sent with every OCR request |
maxPages |
1000 |
|
targetPages |
null |
e.g. "1-5,10,15-20"; null = all pages |
dpi |
150.0 |
Used for OCR and screenshots |
outputFormat |
OutputFormat::Json |
Informational only in this binding — text()/markdown()/json() are always available regardless |
preserveVerySmallText |
false |
|
password |
null |
For encrypted/protected documents |
quiet |
true |
Suppresses liteparse's stderr progress logging (Rust default is false) |
numWorkers |
1 |
Concurrent OCR requests to the HTTP server |
imageMode |
ImageMode::Placeholder |
Affects markdown() image references only |
extractLinks |
true |
Hyperlinks as [text](url) in markdown |
extractImages |
false |
Extract embedded image metadata into ParseResult::images() (never pixel bytes) |
imageOutputDir |
null |
Directory where extracted embedded images are written; requires extractImages |
extractAnnotations |
false |
Extract all PDF annotations into each parsed page |
cropBox |
null |
Restrict output to a sub-region of every page: ['top' => ..., 'right' => ..., 'bottom' => ..., 'left' => ...] fractions |
skipDiagonalText |
false |
Drop text items rotated more than 2° off the nearest right angle |
ocrFailureFatal |
true |
Abort the whole parse on systemic OCR failure vs. return degraded results |
ocrHedgeDelaysMs |
[] |
Request-hedging schedule for the HTTP OCR engine |
emitWordBoxes |
false |
Per-word sub-boxes on each text item (roughly doubles payload size) |
includeComplexity |
false |
Attach a complexity object (text/image coverage, OCR reasons, layout signals) to each page in json() |
keepHeadersFooters |
false |
Keep running headers/footers in markdown() instead of stripping them |
extractVectorGraphics |
false |
Expose page-scoped vector path data (shapes, merged lines) in parse results |
extractBlocks |
false |
Populate ParseResult::blocks()/flatBlocks() (and json()'s per-page blocks) — the recommended way to build your own document model, see above |
extractDocumentMetadata |
false |
Populate json()'s top-level doc_meta (dates, encryption, signatures, incremental-save markers, raw XMP) |
extractScreenshots |
false |
Render every page to PNG during parseFile()/parseBytes(), available via ParseResult::screenshots() |
continueOnPageError |
false |
Continue past a page-level extraction failure instead of aborting the parse; failures land in json()'s top-level page_errors |
extractFormFields |
false |
Attach each page's AcroForm widget fields/values to json()'s per-page form_fields |
extractStructureTree |
false |
Attach each page's tagged-PDF logical structure tree to json()'s per-page structure_tree |
extractContentBounds |
false |
Attach each page's content_bounds (union bbox of its top-level content) to json() |
extractXfaPackets |
false |
Extract raw XFA packets from XFA form documents into json()'s top-level xfa_packets |
extractTextMetadata |
false |
Include char_codes/trailing_space_generated on every text item in json() |
detectScreenshotRects |
false |
Detect solid rectangles/lines in rendered screenshots, on Screenshot::$rects (full-bitmap scan per page) |
renderFormFields |
false |
Draw AcroForm field appearances into rendered rasters — initializes a PDFium form-fill environment and runs the document's open/JS actions |
pageOrientationCorrections |
[] |
[['page' => 1, 'angle' => 90], ...] — counter-rotate specific pages by a caller-supplied clockwise angle (0/90/180/270) |
How it works
LiteParse Rust create is exposed as C library to be consumed by the PHP Foreign Function Interface (FFI). The shared library is defined in rust/ exposing handles for the parser/result/screenshot-list lifecycle, plus configuration and exceptions. LiteParse's API is async (tokio-based); the FFI layer owns a single process-wide tokio runtime and block_ons each call, since PHP FFI calls are synchronous. cbindgen generates the committed include/liteparse_php.h header that PHP's FFI::cdef() loads.
PDFium (a separate native dependency liteparse links against) is discovered at runtime by pdfium-sys's loader relative to whichever shared library loaded it.
Development
./scripts/build.sh # cargo build --release, stage lib/ composer install composer test # PHPUnit, against the compiled library composer lint # PHPStan
Adding a new FFI function: add the extern "C" function in rust/src/ffi/, rebuild (cbindgen regenerates include/liteparse_php.h automatically via build.rs), add the corresponding @method annotation to LiteParseFfi, and wrap it in a PHP class.
Building from source
If you're working in this repository directly (or no prebuilt release exists yet for your platform):
./scripts/build.sh release # cargo build --release + stage lib/liteparse_php.* and lib/libpdfium.* composer install composer test
scripts/build.sh compiles the Rust crate in rust/ and copies the resulting native library, plus its PDFium runtime dependency, into lib/.
License
The project is dual licenced. The PHP wrapper code is licenced under MIT. The Rust binding to expose via FFI are licenced under Apache-2.0.