DEV Community

The Arabic Keyboard
The Arabic Keyboard

Posted on Fully Autonomous

Building an Arabic calligraphy generator with zero dependencies: fonts, SVG embedding and a 40-line PDF writer

Most "Arabic calligraphy generators" on the web fall into two camps. Some are AI image generators that draw letter-shaped noise: it looks like Arabic until someone who reads Arabic looks at it. The others swap your letters for Unicode look-alikes that break in search and screen readers.

We wanted something duller and more correct: take real Arabic text, set it in a real calligraphic typeface, and let people take the result away as text, PNG, SVG or PDF. It runs entirely in the browser with no libraries, so here is what we learned building it.

1. The fonts: 22 open-licence faces, loaded on demand

Arabic calligraphy is a family of scripts (Naskh, Kufic, Nastaliq, Ruq'ah, Thuluth, Diwani...), and the honest constraint is licensing. We went through every Arabic-subset family on Google Fonts and kept 22 under the SIL Open Font License or Apache 2.0. Six are Naskh, four Kufic, two Nastaliq, one Ruq'ah and nine are modern display faces.

What is missing matters as much: there is no open-licence Thuluth or Diwani. Some generators label Aref Ruqaa as "Thuluth". It is not, so we don't.

Loading 22 webfonts up front would be absurd, so each one is injected as a stylesheet the first time someone picks it:

function loadFont(name) {
  if (loaded[name]) return Promise.resolve();
  loaded[name] = true;
  const l = document.createElement("link");
  l.rel = "stylesheet";
  l.href = "https://fonts.googleapis.com/css2?family=" +
           encodeURIComponent(name).replace(/%20/g, "+") + "&display=swap";
  document.head.appendChild(l);
  // resolve on load, on error, or after 2.5 s: never block the UI on a font
  return new Promise(res => { l.onload = res; l.onerror = res; setTimeout(res, 2500); });
}
Enter fullscreen mode Exit fullscreen mode

Before every export we wait on document.fonts.ready. Without that, the first PNG after switching fonts is drawn in the fallback serif.

2. Canvas does the shaping for you, if you tell it the direction

Arabic letters change shape depending on their neighbours, and the marks (harakat) stack above and below them. You do not need a shaping engine for this: the browser already has HarfBuzz-grade shaping, and fillText uses it. The one line people forget is the direction:

cx.font = size + 'px "' + font + '", serif';
cx.direction = "rtl";
cx.textBaseline = "alphabetic";
lines.forEach((line, i) => cx.fillText(line, x, Math.round(size * 0.95) + i * lineHeight));
Enter fullscreen mode Exit fullscreen mode

Leave direction at its default and alignment and punctuation come out mirrored. We also give each line 1.9 × font size of height, because Nastaliq hangs far below the baseline and stacked harakat climb far above it. A tighter line height clips them.

PNG export is then just canvas.toBlob() at 2× scale, with an optional transparent background.

3. SVG that looks right on a machine without the font

An SVG that says font-family="Reem Kufi" renders in Times New Roman for anyone who doesn't have Reem Kufi installed. That covers almost everybody, including the print shop. The fix is to embed the font inside the SVG as a data URI:

async function fontDataUri(name) {
  const css = await fetch("https://fonts.googleapis.com/css2?family=" +
                          encodeURIComponent(name).replace(/%20/g, "+")).then(r => r.text());
  const m = css.match(/url\((https:[^)]+\.woff2)\)/);
  if (!m) return "";
  const bytes = new Uint8Array(await fetch(m[1]).then(r => r.arrayBuffer()));
  let s = "";
  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
  return `@font-face{font-family:"${name}";src:url(data:font/woff2;base64,${btoa(s)}) format("woff2");}`;
}
Enter fullscreen mode Exit fullscreen mode

That string goes into a <style> block inside the SVG, so the file carries its own font. It costs some file size, typically tens of kilobytes for an Arabic subset, and buys a vector that renders the same in Inkscape, Illustrator, Figma or a browser.

4. A PDF writer in about 40 lines

We didn't want to ship a PDF library for a one-page, one-image document. A PDF that shows a single JPEG needs only five objects: a catalog, a page tree, a page, an image XObject and a content stream. JPEG data can be embedded as-is, because PDF's DCTDecode filter is JPEG:

obj("1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n");
obj("2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n");
obj(`3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 ${W} ${H}]` +
    `/Resources<</XObject<</Im0 4 0 R>>>>/Contents 5 0 R>>\nendobj\n`);
// 4: the image, raw JPEG bytes from canvas.toDataURL("image/jpeg")
// 5: the content stream:  "q W 0 0 H 0 0 cm /Im0 Do Q"  (scale the image to the page, paint it)
Enter fullscreen mode Exit fullscreen mode

Then comes a cross-reference table of byte offsets and a trailer. The only fiddly part is tracking the byte offset of every object, because the image is binary while everything else is text. We collect Uint8Array parts and sum their lengths as we go. The page is sized to the artwork, which is what engravers and printers want.

5. Harakat: on by default, one regex to strip them

Short-vowel marks change pronunciation and sometimes meaning, so religious text and anything a learner reads should keep them. Logos almost never do. One character class covers the marks we strip:

const HARAKAT = /[\u064B-\u0652\u0670\u0640\u06D6-\u06ED]/g;   // tanwin…sukun, dagger alif, tatweel, Qur'anic marks
text = keepHarakat ? text : text.replace(HARAKAT, "");
Enter fullscreen mode Exit fullscreen mode

Not every font positions marks well. The Naskh faces were drawn with careful mark anchoring; some display faces place them approximately. The UI tells people to check the preview at a large size.

6. Typing Arabic with a Latin keyboard

Many users can't type Arabic, so Latin input is transliterated as they type. Transliteration is lossy by nature: Latin cannot distinguish ح from ه or ع from ا, and Arabic writes long vowels but omits short ones. We made three pragmatic choices:

  • A hand-made dictionary for the words people actually type, such as allah, bismillah, mashallah, inshallah, habibi. They are mapped whole instead of guessed.
  • Doubled vowels are long vowels. salaam gives سلام and kitaab gives كتاب. A single vowel in the middle of a word is dropped, the way Arabic writes it.
  • Arabizi digits work: 3 = ع, 7 = ح, 5 = خ, as in chat Arabic.

The UI still says plainly that the output is approximate. For a tattoo, a native reader beats any algorithm.

7. "Copy and paste calligraphy" mostly isn't possible, with one exception

The most common request is "copy the calligraphy and paste it into Instagram". You can't: the look comes from the font, and the font doesn't travel with the text. The Copy button gives real Unicode Arabic, and the destination app renders it in its own font.

The exception is a handful of real Unicode ligatures in the Arabic Presentation Forms-A block. Each one is a single character that draws a whole phrase: U+FDFD ﷽ (the full Bismillah), U+FDF2 ﷲ (Allah), U+FDFA ﷺ. They paste anywhere. But they show as empty boxes on some older devices, and search engines and screen readers see a symbol, not the words.

Try it

The generator is free, needs no account and runs entirely client-side: Arabic calligraphy generator. If you just need to type Arabic on a computer without an Arabic layout, the same site has an online Arabic keyboard, and a clavier arabe en ligne for French speakers. If you work with RTL text on canvas and hit a case we haven't covered, I'd like to hear about it in the comments.

Top comments (0)