---
title: "Text layout and reading order"
url: https://recursiveai.org/onepdf/docs/text-layout/
description: "How glyphs become words, lines and blocks, and the parameters that decide it. The code is in src/OnePdf/Text/Extraction/; every number below is a constant…"
version: "1.11.0"
source: https://github.com/recursive-ai-oss/onepdf-csharp/blob/b90019f86b3d08820577502740a10c66befdd989/docs/text-layout.md
---

# Text layout and reading order

How glyphs become words, lines and blocks, and the parameters that decide it. The code is in `src/OnePdf/Text/Extraction/`;
every number below is a constant in `LayoutParameters`. Distances are in em of the glyphs concerned, so the analysis does not depend on the
page scale. The analysis is deterministic: no randomness and no dependence on thread count; the same glyphs in the same order give the same result.

## Input

A producer supplies, per page, glyphs in content order in *upright page space* (points, y up, `/Rotate` applied). Each glyph has its
Unicode text, a matrix from em space to the page, an advance, ascent and descent, a vertical-writing flag, fill colour, render mode,
visible/clipped flags, a marked-content id and, for `/ActualText` spans, a span number plus the string on the span's first glyph. See
`PdfTextGlyph` for the glyph box definition.

## The producer

`PdfTextExtractor` interprets each page with `GlyphOutlines` off and `Annotations` none, into a device that turns every shown character into a glyph:

- The matrix is the glyph matrix of the run (font size, horizontal scale, rise, text matrix, CTM) followed by the flip from output space (y down) to upright page space. In vertical writing the box hangs from the text position (origin 1), which is the glyph origin plus the position vector v (ISO 32000-2 9.7.4.3); `Advance` is |w1|.
- Text is the font's Unicode, or U+FFFD when no source knew it. Glyphs with no text, no glyph and no width are dropped, as are glyphs whose font size is zero.
- `Ascent` and `Descent` keep their defaults (0.9, -0.2): descriptor values are too often wrong to steer line formation.
- Colour is 0xRRGGBB for gray, RGB and CMYK (naive conversion) and spaces with the same component count; -1 for patterns and Lab. `IsVisible` is false in render modes 3 and 7 and when the opacity of everything the mode paints is 0.
- `IsClipped` is set when the glyph box misses the intersection of the clip paths' bounding boxes (a conservative test: a rotated or curved clip never removes more than it should). Text clips do not narrow it.
- `MarkedContentId` is the innermost `/MCID`. The outermost marked-content sequence with `/ActualText` forms a span; its first glyph carries the string.
- Text inside annotation appearances, soft masks and Type 3 glyph procedures is not page text: a Type 3 run already carries the text.

## Clean-up (before layout)

1. Glyphs with a non-finite matrix or metrics are dropped.
2. Unless `IncludeClippedAndOffPage`: glyphs the clip removes (`IsClipped`) and glyphs whose centre is more than 1 pt outside the page are dropped.
3. `/ActualText`: in a span that has the string, the first glyph carries it and the rest carry empty text (they keep their geometry).
4. Ligatures U+FB00–U+FB06 are replaced by their letters (`DecomposeLigatures`). Then presentation forms and compatibility characters are folded to the characters they stand for (`FoldCompatibilityForms`): Kangxi and CJK radicals (U+2E80–2FDF), CJK compatibility ideographs (U+F900–FAFF, U+2F800–2FA1F), Hebrew presentation forms (U+FB1D–FB4F) and Arabic presentation forms (U+FB50–FDFF, U+FE70–FEFF). The table (`FoldData.g.cs`) holds the full compatibility decomposition from Unicode 17.0.0 `UnicodeData.txt`, generated by `tools/fold/generate.py` because `string.Normalize` does nothing when globalization is invariant. A decomposition that begins with a space before a combining mark (the isolated Arabic diacritics) loses the space.
5. Unpaired surrogates become U+FFFD.
6. Duplicates (`CollapseDuplicates`): a glyph with the same text, direction and size (within 5%) as an earlier one, whose origin is within
   0.1 em on both axes, is dropped (fake bold, drop shadows). Candidate neighbours are looked up by x; at most 32 are compared.

## Reading direction

Each glyph's reading direction is its baseline direction (the matrix x axis; for vertical writing the downward glyph axis), snapped to the nearest of the four axis
directions. Glyphs are laid out per direction: the direction with most glyphs first, then the others (right, up, left, down order). All geometry
below is done in a frame where *x runs along the text* and *y towards the next line*, so rotated pages and vertical writing use the same code.
Text at other angles is analyzed by its nearest axis direction.

**Ideographs stacked by a horizontal producer.** Some producers (browsers printing `vertical-rl` text) set each upright ideograph with its own horizontal matrix, so the
glyphs look like horizontal text one character wide. Two successive glyphs in content order, both ideographs, kana or hangul, form a *downward step* when the second origin is
at most 0.35 em to either side of the first and 0.4 to 2 em below it (an *across step* is the same turned 90°). Once a page has at least 2 downward steps, every
horizontal ideograph that takes part in a downward step and in no across step is read in the downward direction: columns, right to left.

## Lines

Glyphs sorted by baseline join the current line while their baseline is within 0.3 × the smaller size of the line's mean baseline.
A smaller run joins a neighbouring line (superscripts and subscripts) when it is a script of one of that line's glyphs: some non-space glyph of the run is at most 0.85 × the size of a
non-space glyph of the line, its baseline is within 0.7 em (of the larger size) of that glyph's and it lies within 0.4 em (of its own size) horizontally of it. The test is made on
glyph pairs, so two real lines that merely sit near a large glyph stay apart. Space glyphs never decide this: producers park them anywhere (often at the start of the next line).

## Fragments

A line is cut into fragments where two successive non-space glyphs are more than 1.25 em apart (mean of their sizes). Fragments are the
atoms of block segmentation; a cell of a table or a column of a multi-column page is made of fragments. A wider gap is still a word space, not a cut, when space glyphs
span it end to end (holes of at most 0.1 em): a font whose space is 1.25 em wide, or a justified line, would otherwise split into cells.

## Blocks (XY-cut on fragments)

A region (initially the whole page, per direction) is split recursively, with an explicit stack:

1. A region with one fragment, or fragments of one line only, is a block.
2. **Vertical cut:** if an empty stripe at least 1.25 em (of the region's median fragment size) wide separates the fragments' extents along the line,
   split at every such stripe. Columns are ordered left to right, or right to left when more right-to-left than left-to-right letters occupy the region.
   Before cutting, if the region looks like a **table** it becomes one block instead: at least 3 rows (distinct lines), at least 60% of the rows have
   fragments in more than one column, and the median fragment width is at most 10 em. A table's lines are its rows; cells are separated by a tab.
3. **Horizontal cut:** otherwise, if an empty band at least 0.5 em tall separates the fragments, split at every such band, top to bottom.
4. **Mixed cut:** otherwise, if lines with a gutter (several fragments) alternate with single-fragment lines, split at the changes (a heading set tight
   above two columns).
5. Otherwise the region is a block.

**Column bands.** When the horizontal cut finds bands (step 3), successive bands are first joined if each is made of columns with at least one multi-line column
(an empty stripe of at least 1.25 em, with two or more lines in a column on its side) and the stripes of the two bands overlap by at least 1.25 em. This is the case
where something spanning both columns (a full-width note, a caption) defeats the vertical cut, and the columns' paragraph gaps happen to sit at the same heights: cutting at
them would read the columns band by band. The joined bands go back on the stack and are cut into columns by step 2. Bands of single lines (rows of a form or table)
are never joined this way. If every band joins, the region is left uncut by step 3 and goes on to step 4.

The vertical cut is tried first so columns whose paragraphs happen to share gaps are not cut across; a full-width title is separated by the
horizontal cut because it blocks every vertical stripe.

## Words

Within a fragment, glyphs sorted along the line start a new word at a space glyph or when the gap exceeds 0.15 em (0.6 em next to ideographs, kana
and hangul, which are written without spaces). The first word of each fragment after the first starts a cell. A space glyph drawn over the glyph before it (by more than half of its own width; some producers park the
trailing space of a line at its start) neither ends the word nor adds text.

## Bidirectional text (UAX #9)

Lines are read in visual order (by x) and put into logical order by running the Unicode Bidirectional Algorithm (UAX #9, Unicode 17 data, rules
X1–X10, W1–W7, N0–N2, I1–I2, L1, L2 in `Bidi.cs`) on the visual string and reordering it: applying the reordering to text that a layout engine already reordered
gives back the logical text. The paragraph direction of a block is right-to-left when its right-to-left letters (R, AL) outnumber its left-to-right letters.
Words and the words of a line come out in logical order. Mirrored-bracket glyph substitution (rule L4) is not undone; brackets are taken as extracted.
Conformance: all 91,707 cases of `BidiCharacterTest.txt` (Unicode 17.0.0) pass (set `ONEPDF_UNICODE` to its directory to run them).

## Structure tree

When the document is tagged (`/StructTreeRoot`), the tree is walked depth first through `/K` (explicit stack, each indirect element once, at most 2²² nodes).
For each page, marked-content ids are ranked in tree order; the ids under one structure element form a group. Inline-level elements (`Span`, `Link`, `Quote`, `Code`, `Em`, `Strong`, `Sub`, `Sup`, `Note`, `Reference`, `Lbl`, ruby and the like) and the purely grouping `NonStruct` and `Private` share the group of the element around them, so a style change or a link does not cut a line out of its paragraph. A page uses the tree when **at least 90%** of its visible,
non-blank glyphs carry an id the tree lists; otherwise it falls back to geometry and records an informational diagnostic. With the tree, groups are laid out
separately and emitted in tree order. Glyphs the tree does not list (artifacts such as running heads) are laid out on their own and placed above the content when their
centre is in the top 24% of the page, otherwise below it.

## Headers and footers

Whole-document extraction flags a block as header (or footer) when it lies entirely in the top (bottom) 12% of the page and the same text (digits replaced by `#`,
lower-cased, white space collapsed) appears in that band on at least half of the pages and at least two. Flagged blocks keep their place in the reading order and
`Role`; `ExcludeHeadersAndFooters` omits them from the reading-order text.

## Plain text modes

- **ReadingOrder:** blocks separated by a blank line, lines by a line feed, cells by a tab. With `JoinHyphenatedWords`, a line ending in a hyphen (`-`, U+00AD, U+2010) after a
  letter, followed by a line starting with a lowercase letter, is joined to it without the hyphen.
- **ContentOrder:** glyphs in producer order. A new line starts when the direction or baseline changes by more than 0.5 em or the position jumps back by more than 3 em;
  a space is inserted for a gap over the word-gap threshold.
- **Layout:** lines are formed over the whole page by baseline (no block segmentation), words are put on a grid whose cell is 0.5 × the median glyph size wide
  (column = round((x − left margin) / cell), at least one blank after the previous word, at most 400 columns); a gap between baselines over 1.6 line pitches adds up to 3 blank lines.

## Implementation notes

The analysis keeps its working data in arrays rented from the shared pool (items, fragments, sort keys, word runs) and builds strings, word and line objects only for the
result; regions of the XY-cut are runs of one index array that a split rewrites in place. None of this is visible in the output, and none of the thresholds depend on it.
Where a sort could leave equal keys in either order (the per-line sort by x, the baseline sort, the line order inside a block), the same introsort over the same key sequence
is used as before, so ties resolve identically; the duplicate search uses a total order (x, then stream position), so any correct sort gives the same visit order.
A page's analysis uses no shared mutable state: pages of a document may be analyzed on any number of threads and the results do not depend on the degree.

## Not done yet

Table cell spans, justified-text hyphen detection by font, non-axis text angles, mirrored-bracket repair and an `/Artifact`-aware header detector (the tagged-page rule above
is the only artifact handling). Thresholds are untuned beyond the synthetic and generated test sets.
