Skip to main content

textsynth

Render Perso-Arabic text and mess it up on purpose: a string goes in, a PIL.Image comes out, having passed through spelling variation, a temporarily patched font, HarfBuzz shaping, and a stack of ink effects.

It is a generalised repackaging of the rumi2jawi notebooks — the rendering and distressing half of them — rebuilt so that nothing is specific to Jawi, to Malay, or to one hand-drawn glyph.

from textsynth import render

image = render("بسم الله", "assets/fonts/Amiri-Regular.ttf", size=72)
image.save("out.png")

Install

uv sync            # or: pip install -e .
uv run pytest      # 185 tests, ~22s

Dependencies: Pillow, NumPy, SciPy, uharfbuzz, freetype-py, fontTools.

The five stages

Synthesizer runs a string through five stages, each of which you can use on its own:

stage module what it does
text rules textrules, kashida rewrite the string: spelling variation, kashida insertion, transliteration, anything str -> str
font patches patching, glyphs, outlines build a temporary in-memory font with custom glyphs and the substitution rules that reach them
shaping + layout shaping, raster, layout HarfBuzz shaping, then placement with tracking, jitter and supersampling
glyph rules glyphrules, sweeps rewrite the shaped line, now that its measurements are known
effects effects smudge, blot, blob, bleed, erode, speckle, warp — on an ink field, not a bitmap
from textsynth import (
    Synthesizer, Layout, GlyphSubstitution, BorrowedGlyph,
    Smudge, Blobify, InkBleed, orthographic_variation,
)

synth = Synthesizer(
    font="assets/fonts/Amiri-Regular.ttf",
    layout=Layout(size=72, tracking=-1, supersample=2, jitter_y=0.6),
    text_rules=[orthographic_variation(swap=0.3)],
    font_patches=[GlyphSubstitution("مم", BorrowedGlyph("assets/fonts/Oblegg-Jawi.otf", "A"))],
    effects=[Smudge(length=7), Blobify(), InkBleed(1.0)],
    seed=7,
)
image = synth("سايا ممباچ سورت خبر")   # "saya membaca surat khabar"

Run examples/jawi_pipeline.py to write one image per stage into out/.

Or read notebooks/ — one notebook per notebook in the original repository, each opening with what the original did and what replaces it, plus two for the features that have no counterpart there. They are committed with their outputs, so they read on GitHub without a kernel.

Calligraphic elongation

Two things a hand does to a line that a font will not do for you.

Kashida

Where to elongate is a text problem, and Kashida is a port of kashida-js: find every place in a word where a connection can be stretched, then share a budget of tatweels out over them, with contrast deciding whether the elongation piles onto the first opening or is spread evenly.

Kashida(frequency=6, contrast=0.8).apply("حكيم", rng)   # 'حــــــكـيم'

What elongation looks like is a glyph problem, and it is the half that text alone gets wrong. Insert U+0640 after a dotted letter and the font draws a bar next to the letter and leaves the dot exactly where it was, so a stretched beh becomes a long bald stroke with its dot clinging to one end. KashidaForms merges the bar and the letter into a single glyph and moves the dots along the extension:

Synthesizer(
    font=FONT,
    text_rules=[Kashida(frequency=6, contrast=0.8)],   # last: see below
    font_patches=[KashidaForms(dot_shift=0.5)],        # 0 leaves them, 0.5 centres them
)

Put Kashida last among the text rules: orthographic_variation counts the tatweel among the diacritics it strips, so a kashida inserted before it runs is taken straight back out.

Every letter-plus-tatweel run is shaped as a probe, so whatever the font makes of it — a bar and a letter, or a ready-made wide glyph — is what gets rebuilt. Fonts with no tatweel glyph at all fall back to StretchedForm, which cuts the letter's own connecting stroke where it runs flat and pulls it apart. Letters with no dots are skipped, because merging would draw what the font already draws. examples/kashida.py writes the comparison.

Tadakhul

A swept terminal: the letter's tail leaves the baseline, dives, and runs the length of the word underneath it, with the letters it passes under raised out of its way. Its length is given in letters — length=2 means "make this letter as long as the two letters after it" — so a word can carry several short sweeps instead of one that swallows it whole.

Synthesizer(font=FONT, glyph_rules=[TadakhulMerged(letters="و", length=2)])

That length is not knowable until the line has been shaped, which is what glyph_rules are for: they are handed the shaped line and return another one.

The sweep is the letter's own stroke, carried on. Attaching a drawn stroke to a letter does not work, and the reason is worth stating: contour union can only add ink, so the letter's own terminal is still there, poking out from under whatever is laid over it. Nor can that terminal be found by looking for the lowest point — Amiri's waw is lowest at the bottom of its bowl, a long way before its tail ends.

So outlines.cut_terminal finds the real end of the stroke (the extreme point in the direction of travel, confirmed by walking both edges back and checking they stay parallel) and cuts the stroke square across where it is still at full width; sweeps.continue_stroke then closes the wound with the sweep. Letter and sweep come out as a single contour, starting at exactly the width the letter was cut at — there is no join because there is nothing joined. Letters whose extreme point turns out not to be a free terminal are left alone rather than guessed at.

The path is a motion, not a curve between two points. A letter's terminal is already turning when the letter stops — Amiri's waw leaves its bowl at a radius of about a fifth of an em and eases off as it runs — so SweepShape gives the path as a curvature profile that starts at exactly the curvature the letter was cut at and opens out from there:

k(s) = k0 * exp(-s / ease) + arc

One sign throughout and no inflection, which is what stops it reading as a flourish stuck on the end. Nothing sets how deep the sweep goes: belly says where along its length it bottoms out, the arc is solved for that, and the depth follows from how far the sweep has to travel — a longer sweep dives further, as it should. max_depth is only a backstop.

The width comes from the nib. Classical letterforms are measured in nuqta, the mark a reed pen leaves, and the pen is held at one angle throughout, so a stroke is thick where it travels across the nib and thin where it travels along it. SweepShape draws with a nib at nib_angle (default 135°, the classical rhombic-dot angle), sized so that the first stroke of the sweep is exactly as thick as the letter was where it was cut. The only thing applied on top is lift, the pen coming off the page at the end. nib_angle=None fits the nib to the letter's own stroke instead — outlines.fit_nib recovers 136° from Amiri's ra, though a terminal that tapers all the way (its waw) has nothing at full width to fit from.

Which letters can sweep is narrower than it looks. A sweep needs a free terminal, going the way the sweep travels, below the baseline — and in Amiri seven letters out of the alphabet have one: ر ز و ژ ۏ ؤ ڑ. The rest are refused, each for a reason worth knowing:

  • a letter in the middle of a word has a join where its terminal would be, so cutting there takes the join to the next letter with it. Right-joining letters (ra, waw and the rest) qualify anywhere; the others qualify only at the end of a word, where they can still sweep under the word that follows;
  • a bowl-final — noon, seen, ya, lam, meem — has the outside of a curve at its leftmost point, not a free end. Walking both edges back from it makes them fly apart rather than stay parallel, which is what cut_terminal tests for. Their elongation is a wider bowl, a different operation;
  • dal, tah and an isolated beh stop on the baseline. Continue those and the sweep runs through the following word instead of underneath it;
  • an alif ends heading straight up, which is not a direction a sweep travels.

examples/tadakhul.py puts every letter through this and prints the verdict.

Three rules deliver that same geometry three ways:

rule length cost trade-off
TadakhulMerged exact a font rebuild per line, plus a few ms per sweep to solve the arc one contour, so nothing to see at the join
TadakhulQuantised nearest of N rungs one rebuild ever rounded, but free per line
TadakhulScaled exact one rebuild ever letter and sweep are two glyphs meeting at the cut, and the stretch flattens the dive

examples/tadakhul.py renders all three side by side, along with what each length and each geometry parameter does.

The two features compose, and tests/test_combinations.py is what says so: both at once, with spelling variation rewriting the text underneath them, a borrowed glyph patched into the same font, tracking, word spacing, alignment, several lines, a fallback font and effects on top. A sweeping letter followed by a tatweel is no longer free to sweep — a tatweel joins on both sides — so the two do not tread on each other.

Why this is not the notebook code

Shaping instead of presentation forms. The notebooks reshaped text with arabic_reshaper, reordered it with python-bidi, and drew it one character at a time so that a gap could be inserted between letters. That works for the subset of Arabic that arabic_reshaper's tables cover, and it throws away every ligature, mark position and contextual alternate the font knows about.

Here the font's own OpenType tables do the work through HarfBuzz, and the result is a list of positioned glyphs that layout is free to tighten, spread or jitter afterwards. tracking=-1 reproduces the notebooks' gap=-1 without breaking a single join, and the same code renders Persian, Urdu, Pashto, Sorani or Jawi because none of it knows which of those it is looking at.

Patching instead of splitting the string. The notebooks needed the pair mim-mim to be drawn as one hand-made shape. They split the Jawi string on "مم", drew the Latin letter A from a second font in the hole, and glued the pieces back together — which only works for that pair, in that font, and leaves the shape unable to join to its neighbours.

GlyphSubstitution does what a font would do:

GlyphSubstitution("مم", BorrowedGlyph("Oblegg-Jawi.otf", "A"))
  1. the sequence is shaped as a probe in each joining context — alone, after a joining letter, before one, and between two — and the resulting glyph runs become the inputs of the rule. Nothing is hard-coded about mim, or about which presentation forms a font happens to use;
  2. the outline is injected as a real glyph (glyf or CFF, cubics converted to quadratics as needed, metrics and glyph order updated);
  3. a GSUB lookup mapping those runs to the new glyph is appended and wired into rlig, creating the feature record if the font lacks one. Being last in the lookup list, it runs after the font's own joining and ligature lookups — which is exactly why matching on presentation forms is sound.

The probe is shaped with every feature that runs after rlig switched off, so the rule matches the glyph stream that will actually exist when it fires.

Nothing is written to disk: patch_font returns a new Font holding new bytes, and the original file and Font object are untouched.

Outlines can come from an SVG (SVGGlyph), from another font (BorrowedGlyph), or from a callable that draws into a pen (PenGlyph). Substitutions can be limited to some contexts, or use a different glyph per context:

GlyphSubstitution(
    "مم",
    {"isolated": BorrowedGlyph(donor, "A"), "medial": SVGGlyph("meem.svg")},
    contexts=("isolated", "medial"),
)

AddGlyph injects a glyph and maps a character to it, for a letter the font simply does not have.

Effects on ink, not on pixels. The notebooks binarised the image, found connected components with OpenCV, and subtracted darkness from a white canvas. Effects here map an ink field (float32, 0..1, 1 = ink) to an ink field, so they compose in any order without re-thresholding, and colour is applied once at the end. Smudge, Blot and Blobify are the notebooks' three effects generalised — which components they touch is a ComponentFilter rather than a hard-coded area range and top_k. image_to_ink lets you run the same effects over a scan.

Variation as rules, not as ifs. The notebooks' to-do list wanted to "randomly remove alif", "change p and f", "change k and the other one". orthographic_variation() is the general form: weighted rewrites over sets of interchangeable letters, optional matres lectionis and diacritics, covering the same ground for any Perso-Arabic orthography. Add your own with Substitute, Swap, Drop, Regex or Callback.

Command line

uv run python -m textsynth "ممتاز سايا" \
    -f assets/fonts/Amiri-Regular.ttf \
    --size 72 --tracking -1 --distress smudged \
    --substitute "مم=assets/fonts/Oblegg-Jawi.otf:A" \
    --vary 0.3 -o out/line.png

--substitute takes SEQUENCE=file.svg or SEQUENCE=font.ttf:CHAR; --distress picks one of none, light, smudged, heavy.

uv run python -m textsynth "بسم الله الرحمن الرحيم" \
    -f assets/fonts/Amiri-Regular.ttf --size 70 --kashida 5 -o out/line.png

uv run python -m textsynth "والله" \
    -f assets/fonts/Amiri-Regular.ttf --size 110 \
    --sweep merged --sweep-letters و --sweep-length 4 -o out/sweep.png

--kashida N elongates every word by N tatweels and moves the dots to suit (--kashida-contrast shares them out); --sweep picks one of merged, quantised or scaled.

Does it work in a font other than Amiri?

Mostly. examples/other_fonts.py runs each stage against every Arabic face on the machine and prints what each face can take; this is what it says about thirteen of them, from naskh to kufi to nastaliq. Four of the five stages work in each of the thirteen. The font patch is the one that does not.

Shaping, layout and the effects work in each face. No stage of the render knows which face it has. This includes the faces of macOS, which carry their joining in the AAT morx table and not in GSUB: HarfBuzz reads that table too, so Geeza Pro, Nadeem, Diwan Kufi, DecoType Naskh and Mishafi all join correctly.

A patch in GSUB does not reach an AAT face. HarfBuzz reads morx in preference to GSUB, thus a rule that the library appends to GSUB never fires. GlyphSubstitution, AddGlyph and KashidaForms then do nothing at all, and they do not say so: the font is patched, the render succeeds, and the image is the one you started with. Six of the eight macOS faces in the panel are in this class. Damascus carries the two tables, and morx still wins. Removing morx recovers the patch in a face whose GSUB also holds the joining (Damascus), but not in a face where GSUB holds nothing (Geeza Pro), which then loses its joins. Test the patch before you trust it:

before = [g.name for g in shape("ممتاز", font)]
after = [g.name for g in shape("ممتاز", patch_font(font, [rule]))]
assert before != after, "the rule never fired"

A glyph rule is not affected. Tadakhul puts its new glyph into the shaped line by hand, so it needs no rule in GSUB, and the sweep works in the AAT faces that refuse a substitution.

The probe must match the stream that the font really produces. In Gulzar, a nastaliq face, the features that run after rlig rewrite the pair that the probe matched, so the rule does not fire there either. A face with a long contextual chain is the one to check.

A kashida wants a joined face with a flat connection. Amiri, Scheherazade, Noto Naskh, Noto Sans, SF Arabic and Reem Kufi all elongate and move their dots. Nastaliq does not work: Gulzar breaks into loose letters, and Noto Nastaliq Urdu drops the tatweel and draws the word unchanged.

Which letters can sweep is a fact about the face, as SWEEPERS says. Amiri gives ر ز و ژ ۏ ؤ ڑ, SF Arabic و ۏ ؤ, Diwan Kufi س ش ص ض ق, and Reem Kufi, whose terminals all stop flat, gives none. A letter can also pass the test and still sweep badly — Noto Nastaliq Urdu accepts peh and the result is not a letter any more. Run the survey, then look at it.

One size is not one size. At size=64 the ink of the same line is 55 px high in Mishafi and 120 px in Noto Nastaliq Urdu. For a dataset of many faces, measure the ink and scale each face to suit.

Does it still do what the notebooks did?

Yes — every notebook that produces an image was run with its original code and against the library, side by side. Blobify reproduces its notebook to within 0.04 % of pixels, Blot to 0.43 %, Smudge to 1.8 %; the full pipeline and the mim-mim substitution match visually. Where the output differs it is because the notebook was wrong: Jawi's own letters ڠ and ڽ have no Unicode presentation forms, so arabic_reshaper left them unjoined, and meem.svg was being clipped at its viewBox. See docs/notebook-parity.md for the images and the numbers.

Not included

Rumi (Malay Latin) to Jawi transliteration. It is a dictionary and a set of Malay affix rules — language-specific data with no place in a library about scripts, and it belongs upstream. Plug one in as a text rule:

Synthesizer(font=..., text_rules=[Callback(my_transliterator)])

Assets

assets/fonts/Amiri-Regular.ttf is Amiri, under the SIL Open Font License (Amiri-OFL.txt). assets/fonts/Oblegg-Jawi.otf and assets/meem.svg come from the rumi2jawi repository and are used here as demo glyph donors; check with its author before redistributing them.

Known limits

  • A substitution rule is matched against the glyph run the unpatched font produces, so two patches in one batch cannot chain into each other.
  • The joining probe is a dual-joining letter (beh); a font whose contextual rules depend on the specific neighbour rather than on its joining class may shape a probe differently from real text.
  • A font patch is silent when it fails. A face that shapes through AAT morx, or one whose later features rewrite the run that the probe matched, takes the patch and ignores it. See the section above.
  • Bidi is not run: mixed Arabic and Latin in one line is laid out by HarfBuzz's guessed direction, not by the Unicode bidi algorithm. Split the line yourself if you need mixed runs.
  • A fallback font divides the line into one run per font, so a letter drawn from the fallback does not join to the letters around it.
  • A variable font is used at its default instance. There is no axis control; instance the font first if you want another weight or width.
  • All four of those tests are about this font. SWEEPERS is the set that works in Amiri; another face will have a different one, and the way to find it is to run the survey rather than to trust the default.
  • TadakhulScaled cannot merge letter and sweep into one contour, since the sweep has to stay a glyph of its own to be stretched. They share an edge and overlap by SweepShape.overlap to hide the hairline, which works, but it is two shapes pretending to be one.
  • Continuing a letter's motion faithfully is not always what you want to see. Amiri's waw has levelled off by the time its tail ends, so a faithful sweep runs flat; the dive comes from belly, which is a decision, not a measurement.
  • Fitting the nib needs a stretch of stroke at full width. Amiri's waw tail tapers the whole way, and the fit then explains the taper as the pen having turned — hence the classical angle as the default.
  • A sweep spanning a ligature reaches under the whole of it: length is counted in characters, but the span can only end where a glyph does.
  • Every letter over a sweep is lifted by the same amount, where the sweep's depth under each of them differs.
  • KashidaForms builds a glyph per dotted letter, per run length, per joining context: the defaults come to 216 glyphs and about a third of a second of patching, once. Narrow letters or max_run if that matters.

Metadata

Release files for textsynth 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for textsynth 0.1.0
File Size Uploaded Provenance Actions
textsynth-0.1.0.tar.gz 2.1 MB Without provenance Details for textsynth-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for textsynth 0.1.0
File Interpreter ABI Platform Actions
textsynth-0.1.0-py3-none-any.whl Python 3 none any Details for textsynth-0.1.0-py3-none-any.whl

Total release size: 2.2 MB

Release files / textsynth-0.1.0.tar.gz

Download URL textsynth-0.1.0.tar.gz
Size 2.1 MB
Tags Source
SHA-256 checksum
How to use checksums
dd9ff7cf3886b30312b2d7489815a9864f148052c7934ece28d6f9df338e8993
BLAKE2b-256 checksum
How to use checksums
4214477dc6bb10215afd5b66a0cc50ee097a6b0f9972b4cd5e2ba48526a50d12
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.16 {"installer":{"name":"uv","version":"0.9.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / textsynth-0.1.0-py3-none-any.whl

Download URL textsynth-0.1.0-py3-none-any.whl
Size 67.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3fbdaa60dff39c20aab7fd003a6e01fd5700edd5ca2fb78b3547215488297faf
BLAKE2b-256 checksum
How to use checksums
cfc2c61a823685e0b07989ffb963a768dfb867dfdd881d7d1cf2ac01bbcbcf32
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.16 {"installer":{"name":"uv","version":"0.9.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for textsynth 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page