PeachDrawing.Text
PeachDrawing.Text is the font and text engine PeachPDF renders HTML with, published as its own NuGet package so other
.NET applications can use it without PeachPDF. It has no third-party dependencies (only its own sibling data package,
PeachDrawing.Text.Data, described below), is trimmable and
Native AOT compatible, and is versioned in lockstep with PeachPDF: the same version number for every release, and PeachPDF depends on it.
dotnet add package PeachDrawing.Text
Status: pre-1.0. The library is being opened up area by area. Today the public surface is font loading and matching (
FontSetand the types around it), what aTypefacesays about itself (metrics, glyph mapping and advances), shaping, glyph outlines and colour glyphs, theMATHtable, and thePeachDrawing.Text.Unicodenamespace, all described below. Font subsetting for embedding, and paragraph layout (PeachDrawing.Text.Layout), described below, are public too. Until 1.0, the public API may change between releases.
What the engine does
- Fonts: TrueType, OpenType (
glyfand CFF), WOFF and WOFF2 loading; TrueType/OpenType collections; installed-font discovery on Windows, macOS, Linux (through fontconfig) and Android; CSS Fonts 4 face matching by weight, width and style;unicode-rangeand glyph-coverage fallback. - Shaping: GSUB and GPOS (ligatures, kerning, mark attachment, contextual lookups), Arabic and Syriac joining, the
Universal Shaping Engine for Devanagari, Bengali, Gujarati and Tamil, Khmer’s own separate coeng/subjoined-consonant
shaping, default-ignorable handling, and
cmapformat 14 variation sequences. - Outlines and colour: glyph outlines for
glyf, CFF and CFF2, COLR v0 and v1 with CPAL, CBDT/CBLC and sbix bitmaps, and the SVG documents of theSVGtable. - Variable fonts: the axes of a font and reading it at a location (
Typeface.WithAxes): TrueType and CFF2 outlines, advance widths and font-wide metrics follow the axes. - Mathematics: the
MATHtable: layout constants, per-glyph italics corrections and accent attachment, and the variants and assemblies of stretchy glyphs. - Text layout: a paragraph of styled text, laid out at any width into lines of placed glyph runs, with hit testing, carets and selection boxes.
- Unicode: the Unicode Line Breaking Algorithm (UAX #14) and the grapheme cluster, word and sentence boundaries of UAX #29, all checked against the Unicode Consortium’s conformance files; the Unicode Bidirectional Algorithm, script itemization, vertical orientation, emoji presentation, and TeX/Liang hyphenation for 73 languages.
Fonts: FontSet, families and matching
A FontSet is the fonts a piece of text can be set in: the fonts installed on the machine, plus the ones you add to it.
A font you add under the name of an installed family joins that family for that set only, taking the place of the face
with the same weight, slant, width and code point ranges. Two sets never see each other’s fonts, so two callers can
register different data under one family name. A set is not safe for concurrent use: give each thread its own. Add
the fonts before you match: an answer a set has already given is remembered and does not change when a font is added
afterwards, so that measuring and drawing one piece of text cannot end up in different faces.
using PeachDrawing.Text;
var fonts = new FontSet();
// TrueType, OpenType (glyf and CFF), WOFF and WOFF2 are recognised by their content.
TypefaceFamily brand = fonts.AddFile("Brand-Regular.otf", new AddOptions { FamilyName = "Brand" });
fonts.AddFile("Brand-Bold.otf", new AddOptions { FamilyName = "Brand", Weight = 700 });
// Ask a family for the face that fits a query. A query has a weight, a width class, a slant and, optionally, a
// character the face has to be able to draw.
if (brand.TryMatch(new TypefaceQuery(Weight: 600, IsItalic: true), out TypefaceMatch match))
{
Typeface face = match.Typeface; // the face that matched; it has no size
SyntheticStyle toFake = match.Synthesis; // what is still missing: Bold, Italic, both, or None
}
TryMatch follows CSS Fonts 4 face matching: the width first, then the slant (upright, italic or oblique), then the
weight, taking the nearest face when none is exact, so a request for condensed italic text gets the condensed face of a
family whose condensed face is upright and whose italic face is of normal width, and the lean is faked. Among faces that
declare an oblique range, the query’s ObliqueAngle chooses the one that holds the angle or else the nearest; a face
declared italic beats an oblique range for an italic request with no angle. For an explicit oblique <angle> of 0
degrees or more, the oblique ranges leaning the same way as the angle are tried first, then a declared italic face, and
only then the ranges leaning the other way. A request for oblique 0deg is upright’s equivalent on this scale, and a
genuinely upright face is preferred to any oblique range - one that merely includes 0 as much as one that excludes it.
The weight is a number, not a whole number: 350.5 is a weight, and a face whose range holds it is preferred to
one that only holds 350. A face is taken to cover the characters of its unicode-range if it has one, and the ones its
cmap maps otherwise. Synthesis says what the caller has to fake because the face falls short: bold when 600 or more
was asked for and the face is lighter, italic when italic was asked for and the face is upright.
A typeface has no size. Text size belongs to whoever draws the text, and the same Typeface serves every size.
What a typeface says about itself
Everything a Typeface reports is in design units: whole numbers on the grid the font was drawn on, UnitsPerEm of them
to the em. To get a length at a size, multiply by the size and divide by UnitsPerEm.
TypefaceMetrics metrics = face.Metrics;
double size = 16;
double ascent = size * metrics.CellAscent / metrics.UnitsPerEm;
if (face.TryMapRune(new Rune('A'), out ushort glyph))
{
double advance = size * face.GetAdvance(glyph) / metrics.UnitsPerEm;
}
Metrics(TypefaceMetrics) has two sets of line dimensions, because platforms disagree about which one text is set with.CellAscent,CellDescentandLineSpacingare the rectangle Windows draws a line in.NormalLineAscent,NormalLineDescentandNormalLineGapare what browsers use for CSSline-height: normal. It also has the underline and strikeout stroke positions and thicknesses,CapHeight,XHeight(withHasMeasuredXHeightsaying whether the font recorded it or it is an estimate),ItalicAngle, and the font’s bounding box (XMin,YMin,XMax,YMax).TryMapRunefinds the glyph a character is drawn with through the font’scmap, andHasGlyphasks only whether there is one.GetAdvanceis the horizontal advance of a glyph.HasVerticalMetrics,GetVerticalAdvance,HasVerticalOriginandGetVerticalOriginare what vertical text needs. A font with no vertical metrics answers one em for every advance, which the OpenType specification allows.TryGetScriptPositiongives the size and offset the font’s designer recommends for subscripts and superscripts.HasColorGlyphssays whether the face draws colour glyphs as vector fills,SupportsFeatureswhether itsGSUBtable has an active lookup for every one of a set of OpenType feature tags, andMatchesEmojiPresentationwhether it is a face to prefer for a character drawn as text or as emoji.
AddOptions is the counterpart of the descriptors of a CSS @font-face rule: the family name, weight, italic, width
class and unicode-range to register a font under in place of what the file itself declares.
Other things a FontSet does:
TryFindFamilylooks a family up by name, ignoring case.MatchOrFallbacknever fails for a set that holds a font: a family the set does not know is answered with a face of its first family.TryFindCoveringFamilyis the last-resort search of CSS font matching: which family draws a character that none of the families asked for can, preferring the one whose coverage mostly lies in the character’s own script, and, for a character with a text and an emoji form, a face that supports the presentation asked for (seeEmojibelow).ResolveGenericnames a family for a CSS generic such asseriformonospaceon this platform: fixed names on Windows, macOS and Android, fontconfig on Linux, and the first available of a list of math fonts formath.HasExplicitRangessays whether any face of a family declares aunicode-range, which is when text has to be resolved character by character.FontSet.InstalledFamilyNameslists the installed families.- Data that is not a font is reported with a
TypefaceFormatException. Of a TrueType or OpenType collection, only the first face is added.
Shaping: PeachDrawing.Text.Shaping
Shaper.Shape turns text into glyphs in one face: the cmap mapping, the substitutions of the font’s GSUB table and the
positioning of its GPOS table.
using PeachDrawing.Text.Shaping;
GlyphRun run = Shaper.Shape(face, "office", new ShapeSettings(Caps: CapsMode.SmallCaps));
foreach (PlacedGlyph glyph in run.Glyphs)
{
// glyph.GlyphIndex is the glyph, glyph.ClusterStart and ClusterLength say which characters it stands for, and the
// deltas and offsets are the GPOS adjustments, in design units.
double advance = face.GetAdvance((ushort)glyph.GlyphIndex) + glyph.XAdvanceDelta;
}
ShapeSettingsfolds every request into one: ligatures (LigatureSet), caps (CapsMode), numerals (NumeralSet), East Asian forms (EastAsianSet), sub- and superscripts (SubSuperMode), kerning, a language, an OpenType script tag, features asked for by tag (FeatureSetting), and which presentation of a text-or-emoji character to choose a glyph for. The names are CSS’s, fromfont-variant-*andfont-feature-settings. A tag that a typed group controls is always decided by the group and not by an explicit setting, which is CSS’s precedence. Writenew ShapeSettings()orShapeSettings.Defaultfor the defaults;default(ShapeSettings)is all zeros and means no ligatures and no kerning.- A ligature is one glyph whose cluster covers the matched characters. A variation selector, and another invisible character (a joiner, a bidi control) that the font has no glyph for, takes part in substitution and positioning, so a lookup that matches on it still sees it, and is removed from the result at the end; a font that does map such a character keeps its glyph.
- Explicit features (
FeatureSetting) are substitution features asked for by tag. Kerning is its own setting, and a value of 0 leaves a feature unrequested; it cannot switch off one that shaping applies on its own, such asccmpandlocl. - For a run of a joining script,
ArabicJoining.Resolvegives the positional form of every character, and for an Indic scriptUniversalShaping.Classifygives its Universal Shaping Engine category; both go intoShapeSettings(JoiningFormsandUseCategories).ReverseForDisplayasks for the glyphs in visual order for a right-to-left run. GlyphRun.Advanceis the distance the pen travels along the run, in design units and without rounding.Shaper.GetFeatureTagsnames theGSUBtags behind a caps or position mode. WithTypeface.SupportsFeaturesit says whether a face has a feature for real, or a caller has to fall back to something synthesized.
Outlines and colour glyphs: PeachDrawing.Text.Outlines
Typeface.TryGetOutline reads the shape of a glyph as data: a GlyphOutline of closed contours, each a start point and a list
of segments that are straight lines or cubic curves, in design units with the y axis up. A glyph is filled by the nonzero
winding rule, which is how it gets its counters. TrueType quadratic curves are raised to cubic ones, so a consumer has two kinds
of segment to draw. This form is never grid-fitted; for text drawn into pixels, see Grid fitting (hinting).
using PeachDrawing.Text.Outlines;
if (face.TryMapRune(new Rune('g'), out ushort glyph) && face.TryGetOutline(glyph, out GlyphOutline outline))
{
foreach (OutlineContour contour in outline.Contours)
{
// move to contour.Start, then for each OutlineSegment draw a line to End, or a cubic through Control1 and Control2
}
// Where the ink lies across a band between two heights: what text-decoration-skip-ink needs.
foreach (var (start, end) in outline.Crossings(bandLow: -200, bandHigh: -100)) { /* ... */ }
}
- Colour glyphs from outlines.
HasColorGlyphssays a face has them, andColorPalettegives its palettes:TryGetColorreturns aSystem.Drawing.Color, andFirstLightPaletteandFirstDarkPaletteare what CSSfont-palette: lightanddarkask for. A version 0COLRglyph is a list of layers fromTryGetColorLayers, each a glyph filled with one palette colour. A version 1 glyph is a paint graph:GetColorPaintreturns the rootColorPaint, and the sealed types that derive from it are named as theCOLRspecification names its paint formats (PaintSolid,PaintLinearGradient,PaintRadialGradient,PaintSweepGradient,PaintGlyph,PaintTransform,PaintComposite,PaintColrGlyph, andPaintColrLayers, whose layers are read withGetColorLayerPaint). At a location of a variable font (WithAxes) the paints are read there: the variable formats (PaintVarSolid, the gradients,PaintVarTransformand the translate, scale, rotate and skew variants), their colour lines and the clip boxes have the deltas of the font’sCOLRvariation store added, so the nodes carry the numbers that apply at the location (an opacity that a delta pushes outside 0 to 1 is kept inside it, and colour stops a delta moves out of order are put back in order). Nodes are made for each location, and a caller needs to know nothing about variations.TryGetColorClipBoxgives the rectangle that holds everything a glyph paints (the font’sClipList), when the font has one for it. The angles of a sweep gradient are counter-clockwise from the positive x axis, as the specification’s half-turn bias is undone. - Colour glyphs from SVG. A font with an
SVGtable reportsHasSvgGlyphs, andTryGetSvgGlyphgives the SVG document that draws a glyph (gzip-compressed documents are inflated, up to 4 MiB), the id of the element in it that is the glyph (glyphand the glyph’s number), and the range of glyphs the document covers. The library does not render SVG: a caller draws the document with the glyph’s origin at (0, 0), y pointing down and one design unit as one unit, with the font’s palette colours forvar(--color0)and the text’s fill forcontext-fill. The document comes from the font file and is untrusted. - Colour glyphs from pictures. A font whose colour glyphs are bitmaps (
CBDT/CBLCorsbix) reportsHasBitmapGlyphs, andTryGetBitmapgives the picture of a glyph from the strike best suited to a size, with its bearings.
Grid fitting (hinting)
A font can carry hints that move the points of a glyph, at one size, so that stems, x-heights and baselines land on whole pixels: a
TrueType font as small programs, a font with CFF (PostScript) outlines as stem hints and blue zones in its charstrings. That makes small
text drawn into a pixel raster sharper, and means nothing for vector output. An OutlineRequest asks for it: a size in pixels per em and
a GridFitting.
var request = new OutlineRequest { PixelsPerEm = 11, GridFitting = GridFitting.Standard };
if (face.TryGetOutline(glyph, request, out GlyphOutline fitted))
{
// fitted.IsGridFitted: the font's hints were applied. Coordinates are in pixels at 11 ppem, y up, origin at (0, 0).
// fitted.GridFittedAdvance: the advance after fitting, rounded to a whole number of pixels as the font's hinting leaves it
// (in Monochrome mode from the font's hdmx table where it has one for the size).
}
- The size is the size the font is fitted at. It may be fractional, but a TrueType font whose
headtable asks for whole pixels per em (nearly all do) is fitted at the nearest whole size, as in FreeType: asking for 11.4 gives an outline fitted at 11, whichGlyphOutline.PixelsPerEmreports. Every fractional size of such a font shares one cached fitting. A font with CFF outlines is fitted at the size asked for. - The horizontal and vertical size can differ.
PixelsPerEmis a convenience that setsOutlineRequest.PixelsPerEmXandPixelsPerEmYto the same value; a device whose pixels are not square (a non-uniform scale, or a different horizontal and vertical resolution) sets them independently, and the font is fitted for that stretched grid instead of a square one: a TrueType font’s instructions read the horizontal and vertical scale on their own terms wherever they measure a distance that is not purely horizontal or vertical (FreeType’s non-square-pixel paths), and a font with CFF outlines scales its two axes independently while still choosing its blue zones and stem widths from the vertical axis alone, exactly as Adobe’s engine does.GlyphOutline.PixelsPerEmXandPixelsPerEmYreport the two axes the outline was fitted at (PixelsPerEmgives the horizontal one). PeachPDF’s own raster backend computes both axes from the device transform it is drawing into, so a page rendered at a non-square DPI, or under a CSS transform that scales the two axes differently, reaches this on its own. GridFitting.Noneis the default and gives exactly the design-unit outline of the overload without a request.GridFitting.Standardruns the font’s instructions in the interpreter FreeType uses by default (its “v40” behaviour). It fits the vertical direction only, so glyphs keep the horizontal positions and widths of the design, which is what anti-aliased text wants. It applies the compatibility adjustments that modern fonts, built for that interpreter, rely on.GridFitting.Monochromeruns them in the original interpreter (FreeType’s “v35”), which fits both directions, as for text drawn without anti-aliasing.- Fonts with CFF outlines are fitted by Adobe’s CFF engine, the one FreeType uses: the horizontal and vertical stem hints of a glyph
and the blue zones of its font (the heights of the baseline, the x-height, the caps and the ascenders, with their overshoots) place
stems and flat edges on whole pixels, overshoots are suppressed at small sizes, and hints are substituted where the charstring says so.
It fits vertically only, so the two modes give one outline; a font whose
LanguageGroupsays it is ideographic gets the em box alignment of ideographic fonts. The advance is the design advance rounded to a whole pixel. A variable font with CFF2 outlines is fitted the same way at the location of the typeface (WithAxes): the operands of its hints, and the blue zones and stem widths of its Private DICTs, are blended for the location before the hints are applied, so a weight or width moves the stems the way the font’s designer set out. The blending is done in FreeType’s 16.16 arithmetic, and the outlines are FreeType’s exactly (see the note on variable fonts below). - Stem darkening (
OutlineRequest.StemDarkening, off by default, as it is in FreeType) makes the stems of a CFF font’s glyphs a little heavier when it is grid-fitted, which offsets the way anti-aliasing thins the thinnest stems of small text. Adobe’s engine decides the amount from how thick a stem is on the pixel grid: the thinnest stems gain the most, and a stem of more than about two and a third pixels (that is, text at a large size) gains nothing. It changes the points of the outline and not the advance, applies to fonts with CFF outlines only (a TrueType font gives the same outline whatever the flag says), and is ignored forGridFitting.None. The fitted outlines of the two settings are cached apart.
var request = new OutlineRequest { PixelsPerEm = 9, GridFitting = GridFitting.Standard, StemDarkening = true };
- When a font cannot be fitted, nothing throws: a TrueType font without
fpgm/prep/glyph programs, a font that has neither TrueType nor CFF outlines, a size the font’s own programs (or, for CFF, the engine: 2000 ppem at most) refuse, or a glyph whose program or charstring is broken, gives the scaled design outline withIsGridFittedfalse. A TrueType font may also switch its own glyph instructions off at a size, and is then treated the same way.TryGetGridFittedAdvancegives the fitted advance directly, including for glyphs that have no ink. - Untrusted fonts are safe to hint. The instruction interpreter bounds every table access, the number of instructions run, the loop work of a program and the depth of composite glyphs; the CFF engine bounds the instructions of a charstring, the depth of subroutines, the operand stack and the number of points; and a font that goes past a limit is answered with the unhinted outline. Hinted outlines are cached per face, size and mode.
- Layout is not hinted.
GetAdvanceand the metrics stay unhinted; fitting is a property of an outline drawn at one size, and a caller that lays text out keeps the design advances so that layout does not change with the size of the device. - Variable fonts are hinted at the location of the typeface (
WithAxes) as FreeType hints a variable font, in its own 16.16 arithmetic, so the fitted points are FreeType’s exactly. The normalized coordinates of a location are made from its design coordinates the way FreeType makes them (the ranges of the axes and theavartable, version 2 included, in 16.16: not the 2.14 the variation tables are written in, which the unhinted outlines of a variable font use). With TrueType outlines the points are moved by thegvardeltas first (the scalar of each tuple, the sum of the deltas, the points a tuple leaves out interpolated as theIUPinstruction would, and the rounding of the sum are FreeType’s) and then the instructions run, on the control values that the font’scvartable has moved for the location. The advances followHVAR, and the vertical onesVVAR, or the phantom points ofgvarfor a font that has none, andMVARmoves the ranges of thegasptable (below) and the font’s typographic ascender and descender. With CFF2 outlines the operands of the hints and the Private DICTs are blended for the location (above). A variation table that is wrong is not an error: a glyph whose variation data cannot be read is answered with the unhinted outline, and so is every glyph of a location FreeType would refuse to set (acvarorgvartable with a bad header, for example), and a glyph or a set of control values whose tables ask for an unreasonable amount of work (more than 16 million deltas) is refused. Fitted outlines are cached per location. - The font’s
gasptable decides which sizes are fitted. A font that has one says, for each range of sizes, whether it wants grid-fitting there (GASP_GRIDFIT); fonts often turn hinting off at the smallest sizes, where their programs do more harm than good, and a request for fitting at such a size is answered as for a font that cannot be fitted: the scaled design outline withIsGridFittedfalse, and no fitted advance. The size compared is the whole number of pixels per em the outline is fitted at (11.4 asked of a font that wants whole sizes is 11). A size that no range reaches, a font with nogasptable, and a table of a version above 1 or one that is cut short are treated as saying nothing, and the font is fitted. OnlyGASP_GRIDFIT, the flag for standard rasterization, is looked at, for both modes; the flags for ClearType (GASP_SYMMETRIC_GRIDFIT,GASP_SYMMETRIC_SMOOTHING) are not, since nothing here draws with it. At a location of a variable font theMVARtable moves the largest size of the first ten ranges (itsgsp0togsp9values), so which sizes are fitted can change with the weight or the width.LTSHandVDMXare not read (FreeType does not use them to load a glyph either). When the two axes differ, the size compared against the table is the larger of the two, in pixels per em.
The instruction interpreter and the CFF engine are ports of FreeType’s (the CFF engine is the one Adobe contributed to FreeType), which is why the package carries the FreeType Project License notices and Adobe’s (see Licences). They give the same fitted points as FreeType 2.14.3, in 26.6 fixed point, for every font the test suite checks them with, including fonts made of random programs and charstrings, and variable fonts at many locations of their design spaces, that FreeType is compared with point for point.
Variable fonts
A variable font is one file that holds a whole design space: axes such as weight and width, and the outlines and metrics at every
point in between. Typeface.IsVariable says whether a face is one, Typeface.Axes lists its axes (a VariationAxis with a tag, a
range and a default; the tags the specification registers are in AxisTags), and Typeface.NamedVariations lists the named
locations the font declares. Typeface.WithAxes returns the typeface at a location.
if (face.IsVariable)
{
Typeface bold = face.WithAxes([new AxisSetting(AxisTags.Weight, 700)]);
Typeface condensedBold = bold.WithAxes([new AxisSetting(AxisTags.Width, 80)]); // builds on what bold has
ushort glyph = ...;
int advance = condensedBold.GetAdvance(glyph); // design units at that location
bool hasOutline = condensedBold.TryGetOutline(glyph, out GlyphOutline outline);
}
- An axis you leave out keeps the value the typeface has, a tag the font has no axis for is ignored, and a value outside the axis’s
range is clamped to it. A value is rounded to the nearest 1/64 of a unit, so values that close are one location. Asking for the
same location again gives an equal
Typeface, and every axis at its default gives the font’s own default typeface. ANaNmeans the axis’s default, axis tags are compared exactly (wght, notWGHT), and for a tag given twice the last one counts. IsBold,IsItalicand the weight the family matching sees are the file’s own, whatever the location is: a location changes how the glyphs are drawn, not what the file declares.- A
TypefaceQuerygiven toTypefaceFamily.TryMatchis answered with the face of a variable font at the location the query asks for: its weight, width class and italic-ness set thewght,wdthanditalaxes (orslnt, for a font that has a slant axis and no italic one), the query’s ownAxesare applied after those, and nothing is left for the caller to fake bold or italic where an axis did it.Typeface.VariationKeynames the location, so a cache of things made from a typeface can tell two instances of one font apart. - A query can ask for a width as a percentage of the normal width (
TypefaceQuery.WidthPercent, where the width class only has nine places) and for an oblique angle (ObliqueAngle, 14 degrees when it is left out), which set thewdthandslntaxes. - A font added with a range (
AddOptions.WeightRange,WidthRangeandObliqueRange, theAxisRangeform of the@font-facedescriptors) is matched as covering every value in it: a request inside the range is exact, and one outside it is measured from the nearest end. The axes of a variable face are then set to the request kept inside the range, and no bold or italic is left to fake that the axes supply. A face oblique over a range that includes 0 also serves upright text. A variable font added with no range covers the range of its own weight, width and slant axes. - Outlines (including composite glyphs), advance widths, the font-wide metrics of
Typeface.Metricsand shaping advances follow the location. So do the vertical advances (GetVerticalAdvance:VVAR, or the phantom points ofgvarin a font without it) and, in a font with aVORGtable, the vertical origins (GetVerticalOrigin, through the vertical origin mapping ofVVAR); the origin of a font withoutVORGis thevheaascent, whichMVAR(vasc) varies. The font bounding box (TypefaceMetrics.XMintoYMax) is worked out from the glyphs as they are drawn at the location, since no table says how it moves: for TrueType outlines the box of every point of every glyph (off-curve points included, as a font’s own glyph bounds are), forCFF2outlines the box of the curves, each rounded to whole design units; a font with more glyphs to read than the engine’s limits allow keepshead’s box. What a location changes is what thegvar,HVAR,VVAR,MVARandavartables of a font with TrueType outlines say (avarversion 2, in which the value of an axis depends on the others, included), plus the deltas of theGPOSvalue records and anchors (kerning, single adjustments, mark and cursive attachment) that name theGDEFitem variation store, and theFeatureVariationsofGSUBandGPOS(a feature that uses other lookups at a region of the design space, such asrvrnglyph swaps at a weight). - A variable font with CFF2 outlines (a
CFF2table) is read the same way:TryGetOutlineruns the glyph’s charstring with everyblendresolved at the location (thevsindexoperator and thevsindexof each Font DICT’s Private DICT choose the regions), so the coordinates of an outline at a location between the masters are not whole numbers. The layout tables and the advances (HVAR) follow the location as they do for TrueType outlines. CFF2 outlines are grid-fitted at the location, and a font with onlyCOLRcolour glyphs over CFF2 outlines is not reported as a colour font, as for CFF. TypefaceExporter.ExportSubset(see Embedding below) writes an instance as a static font, with the location’s variations applied to the outlines and metrics of the glyphs you ask for and no hinting instructions, because a PDF cannot embed a variable font.
Mathematics: PeachDrawing.Text.OpenType
A face made for setting mathematics has a MATH table, and Typeface.HasMathData says so. Typeface.MathData returns it as a
MathTable with three parts. Constants holds the values a math layout algorithm positions fractions, radicals, scripts,
stacks and limits with, in design units apart from the percentages. GlyphInfo answers per glyph: the italics correction,
the horizontal position an accent attaches at, and whether the glyph is an extended shape. Variants gives the glyphs that
stretch (fences, radicals, accents, arrows) their pre-sized variants and, for a size beyond the largest, the parts to
assemble them from.
using PeachDrawing.Text.OpenType;
if (face.MathData is MathTable math && face.TryMapRune(new Rune('('), out ushort paren))
{
double axis = math.Constants.AxisHeight; // design units above the baseline
MathGlyphConstruction? tall = math.Variants.GetVerticalConstruction(paren);
foreach (MathGlyphVariant variant in tall?.Variants ?? []) // smallest to largest
{
// the first variant whose AdvanceMeasurement reaches the size you need is the one to draw
}
if (tall?.Assembly is MathGlyphAssembly assembly)
{
// bottom to top: repeat the parts that IsExtender until the target height is reached,
// overlapping neighbours by at most their connector lengths and at least Variants.MinConnectorOverlap
}
}
The per-glyph corner kerning of MathKernInfo and the device tables that adjust a value at particular sizes are not read.
Embedding: PeachDrawing.Text.Export
A document that embeds a font wants only the glyphs it uses. TypefaceExporter.ExportSubset cuts a typeface down to the glyph
indices you give it and returns the bytes of a font file, an ExportedFont.
using PeachDrawing.Text.Export;
ExportedFont subset = TypefaceExporter.ExportSubset(face, usedGlyphs, keepCharacterMap: false);
byte[] fontFile = subset.Data.ToArray();
// subset.HasCffOutlines says which kind of font stream to write; subset.IsSubset says whether it was cut down.
- The glyphs keep their indices, so text already encoded as glyph indices stays valid against the subset. The glyphs a composite glyph is made of come along, and so does the notdef glyph.
- A colour glyph that has no outline of its own (its shapes are its layers) is given a small outline, so a reader can still select the text it stands for.
- A subset carries no name table, so it is meant to be embedded, not loaded back into a
FontSet. - For a typeface from
WithAxesthe subset is a static font at that location: each glyph’s points and component offsets have the variations applied, the side bearings and advances of the glyphs are set to match, and the hinting tables are left out. - A font with CFF outlines is not cut down: it is returned whole, and
IsSubsetisfalse. - A variable font with CFF2 outlines is always written afresh, at its default location or at the one
WithAxesgave: each glyph you ask for is drawn at the location and written as a charstring of lines and curves over whole-number coordinates (no hints, no subroutines), in a CID-keyed OpenType font with CFF outlines whose glyph indices are its CIDs.HasCffOutlinesistrueandIsSubsetistrue; a glyph that was not asked for is an empty glyph in its place. keepCharacterMapsays whether the character map stays. A font whose text is encoded as glyph indices is smaller without it.
What a font descriptor records about a face comes from the members you already have: Typeface.Metrics (with IsSymbolic,
IsFixedPitch, HasSerifs, IsItalicStyle and FirstCharIndex for the descriptor flags), Typeface.GetAdvance for widths,
Typeface.FullName for a base font name, and Typeface.ContentHash (a 128-bit hash of the font data, so two different fonts never share one) to key a cache of what you made from a face.
Laying out text: PeachDrawing.Text.Layout
ParagraphBuilder collects text and styles, and Build() prepares a Paragraph: the text’s direction (UAX #9), scripts, joining and line break
opportunities (UAX #14) are worked out once, and the paragraph can then be laid out at any width. A Paragraph is immutable and can be
laid out from several threads.
var builder = new ParagraphBuilder(new RunStyle(typeface, 16))
.SetStyle(new ParagraphStyle { Align = TextAlign.Start, OverflowWrap = OverflowWrap.BreakWord });
builder.AddText("Some ").PushRun(new RunStyle(bold, 24)).AddText("large").PopRun().AddText(" text.");
Paragraph paragraph = builder.Build();
ParagraphLayout layout = paragraph.Layout(availableWidth: 300);
foreach (LineBox line in layout.Lines)
{
foreach (PlacedRun run in line.Runs) // left to right, in the order they are drawn
{
// run.Glyphs.Glyphs are in drawing order; run.X is the left edge and run.Baseline the baseline, in layout units.
}
}
- Lines. A line breaks where UAX #14 allows and the next word does not fit. The space at a soft break hangs at the end of the line: it is in
LineBox.Rangebut not inLineBox.Width, and it has no run. A newline forces a break (LineBox.Endsays why every line ended), and text that ends in one gets an empty last line to put a caret on. A word wider than a line overflows unlessParagraphStyle.OverflowWrapisBreakWordorAnywhere, which move it to a line of its own first and then cut it between grapheme clusters.ParagraphStyle.NoWrapbreaks only at forced breaks, and a width ofdouble.PositiveInfinitydoes the same. - Direction. Each line is reordered visually (rule L2), so
LineBox.Runsis in the order to draw. A right-to-left run’s glyphs are already in drawing order and its mirrorable characters already mirrored. - Alignment and height.
TextAlignis start, end, left, right or center. A line is as tall as its faces ask for (their ascent, descent and line gap), orLineHeighttimes its largest size, with the leading shared above and below. - Editing.
PositionAt(point)gives the boundary nearest a point, always between grapheme clusters;CaretRect(position)gives the caret, whereTextAffinitychooses the line at a soft break;SelectionBoxes(range)gives the rectangles to fill, one per visually contiguous stretch of a line;WordRangeAtandGraphemeRangeAtgive the UAX #29 units.PlacedRun.GetCaretXgives the caret’s place inside one run, sharing a ligature’s width out equally between its characters. - Content widths.
MeasureContent()gives the width of the widest unbreakable piece (withOverflowWrap.Anywhere, of the widest character) and of the widest line when only forced breaks end one. -
TextRuler.WidthOfmeasures one piece of text in one face without building a paragraph. - Font fallback. Text is set in the typeface of its run. When
RunStyle.Fallbackis set, it is asked (once for each user-perceived character the face cannot draw, with the character’s first code point) for a typeface to stand in, and the character and the marks that follow it are set in that face at the run’s size; without one, or where it answersnull, the face’s missing-glyph shape is drawn.FontSet.CreateFallback(query)makes one from the families of a set, choosing the family whose coverage best fits the character’s script. - Spacing and justification.
RunStyle.LetterSpacingandWordSpacingadd distance after every glyph and every space; both count in where lines break and in the caret positions, and letter spacing turns off the optional ligatures of the text it is on.TextAlign.Justifyshares the room a line has left between its opportunities so that it fills the width, equally, in every line that is not the last (nor ends in a forced break);ParagraphStyle.TextJustifysays where the opportunities are:Auto(the spaces and the boundaries next to a Han, Hiragana, Katakana, Bopomofo or Yi letter),InterWord(the spaces only),InterCharacter(every pair of adjacent characters, except joined cursive letters) orNone(no justification). A line with no opportunity is left as it is, a tab is a wall that nothing is added next to, andParagraphStyle.AlignLastsets how the last line and forced-break lines are aligned (the start, by default).PlacedRun.GetGlyphAdvancegives the pen movement after each glyph, spacing and justification included, which is what a caller draws with. -
Indent and tab stops.
ParagraphStyle.TextIndentmoves the start of a line in from the start edge (the left of a left-to-right paragraph, the right of a right-to-left one): by default the first line only, withEachLinealso the line after every forced break, and withHangingevery line except those. The indent takes room from the line, which breaks earlier, and alignment and justification work in what is left; a negative indent moves the text out of the paragraph. It is a length in layout units, so a caller with a percentage resolves it against its own width. A tab character advances the pen to the next tab stop, at multiples ofParagraphStyle.TabSizefrom the start edge (TabSize.FromSpaces, counted in spaces of the face the tab is in with their letter and word spacing, eight by default, orTabSize.FromLength). The stops are measured along the line in the order the text is written, indent included; a tab at the end of a line hangs; a tab is a run of its own with no glyphs, so it draws nothing, and it is not a justification opportunity.ContentWidthandMeasureContent()count the indent. -
Hyphenation.
ParagraphStyle.HyphensisManualby default: a soft hyphen (U+00AD) is a place a line may break, the line then ends with a hyphen (LineBox.EndisHyphenated), and a soft hyphen the line does not end at draws nothing and takes no room.Nonemakes soft hyphens no place to break, andAutoalso breaks words where the patterns of their language allow (Hyphenator; the language is theShapeSettings.Languageof the run the word is in, or theLanguageofParagraphStyle.LineBreak; a word with neither is left whole). A word is hyphenated when it would not fit, as far along as it goes, before the emergency cut ofOverflowWrapis tried. The hyphen is a generated run (PlacedRun.IsGenerated, an emptyRange, drawn like any run) at the end of the line in the paragraph’s direction, U+2010 if the face has it and a hyphen-minus otherwise, orParagraphStyle.HyphenateCharacter; it counts in the line’s width. If the hyphen would not fit after a soft hyphen the line ends at the last earlier place that has room for it.HyphenateLimitChars(word, before and after; 5, 2 and 2 by default),HyphenateLimitLines(hyphenated lines in a row),HyphenateLimitZone(room a line may leave before its last word is hyphenated) andHyphenateLimitLast(Alwayskeeps the last full line, the one before a rest that fits a line of its own, from ending with a hyphenation) restrict it. A caret at a hyphenated break can be on either line as at any soft break, and lies before the hyphen.MeasureContent()counts hyphens, and withAutoits minimum is the widest piece between two places a word may be hyphenated. -
Line limit and ellipsis.
ParagraphStyle.MaxLineslays the paragraph out in at most that many lines. When text is left over, the last line keeps as much of its text as fits withParagraphStyle.Ellipsisafter it (U+2026, or three full stops if the face has no such character; an empty string means only cut), cut at a boundary between characters that a reader sees as one and never after a space;ParagraphLayout.IsTruncatedis set, and the last line isLineEnd.LastwithLineBox.IsTruncated, aRangethat runs to the end of the text (the part afterContentEndis hidden, like hanging space) and the ellipsis as a generated run at its end in the paragraph’s direction, in the style of the last character drawn.TextOverflow.Ellipsiscuts the same way any line that is wider than the width (aNoWrapline, or a word wider than the paragraph) without ending the paragraph. A line limit is about lines, so one line that overflows its width is cut only whenTextOverflowasks for it, and text that ends in a newline does not count as text left out. Only the lines that are laid out are worked out, so a limit on a very long text costs what its lines cost. -
Inline boxes.
ParagraphBuilder.AddInlineBox(new InlineBox(width, height, ...))puts a box of a known size in the text like one character (an image, an inline block, a formula the caller lays out itself). The paragraph’s text holds a U+FFFC for it; a line may break before and after it and never inside it; and a box wider than the line overflows on a line of its own. It is placed as aPlacedRunwith no glyphs, one character inRange, the box’s width,PlacedRun.InlineBox(with theTagthe caller gave) andPlacedRun.InlineBoxBounds, the rectangle to draw it in.Baselineis the distance from the top of the box to the point that sits on the line’s baseline (the bottom edge by default, as for an image),BaselineShiftraises or lowers it, andVerticalAlignchoosesBaseline,Middle,TextTop,TextBottom, orTop/Bottom(which align to the line, and make it as tall as the box, growing it away from the text). The line is as tall as the text around the box and the box together need, and text around a box counts as its strut, so a line of one box is as tall as its text would be. Letter spacing does not apply to a box, a tab measures from the end of it, and it is a wall for justification: no room is added next to it. Carets, selection and hit testing treat it as one character. - One line at a time.
Paragraph.CreateFlow()gives aLineFlowfor a caller that owns what the text flows around (floats, columns, pages):TryNext(cursor, space, out line, out next)lays out the line that starts at aFlowCursorin aLineSpace(its left and right edges, its indent, and where its top is) and gives back the cursor of the next.default(FlowCursor), orflow.Start, is the start of the paragraph;cursor.IsEndsays the last line has been laid out. The call is a pure function of the paragraph, the cursor and the space: it never changes its arguments and keeps nothing, so a caller that wants to undo a line (its height grew, a float now intrudes) calls it again with the cursor from before, and one flow can be used from several threads.flow.GetIndent(cursor)gives thetext-indentof the line, for a caller that follows the style. The lines are the same asLayoutgives when every space is the full width and each line is under the last (a test holds the two to that), except thatMaxLinesis the caller’s to apply, tab stops start at the space’s edge, the limit on hyphenating the last full line guesses the next line’s room from this space’s width, and a right-to-left line is placed against the space’s left edge, not a right edge the flow does not know, when the space has no end.
Layout units are the units of RunStyle.Size; coordinates run right and down from the top left of the paragraph.
Drawing a layout
PeachDrawing.Text stops at positions; painting is the caller’s. With a PeachDrawing.Core canvas (the RasterCanvas from the PeachDrawing package, or any other Canvas), one call paints a laid-out paragraph:
var layout = new ParagraphBuilder(new RunStyle(typeface, 16))
.AddText("Hello, ").PushRun(new RunStyle(bold, 16)).AddText("world").PopRun()
.Build().Layout(availableWidth: 300);
canvas.DrawParagraph(layout, new PaintPoint(10, 10), PaintColor.FromArgb(255, 0, 0, 0));
Glyphs are drawn where the shaper put them (nothing is reshaped), from the face each run was shaped in, so fallback faces, kerning, ligatures, bitmap glyphs and COLR/CPAL colour glyphs come out exactly as laid out. The overload taking a Func<PlacedRun, ParagraphPaint> chooses a colour and TextDecorations (underline, overline, line-through, drawn from the face’s own metrics) per run, and an optional callback receives each inline box’s bounds. canvas.DrawGlyphRun paints a single GlyphRun from Shaper.Shape. Layout units are the canvas’s user units.
The PeachDrawing.Text.Unicode namespace
Each entry point is a static class named for the algorithm or property it implements, and takes plain strings, runes and arrays.
Line breaking and text segmentation
LineBreaker.FindOpportunities implements the Unicode Line Breaking Algorithm (UAX #14).
It answers for every UTF-16 index of a paragraph, and one past its end: Prohibited, Allowed or Mandatory for a line
that would end just before that character. Where you break is still yours to decide: whether the space at a break stays on
the line, how a word too long for a line is split, and where hyphenation adds breaks.
using PeachDrawing.Text.Unicode;
string text = "Wrap this line, please.\nNext line.";
LineBreakOpportunity[] opportunities = LineBreaker.FindOpportunities(text);
for (int i = 1; i < text.Length; i++)
{
if (opportunities[i] == LineBreakOpportunity.Allowed) { /* a line may end before text[i] */ }
if (opportunities[i] == LineBreakOpportunity.Mandatory) { /* it must */ }
}
LineBreakOptions applies the tailorings of CSS Text: WordBreak (a WordBreakMode: Normal, BreakAll, KeepAll) is word-break, and
Strictness (Auto, Loose, Normal, Strict, Anywhere) is line-break. Strict is the algorithm’s own default,
in which a small kana or a wave dash may not start a line; Auto (which is Normal) also lets a wave dash and the katakana
double hyphen start one, and Loose lets a line start with a small kana, an iteration mark, and with a hyphen after an ideograph;
between two ellipses it may break, but not before one. Language (a BCP 47 tag, or null) is what the rest depends on: the wave dash
and the katakana double hyphen may start a line in Normal and Loose only where the language is Chinese or Japanese, and
there Loose also lets a line start with a middle dot, the colon and semicolon of CJK text and a fullwidth or double exclamation or
question mark, end before a suffix and after a prefix of East Asian width (%, ℃, ¥), which the number rules would otherwise
keep with their digits. Anywhere allows a break after every grapheme cluster, whatever the
character rules say, and keeps only hard line breaks.
Thai, Lao, Khmer and Burmese write no spaces between words, so no rule of the algorithm can find where a line may end (UAX #14 leaves
those characters, its SA or Complex_Context class, to a dictionary). The library carries a word list for each, taken from ICU’s
break-iterator dictionaries, and by default LineBreaker allows a break between the words it finds, as browsers do. It chooses the
words by looking a few words ahead for the choice that covers the text best, preferring the longer word when two choices cover it
alike; a stretch that no word matches stays whole, cut off from the words around it; and it never breaks inside a syllable (no
break before a dependent vowel, tone mark or other sign, after a leading vowel, inside a Khmer or Burmese subscript/stacked consonant,
or before a Burmese asat that closes the syllable before it). The script decides, not Language, and WordBreak, Strictness and
overflow wrapping apply on top of it. A word list is read the first time text of its script is analysed (about 0.44 MB of embedded
data in all, in the PeachDrawing.Text.Data package this one depends on, Brotli-compressed like the rest of its Unicode data), and is
kept for the life of the process. A compound that the list has as one word stays whole even where a browser splits it. Set
LineBreakOptions.ComplexContext to ComplexContextBreaking.GeneralCategory to have no opportunity inside a run of these scripts,
which is what rule LB1 itself falls back to (a caller with its own dictionary wants that); the other Complex_Context scripts (Tai
Tham, Cham and the rest) have no word list and always get it. A host with no Brotli decoder of its own (WebAssembly, at the time of
writing) gets no word list either, and every script falls back the same way, unless it registers one with
PeachDrawing.Text.Compression.BrotliDecompression.SetDecompressor - see The Unicode/hyphenation/dictionary data, and its Brotli
decoder seam below.
Segmenter finds the boundaries of UAX #29: FindGraphemeBoundaries (extended
grapheme clusters: a letter with its accents, a Hangul syllable, an emoji sequence, a flag), FindWordBoundaries and
FindSentenceBoundaries. Each returns increasing UTF-16 indices, including the start and the end of the text, and none
for empty text; the pieces are the text between neighbouring boundaries.
int[] clusters = Segmenter.FindGraphemeBoundaries("e\u0301\U0001F1FA\U0001F1F8"); // [0, 2, 6]
Bidirectional text
UAX #9 works in two steps, and so does the API. Bidi.Analyze resolves an
embedding level for every UTF-16 code unit of a paragraph. Once your layout has decided where lines break,
Bidi.ReorderLine puts one line’s runs in the order they are drawn.
using PeachDrawing.Text.Unicode;
string text = "abc אבג";
BidiAnalysis analysis = Bidi.Analyze(text, BaseDirection.Ltr);
foreach (BidiRun run in Bidi.ReorderLine(analysis.Levels, 0, text.Length))
{
string piece = text.Substring(run.Start, run.Length);
// A run at an odd level reads right to left: Mirror reverses it and swaps mirrored characters such as brackets.
Console.WriteLine(run.IsRtl ? Bidi.Mirror(piece, run.Level) : piece);
}
BaseDirection.Auto detects the direction from the first strong character. A host with its own markup, such as CSS
unicode-bidi or an SVG direction attribute, passes EmbeddingSpan values to describe embeddings the text itself
does not spell out. Bidi.ClassOf returns a character’s Bidi_Class, and Bidi.TryGetMirror finds a bracket’s
counterpart.
The implementation is checked against Unicode’s BidiCharacterTest.txt conformance file. Rule L1’s last clause, which
resets the whitespace at the end of each line, is left to the caller because it depends on whether the caller lays
out in characters, words or glyphs.
Scripts and OpenType tags
Scripts.Of returns the Unicode Script of a character (Latin, Arabic, Han, and the shared Common and
Inherited). Scripts.Resolve gives every character of a text the script it is to be treated as, so that a comma
between two Arabic words counts as Arabic (UAX #24 section 5.1). OpenTypeTags.ForScript and OpenTypeTags.ForLanguage
turn a script name or a BCP 47 language tag into the four-letter tag an OpenType font’s layout tables are keyed by.
Both answer null for a script or language the built-in table does not cover.
Vertical text, invisible characters, hyphenation and emoji
VerticalOrientation.IsEffectivelyUprightsays whether a character stays upright in vertical text set with CSStext-orientation: mixed;VerticalOrientation.Ofreturns the UAX #50 class behind it.DefaultIgnorables.Containsrecognises the characters that carry meaning but draw nothing, such as joiners, variation selectors and bidi controls, for which drawing a “missing glyph” box would be wrong.Hyphenator.FindBreakPoints("hyphenation", "en-US")returns the indexes at which a hyphen may be inserted, using the TeX patterns for the closest supported language. An unsupported language, a word shorter than that language’s minimums, or a word with non-letters in it, yields an empty list.Emoji.ResolveandEmoji.ResolveAtdecide whether a character that has both a text and an emoji appearance is drawn as one or the other, from anEmojiMode(CSSfont-variant-emoji) and any variation selector that follows.
The Unicode/hyphenation/dictionary data, and its Brotli decoder seam
The tables above (Bidi, Script, vertical orientation, Arabic joining, the Indic Use tables), the hyphenation patterns and the
Thai/Lao/Khmer/Burmese word lists ship Brotli-compressed, in a separate package, PeachDrawing.Text.Data, that PeachDrawing.Text
depends on (see Fonts above for what “no third-party dependencies” means alongside this). A
host whose Brotli decoder does not work - WebAssembly in a browser, at the time of writing, where System.IO.Compression.BrotliStream
throws PlatformNotSupportedException - gets an empty table or an unhyphenated line instead of a failed render, exactly as before this
data moved packages. PeachDrawing.Text.Compression.BrotliDecompression.SetDecompressor lets a host register a managed Brotli
decoder of its own instead, to recover that data there; call it once, before using any feature backed by this data, since each table
is read once and cached for the life of the process.
PeachDrawing.Text.Brotli is a ready-made decoder for that seam: a pure-managed port of
google/brotli’s own C# decoder, with no dependency beyond PeachDrawing.Text itself, kept as a
separate opt-in project rather than folded into PeachDrawing.Text so a host that never needs it never pays for it. Call
PeachDrawing.Text.Brotli.ManagedBrotliDecompressor.Register() once at startup:
using PeachDrawing.Text.Brotli;
if (OperatingSystem.IsBrowser())
{
ManagedBrotliDecompressor.Register();
}
PeachPDF.Demo.BlazorWasm’s Program.cs does exactly this, which is how its own WOFF2 fonts, hyphens: auto and Thai/Lao/Khmer/
Burmese dictionary line breaking all work in the browser.
Licences
The engine (PeachDrawing.Text) is BSD 3-Clause. It carries its third-party notices with it, in THIRD-PARTY-LICENSES.md: the font
readers derive from PDFsharp (MIT), several shaping algorithms are ports of HarfBuzz code, and the TrueType instruction interpreter and
Adobe’s CFF engine that do the grid fitting are ports of FreeType’s (under the FreeType Project License,
whose text ships in the package as FTL.TXT, with Adobe’s patent licence grant for the CFF engine; an application that redistributes
the package has to credit the FreeType Team in its documentation). The data tables - the Unicode Character Database, the hyph-utf8
pattern collection and ICU’s Thai, Lao, Khmer and Burmese word lists - live in PeachDrawing.Text.Data and carry their notices in
that package’s own THIRD-PARTY-LICENSES.md. See License for the whole list.