Streaming Parsers

ProtoMolt has two parsing front ends. module-parser is a JVM pipeline module that wraps a shaded Apache Tika 4 and extracts text plus typed, per-format protobuf metadata from 100+ file formats. gRParse is a standalone C++ gRPC service that turns PDFs and raster images into a page-streamed protobuf document — OCR and layout detection on ONNX Runtime, nothing touching disk — and coordinates a fleet of smaller collector services around it.

gRParse predates the platform's module runtime and is being integrated as a first-class parsing backend. The two are described separately below because they are separate codebases with separate contracts today.

module-parser: Tika 4 in a pipeline module

module-parser is an ordinary platform module: it implements the same ModuleProcessor<PipeStream> contract as every other module, dials the engine's work stream, and pulls units. Inside, parsing is done by Apache Tika 4.x shipped as the platform's shaded artifact ai.pipestream:tika4-shaded (classes relocated under ai.pipestream.shaded.tika), covering Office documents, PDF, images (EXIF/IPTC), email, EPUB, fonts, NetCDF climate data, WARC archives, and more — the 100+ formats Tika supports.

What distinguishes it from a raw Tika call is where the metadata lands. Rather than a flat string map, each format family gets its own protobuf message — one proto each for PDF, Office, image, email, media, HTML, EPUB, RTF, font, database, WARC, and climate, plus Dublin Core and Creative Commons — under core-services/pipestream-protos/common/proto/ai/pipestream/parsed/data/*. Over 1,000 Tika metadata fields are mapped onto typed fields (catalogued in the module's TIKA_INTERFACE_MAPPING.md), so a consumer reads a PDF's page count or an email's envelope from a typed field instead of parsing strings.

The module also extracts structure Tika itself does not expose: PDF bookmark outlines (via PDFBox), EPUB tables of contents, HTML heading hierarchies, and a full CommonMark AST for Markdown — a CommonmarkDocument message stored in parsed_metadata["commonmark"], built with commonmark-java and ten GitHub Flavored Markdown extensions.

Optional Docling OCR, gated by CEL

For documents Tika reads poorly — scanned PDFs, images — the module can call a Docling sidecar for OCR, layout, and table extraction. The sidecar is not invoked unconditionally: a CEL expression is evaluated per document after the Tika pass, and only matching documents take the expensive path. The shipped default routes every image and every near-empty PDF to OCR:

modules/module-parser/docs/architecture.md §5.2 — the parser's default Docling gate

mimeType.startsWith("image/") || (bodyLength < 200 && mimeType == "application/pdf")

The sidecar endpoint can be swapped live through BackendEndpointService.UpdateBackendEndpoint without redeploying the module. The module's configuration schema is self-describing: the published JSON Schema is generated from the ParserConfig Java record's OpenAPI schema — there is no second copy to drift — and enhanced at runtime with Tika's actual supported-MIME list as x-suggestions.

Extraction has a terminal-reject policy: a document with neither body nor title after extraction is rejected outright (REJECT_REASON_EMPTY_PARSE / REJECT_REASON_PARSE_ERROR) rather than retried. A document that produced a body but hit a parse error continues through the pipeline with the (truncated, 500-character) error stamped into search_metadata.metadata["parse_error"], and degradation is reported on the response, not only in server logs.

gRParse: diskless PDF/image parsing in C++

gRParse is a C++ (CMake) gRPC service whose contract is ai.pipestream.parse.v1.ParseService. Its defining property is in the name of its architecture document: document bytes stream in over gRPC and results stream out as protobuf, and nothing — not the input, not rendered pages, not OCR intermediates — is ever written to disk. The streaming RPC, ParseStreamingService/StreamProcessDocument, accepts a stream of DocumentChunk messages (up to 500 MiB) and emits one DocumentStreamEvent.page per page in page-number order, followed by a single complete event. Each outbound event is allocated in a short-lived google::protobuf::Arena that lives until the asynchronous write completes.

Recognition runs on the maintained C++ RapidOCR implementation (RapidOcrOnnx) through ONNX Runtime, on NVIDIA GPUs via CUDA or Intel GPUs/CPUs/NPUs via OpenVINO, selected with GRPARSE_ORT_EP (cuda by default; openvino, cpu, or auto). OCR is selective: pages with a full embedded text layer skip raster OCR entirely, pages with a weak or partial digital layer keep their native boxes and still run OCR, and a geometry merge drops overlapping duplicates. Per-request do_ocr / force_ocr options override the heuristic; the contradictory combination is rejected by name. Pages rasterize at 200 DPI by default, with a per-request render_scale in multiples of 72 DPI (accepted range [1.0, 8.0]). Scanned pages fed in sideways or upside down are re-read at the likely rotation and the best read wins, with the applied turn recorded in the page's typed PageItem.quality.rotation_degrees.

Beyond text, the page pipeline produces structure: layout detection (the default heron model predicts seventeen labels; a legacy five-label picodet model is compiled in as an alternative), reading order by recursive XY-cut so multi-column pages read column by column, SLANet-plus table structure with cell spans and header rows (a geometry fallback clusters lines into a grid when the model is absent), a 26-class figure classifier on picture crops (bar charts, QR codes, signatures, photographs), and ZXing barcode/QR decoding that needs no model at all. Every OCR line becomes a TextItem with its page and bounding box in provenance; running headers and footers land on a separate #/furniture content layer rather than in the body.

The server fails loudly at startup if a required model is absent or the OCR sessions cannot initialize on the configured provider, rather than silently running CPU OCR. Backpressure is explicit: a client that stops reading page events stops returning page credits to the scheduler, so that document stalls within its configured page window while other documents continue. Concurrency and queue memory are tuned through a family of GRPARSE_* knobs (GRPARSE_PAGE_WORKERS, GRPARSE_RENDER_WORKERS, GRPARSE_INFERENCE_QUEUE, GRPARSE_PAGE_WINDOW, and others), and a Prometheus endpoint exposes per-stage busy fractions and a page-latency histogram.

Deterministic chunking

Two further RPCs, ChunkHierarchicalSource and ChunkHybridSource, parse the source exactly as ConvertSource does and chunk the resulting document. Determinism is the stated goal: the same input bytes produce the same chunk bytes on every machine and every run — no tokenizer download, no locale, no defaulted budget — and every boundary rule carries a version, reported per chunk in rules_digest:

grpc-services/gRParse/README.md — the versioned chunking rule sets

hierarchical walk  grparse-hier/1
hybrid             grparse-hybrid/1;tok=wordish/1;sent=sentence/1;max_tokens=N;merge_peers=B
tokenizer          wordish/1
sentences          sentence/1

Chunks report start_offset/end_offset as UTF-8 code-point positions in the document's concatenated body text when the parse supplied an offset table for every consumed text item; otherwise both stay unset rather than being guessed.

Collector scatter-gather

gRParse is also the coordinator of a family of standalone parser services, the collectors. Incoming bytes are routed by format into one or more collectors; every collector's output is an ai.pipestream.document.v1.Document whose items carry a CollectorSource tag, and the coordinator merges them additively — item references renumber, sources never overwrite each other, and choosing a winner among sources is deliberately left to downstream consumers. A failed collector degrades to an error entry instead of failing the parse; the parse fails only when every selected collector fails.

Two collectors are compiled in (the CV path itself and a Confluence wiki-storage XHTML handler). The rest are remote services, each enabled by one environment variable — an unset variable means the collector does not exist for that deployment:

grpc-services/gRParse/README.md — the remote collectors and their target variables

GRPARSE_LIBREOFFICE_TARGET   office formats (doc/x, xls/x, ppt/x, odf, rtf, csv)
GRPARSE_ASR_TARGET           audio and video (whisper.cpp; GRPARSE_ASR_MODEL required)
GRPARSE_EMAIL_TARGET         .eml, .msg, message/rfc822
GRPARSE_XML_TARGET           .xml, .nxml, .xbrl, archive forms (.dclx, .tar.gz)
GRPARSE_EBCDIC_TARGET        mainframe fixed-width; explicit selection only
GRPARSE_EPUB_TARGET          .epub (plus GRPARSE_MARKUP_TARGET for the chapters)
GRPARSE_MARKUP_TARGET        .md, .html, .adoc, .tex, .vtt, .boxnote, Docling JSON
GRPARSE_LOL_HTML_TARGET      targeted CSS-selector extraction; explicit selection only
GRPARSE_FASTWARC_TARGET      .warc, .warc.gz, .warc.zst, .warc.lz4
GRPARSE_PDF_TARGET           the PDF routing oracle (grpc-pdf-inspector)

The wider family also includes grpc-calamine (spreadsheets), grPOIc (Apache POI), and grpc-vlm-convert, which the demo shell dials directly. No code path converts office bytes to PDF in order to parse them: office text and tables come exactly from the LibreOffice core, while the collector's page renders flow through the same layout, figure-classification, and barcode engines as the CV path — so a chart or QR code inside a DOCX is still spotted and decoded.

The PDF collector is a routing oracle rather than another source of pages. When GRPARSE_PDF_TARGET is configured, an unrouted PDF is streamed to grpc-pdf-inspector first: a text-based classification takes a fast path (the in-process CV/ONNX pipeline is skipped entirely), while a scanned or mixed document falls through to the CV pipeline with recognition restricted to the inspector's pages_needing_ocr list. If the inspector is unreachable, the parse degrades to the unrouted CV path with the failure noted — never to a failed parse.

After the merge, a format-agnostic repair pass runs on the finished document: repeated top/bottom-band lines are relabelled PAGE_HEADER/PAGE_FOOTER and moved to the furniture tree, hyphenation splits are rejoined, and paragraphs broken across page or column boundaries are merged with provenance appended. Heading levels follow the numbering, the first page's opening heading block becomes the TitleItem, and everything the pass changes is counted in the Prometheus exposition (grparse_repair_changes_total). GRPARSE_REPAIR=off disables it.

Output formats

gRParse renders the merged document into every format its wire contract declares: TEXT, MARKDOWN, HTML, HTML_SPLIT_PAGE, JSON, YAML, DOCTAGS, DOCLANG, and VTT. An optional result Target delivers the same canonical bundle — a manifest.json with SHA-256 and byte size per member, a deterministically serialized document.pb, canonical document.json, one export file per requested format, and embedded page/picture PNGs — either as a ZIP in the response or written to an S3-compatible store with request-supplied credentials (AWS Signature V4 over libcurl, path style, no SDK). The bundle is byte-identical across machines for the same input: sorted members, fixed archive timestamps, one compressor setting.

Status and honest edges. gRParse's integration into the platform as a first-class parsing backend is in progress; today it is a standalone service with its own contract, and its README documents the seams. The fastwarc collector is a collector on paper but a demo-shell peer in the shipped stack: gRParse speaks a vendored fastwarc.v1 dialect that is not wire-compatible with the published pipestreamai/fastwarc-grpc image, so GRPARSE_FASTWARC_TARGET is deliberately left unset. grPOIc and grpc-calamine are likewise dialed only by the demo shell — gRParse's merge already ranks poi and calamine claims below its own, but wiring them in as collectors is an open item. In module-parser, the shipped Docling client is a purpose-built HTTP/1.1 client, not the quarkus-docling injection the original integration plan describes; the architecture document is the accurate source. Markdown link extraction is implemented but unwired, so discovered_links is empty for Markdown.

Related