Skip to content

DocTags Output

DocTags is the tag-stream vocabulary used by Docling and the SmolDocling / Granite-Docling vision-language models. Xberg can both render its internal document model to DocTags and parse DocTags back in, so it works as an interchange format with that ecosystem.

DocTags is not a markdown-like format. It has no headings-with-#, no inline emphasis markup, and no escaping mechanism. Every element in the document is wrapped in its own tag, one element per line, and the whole stream is wrapped in <doctag>...</doctag>.

Terminal
xberg extract report.pdf --content-format doctags

Xberg also accepts DocTags as input, parsing it back into its internal document model, so it round-trips through xberg’s own pipeline — reranking, chunking, redaction and so on all work on parsed DocTags the same as on any other source format.

Detection is by the .doctags extension or the text/vnd.docling.doctags MIME type (application/vnd.docling.doctags is accepted as an alias). Note that DocTags has no registered IANA media type and its files are conventionally named *.doctags.txt — that double extension resolves to plain text, so for those files you must pass the MIME type explicitly rather than relying on extension detection.

A one-page PDF (US Letter, 612 × 792pt) with a title and a body paragraph renders to:

<doctag><title><loc_50><loc_0><loc_250><loc_250>Report</title>
<text><loc_50><loc_0><loc_250><loc_250>Body text.</text>
</doctag>

Every element gets its own line and its own closing tag. <loc_*> tokens (left, top, right, bottom) appear only when the element carries both a bounding box and a page with recorded dimensions — in practice that means the PDF path today. Formats with no geometry (Markdown, HTML, plain text sources, etc.) render the same elements with no <loc_*> tokens at all; this is a deliberate degradation, not a bug.

<loc_*> values are not page points — they are normalized onto a fixed 0–500 integer grid per page, following Docling’s own convention. A box that spans the full width of the page renders as <loc_0><loc_500> regardless of whether the page is US Letter or A4.

The PDF BoundingBox type has a bottom-left origin (y0 is the bottom edge, following PDF user space), while DocTags counts from the top-left. The renderer flips the vertical axis when converting, and the parser flips it back when reading DocTags in. A box near the top of the page has a small second <loc_*> value; a box near the bottom has a large one.

Because the grid — not the true page size — is what’s encoded, the original page dimensions are not recoverable from a DocTags stream. When xberg parses DocTags, it reconstructs each page as a 500×500 square rather than the source page’s real dimensions. This is intentional: using the grid itself as the reconstructed page means re-rendering a parsed document reproduces the exact same <loc_*> tokens it started with, even though the “page size” it’s built against is fictional.

Tables render as OTSL (the table markup Docling defines), nested inside <otsl>...</otsl>:

<otsl><ched>Name<ched>Age<nl><fcel>Alice<fcel>30<nl></otsl>
  • <ched> — header cell (first row)
  • <fcel> — filled (body) cell
  • <ecel> — empty cell (also used to pad ragged rows out to a rectangular grid)
  • <nl> — row separator

Merge tokens are asymmetric between read and write. OTSL also defines <lcel> (continues the cell to its left), <ucel> (continues the cell above), and <xcel> (continues either), used to represent row/column-spanning cells. Xberg’s parser expands these when reading DocTags produced elsewhere — a merged cell becomes duplicate content in each of the grid positions it spanned. Xberg’s renderer never emits them: Table::cells is a flat Vec<Vec<String>> with no span information, so there is nothing to encode a merge from. A table with spanning cells that round-trips through xberg (DocTags in → xberg internal model → DocTags out) loses the span and comes back as literal, repeated cell values instead of merge tokens.

DocTags has no escaping mechanism, matching Docling’s own serializer: & and < in source prose are written literally, and only recognized tag names (text, title, otsl, loc_*, and so on) are treated as markup — anything else, including a stray <, is content.

result.content # e.g. "<text>results & performance for a < b</text>"

If your source document’s prose contains results & performance for a < b, that string appears in the DocTags output byte-for-byte — it is not turned into &amp; or &lt;. This is required for compatibility with real Docling output, which does the same thing (the vendored corpus includes a caption that literally discusses ' < td > ').

<checkbox_selected> and <checkbox_unselected> wrap their label text rather than standing alone, matching real Docling output, e.g. <checkbox_unselected><loc_...>Confirmed</checkbox_unselected>.

DocTags round-trips well for the element kinds it has direct tags for (headings, paragraphs, lists, code, formulas, images, footnotes, page headers/footers, tables without spans). Several element kinds are lossy, because DocTags simply has no tag for them:

  • Admonitions (callouts) have no DocTags tag. An admonition’s title (or its kind, e.g. "warning", if it has no title) renders as a plain <text> element. Parsing that back does not reconstruct it as an admonition — it becomes an ordinary paragraph. The admonition’s distinct body content is also not preserved separately: xberg stores only one string for an admonition (title-or-kind), so there was nothing else to lose here, but the admonition-ness itself does not survive a round trip.
  • Comments have no DocTags tag either. A CommentDefinition renders through whatever content layer it was tagged with — <footnote> if marked as a footnote, <text> otherwise — rather than being dropped, so the text survives, but it is indistinguishable from an ordinary footnote or paragraph on the way back in. A CommentRef (the inline marker pointing at the comment) carries no content of its own and is dropped entirely; only the resolved comment body is emitted.
  • Metadata blocks are exploded into one <text> element per key: value entry (e.g. <text>Author: Alice</text>, <text>Date: 2026</text>). Parsing DocTags back in reads these as ordinary paragraphs — the “this was one metadata block with N entries” structure does not survive.
  • Orphaned captions. If a caption’s target element didn’t end up rendering — for example a table with no cells or no columns, which the renderer drops silently — the caption doesn’t vanish with it. It falls back to rendering as a plain <text> element instead of a nested <caption>. This avoids silently losing caption text, but the caption-to-table relationship is lost.
  • Page dimensions, as noted above, are never recoverable from a parsed stream; only the 0–500 grid is preserved.

Everything else — element ordering, list nesting (ordered/unordered), tables without spanning cells, code language tokens, formulas, footnote text, page header/footer classification — round-trips exactly: parsing xberg’s own DocTags output and re-rendering it reproduces the identical stream.