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>.
Quick Start
Section titled “Quick Start”xberg extract report.pdf --content-format doctagsfrom xberg import ExtractInput, ExtractionConfig, extract
config = ExtractionConfig(output_format="doctags")output = await extract(ExtractInput(kind="uri", uri="report.pdf"), config=config)result = output.results[0]print(result.content) # DocTags streamuse xberg::{extract, ExtractInput, ExtractionConfig, OutputFormat};
let config = ExtractionConfig { output_format: OutputFormat::DocTags, ..Default::default()};let output = extract(ExtractInput::from_uri("report.pdf"), &config).await?;let result = &output.results[0];println!("{}", result.content);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.
Example
Section titled “Example”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.
The 0–500 Location Grid
Section titled “The 0–500 Location Grid”<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: OTSL
Section titled “Tables: OTSL”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.
No Escaping
Section titled “No Escaping”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 & or <. This is required for compatibility with real Docling output, which does the same thing (the vendored corpus includes a caption that literally discusses ' < td > ').
Checkboxes
Section titled “Checkboxes”<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>.
Round-Trip Fidelity
Section titled “Round-Trip Fidelity”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
CommentDefinitionrenders 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. ACommentRef(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 perkey: valueentry (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.
See Also
Section titled “See Also”- Output Formats — other content formats (
Plain,Markdown,Djot,Html,Json,Structured) - Document Structure — xberg’s own hierarchical tree representation
- Configuration Reference — full
ExtractionConfigfield list