Fonts
Bundled Inter, your own TrueType and OpenType files, kerning, justification, a hook for a shaper, per-character fallback and installable font packs.
Bundled Inter#
Zero configuration.
Inter (regular, bold, italic, bold italic; SIL Open Font License) is bundled and used when a document declares no family. font_family "Inter" with no paths selects it explicitly, and Stationery.bundled_fonts lists what ships. Font files are read lazily, on first use, never when the gem is required.
Your own families#
TTF and OTF.
class InvoicePdf < Stationery::Document
font_family "Open Sans",
regular: "fonts/OpenSans-Regular.ttf", bold: "fonts/OpenSans-Bold.ttf",
italic: "fonts/OpenSans-Italic.ttf", bold_italic: "fonts/OpenSans-BoldItalic.ttf"
default_text font: "Open Sans", size: 9
endFonts are TrueType (.ttf) or OpenType/CFF (.otf, name-keyed or CID-keyed) files. Only the glyphs a document uses are embedded (a CFF font keeps its glyph numbering and subroutines; unused glyphs are blanked), with a ToUnicode map so text copies and searches correctly. A style without its own file (bold, italic) is synthesised.
A family name used at render time is resolved in order: registered families, bundled families, installed packs in Stationery.font_paths, then the first registered family, then bundled Inter. font_family "Name" with no paths raises ArgumentError when the name is neither bundled nor installed.
Kerning and justification#
Text is pair-kerned from the font's GPOS kern feature (PairPos lookups, including class-based pairs and Extension lookups), falling back to the legacy kern table. kerning: false on an element or in default_text turns it off. Pairs that straddle a style or font change are not kerned. Kerning only tightens in practice, so a kerned line is never wider than the same line unkerned.
Standard ligatures (fi, fl, ffi, …) come from the font's GSUB liga feature and are on by default; ligatures: false on an element or in default_text turns them off, and any letter_spacing does too. A ligature glyph maps back to all of its characters, so extracted and copied text is unchanged. Fonts without liga ligatures, such as bundled Inter, are unaffected.
Hyphenation
Off unless asked for. hyphenate: "de" on text, text_style, default_text or an html/markdown style (styles: { p: { hyphenate: "de" } }) breaks a word that does not fit at the longest point Liang's algorithm allows over the bundled TeX hyph-utf8 patterns — true or "en" for American English, "de" for German (2006 orthography), "sv" for Swedish — and draws a hyphen at the break. Two letters stay together at the start of a word and two (three in English) at its end. A soft hyphen (U+00AD, ­ in HTML) names the break points of a word yourself and, as in TeX, exempts that word from the patterns; it is never measured or drawn and never reaches the PDF, so an unbroken word copies out whole. A hyphenated line is justifiable like any other wrapped line.
Line breaking
Lines break at spaces, after hyphens and at hyphenation points, and also at a zero-width space (U+200B, <wbr> in HTML), which is never drawn and never reaches the PDF, and between ideographic characters — CJK ideographs, kana, Hangul, fullwidth forms — so Japanese, Chinese and Korean text wraps without spaces. A closing mark (。、」)ー and the small kana) stays on the line before it and an opening bracket (「() with what follows, the usual kinsoku rules. Justification still widens spaces only, so a line of CJK text is set flush left.
default_text hyphenate: "de"
text "Die Silbentrennung der Donaudampfschifffahrt" # Silben- / trennung … at a narrow width
text "Zei\u00ADtungsleser" # breaks only after "Zei"
Stationery::Hyphenation.hyphenate("Silbentrennung", "de") # => ["Sil", "ben", "tren", "nung"]The pattern sets ship with the gem under lib/stationery/hyphenation/patterns/ with their authors' notices (LICENSES.md: the American English patterns' own permissive notice, MIT for German, LPPL for Swedish). Another language raises ArgumentError naming the bundled ones.
align: :justify (on text, text_style and table cells) stretches the spaces of wrapped lines to the full width, keeping kerning. The last line, lines ending in a newline and lines without spaces stay left-aligned; tabs are never stretched.
OpenType features#
Small caps, figure styles, stylistic sets.
features: on text, text_style and default_text applies the font's other GSUB features on top of liga: smcp small caps, onum/lnum oldstyle or lining figures, tnum/pnum tabular or proportional figures (tabular for the number columns of a table), zero a slashed zero, dlig discretionary ligatures, ss01… stylistic sets, case case-sensitive punctuation. Tags are Symbols or Strings.
default_text font: "Open Sans", features: %i[onum]
text "Total 2,026.00", features: %i[tnum lnum], align: :right
text "Chapter One", features: [:smcp], letter_spacing: 0.5The reader applies single (SingleSubst formats 1 and 2) and ligature (LigatureSubst) lookups, also behind Extension lookups, in the font's own lookup order; a feature the font does not have is ignored without a warning, and Stationery::Fonts::Font#features lists the tags a font offers (bundled Inter: tnum, zero, dlig, case and stylistic sets; Open Sans: onum, lnum, pnum, tnum, salt). A substituted glyph keeps its source characters in the ToUnicode map, so extracted text is unchanged. Contextual features (calt, clig, frac) need lookup types the reader does not implement and do nothing, unless a shaper places the glyphs (below).
Complex scripts#
The shaper hook: bring HarfBuzz, stationery draws what it places.
Stationery places glyphs itself: one per character, the font's ligatures and single substitutions, pair kerning. Arabic, Hebrew, Indic and Thai text need a shaper, and the gem does not have one. An application that does (HarfBuzz) plugs it in, for a document class or for one render:
class Invoice < Stationery::Document
shaper HarfBuzzShaper.new # inherited; `shaper nil` takes an inherited one away
end
Invoice.new.to_pdf(shaper: my_shaper) # this render onlyA shaper is anything that answers call:
def call(text, font, size:, features:, language:, **)
# => [Stationery::Shaper::Glyph.new(gid:, advance:, cluster:, x_offset: 0, y_offset: 0), …] or nil
end| Argument | What it is |
|---|---|
text | One stretch of a line in one font and style |
font | A Stationery::Shaper::Face: path (the font file, nil when there is none), index (the face of a collection), data (the sfnt bytes; a WOFF is already unpacked), units_per_em, postscript_name, glyph_count |
size: | The size drawn at, in points |
features: | A frozen Hash of OpenType tags to switches: "kern" and "liga" always, following kerning: and ligatures: (letter spacing switches liga off), and the style's features: as true |
language: | The document's metadata lang:, or nil |
The answer is the glyphs in visual order, left to right. gid is the glyph id in the font, advance and the offsets are in font units, and cluster is the index, in characters, of the first character of text the glyph stands for; glyphs of the same cluster stand together for the characters up to the next cluster. A Hash of the same fields does for a Glyph. Answering nil declines a text, which is then drawn as without a shaper. Anything else (a glyph id the font does not have, a cluster outside the text, an advance that is not a number) raises Stationery::ShaperError. Direction and script are not passed, because stationery knows neither: the shaper works them out from the text.
What is shaped, and when:
- Line breaking works on the text as written and measures each word and each stretch of spaces on its own. Each line is then cut where the font or the style changes, and every such stretch is shaped whole. That one answer is the stretch's width (alignment, underline, link rectangle) and what is drawn, so the two always agree. The width a break was decided on is the sum of the words', which can differ a little from the shaped line's, as kerning across a space already does.
- The shaper is asked once per text, size and set of features in a render, and must answer the same for the same.
- Every text a document draws goes through it:
text, table cells, lists,html,markdown, headers and footers, the table of contents, text insvg. Form fields do not: a viewer that redraws a field after an edit would not shape it either.
In the PDF the glyph ids are written as the shaper gave them. The font's widths stay its own, so an advance the shaper changed is a TJ adjustment, an x offset moves the pen before the glyph and back after it, and a y offset is a text rise (Ts) around it, which keeps marks in place under a synthetic oblique, a superscript or letter spacing. Glyphs the shaper placed are embedded whether or not a character maps to them. Where the ToUnicode map cannot give the text (glyphs out of logical order, several glyphs for one cluster, a glyph that already stands for another text, glyph 0) the glyphs are shown in a Span whose ActualText is the characters in logical order. Glyph 0 is reported as a Warnings::MissingGlyph, like any other, and under a conformance level with missing_glyphs: :replace it is drawn as the font's stand-in at the stand-in's advance.
The limits of a hook:
- The shaper reorders within the stretch it is given and nowhere else. Stretches on a line are placed left to right in the order written and lines break in that order, so there is no bidirectional reordering across a change of font or style; a right-to-left paragraph is set with
align: :right, and the last line of a justified one is set left. - Fallback fonts are chosen per character from the fonts' cmaps before anything is shaped. Give the text the family that covers its script, or digits and punctuation the first family has are drawn from it, as stretches of their own.
- Justification widens U+0020 spaces only (no kashida). Letter spacing is added after each cluster, so a mark stays on its base, but it pulls cursive letters apart.
- Hyphenation and the breaking of a word wider than the line cut the text as written; the pieces are shaped as separate words.
- Vertical advances are not read: text runs horizontally.
- Extracted text depends on the reader: the gem's
Inspectorand PDFium get a right-to-left stretch as written, poppler and MuPDF reversed (see the table below). pdf-reader's ownPage#textdrops aSpanwhose first glyph has no advance (a mark drawn first); theInspectordoes not.
examples/shaping/harfbuzz_shaper.rb is an adapter for HarfBuzz through the harfbuzz-ruby gem (gem "harfbuzz-ruby", require "harfbuzz"; not the older harfbuzz gem, which answers to the same require), about a hundred lines to copy into an application; stationery does not depend on it. It was run with harfbuzz-ruby 1.1.0 and HarfBuzz 14.5.0 against Noto Sans Arabic, Amiri and Noto Sans Hebrew and draws joined, right-to-left Arabic with its marks and with left-to-right digits inside it. It cuts a stretch into runs of one direction by its letters alone, not by the Unicode bidirectional algorithm. The base direction of a stretch is its first letter's, so a line of an Arabic paragraph that starts with a number stays right to left. examples/shaping/rtl_letter.rb is a letter in Arabic set with it (ARABIC_FONT=NotoNaskhArabic-Regular.ttf ruby -Ilib examples/shaping/rtl_letter.rb); its booking number, in Latin letters from Inter, sits in a row beside its Arabic label, since stretches of two fonts on one line are placed left to right.
What comes out of a shaped PDF depends on who reads it, and no way of writing right-to-left text is read as written by every extractor. Seven texts (Arabic, Hebrew, Arabic with digits in it, Arabic with its marks, an Arabic word in a Latin sentence, a justified paragraph, a line that wraps) were rendered three ways and read back: A, what the gem writes, a Span around the stretch with its text in logical order as ActualText (ISO 32000-1, 14.9.4); B, a Span for every cluster, in visual order; C, no Span for a glyph the ToUnicode map gives its text, in visual order, and one per cluster for the others. The cells count the texts that came back as written:
| Arabic font | Inspector | PDFium | PDFKit | pdf.js | poppler | MuPDF | |
|---|---|---|---|---|---|---|---|
| Amiri (a glyph per letter) | A | 7 of 7 | 7 of 7 | 6 of 7 | 4 of 7 | 0 of 7 | 0 of 7 |
| B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 4 of 7 | 5 of 7 | |
| C | 0 of 7 | 1 of 7 | 6 of 7 | 4 of 7 | 4 of 7 | 5 of 7 | |
| Noto Sans Arabic (a letter is its shape and its dots) | A | 7 of 7 | 6 of 7 | 1 of 7 | 1 of 7 | 0 of 7 | 0 of 7 |
| B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 | |
| C | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 | 1 of 7 | 1 of 7 |
- PDFium (153.0.7999, what Chrome reads with, through pypdfium2 5.13) and the gem's
InspectorreturnActualTextas it is: A reads as written. Without it (C) PDFium returned the words of a right-to-left line in reverse order. - poppler (
pdftotext26.09) and MuPDF (mutool1.28.5) reorder what they read,ActualTextincluded: A comes back reversed, B and C in Amiri as written, but for Arabic with digits or marks and, in poppler, a justified paragraph. - pdf.js (pdfjs-dist 6.3.289) and PDFKit (macOS 27, what Preview reads with) returned the same for A and C, which differ in nothing but
ActualText: they read the glyphs through the ToUnicode map and reorder them. A cluster of several glyphs comes back with its text once per glyph, so a letter with a mark is doubled, and so is every dotted letter of the Noto Arabic families (Sans, Naskh and Kufi draw a letter as two glyphs). OnlyActualTextcarries the text of such a font. - The Hebrew text (Noto Sans Hebrew) is the same in both halves of the table.
- veraPDF 1.30.2 passes all 42 renders as PDF/UA-1, so the rules of the standard do not choose.
- Acrobat, Preview itself and screen readers were not tested.
The gem keeps A: it is what the specification describes, the one PDFium reads as written, and the only one that holds with a font whose glyphs are not letters. None of the three is read as written by poppler or MuPDF and by PDFium. examples/shaping/extraction_matrix.rb renders the texts, holds B and C as experiments and writes the table again, with what to install at its head.
Fallbacks#
Per-character, in the same weight and style.
A character the text's family has no glyph for is drawn from the first family in font_fallbacks that has it, then from bundled Inter, in the same weight and style (synthesised when the family lacks the face):
class Report < Stationery::Document
font_family "Brand", regular: "Brand-Regular.ttf"
font_family "Noto Sans Symbols", regular: "NotoSansSymbols-Regular.ttf"
font_fallbacks "Noto Sans Symbols"
def view_template = text("Next → ☃")
endSpaces, joiners, variation selectors and combining marks stay with the character before them. Whitespace no font has (an ideographic space U+3000, a figure space, a narrow no-break space, …) is drawn as a blank of the character's conventional width, never as .notdef. Any other glyph no font has is drawn as the family's .notdef and reported as a Warnings::MissingGlyph counting each drawn occurrence (so strict raises on it, and a conformance level raises ConformanceError unless it is declared with missing_glyphs: :replace, which draws the first of U+FFFD, U+25A1 and ? the font has instead). The characters themselves travel as the ActualText of a Span around the glyphs, so the text still extracts, copies and reads aloud as written. Fallback covers every text element, table cell, list marker, table of contents entry and page template text; direct canvas.text calls draw with the font they are given.
Font packs#
Installed at development time, committed, never downloaded at runtime.
| Pack | Family | License |
|---|---|---|
inter | Inter (copied from the gem) | OFL 1.1 |
noto_sans, noto_serif, noto_sans_mono | Noto Sans, Noto Serif, Noto Sans Mono | OFL 1.1 |
liberation_sans, liberation_serif, liberation_mono | Liberation Sans, Serif, Mono (metric-compatible with Arial, Times New Roman, Courier New) | OFL 1.1, Reserved Font Name "Liberation" |
stationery fonts list # packs, licenses, what is in vendor/fonts
stationery fonts install noto_sans liberation_serif # into vendor/fonts/<pack>/
stationery fonts install noto_sans --into app/fonts --force
stationery fonts install liberation_sans --from ~/Downloads/liberation-fonts-ttf-2.1.5.tar.gz # offline
bin/rails generate stationery:fonts noto_sans # the same, in Railsfonts install downloads pinned files over HTTPS, checks every SHA-256 before writing anything, and writes the license next to the fonts. Files already present are kept unless --force. --from takes a directory or a .tar.gz holding the same files (still SHA-checked).
From Ruby: Stationery::Fonts.install(:noto_sans, into: "vendor/fonts") returns { regular: path, bold: path, … }; Stationery::Fonts.catalog lists the packs. rake fonts:verify (development of the gem only) downloads every pack and checks its SHA-256s.
Stationery.font_paths#
Where installed packs are found.
Stationery.font_paths lists the directories searched for installed packs; the Railtie adds vendor/fonts (and config.stationery.font_paths) when it exists. With the pack's directory there, the family name is enough:
font_family "Noto Sans" # found in Stationery.font_paths
font_family "Noto Sans", **Stationery::Fonts.paths(:noto_sans, dir: "vendor/fonts") # outside Rails
Stationery.font_paths << File.expand_path("vendor/fonts") # or append it yourselfAppend before Stationery.finalize! freezes the list: the Railtie runs it once the app has initialized, so add paths in config/application.rb or an initializer. See Performance.