Metadata-Version: 2.5
Name: textsynth
Version: 0.1.0
Summary: Synthesise training images of Perso-Arabic text: shaping-aware layout, in-memory font patching, and ink effects, from a string to a PIL image.
Author-email: hmzdot <hmzkrb@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: arabic,harfbuzz,htr,jawi,ocr,perso-arabic,synthetic-data,text-rendering
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Natural Language :: Arabic
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Classifier: Topic :: Text Processing :: Fonts
Requires-Python: >=3.11
Requires-Dist: fonttools>=4.50
Requires-Dist: freetype-py>=2.4
Requires-Dist: numpy>=1.24
Requires-Dist: pillow>=10.0
Requires-Dist: scipy>=1.11
Requires-Dist: uharfbuzz>=0.39
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: notebooks
Requires-Dist: ipykernel>=6.0; extra == 'notebooks'
Requires-Dist: jupyterlab>=4.0; extra == 'notebooks'
Requires-Dist: nbconvert>=7.0; extra == 'notebooks'
Description-Content-Type: text/markdown

# 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](https://github.com/villaorlado/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.

```python
from textsynth import render

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

## Install

```bash
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 |

```python
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/`](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](https://github.com/ad-tra/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.

```python
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:

```python
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.

```python
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:

```python
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:

```python
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 `if`s.** 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

```bash
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`.

```bash
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:

```python
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](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:

```python
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.
