# punctilio: the best text prettifier

> *punctilio* (n.): precise observance of formalities.

[![Test](https://github.com/alexander-turner/punctilio/actions/workflows/test.yml/badge.svg)](https://github.com/alexander-turner/punctilio/actions/workflows/test.yml) [![Lint](https://github.com/alexander-turner/punctilio/actions/workflows/lint.yml/badge.svg)](https://github.com/alexander-turner/punctilio/actions/workflows/lint.yml) [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/alexander-turner/punctilio)

Pretty good at making your text pretty. The most feature-complete and reliable English micro-typography package—transforms plain ASCII into typographically correct Unicode, even across HTML element boundaries.

*Smart quotes · Em/en dashes · Ellipses · Math symbols · Legal symbols · Arrows · Primes · Fractions · Superscripts · Ligatures · Non-breaking spaces · HTML-aware · Bri’ish localisation support*

    import { transform } from 'punctilio'
    
    transform('"It\'s a beautiful thing, the destruction of words..." -- 1984')
    // → “It’s a beautiful thing, the destruction of words…”—1984
    
    npm install punctilio
    

Why punctilio?
--------------

As far as I can tell, `punctilio` is the most reliable and feature-complete. I built `punctilio` for [my website](https://turntrout.com/design). I wrote[^0k23cvnpd6ol] and sharpened the core regexes sporadically over several months, exhaustively testing edge cases. Eventually, I decided to spin off the functionality into its own package.

I tested `punctilio` 1.7.13 against [`smartypants`](https://www.npmjs.com/package/smartypants) 0.2.2, [`tipograph`](https://www.npmjs.com/package/tipograph) 0.7.4, [`smartquotes`](https://www.npmjs.com/package/smartquotes) 2.3.2, [`typograf`](https://www.npmjs.com/package/typograf) 7.6.0, and [`retext-smartypants`](https://www.npmjs.com/package/retext-smartypants) 6.2.0.$$[^obi0n44nm2e] These other packages have spotty feature coverage and inconsistent impact on text. For example, `smartypants` mishandles quotes after em dashes and lacks multiplication sign support.

| Input | `smartypants` | `punctilio` |
| --- | --- | --- |
| 5x5 | 5x5 (✗) | 5×5 (✓) |

My [`benchmark.mjs`](https://github.com/alexander-turner/punctilio/blob/main/benchmark.mjs) measures how well libraries handle a [wide range of scenarios](https://github.com/alexander-turner/punctilio/blob/main/benchmark_cases.json). The benchmark normalizes stylistic differences (e.g. non-breaking vs regular space, British vs American dash spacing) for fair comparison.

| Package | Passed (of 159) |
| --- | --- |
| `punctilio` | 154 (97%) |
| `tipograph` | 92 (58%) |
| `typograf` | 74 (47%) |
| `smartquotes` | 72 (45%) |
| `smartypants` | 68 (43%) |
| `retext-smartypants` | 65 (41%) |

| Feature | Example | `punctilio` | `smartypants` | `tipograph` | `smartquotes` | `typograf` |
| --- | --- | --- | --- | --- | --- | --- |
| Smart quotes | "hello" → “hello” | ✓   | ✓   | ✓   | ✓   | ✓   |
| Leading apostrophe | 'Twas → ’Twas | ✓   | ✗   | ✗   | ◐   | ✗   |
| Em dash | \\-\\- → — | ✓   | ✓   | ✗   | ✗   | ✓   |
| En dash (ranges) | 1-5 → 1–5 | ✓   | ✗   | ✓   | ✗   | ✗   |
| Minus sign | -5 → −5 | ✓   | ✗   | ✓   | ✗   | ✗   |
| Ellipsis | ... → … | ✓   | ✓   | ✓   | ✗   | ✓   |
| Multiplication | 5x5 → 5×5 | ✓   | ✗   | ✗   | ✗   | ◐   |
| Math symbols | != → ≠ | ✓   | ✗   | ◐   | ✗   | ◐   |
| Legal symbols | (c) 2004 → © 2004 | ✓   | ✗   | ◐   | ✗   | ✓   |
| Arrows | -\\> → → | ✓   | ✗   | ◐   | ✗   | ◐   |
| Prime marks | 5'10" → 5′10″ | ✓   | ✗   | ✓   | ✓   | ✗   |
| Degrees | 20 C → 20 °C | ✓   | ✗   | ✗   | ✗   | ✓   |
| Fractions | 1/2 → ½ | ✓   | ✗   | ✗   | ✗   | ✓   |
| Superscripts | 2nd → 2ⁿᵈ | ✓   | ✗   | ✗   | ✗   | ✗   |
| English localization | American / British | ✓   | ✗   | ✗   | ✗   | ✗   |
| Ligatures | ?? → ⁇ | ✓   | ✗   | ✓   | ✗   | ✗   |
| Non-English quotes | „Hallo” | ✗   | ✗   | ✓   | ✗   | ◐   |
| Non-breaking spaces | Chapter 1 | ✓   | ✗   | ✗   | ✗   | ✓   |

### Known limitations of `punctilio`

| Pattern | Behavior | Notes |
| --- | --- | --- |
| `'99 but 5' clearance` | `5'` not converted to `5′` | Leading apostrophe is indistinguishable from an opening quote without semantic understanding |
| `«Bonjour»` | Not spaced to `« Bonjour »` | French localization not supported |

Test suite
----------

Setting aside the benchmark, `punctilio`’s test suite includes 1,100+ tests at 100% branch coverage, including edge cases derived from competitor libraries ([`smartquotes`](https://github.com/kellym/smartquotes.js), [`retext-smartypants`](https://github.com/retextjs/retext-smartypants), [`typograf`](https://github.com/typograf/typograf)), and the [Standard Ebooks typography manual](https://standardebooks.org/manual/). Further, all transformations are stable when applied multiple times.

Works with HTML DOMs via separation boundaries
==============================================

Perhaps the most innovative feature of the library is that it properly handles DOMs! (This means it'll also work on Markdown: [convert to HTML](https://github.com/remarkjs/remark), transform with `punctilio`, [convert back to Markdown](https://github.com/JohannesKaufmann/html-to-markdown).)

Other typography libraries take one of two approaches, both with drawbacks.

1.  String-based libraries (like [`smartypants`](https://www.npmjs.com/package/smartypants)) transform plain text but are unaware of HTML structure. If you concatenate text from `<em>Wait</em>...`, transform it into `Wait…`, and then try to convert back—you've lost track of where the `</em>` belongs.
2.  AST-based libraries (like [`rehype-retext`](https://github.com/rehypejs/rehype-retext)) process each text node individually, preserving structure but losing cross-node information. A quote that opens inside `<em>"Wait</em>` and closes outside it `..."` spans two text node. Processed independently, the library can't tell whether the final `"` is opening or closing, because it never sees both at once.

`punctilio` introduces *separation boundaries* to get the best of both worlds:

1.  Flatten the parent container's contents to a string, delimiting element boundaries with a private-use Unicode character (`U+E000`) to avoid unintended matches.
2.  Every regex allows (and preserves) these characters, treating them as boundaries of a “permeable membrane” through which contextual information flows. For example, `.U+E000..` still becomes `…U+E000`.
3.  Rehydrate the HTML AST. For all *k*, set element \*k’\*s text content to the segment starting at separator occurrence *k*.

    import { transform, DEFAULT_SEPARATOR } from 'punctilio'
    
    const sep = DEFAULT_SEPARATOR transform("Wait${sep}") // → “Wait”${sep} // The separator doesn’t block the information that this should be an end-quote!

`punctilio` validates the output by ensuring the separator count remains the same.

For `rehype` / `unified` pipelines, use the built-in plugin which handles the separator logic automatically:

    import rehypePunctilio from 'punctilio/rehype'
    
    unified()
      .use(rehypeParse)
      .use(rehypePunctilio)
      .use(rehypeStringify)
      .process('<p><em>"Wait</em>..." -- she said</p>')
    // → <p><em>“Wait</em>…”—she said</p>
    //  The opening quote inside <em> and the closing quote outside it
    //  are both resolved correctly across the element boundary.
    

+++ Options

`punctilio` doesn't enable all transformations by default. Fractions and degrees tend to match too aggressively (perfectly applying the degree transformation requires semantic meaning). Superscript letters and punctuation ligatures have spotty font support. Furthermore, `ligatures = true` can change the meaning of text by collapsing question and exclamation marks. Non-breaking spaces are also opt-in since they alter whitespace throughout the text.

    transform(text, {
      punctuationStyle: 'american' | 'british' | 'none',  // default: 'american'
      dashStyle: 'american' | 'british' | 'none',         // default: 'american'
    
      symbols: true,           // math, legal, arrows, primes
      collapseSpaces: true,    // normalize whitespace
      fractions: false,        // 1/2 → ½
      degrees: false,          // 20 C → 20 °C
      superscript: false,      // 1st → 1ˢᵗ
      ligatures: false,        // ??? → ⁇, ?! → ⁈, !? → ⁉, !!! → !
      nbsp: false,             // non-breaking spaces (after honorifics, between numbers and units, etc.)
      checkIdempotency: true,  // verify transform(transform(x)) === transform(x)
    })
    

*   Fully general prime mark conversion (e.g. `5'10"` → `5′10″`) requires semantic understanding to distinguish from closing quotes (e.g. `"Term 1"` should produce closing quotes). `punctilio` counts quotes to heuristically guess whether the matched number at the end of a quote (if not, it requires a prime mark). Other libraries like `tipograph` 0.7.4 use simpler patterns that make more mistakes.
*   The `american` style follows the [Chicago Manual of Style](https://www.chicagomanualofstyle.org/):
    *   Periods and commas go inside quotation marks (“Hello,” she said.)
    *   Unspaced em-dashes between words (word—word)
*   The `british` style follows [Oxford style](https://www.ox.ac.uk/sites/files/oxford/Style%20Guide%20quick%20reference%20A-Z.pdf):
    *   Periods and commas go outside quotation marks (“Hello”, she said.)
    *   Spaced en-dashes between words (word – word)
*   `punctilio` is idempotent by design: `transform(transform(text))` always equals `transform(text)`. If performance is critical, set `checkIdempotency: false` to skip the verification pass.




+++

Feedback welcome
================

If you run into bugs, please [create an issue](https://github.com/alexander-turner/punctilio/issues)! You can read about my other open-source projects [on my website.](https://turntrout.com/open-source)

[^0k23cvnpd6ol]: While Claude is the number one contributor to this repository, that’s because Claude helped me port my existing code and added some features. The core regular expressions (e.g. dashes, quotes, multiplication signs) are human-written and were quite delicate. Those numerous commits don’t show in this repo’s history. 

[^obi0n44nm2e]: The Python libraries I found were closely related to the JavaScript packages. I tested them and found similar scores, so I don’t include separate Python results. 

[^-eA4bcAGwrssNR45F9-2]:  

[^-eA4bcAGwrssNR45F9-1]: