Skip to the text

Real quotation marks, dashes and spaces, in 26 languages.

A library that fixes the typography of running text. Give it a string and a language. It gives the string back with the right quotation marks, dashes where there were hyphens, an ellipsis, and no-break spaces where a line should not break.

Install

Package manager
bun add git+ssh://git@github.com/mokhniuk/typography.git#v0.13.0

Version 0.13.0, a pre-release. Not on npm yet; it will be there at 1.0. Until then, install a release tag from git. The repository is private, so the machine needs SSH access. The package ships TypeScript sources, so it needs Bun or a bundler that reads TypeScript.

Read the docs Try it in the playground

“Curiouser and curiouser!” cried Alice (she was so much surprised, that for the moment she quite forgot how to speak good English); “now I’m opening out like the largest telescope that ever was! Good-bye, feet!” — for when she looked down at her feet, they seemed to be almost out of sight…

From Alice’s Adventures in Wonderland. Edit it, or paste your own.

What it does

The engine is one function over a string. Everything else is a way of getting your text into it, or a way of seeing what it did.

Every format your text lives in
Plain text, an HTML string, a Markdown document, a rehype or hast tree, React components, and a command line for files and CI. One engine under all of them. For the browser, a DOM entry hangs the punctuation and checks which characters the page’s font can draw. The API
A pack per language, and no default rule that names one
Quotation marks and how they nest, the dash, the spacing habits, the one-letter words that may not end a line: each is data in a language pack, which is why adding a language is one pack file. Two opt-in rules still have a case for one language: the space before French punctuation, and the English temperature labels of the degree sign. The languages
Two registers
Display text is the default: interfaces, marketing, event pages. The literary register sets book-like text, and so far only English has one: British quotes, “…” with “…” inside, and a spaced en dash. A dash for a line of dialogue is set in both registers, where the language writes dialogue that way.
It tells you what it changed
explain returns every change with the rule that made it and the text before and after, so a surprise is a question with an answer, not a mystery. The rules
Hanging punctuation, measured
An opening mark hangs into the left margin so the first letter lines up with the lines under it, and a closing mark hangs into the right. Each mark’s width is measured in the browser once, and again when the web fonts load. Line breaks are never measured. How it works
It knows what your font cannot draw
A rule that would insert a character the font has no glyph for leaves that stretch of text as you typed it, and the rest of the line is still set: better a straight quote than a box. A quotation keeps both its marks or neither. In React the list fills itself from the page’s own font (TypographyRoot, avoidMissing); …/dom exposes the measure. The font checker
Safe on what is not prose
Web addresses, emails and code spans come back byte for byte; tags, attributes and the contents of pre and code are never touched. An unknown rule name throws instead of passing quietly.
Proved, not promised
Property tests on seeded random text in every language: no letter or digit is lost or added, and a second pass changes nothing. A differential harness proves a speed change rewrote no output. Known limits

Other languages

Each language has its own pack: its quotation marks and how they nest, its dash, its habits with spaces. These four are not translations of one sentence; each is written the way that language is.

Curly quotes and apostrophes, a spaced dash, a real ellipsis, an en dash in ranges, and a number kept with its month.

Before
"It's five o'clock," she said - "and we're late..." The workshop runs 21-26 September, 10:00-12:00, in room 4B.
After 22 changes
“It’s five o’clock,” she said — “and we’re late…” The workshop runs 21–26 September, 10:00–12:00, in room 4B.

Ukrainian guillemets, the dialogue dash with its no-break space, and initials kept with the surname.

Before
- Зачекаємо? - спитав професор К. Новак. - Сказали: "вже п'ята година"... Квитки зі знижкою 15%.
After 14 changes
— Зачекаємо? — спитав професор К. Новак. — Сказали: «вже п’ята година»… Квитки зі знижкою 15%.

German marks, nested the German way, and a range that is a range.

Before
Sie sagte: "Er rief 'Halt' - und blieb stehen" ... Seiten 12-14, ca. 20 Min. am Tag.
After 16 changes
Sie sagte: »Er rief ›Halt‹ – und blieb stehen« … Seiten 12–14, ca. 20 Min. am Tag.

Polish low-and-high quotes, and one-letter words held to the next word so no line ends on them.

Before
Powiedział: "Idę do domu" - i poszedł. W sali nr 5 jest 20 osób, a w drugiej a w trzeciej 12.
After 20 changes
Powiedział: „Idę do domu” — i poszedł. W sali nr 5 jest 20 osób, a w drugiej a w trzeciej 12.
Set by the engine when this page was built. Marked: what the engine replaced, and what it put in its place; a boxed space is a no-break space.

What it changes

22 rules run by default, and 11 more you switch on. Here are some of them on one line each. A boxed space is a no-break space.

RuleYou writeYou get
quote "Hello" “Hello”
apostrophe don't don’t
ellipsis Wait... Wait…
dash.range pages 21-26 pages 21–26
dash.sentence Wait - what? Wait — what?
dash.dialogue - Привіт, - сказала вона. — Привіт, — сказала вона.
space.number 10 km 10 km
space.number USD 5 000 USD 5 000
space.section § 5 § 5
space.operator Tom & Jerry Tom & Jerry
space.initial T. H. Shevchenko T. H. Shevchenko
space.orphan Jadę z Anią w góry Jadę z Anią w góry
symbol.legal (c) 2026 © 2026
symbol.multiply 1920x1080 1920×1080
symbol.minus -5 C −5 C
number.group opt-in 1000000 1,000,000
phone.bind opt-in +380 44 123 45 67 +380 44 123 45 67
space.short-word opt-in Welcome to the city Welcome to the city

All 33 rules, with their reasons

Using it

One function takes a string and a language. The other entry points do the same for plain text, HTML, Markdown, rehype trees and React, and bring the browser side: hanging punctuation and font checks.

import { typographic, explain } from '@mokhniuk/typography';

typographic('He said "we\'re here" - and left.', 'en', { quoteStyle: 'us' });
// He said “we’re here” — and left.  (a no-break space before the dash)

explain('He said "hi"... 21-26', 'en', { quoteStyle: 'us' }).changes;
// quote “ · quote ” · ellipsis … · dash.range –
ImportFor
@mokhniuk/typographyA string. explain lists each change and the rule behind it.
…/textPlain text, a paragraph at a time. Line breaks are kept.
…/htmlAn HTML string. Tags and code are untouched.
…/markdownA Markdown document.
…/rehypeA unified or rehype pipeline, or any hast tree.
…/reactTypeset and TypographyRoot, with hanging punctuation. TypographyRoot measures the page’s font and fills avoid.
…/domHanging punctuation in the browser, without a framework, and which characters the page’s font can draw.
typoThe command line, for files and CI. Needs Bun.

The API reference

What isn’t finished

Before you depend on it, you should know where it is weak.

  • No language has been read by a native speaker. Twenty packs follow published style guides, and some of those we could only read in part. English, Ukrainian, German, Polish, French and Swiss German come from general knowledge. Each language page lists what it was written from.
  • Where the engine and Unicode’s CLDR disagree about quotation marks (English, German, French, Swiss German), the decision record says so, instead of choosing quietly.
  • English sets “” by default. That is this project’s house style. Pass quoteStyle: 'us' for “”.
  • Some input can’t be read with certainty. An inch mark inside a quotation ("a 5" hole") is one: the engine closes the quotation early, at the inch mark. Such cases are listed in the known limits.

Known limits · Languages and their review status

Why it matters

“Typography is what language looks like. A straight quote, a hyphen standing in for a dash, a number torn from the word it counts: each is a small lie about the text, and a reader feels all of them at once without being able to name one.”

The opening mark of that quotation hangs in the margin, where the engine put it. Turn it off in the playground and watch the first line step inward. Why this is hard to automate

Articles

Where each decision came from, and what a reader should know about the marks themselves. Written while building the engine.

  1. · 3 min read

    Typing typographic characters on Windows

    Alt codes on the numeric keypad, the Win + . symbol panel, and Word’s own shortcuts — three ways to type every dash, quote and sign, with the full code table.

  2. · 3 min read

    Why typographic correction is hard to automate

    Replacing straight quotes with curly ones looks like a dozen regexes. Here is what the dozen regexes get wrong, and what an engine has to do instead.

  3. · 3 min read

    The no-break space does the quiet work

    The most useful typographic character is invisible. Where a no-break space belongs, why CSS cannot place it for you, and the rules that set it.

All the articles

Support it

Typography Engine is a solo project by Oleg Mokhniuk: the rules, the language packs, the docs and this site are one person’s work.

If it is useful and you want to give something back, skip the coffee. The best way to support this project is a donation to Come Back Alive, the Ukrainian foundation that equips the people defending Ukraine.

Donate to Come Back Alive

Or support the author directly: paypal.me/mokhniuk.