Skip to content
Lamina PDF

Multilingual text

You’ll put five scripts on one page: Latin, Arabic, Devanagari, Hebrew and Chinese, each as a single line and as a paragraph wrapped in a box. Start from it whenever your text goes beyond Latin, or mixes left-to-right and right-to-left writing.

The function takes one font file per script, draws each script’s line and paragraph, and returns the PDF’s bytes. In TypeScript it needs the text module, init({ text: true }). Both languages write the same bytes:

packages/sdk/examples/multilingual.ts
// Five scripts on one page, each as a single line and as a wrapped paragraph. The scripts are
// Latin, Arabic and Hebrew (right to left), Devanagari and simplified Chinese; each is shaped and
// bidi-resolved, and each paragraph wraps in a text box. Each script needs a font that covers it,
// such as the Noto fonts (OFL).
import { Builder, type BuilderOptions, type Direction, Font } from "@laminapdf/sdk";
/** Each script's line, its paragraph and the paragraph's direction, in the order of the fonts. */
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"],
];
const MARGIN = 56;
const FIRST_BASELINE = 80;
/** The distance from one script's line to the next one's. */
const SCRIPT_HEIGHT = 140;
/** The paragraph's box: this far below the line, this wide and this tall. */
const PARAGRAPH_DROP = 14;
const PARAGRAPH_WIDTH = 220;
const PARAGRAPH_HEIGHT = 100;
/**
* The document's bytes. Needs `init({ text: true })`.
*
* @param fonts - one font file per script, in the order of {@link SCRIPTS}.
* @param options - can fix the file ID, the producer and the creation date.
*/
export function multilingual(fonts: readonly Uint8Array[], options: BuilderOptions = {}): Uint8Array<ArrayBuffer> {
if (fonts.length !== SCRIPTS.length) throw new Error(`${SCRIPTS.length} fonts, one per script, are needed`);
using b = new Builder(options);
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) return;
const y = FIRST_BASELINE + SCRIPT_HEIGHT * row;
s.text(line, { font, size: 16, at: [MARGIN, y] });
// A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right.
const box = [MARGIN, y + PARAGRAPH_DROP, PARAGRAPH_WIDTH, PARAGRAPH_HEIGHT] as const;
s.textBox(paragraph, { font, size: 12, box, direction, align: "start" });
});
page.finish();
using doc = b.finish();
return doc.save();
}

The result is one A4 page, with each script set in its own Noto font: open the reference PDF.

  • Font.fromBytes (font_bytes in Rust) loads each font, which must cover its script. The builder embeds only the glyphs the page draws.
  • Lamina PDF shapes the text with each font, so Arabic letters join, Devanagari letters combine into clusters and the Latin line forms ligatures.
  • textBox breaks each paragraph into lines that fit its 220-point-wide box. It breaks where Unicode’s line-breaking rules allow: between words, and between Chinese characters.
  • direction: "rtl" (Direction::RightToLeft) makes the Arabic and Hebrew paragraphs right to left, so align: "start" starts their lines at the box’s right edge.
  • Each font is embedded with its ToUnicode mapping, so text copied or extracted from the PDF equals the strings you passed in, ligatures included.
  • Your own scripts: change SCRIPTS and pass one font per row, in the same order. A character the font lacks fails with missing-glyph.
  • The paragraph’s box: PARAGRAPH_WIDTH and PARAGRAPH_HEIGHT set its size. Lines that don’t fit are left out, unless you pass overflow: "error" (Overflow::Error) to fail with text-overflow instead.
  • Alignment and spacing: align also takes "end", "left", "right" or "center", and lineHeight (with_line_height) sets the distance between baselines, 1.2 times the size by default.
  • Ligatures and other OpenType features: features: ["liga=0"] on text or textBox turns the ligatures off. In Rust, call features on the run or with_feature on the text box’s options. See Text and fonts.

Next: images and vectors, with shapes, colour, transparency and two images.