Parse: documents in, typed data out

Parsing turns a file nobody can query into data the whole platform can reason about: a typed document with pages, blocks, offsets, and provenance. ProtoMolt parses each document once into one canonical model, streams the pages out as they finish, and renders every output format from that same model.

In plain terms. You hand the platform a file (a PDF, an office document, an email, an ebook, a web archive, even a mainframe dump in EBCDIC) and it hands back a structured description: every paragraph, table, picture, and barcode, each with a label, a position on a page, and a note about which engine produced it. From that one description you can get plain text, markdown, a web page, JSON, or captions. This page explains the shared document model, gRParse (the C++ parser that does the heavy lifting), and the coordinator that routes the work and stores the results.
flowchart LR
        bytes["Document bytes, any format"] --> coord["Parsing coordinator (role parse, port 9093): sniff, route, fan out"]
        coord --> grparse["gRParse C++ service: diskless, collectors, GPU OCR and layout"]
        grparse --> doc["One canonical Document: pages, blocks, offsets, provenance"]
        doc --> faces["TEXT, MARKDOWN, HTML, HTML_SPLIT_PAGE, JSON, YAML, DOCTAGS, DOCLANG, VTT"]
The 10,000-foot view: bytes in, one canonical typed document out, every output format rendered from it.

One document model, shared byte for byte

A parsed document is described with Protocol Buffers (protobuf): you write the schema once and generate typed code for it in many languages, so every service agrees on the shape of a document down to the field number. The model lives in one file, document.proto, in the ProtoMolt repo under dev-tools/protomolt/parse/document. That file is canonical: every other repository in the parser fleet re-vendors it byte for byte, so a table parsed by a spreadsheet specialist and a heading parsed by the C++ engine mean exactly the same thing on the wire. The schema keeps field-for-field parity with the v2 JSON schema of the document format this ecosystem interoperates with (the same format the DOCTAGS and DOCLANG outputs serialize), and every enum that can grow carries a string fallback so a newer vocabulary never breaks an older reader.

The Document is a flat store plus a reference graph, not a nested tree. The body and the furniture (running headers, footers, page numbers) are group roots, and groups hold only references such as #/texts/3, never inline content. The content itself lives in typed tables: texts, pictures, tables, key-value items, and form items. Pages are a map from page number to a PageItem that carries the page size and an optional rendered image. Every content item carries its own address (self_ref), its parent and children, a content layer (BODY, FURNITURE, BACKGROUND, INVISIBLE, NOTES), a label from a vocabulary of 30 (TITLE, SECTION_HEADER, LIST_ITEM, TABLE, PICTURE, FORMULA, CODE, CAPTION, FOOTNOTE, and so on), and its provenance.

Provenance (the record of where something came from) is where the model earns the word typed. Every text item knows its page number, its bounding box on that page (left, top, right, bottom, with a coordinate origin of TOPLEFT or BOTTOMLEFT), and its character span inside its own source text:

dev-tools/protomolt/parse/document/.../document.proto, ProvenanceItem

message ProvenanceItem {
  int32 page_no = 1;     // which page this item came from
  BoundingBox bbox = 2;  // l / t / r / b, origin TOPLEFT or BOTTOMLEFT
  IntSpan charspan = 3;  // where this item's text sits in its source
}

Attribution goes one level deeper with a platform extension called CollectorSource. The upstream format only knows media cues (start and end times for timed content such as captions); the scatter-gather design needs every collector's output attributable, so each item can also name the collector that produced it, the engine inside that collector, the engine version, and a confidence when the engine reports one:

dev-tools/protomolt/parse/document/.../document.proto, CollectorSource: the attribution extension every fleet item carries

Show the actual definition

message CollectorSource {
  string collector = 1;          // "grparse", "libreoffice", "email", ...
  optional string model = 2;     // "rapidocr", "poppler-text", "slanet-plus"
  optional string version = 3;   // engine version, when known
  optional double confidence = 4;  // 0.0 to 1.0, when the engine reports one
}

message SourceType {
  oneof source {
    TrackSource track = 1;           // media cues, for timed content
    CollectorSource collector = 2;   // pipestream extension
  }
}

Sources never overwrite each other. If two collectors both found a title, both claims are stored, and choosing a winner is left to the consumer.

gRParse, the C++ parser

gRParse is the platform's C++ gRPC parser, under active daily development. Three properties carry most of the design: diskless, streamed, and merged.

Diskless means document bytes never touch a filesystem. In the unary ConvertSource RPC the bytes arrive base64-framed; in the streaming RPC they arrive as ordered binary chunks. Parsing happens in memory and nothing spills. The family rule is explicit: the only sanctioned sink for document bytes is object storage, and only when the request itself configures that target.

Streamed means results leave the service as they are computed, not after a batch. For PDFs and images, pages render through Poppler (or decode through OpenCV for rasters) and then pass recognition: RapidOCR for text and a document layout model for structure, both served by ONNX Runtime, the cross-hardware inference engine, on NVIDIA GPUs via CUDA or on Intel hardware via OpenVINO. Recognition is selective: born-digital pages with a real text layer skip OCR, and only pages that need it hit the models, which keeps the CPU and GPU busy on the pages that earn it. Layout labels, reading order, tables with model or geometry cell grids, picture items, figure classification (line, bar, stacked bar, pie, scatter, with the chart data extracted), and barcode decoding through ZXing are live capabilities, and every page is emitted the moment it is done.

The collector family: one specialist per format, one document out

Around the CV path sits a family of collectors, each a standalone gRPC service specializing in one format family: office documents (LibreOfficeKit), email (envelope, body, attachments, both .eml and .msg), EPUB (the book skeleton plus one markup leg per chapter), WARC web archives (one group per record, payload capped so a huge crawl stays readable), XML dialects (JATS, USPTO, XBRL, METS), EBCDIC fixed-width mainframe data (explicit selection only), audio and video (speech to text), and text markup (markdown, HTML, LaTeX, VTT, BoxNote). Spreadsheet specialists (Apache POI and calamine) exist as services but are reserved values on the wire, not wired as collectors yet.

Each collector produces items tagged with its CollectorSource, and gRParse merges them additively into one document: references renumber, sources never overwrite each other. A failed collector degrades to a warning while any collector succeeds; the parse fails only when every selected collector fails. No code path converts office bytes to PDF in order to parse them: office text and tables come from the office core exactly, and only the collector's page renders re-enter the CV engines, so a chart or QR code inside a DOCX is still spotted and decoded.

The fan-in. Nine very different inputs (left) route to their collectors inside gRParse (center) and merge into one typed document (right). Each tag on the document is the provenance label a real item would carry. Press pause any time; without JavaScript the finished picture is simply shown.

inputs, any format

PDF DOCX .eml email EPUB WARC XLSX EBCDIC audio XML

routes by format

gRParsecollectors + CV path

merges into

Document

  • grparse rapidocr
  • libreoffice
  • email
  • epub + markup
  • fastwarc
  • calamine reserved
  • ebcdic
  • asr
  • xml

Without JavaScript: the chips are the inputs, the dark box routes each to its collector, and the tags are the provenance labels the merged document carries on its items.

flowchart LR
          pdf["PDF and images"] --> cv["CV path, in process: render, OCR, layout, tables, figures, barcodes"]
          office["Office documents"] --> lo["libreoffice collector"]
          mail["Email"] --> em["email collector"]
          web["WARC archives"] --> fw["fastwarc collector"]
          book["EPUB"] --> ep["epub + markup collectors"]
          main["EBCDIC data"] --> eb["ebcdic collector, explicit selection"]
          talk["Audio and video"] --> asr["asr collector"]
          cv --> merge["additive merge: references renumber, sources never overwrite"]
          lo --> merge
          em --> merge
          fw --> merge
          ep --> merge
          eb --> merge
          asr --> merge
          merge --> out["one Document, every item tagged with its CollectorSource"]
gRParse as scatter-gather coordinator: every collector's output is source-tagged and merged additively into one page-streamed Document.

A repair pass before anything renders

Once the merge is complete, a format-agnostic repair pass cleans the finished document: a body line that repeats in the top or bottom band of enough pages is relabelled PAGE_HEADER or PAGE_FOOTER and moved to the furniture; a word a line break hyphenated is rejoined (known compounds such as well-known keep their hyphen); a paragraph a page or column break split is merged back with its provenance appended; digital-only bodies get their reading order from an XY-cut over layout regions. Every repair is counted under grparse_repair_changes_total in the metrics export, so drift in the pass is visible, not silent.

Pages stream out as they are ready

The hot path between ProtoMolt and gRParse is a single bidirectional stream. The client sends ordered chunks (the first carries the document id, filename, and content type; the last sets complete=true) and the server answers with one PageData event per completed page. A page event contains only that page's nodes, so a receiver can release it immediately instead of accumulating the whole document before showing anything. Each text span carries append-only offsets into the document's concatenated text stream, the OCR confidence when the text came from OCR, and a discriminator saying OCR or digital text layer. That is transport metadata on purpose, so downstream clients can index pages without renumbering anything in the core model.

Collector documents arrive as they finish, independent of the page stream, and the terminal DocumentComplete event carries the origin and any collector failures. Events are emitted when the underlying work completes; a parser that cannot stream pages advertises that and sends only progress plus the final document.

dev-tools/protomolt/parse/grparse/.../parse_stream.proto, DocumentStreamEvent: what gRParse emits on the hot path (abridged)

Show the actual definition

message DocumentStreamEvent {
  string document_id = 1;
  int32 total_pages = 2;  // known early: PDF page count precedes OCR
  oneof event {
    PageData page = 3;        // one completed page, releasable at once
    DocumentComplete complete = 4;   // terminal metadata
    CollectorDocument collector_document = 5;  // a collector's document, the moment it lands
  }
}
A four-page PDF in the CV path. While a page is in flight, its layout regions and content boxes light up; the moment the page is done, a PageData event is released into the stream and the page can be rendered downstream. Press pause any time; without JavaScript the finished picture is simply shown.
p1
p2
p3
p4

text block layout region table picture or barcode

event stream, oldest first

PageData · page 1 · 2 text items, 1 table

PageData · page 2 · 1 text item, 1 picture

PageData · page 3 · 2 text items, 1 barcode

PageData · page 4 · 2 text items

DocumentComplete · 4 pages · 0 collector failures

Without JavaScript: the boxes are what recognition found on each page, and the stream column is the exact sequence of events a receiver would get.

The parsing coordinator

The parsing coordinator is the platform-side service (module protomolt-parse-service, role parse, gRPC port 9093) that decides which parsers see a document and folds their results back into the stored document. Its work, in order:

  • Load. The coordinator reads the stored document through the repo service with a part-masked read (parts CORE and BLOBS only; PARSED and CHUNKS are its outputs, not its inputs). Payload bytes come from the document's blob, fetched through the document service; the coordinator never touches object storage itself.
  • Route. Magic-byte sniffing over the first 512 bytes builds a routing context: the sniffed content type (the routing source of truth), the declared content type (a hint, never trusted alone), filename, extension, size, and account. Each routing rule is a guard written in CEL, the Common Expression Language, a small portable expression language open sourced by Google. Routing is a set, not first-match: every matching rule contributes a planned parse, in priority order. Rules are service config, not RPCs. There is deliberately no CreateRule or UpdateRule RPC; an empty rule set is refused, and a rule that does not compile fails the boot, not the millionth document.
  • Fan out. One virtual thread (a JDK lightweight thread) per planned parser, deduplicated by parser name, drives the full plugin stream through a shared client: 1 MiB data frames, deadline bounded, page texts and claims collected, and the single final document as the product.
  • Record failures in the open. An unknown parser name records a FAILED result: a misconfigured plan is visible in the stored document, not thrown away. Warnings make a result PARTIAL. Every result carries a config fingerprint of the rule and parser config that produced it, so editing a rule cleanly invalidates old results.
  • Fold. Each claimed key naming a string field of the document's search metadata is arbitrated: the highest-priority non-blank claim wins, because a blank claim must never fold over a real value. When no parser claims a body, one is derived from the streamed page texts of the best non-failed parser.
  • Persist. One partial save of the PARSED and CORE parts, stamped connector_id = "parse-coordinator", serialized per document node so concurrent parses cannot drop each other's results.

dev-tools/protomolt/parse/proto/.../routing.proto, a routing rule guard as authored

when: "mime_type == 'application/pdf' && size_bytes < 52428800"
flowchart LR
          load["Load document: repo service, parts CORE and BLOBS only"] --> sniff["Sniff first 512 bytes, evaluate every CEL rule"]
          sniff --> fan["Fan out on virtual threads, one task per parser"]
          fan --> fold["Fold claims into search metadata"]
          fold --> save["Persist PARSED and CORE parts"]
The coordinator: load the stored document, sniff and route, fan out on virtual threads, fold claims, persist the PARSED and CORE parts.

Every parser on the platform implements the same two-RPC plugin contract: GetParserInfo (identity, supported types, limits, a JSON Schema for its config) and Parse, a bidirectional stream whose first frame carries options and whose later frames carry data, answered by sequence-numbered events with the final document emitted exactly once. A parse that produces nothing fails the stream, and the coordinator stores that failure rather than leaving the parse silently absent.

dev-tools/protomolt/parse/proto/.../parser_plugin.proto, ParseResponse: the envelope every parser emits (abridged)

Show the actual definition

message ParseResponse {
  string document_id = 1;
  uint64 sequence_number = 2;  // receivers order by this, not arrival time
  oneof event {
    ParseProgress progress = 10;    // safe to drop
    ParsedPage page = 11;           // one page, as soon as it exists
    PagePreview preview = 12;       // one page's rendered image
    DocumentClaims claims = 13;     // for the search-metadata fold
    ParserOutput document = 14;     // exactly once, then the stream closes
  }
}

ProtoMolt reaches gRParse through a single adapter. The parse/grparse module is a sidecar that implements the plugin contract and bridges it to gRParse's page stream; the C++ fleet parser joins the platform through this adapter with zero C++ changes, registered under the parser identity grparse. The adapter buffers the plugin's options-first request stream and replays it to gRParse as ordered chunks: metadata first, 1 MiB data chunks, then a terminal complete=true. Page events become plugin page events, collector documents are assembled additively, a collector failure degrades to warnings (a PARTIAL result), and a stream error fails the plugin with the same status class (a FAILED result).

Durability comes from the jobs layer: the same parse runs as a checkpointed workflow run under the jobs executor. The job row survives a process restart, a transient failure requeues with backoff, and a resumed job never re-parses work that already landed.

One parse, every output format

From the one merged document, gRParse renders every output format the wire declares: TEXT, MARKDOWN, HTML, HTML_SPLIT_PAGE, JSON, YAML, DOCTAGS, DOCLANG, and VTT. DOCTAGS and DOCLANG are the Docling-compatible tag and XML serializations, for tools that speak that dialect; VTT is the caption format, the natural face for audio and video parsed by the speech collector; HTML_SPLIT_PAGE renders the document as one HTML page per source page. Chunking is deterministic: the same input bytes produce the same chunk bytes on every machine, with every boundary rule versioned in a rules_digest string, so downstream caches can key on chunks safely. The formats page walks all nine faces and shows what each one keeps and throws away.