Skip to content

Fonts and text

Text needs the text module: init({ text: true }) or loadText() in TypeScript, and the default text feature in Rust.

  • The standard 14 (Font.standard("Helvetica"), Standard14::Helvetica in Rust) need no bytes and aren’t embedded. They cover Latin text only, without shaping. Text they can’t encode fails with missing-glyph. More than 37 distinct letters outside WinAnsi fails with encoding-full.
  • Your own fonts (Font.fromBytes(bytes): TrueType, OpenType or a collection) are shaped with HarfRust and subset to the glyphs you draw. They’re embedded as Type 0 fonts with /W widths and a ToUnicode CMap, so the text can be extracted again. Equal fonts are embedded once per document, however many Font objects name them.

fsType: a font whose licence bits forbid embedding (restricted, or bitmap only) fails with font-not-embeddable. In Rust, EmbedPolicy::Override embeds it anyway, if you hold the rights. A font marked “no subsetting” is embedded whole.

surface.text(str, { font, size, at }) draws one shaped line, with at[1] its baseline. surface.textBox(str, { font, size, box, direction, align }) breaks a paragraph at UAX #14 opportunities to fit its box, lays out each line in visual order (UAX #9 bidi), and reports overflow.

This example draws five scripts (Latin with ligatures, Arabic, Devanagari, Hebrew and Chinese), each in its own embedded font:

packages/pdf/examples/multilingual.ts
// Five scripts, each with its own embedded font: a shaped line and a paragraph broken into a
// narrow box (right-to-left for Arabic and Hebrew). The TS twin of
// crates/lamina/examples/multilingual.rs.
import { Builder, type Direction, Font } from "@lamina/pdf";
/** Each script's line, its paragraph and the paragraph's direction (fonts in the same order). */
export const SCRIPTS: readonly (readonly [line: string, paragraph: string, direction: Direction])[] = [
[
"Latin: the office staff fill the affine waffle flask",
"A paragraph breaks at spaces and fits the width of its box, line by line.",
"ltr",
],
["مرحبا بالعالم السلام عليكم", "مرحبا بالعالم السلام عليكم مرحبا بالعالم السلام عليكم", "rtl"],
["नमस्ते दुनिया। हिन्दी पाठ का परीक्षण", "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण नमस्ते दुनिया।", "ltr"],
["שלום עולם וברוכים הבאים", "שלום עולם וברוכים הבאים שלום עולם וברוכים הבאים", "rtl"],
["你好世界,这是中文字体子集测试。", "你好世界,这是中文字体子集测试。你好世界,这是中文。", "ltr"],
];
/**
* The document's bytes. Needs `init({ text: true })`.
*
* @param fonts - Noto Sans, Noto Sans Arabic, Devanagari, Hebrew and SC (`fixtures/fonts/`), in
* the order of {@link SCRIPTS}.
*/
export function multilingual(fonts: readonly Uint8Array[]): Uint8Array {
const b = new Builder({
id: new TextEncoder().encode("lamina-ex-multil"),
producer: "lamina examples",
creationDate: "D:20261030120000Z",
});
const loaded = fonts.map((bytes) => Font.fromBytes(bytes));
for (const font of loaded) b.font(font);
const page = b.page("A4");
const s = page.surface();
SCRIPTS.forEach(([line, paragraph, direction], row) => {
const font = loaded[row];
if (font === undefined) throw new Error(`no font for script ${row}`);
const y = 80 + 140 * row;
s.text(line, { font, size: 16, at: [56, y] });
// A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right.
s.textBox(paragraph, { font, size: 12, box: [56, y + 14, 220, 100], direction, align: "start" });
});
page.finish();
return b.finish().save();
}

See the output. Text extracted from it (with pdf.js, pypdf or pdftotext) equals the input strings, ligatures included.