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_terminaltests 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"))
- 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;
- the outline is injected as a real glyph (
glyfor CFF, cubics converted to quadratics as needed, metrics and glyph order updated); - 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.
SWEEPERSis 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. TadakhulScaledcannot 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 bySweepShape.overlapto 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:
lengthis 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.
KashidaFormsbuilds 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. Narrowlettersormax_runif 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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.