Document outputs

A document that has been parsed is not a pile of text files. It is one canonical, typed model, and every output format is a rendering of that model. The structure you paid the parse to discover (pages, blocks, offsets, provenance) survives into every face.

In plain terms. When gRParse, the platform's parsing fleet, reads a PDF or an image, it builds a single structured description of the document: which blocks are headings, which are table cells, where each line sits on which page, and which engine produced it. From that one description, the platform renders whatever face the next consumer needs: plain text, markdown, a web page, JSON, or timed captions. You never parse the document again to get a different format; you just render the model differently.
flowchart LR
        bytes["PDF page or image bytes, streamed over gRPC, nothing written to disk"] --> parse["gRParse: routing, OCR, layout, tables"]
        parse --> doc["One canonical Document: typed protobuf, pages, blocks, offsets, provenance"]
        doc --> plain["TEXT"]
        doc --> md["MARKDOWN"]
        doc --> html["HTML and HTML_SPLIT_PAGE, rendered page by page"]
        doc --> data["JSON and YAML"]
        doc --> dc["DOCTAGS and DOCLANG, Docling-compatible formats"]
        doc --> vtt["VTT captions"]
The 10,000-foot view: parse once into one canonical typed document, render it to any face a consumer asks for.

One canonical model, structure intact

The parsed document is the protobuf message ai.pipestream.document.v1.Document, and it is the same shape whether the source was a born-digital PDF, a scanned page, a spreadsheet, or a slide deck. The body is a tree of groups and references (each text item addressable as something like #/texts/17); the furniture (running headers, footers, page numbers) lives on its own layer so it never contaminates the reading flow; and every item carries its provenance: page number, bounding box, and the source that produced it.

grpc-services/gRParse document.proto, the Document message

message Document {
  DocumentOrigin origin = 4;      // mimetype, binary hash, filename, uri
  GroupItem furniture = 13;       // headers, footers, page numbers
  GroupItem body = 5;             // main structure, refs like #/texts/17
  repeated BaseTextItem texts = 7;
  repeated PictureItem pictures = 8;
  repeated TableItem tables = 9;
  map<int32, PageItem> pages = 12;
}

The origin block alone explains why downstream caching works: the source file's hash is stamped on the document itself, so two parses of the same bytes are recognizably the same document, and a re-ingest of identical content dedupes instead of duplicating.

grpc-services/gRParse document.proto, ProvenanceItem and BoundingBox: where every item says it came from

Show the actual definition

message ProvenanceItem {
  int32 page_no = 1;
  BoundingBox bbox = 2;        // l / t / r / b plus coord_origin
  IntSpan charspan = 3;        // character span inside the item's own text
  optional TimeSpan time = 4;  // position in a media timeline
}

message BoundingBox {
  double l = 1;  double t = 2;  double r = 3;  double b = 4;
  optional CoordOrigin coord_origin = 5;  // gRParse always emits COORD_ORIGIN_TOPLEFT
}

Provenance is plural on purpose. An item may know its page and box, its span in a media timeline, or its byte range in an archive member, and each claim names its source: a CollectorSource records which collector produced the item (for example grparse or libreoffice), which engine inside it (for example rapidocr or poppler-text), and the engine's confidence when it reports one. Sources never overwrite each other; choosing a winner is left to the consumer.

Same document, many faces

One request names the formats it wants, and the response carries every one of them, rendered from the same merged document. The wire enumerates nine: TEXT, MARKDOWN, HTML, HTML_SPLIT_PAGE, JSON, YAML, DOCTAGS, DOCLANG, and VTT. DOCTAGS and DOCLANG are Docling-compatible formats for tools that speak that dialect; an empty format list keeps the plain-text default. Below, one small field note is rendered four ways. Flip the tabs and notice what each face keeps and what it throws away.

One two-paragraph field note about a pump check, parsed once. Each tab is the same document rendered for a different kind of reader, with one line on when you would want exactly that face.

Pump station 4: weekly check

Impeller vibration stayed inside the normal band for the full run. Bearing temperature held at 41 C.

  • Intake screen: clear
  • Seal flush: no weep
ReadingValueIn limit
Vibration2.1 mm/syes
Bearing temperature41 Cyes

Reach for it when a person reads it next: chat tools, notebooks, review threads. Headings, lists, and pipe tables survive; page geometry does not.

One text item of the same document, in the protobuf JSON form (shortened illustration)

{
  "text": "Impeller vibration stayed inside the normal band for the full run.",
  "label": "DOC_ITEM_LABEL_TEXT",
  "prov": [{
    "pageNo": 1,
    "bbox": { "l": 96, "t": 210, "r": 520, "b": 228,
           "coordOrigin": "COORD_ORIGIN_TOPLEFT" }
  }],
  "source": [{ "collector": {
    "collector": "grparse", "model": "poppler-text"
  } }]
}

Reach for it when a program reads it next: pipelines, indexes, agents. Nothing is thrown away: the label, the page box, and the producing engine all ride along. YAML renders the same structure for configuration-shaped consumers.

<article>
  <h1>Pump station 4: weekly check</h1>
  <p>Impeller vibration stayed inside the normal band
     for the full run. Bearing temperature held at 41 C.</p>
  <ul>
    <li>Intake screen: clear</li>
    <li>Seal flush: no weep</li>
  </ul>
  <table>
    <tr><th>Reading</th><th>Value</th><th>In limit</th></tr>
    <tr><td>Vibration</td><td>2.1 mm/s</td><td>yes</td></tr>
    <tr><td>Bearing temperature</td><td>41 C</td><td>yes</td></tr>
  </table>
</article>

Reach for it when the document becomes a web page. The HTML_SPLIT_PAGE variant renders the same document page by page, for viewers that present one page at a time.

WEBVTT

00:00.500 --> 00:05.000
Pump station 4: weekly check

00:05.500 --> 00:12.000
Impeller vibration stayed inside the normal band
for the full run. Bearing temperature held at 41 C.

00:12.500 --> 00:16.000
Intake screen: clear. Seal flush: no weep.

Reach for it when text is timed: transcripts and captions. VTT cues are rendered from track-timed text items, so the same model that prints a page can subtitle a recording of it.

Without JavaScript the four faces simply stack, which is also the point: every rendering is complete on its own, because all four came from the same model.

Offsets that never renumber

Every face above shares one invisible property: the text offsets are stable. The platform's offset contract says that the assembled plain-text form of a document joins its text items with a single newline, in reading order, and that a character position in that string means the same thing on every machine. Offsets are Unicode codepoint positions, not bytes, and they are append-only: when a document streams page by page, a later page never renumbers an earlier one. Item references such as #/texts/17 are stable from the moment they are emitted.

The geometry side is equally fixed. Every provenance box lives in one shared coordinate space: origin at the top-left, y growing downward, integer pixels on the 200 DPI render raster, with the page's own rotation already applied. Each box says so explicitly (coord_origin = COORD_ORIGIN_TOPLEFT), and consumers are expected to assert on it rather than assume. This is what makes search highlights and semantic heatmaps possible: a hit at character range 340 to 355 maps back to a rectangle on page 2, in any rendering.

Chunks you can cache on

Downstream systems rarely want a whole document at once; they want chunks for indexing and embedding. The platform's two chunk operations (hierarchical and hybrid) parse the source exactly the way a conversion does and then split the resulting document. The hierarchical walker makes one chunk per item or list group in body-tree order, carrying the heading trail; the hybrid walker adds peer merging under a token budget and a sentence-wise split, and it requires an explicit max_tokens.

Determinism is the design goal: the same input bytes produce the same chunk bytes on every machine and every run. There is no tokenizer download, no locale dependence, and no defaulted budget; every boundary rule is versioned, and each chunk carries the version it was produced under in rules_digest (for example grparse-hier/1, or grparse-hybrid/1;tok=wordish/1;sent=sentence/1;max_tokens=N;merge_peers=B). Change a rule and the digest changes, which is exactly how a corpus knows it must re-chunk.

grpc-services/gRParse parse_types.proto and the chunking README, one chunk illustrated

{
  "text": "Impeller vibration stayed inside the normal band ...",
  "headings": ["Pump station 4: weekly check"],
  "pageNumbers": [1],
  "startOffset": 42,          // UTF-8 codepoint positions in the
  "endOffset": 108,           // document's concatenated body text
  "rulesDigest": "grparse-hier/1"
}

A chunk reports start_offset and end_offset as codepoint positions in the document's concatenated body text, but only when the parse supplied an offset table for every text item the chunk consumed; otherwise both stay unset rather than guessed. Combined with byte-for-byte determinism, that means a content hash over a chunk is a stable identity: embed it, index it, or store it, and the same content hashes the same way tomorrow, so caches hit and duplicates are detected by comparing hashes instead of re-reading text. This is the same hash-first discipline as the rest of the document lane, where intake derives the document id from the payload's SHA-256 and the repository dedupes on content. Chunks feed the search lane from here: protomolt-search indexes and embeds them, and two indexes agree on their chunk boundaries and vector spaces exactly when their chunking-policy digests agree.

Metadata stays typed, per source family

A PDF, an office file, an XML document, and a web page each declare different things about themselves, and the model keeps those declarations typed instead of flattening everything into string pairs. One DocumentMeta message carries the shared fields (title, authors, creation and modification instants) plus the fields particular to each family: format_version and a structured flag for PDFs, template and editing-cycle counts for office files, namespace bindings and schema locations for XML sources, typed identifiers and classification codes for scholarly files. Dates keep a parsed form and a _raw twin holding the source's own spelling, and the source's embedded XMP packet is kept verbatim when present.

grpc-services/gRParse document.proto, Identifier: one typed metadata field

message Identifier {
  string kind = 1;            // doi, pmid, issn, publisher-id
  string value = 2;
  optional string scope = 3;  // electronic vs print ISSN
}

The rule that keeps this accountable is short: data whose shape the fleet knows gets a typed field, never an entry in the open-vocabulary extra map. When several collectors contribute metadata, FieldSource records which collector's answer each resolved field carries, so a value that lost the resolution is still attributable on the wire.