The rules as built. The engine turns styled inline text into lines and draws them; it does not depend on the element tree (src/OnePdf.Layout/Paragraphs/), and the Text element is one client of it. Shaping, bidi and break data come from OneFont.
var fonts = TextFontRegistry.CreateDefault(); // Liberation Sans/Serif/Mono fonts.Add(File.ReadAllBytes("NotoSansArabic-Regular.ttf"), "Noto Arabic"); fonts.FallbackFamilies.Add("Noto Arabic"); var paragraph = new TextParagraph( [new TextSpan("Plain, "), new TextSpan("bold", new TextStyle { Weight = 700 }), new TextSpan(" and مرحبا.")]) { Alignment = TextAlignment.Justify, Hyphenator = myHyphenator }; ShapedParagraph shaped = paragraph.Shape(fonts); // width-independent ParagraphLayout layout = shaped.Layout(300); // lines, heights, baselines (ParagraphLayout first, ParagraphLayout rest) = layout.Split(3); var renderer = new TextRenderer(document); // one per document or writer renderer.Draw(builder, first, x: 50, top: 780);
Fonts
- A registry holds faces by family, weight, width class and italic. A style asks for families in order; the face of a family is the one with the right italic flag, then the nearest width class, then the nearest weight (an equally near heavier face wins for a requested weight of 500 or more). Weights are never synthesized.
CreateDefaultregisters the twelve Liberation faces fromRecursiveAI.OnePdf.Fontsand the aliasesSans,Serif,Mono,Monospace. The default family isSans. Text in them is shaped and embedded as a subset of the glyphs used (a one-page Latin page is about 13 kB).- Standard-14 names (
Helvetica,Arial,Times-Roman,Courier-Bold, ...) select the unshaped path: advances come from the metric-compatible Liberation face, no kerning, no ligatures, no fallback, drawn with the PDF standard font and not embedded. A standard font cannot be combined with others in one chain. - Characters a family lacks come from the later families of the style, then from the registry's
FallbackFamilies. OneFont picks the face per grapheme cluster. A character no face has is drawn as the first face's missing-glyph box and reported inShapedParagraph.Warnings. - System fonts are consulted only after
UseSystemFonts(provider).
Pipeline
- Normalise.
\r\n,\r, U+2028, U+2029 become\n; tabs become spaces; other control characters are dropped; U+00AD is removed and remembered. Each\n-separated line is its own bidi paragraph and its own list of lines; the line before a\nis never justified. - Itemise. Within a line, runs of one bidi level (UAX #9,
Directionforced or taken from the first strong character) and one shaping group (same families, weight, italic, width, language and script position) are shaped together, so Arabic joining and kerning continue across a change of colour or size. Spans that differ only in size, colour, spacing or decoration still share a run. - Shape.
FontFallbackChain.ShapeRunper run. Each cluster becomes one item with its glyphs, advance and the text it stands for. Letter spacing is added to the cluster's advance. - Superscript and subscript. Shaped once with
sups/subson and once without; if the glyphs differ the font's own forms are used at full size. Otherwise the text is scaled to 0.65 and shifted by the font'sySuperscriptYOffset/ySubscriptYOffset(0.35 em up and 0.14 em down when the font gives none). - Break. Opportunities are UAX #14 (
TextSegmentation) plus soft hyphens andIHyphenatorpoints, which are used only where UAX #14 offers no break. Thai and similar scripts break at spaces and punctuation only (no dictionary); ideographs break between characters. - Fill, greedy first fit. A line takes the last opportunity at which its content, without trailing spaces, fits; trailing spaces hang. At a hyphenation point the hyphen's width counts. If no opportunity fits, the piece up to the next one stays whole on a line and the line has
Overflows = true. - Align.
Start/Endfollow the direction of that bidi paragraph;Left/Rightdo not. The first-line indent is on the start side and shrinks the width of the first line.Justifyadds equal extra width to every stretchable space (U+0020, U+00A0, U+3000) so a justified line is exactly the width (±1e-9); a line with no space stays at the start edge unlessJustifyWithLetterSpacingspreads the extra between characters. - Line boxes. Each item contributes the ascent and descent of its font at its size (
hhea, or the typographic values whenUSE_TYPO_METRICSis set) plus half the line gap on each side. WithLineHeightset (em of the style's size) the box is that tall, extra space split above and below; the tallest span decides. Raised or lowered script extends a line only when noLineHeightis set. An empty line has the height of the font at its position.
Knuth–Plass line breaking
By default lines are broken greedily (first fit). A paragraph can opt in to total-fit breaking after Knuth and Plass, "Breaking Paragraphs into Lines" (Software: Practice and Experience 11, 1981): the breaks of the whole paragraph are chosen together, so a loose line is not the price of a tight one three lines later. Greedy stays the default and its output does not change.
using OnePdf.Layout; LayoutDocument.Compose(doc => { doc.LineBreaking(LineBreakMode.KnuthPlass); // every text of the document, or doc.Page(p => p.Content().Text(t => { t.Justify(); t.Hyphenator(myHyphenator); t.OptimalLineBreaks(new KnuthPlassOptions { Tolerance = 300 }); // this text only (GreedyLineBreaks() opts out) t.Span(longText); })); }); // the paragraph engine directly var paragraph = new TextParagraph(text) { Alignment = TextAlignment.Justify, LineBreakMode = LineBreakMode.KnuthPlass };
- Model. The paper's boxes are the shaped clusters, its glue the spaces (stretching by half and shrinking by a third of their width) and its penalties the break opportunities: UAX #14 breaks cost nothing, a break after a hyphen in the text costs
ExplicitHyphenPenalty, and a soft hyphen orIHyphenatorpoint costsHyphenPenaltyand adds the hyphen's width to its line. A line's adjustment ratio is the change its spaces must make over the change they can make; badness is100·|r|³up to 10000; demerits are(LinePenalty + badness)² ± penalty², plusConsecutiveHyphenDemerits,FinalHyphenDemerits(the last line follows a hyphen) andAdjacentFitnessDemerits(neighbouring lines of very different looseness: very loose, loose, decent, tight). The dynamic programme keeps one node per break and looseness class and prunes as the paper does. - Text it applies to. Justified lines use their spaces; a justified line with no space to stretch is as bad as can be, so single-word lines are avoided. Left, right and centred text counts as ragged: lines cannot shrink, and slack is judged against a quarter of the line width. The last line of a paragraph and lines before a hard line end are free to be short. Lines of different widths (a first-line indent) are handled; mixed fonts and sizes, bidi paragraphs and clusters are used as shaped. Justification then spreads each line's slack evenly over its spaces, as for greedy lines, and a line the breaker squeezed has its spaces narrowed.
- Defaults (
KnuthPlassOptions, TeX's plain values):Tolerance200,PreTolerance100,LinePenalty10,HyphenPenalty50,ExplicitHyphenPenalty50,ConsecutiveHyphenDemerits10000,FinalHyphenDemerits5000,AdjacentFitnessDemerits10000,Looseness0,EmergencyStretch0.1 of the line width.Loosenessasks for that many lines more or fewer than the optimum when the paragraph can be set so. WithPreTolerance100 a paragraph that sets well without hyphens gets none; a negative value always considers them. - Passes and fallbacks. Pass 1 has no hyphens and accepts badness up to
PreTolerance(skipped when the paragraph has no hyphenation points orPreToleranceis negative). Pass 2 allows hyphens up toTolerance. Pass 3 repeats it with emergency stretch. Pass 4 accepts any line that is not too full and, when a piece wider than the line leaves no choice, puts that piece alone on an overfull line as greedy does, so a paragraph is always set. Each hard line (separated by a line end) is solved on its own. - Bounds. Time and memory are proportional to the number of break opportunities times the candidate line starts within one line width. A hard line of more than 500000 clusters, or a search with more than 4096 live candidates (looseness multiplies them), is broken greedily instead and
ShapedParagraph.Warningssays so. There is no recursion. Pagination is unaffected: a Knuth–Plass paragraph is an ordinary list of lines, so splitting, widows and orphans and keep rules work on it as before. The breaker never looks beyond its paragraph. - Determinism. Plain double arithmetic in a fixed order; ties go to the earlier break; output is byte-identical between runs.
- Cost. Compared with greedy,
book500(500 justified pages) allocates about 21 % more and takes between no measurable difference and about 15 % more time (measured locally on a busy machine; seebench/OnePdf.Layout.Benchmarks/README.md).
Measure, split, content widths
ShapedParagraph.MinContentWidth: the widest piece between break opportunities, counting a hyphen where the piece ends at a hyphenation point.MaxContentWidth: the longest line with nothing wrapped, with the first-line indent.ParagraphLayout.LinesgiveTop,Height,Baseline,Width,Xand the text range of each line; any boundary between two lines is a place to split.Split(n)returns the firstnlines and a continuation holding the rest exactly as an unsplit layout laid them out, moved up to the top; drawing the two parts draws the same lines.HeightOf(first, count)sums heights for widow/orphan decisions.
Drawing
TextRenderer.Draw draws a range of lines. Per line it orders the items visually (UAX #9 rule L2 on the item levels) and groups neighbours of one font and level into one DrawGlyphRun each, with glyph advances that include letter spacing and justification, so the glyphs sit where the layout put them. Right-to-left runs are drawn in display order; the text given with each run is in logical order, so extraction returns logical text.
- A line-end hyphen is the font's hyphen glyph (U+2010 if the font has it, else
-) mapped to U+00AD, as tagged PDF asks for incidental hyphens (ISO 32000-2 14.8.2.2.3). Standard-font text draws-. - Underline and strike-through are filled rectangles in the text colour, at the position and thickness of
postandOS/2, at least 0.25 pt thick. - Spans with
TextStyle.Linkare returned asTextLinkArearectangles (line-box height); placing the annotation is up to the caller. - The builder's graphics state is restored.
Inline objects
A span can hold an object instead of text: TextSpan.ForInline(InlineObject, style). InlineBox(width, height) is the standalone kind, drawn by a Paint callback (a CanvasSurface clipped to the box) and/or an Image (PdfCreatedImage); InlineElement (layout Text only) holds an element tree. Common settings: Alignment (InlineAlignment: baseline, top, middle, bottom, text top, text bottom), BaselineOffset, InnerBaseline, GrowsLine, KeepWithNeighbours, AltText, Artifact and Link.
- In the paragraph the object is one character, U+FFFC OBJECT REPLACEMENT CHARACTER, one item as wide as the object. It has line break class CB (a break is allowed before and after, UAX #14 rule LB20) and bidi class ON (it takes the level of its position, UAX #9), so right-to-left lines place it correctly.
KeepWithNeighboursclears the two opportunities after the line breaker has found them. A U+FFFC in ordinary span text is dropped. Hyphenation points never fall next to an object. - Line box. The object takes the font of the span it stands in, which gives the line its minimum height and the ascent and descent that middle, text-top and text-bottom use. Objects against the baseline or the text come first and raise the line's ascent and descent as far as they reach; objects aligned to the top then add what they lack below the baseline, and objects aligned to the bottom what they lack above it, in that order. With
GrowsLinefalse the object is placed by the same rules but changes nothing.ParagraphLine.BaselineandHeightare the result. - Drawing.
TextRenderer.Drawcloses its saved graphics state around each stretch of text and draws the object between stretches, so the object may open marked content of its own. An object withAltTextis preceded by an invisible space inside a/Spanwith/ActualText, so extraction returns the description; one withLinkis reported in theTextLinkAreas. Without a layout host nothing is tagged. - Content widths (
MinContentWidth,MaxContentWidth) count the object like a word.
Colour glyphs
A font with OpenType COLR version 0 and CPAL tables (colour emoji, colour icon fonts) draws its colour glyphs in colour. A base glyph that has a COLR record is drawn as its layers, bottom to top; each layer is a glyph of the same font filled with a palette colour (OpenType COLR version 0, CPAL). The foreground entry 0xFFFF takes the text colour. Glyphs without a record draw as before, and a document whose fonts have no COLR table produces the same bytes as without this feature.
doc.Fonts.Add(File.ReadAllBytes("ColorEmoji.ttf"), "Emoji"); page.Content().Text(t => { t.Span("Done ").FontSize(14); t.Span("\U0001F600").FontFamily("Emoji", "Sans").FontSize(24); // palette 0 t.Span("\U0001F600").FontFamily("Emoji", "Sans").FontSize(24).Palette(ColorPalette.Dark); }); doc.Options.ColorPalette = ColorPalette.Light; // the default for text that does not choose
- Palettes.
ColorPalette.Defaultis palette 0.ColorPalette.LightandDarkpick the first palette thatCPALversion 1 tags for light (0x0001) or dark (0x0002) backgrounds, and palette 0 when the font tags none.ColorPalette.FromIndex(n)picks palette n, and palette 0 when the font has fewer. The choice isTextStyle.Palette,Text.Palette, the fluentPalette(...)on spans and texts, orLayoutOptions.ColorPalettefor text that does not choose. - Alpha in a palette entry is not used: layers are painted opaque. This keeps PDF/A and tagged output free of transparency.
- Output. Each layer is a one-glyph text run in its fill colour, and the layers of one glyph sit inside one
/Spanwith/ActualTextof the character (ISO 32000-2 14.9.4), so extraction returns the character once, not once per layer. The layers' glyphs are subset and embedded with the font like any other glyph, and the span lies inside the paragraph's tagged content, so the layers are part of the text, not artifacts. Colour glyph runs are written one glyph perBT/ET, which makes them larger than plain text. - Tables that cannot be used. The tables are read with bounds checks, and a table that does not hold together is rejected whole: record arrays outside the table, base glyphs out of order or outside the font, more than 256 layers on a glyph, layers that name a glyph outside the font or a palette entry that does not exist, palettes that point outside
CPAL, a missingCPAL, or aCOLRversion above 1. Text in such a font is drawn in one colour and the layout records one diagnostic per font (LayoutDocument.Diagnostics). Layers are looked up once per glyph and never followed further, so there are no cycles to detect. - COLR version 1 (paint graphs: gradients, transforms, compositing) is not drawn. A glyph that only has a version 1 description draws its plain outline, with one diagnostic per font; a glyph that also has a version 0 record is drawn from that.
- Not included: bitmap and SVG colour fonts (
CBDT,sbix,SVG), palette alpha andCPALpalette labels.
Limits and behaviour
A paragraph holds at most 2^24 characters. Layout work is linear in the text for any number of lines: the scan for a break stops once the content has passed the width. The shaped paragraph is immutable; Layout caches hyphen glyphs, so use one shaped paragraph from one thread at a time. Output is byte-identical between runs (no fused multiply-add, fixed evaluation order).
Not included: vertical writing, COLR version 1 and bitmap colour fonts, tab stops.
Hyphenation patterns
The RecursiveAI.OnePdf.Hyphenation package supplies IHyphenators built on Liang's algorithm (the method of TeX). Layout does not depend on it, and without a hyphenator only soft hyphens break words.
using OnePdf.Hyphenation; page.Content().Text(t => { t.Language("de-DE"); t.Hyphenator(Hyphenators.Auto()); // picks patterns from each span's language; no language, no hyphenation t.Justify(); t.Span("Donaudampfschifffahrtsgesellschaft ..."); });
Hyphenators.For("en-GB")gives one language'sPatternHyphenator(null when none is bundled);Hyphenators.Auto(options, defaultLanguage)dispatches on the language of each word's style.Hyphenators.Languageslists the 26 bundled ids;Resolvemaps a tag to one (enis American unless the region is GB, AU, NZ, IE, ZA, IN, SG or HK;deis the 1996 spelling,de-1901the traditional one,de-CHthe Swiss traditional patterns;pt-BRispt).- Defaults. Words under 5 characters are not hyphenated. At least
LeftMinletters stay before a hyphen andRightMinafter it; by default these are the language's own typesetting minimums from its pattern file (2 and 3 for American and British English, Portuguese, Slovak, Estonian, Welsh and Irish; 2 and 2 for most others, 1 and 1 for monotonic Greek). All three are set throughHyphenationOptions. Words over 255 characters are left alone. - Exceptions in the pattern files replace the patterns for that word, and are held to the same minimums. Custom data goes through
PatternHyphenator.FromPatterns. - Output. The engine draws the hyphen from the line's font and maps it to U+00AD, so the text layer reads
hyphen\u00ADation; extraction withJoinHyphenatedWordsreturns the whole word. Tagged and PDF/A output stay compliant (checked with veraPDF). - Data. Each language is a compressed embedded resource read the first time it is used. Sources, licences and per-file notices are in the package's
THIRD-PARTY-NOTICES.md; the data is regenerated withtools/hyphenation/generate.py. Not bundled, for licence reasons: Swedish, Finnish, Icelandic, Catalan, Croatian, Russian, Turkish, Esperanto, Czech, Hungarian, Latvian and Romanian. - A word is the run of letters and combining marks the engine finds, so apostrophes and hyphens split words (
l'avionislandavion).