font-decipher

font-decipher recovers the real text from web pages that use font-substitution anti-scraping.

Some sites show perfectly readable English, but the characters in the page are actually at strange codepoints, such as Kangxi radicals or Private Use characters. A custom webfont draws those codepoints as normal letters, so it looks fine on screen, but copying, scraping or text-to-speech gets gibberish like this:

⽔⽣⽠ ⽪⽫⽠⽩⽠⽟ ⽯⽣⽠ ⽟⽪⽪⽭

How It Works

Obfuscation fonts almost always copy their real ASCII letter shapes over to the substituted codepoints, without changing them. That’s the weak spot. If you compare glyph outlines (or rendered glyph bitmaps in the page), you can rebuild the codepoint-to-letter map without guessing.

The tool came from one case: storyseedling.com. Its “Vagre” font maps U+2F42..U+2F5B to A–Z and U+2F5C..U+2F75 to a–z. That map (54 entries) ships with the decoder. The derivation tools can rebuild it, or a new one, from the font.

           [ Page text: ⽔⽣⽠ ⽪⽫⽠⽩⽠⽟ … ]     [ The site's webfont ]
                        │                          │
                        │                          ▼
                        │                 [ derive_cipher.py  /  ]
                        │                 [ derive_browser.js    ]
                        │                          │
                        │                          ▼
                        │                  [ map.json ]
                        └──────────┬───────────────┘
                                   ▼
                     [ decipher.js / decipher.py ]
                                   │
                                   ▼
                       [ "She opened the door" ]

Who Is This For?

  • Built for: People building readers, extractors, text-to-speech or archiving tools who keep hitting pages that look fine but come out as gibberish.
  • Not for: Pages where there’s no text to decode. Chapters drawn as image slices or on a canvas need the images instead. It isn’t a way to get at content you can’t already read on screen.

What’s in the Repo

FileWhat it does
derive_cipher.pyTakes a woff2/woff/ttf font, compares glyph outlines against the font’s own ASCII, and writes a JSON map. Dotted i/j are filled in by sequence.
decipher.jsA decoder for the browser or Node, with the StorySeedling map built in. Use loadMap() to load your own map.
decipher.pyThe same decoder as a Python module and CLI.
derive_browser.jsWorks out the map inside the live page (deriveCipherInPage()). It renders the suspicious characters and the font’s own ASCII to a canvas and matches them pixel by pixel, so it copes with fonts that rotate on every page load. No font download needed.
derive_browser.js → findContentContainer()Mitigation utility that finds the real chapter container: it tries known platform selectors first, then votes using only paragraphs that are actually visible, so hidden decoy text and long comment sections are ignored.
examples/storyseedling-vagre.jsonThe derived map for storyseedling.com.

Install and Quick Start

Python

Python 3. Decoding needs nothing extra. Deriving a map from a font file needs fontTools and Brotli:

git clone https://github.com/PatchScratch/font-decipher
cd font-decipher
pip install fonttools brotli

Decode text with the built-in StorySeedling map:

$ echo "⽔⽣⽠ ⽪⽫⽠⽩⽠⽟ ⽯⽣⽠ ⽟⽪⽪⽭" | python3 decipher.py
She opened the door

Crack a new font. Save the font file from a logged-in browser (a plain curl usually hits Cloudflare), then:

python3 derive_cipher.py font.woff2 -o map.json
python3 decipher.py page.txt -m map.json

If the substitutions sit outside the default ranges (Kangxi and Private Use), pass --range, for example --range 3400 4DBF.

Node or browser

decipher.js has no dependencies. Use it from Node, or load it in the page (for example from a userscript or an extension content script):

const { decipher } = require("./decipher.js");
decipher(pageText);            // built-in StorySeedling map
decipher(pageText, myMap);     // or a map you derived

For sites that rotate or hide their fonts, run derive_browser.js in the live page to build the map there, then pass it to decipher().

Leaves Real CJK Text Alone

The decoder only rewrites text when substituted characters make up at least 30% of it. You can change that threshold. Genuine Chinese and Japanese text uses different Unicode ranges, so it passes through untouched.

Field Guide (Short Version)

The README has a field guide to anti-scraping tricks reported around the NovelUpdates translation scene, with the mitigation for each. Only StorySeedling is verified by the project; the rest are leads to test against.

  • Fixed font ladders (Kangxi radicals): built-in map, or re-derive from the font file.
  • Fonts rotated per page load: deriveCipherInPage() reads the current font from the live page every run, so nothing goes stale.
  • Ladders in Private Use or CJK ranges, and partial substitution of only common letters: the runtime derivation scans those blocks and maps just what was substituted.
  • Hidden decoy text and comments that outweigh the chapter: findContentContainer().
  • Watermark tokens like cls1b7bcfa179…: strip them with /cls[0-9a-f]{12,}/g.
  • Chapters drawn as images or on a canvas: no text to decode; use the images.

It also lists shortcuts that don’t work, such as matching letters by measureText width (letters collide) or cleaning up a detached cloneNode copy (hidden text slips back in).

Limits

  • Outline matching needs the font to include its own real ASCII glyphs. So far obfuscation fonts always have.
  • Some fonts substitute digits or punctuation as well. Check the map it produces.
  • If a site rotates its fonts, run the derivation again. Only the map changes.

Project Info

font-decipher was taken out of NovelNotifier, where it runs the in-app reader’s decoding.