DEV Community

Low Key Builds
Low Key Builds

Posted on Originally published at nikahdraft.appin60.com

A .docx file in 60 lines of browser JavaScript, and other lessons from a Nikah Nama translation tool

Disclosure: I built the tool this post is about, NikahDraft. It has a free preview and a $9 unlock.

Pakistani couples applying for US, UK or Canadian spouse visas need an English translation of their Nikah Nama, the marriage contract. It is a printed Urdu form with 25 numbered columns, filled in by hand. People search for "nikah nama in english word format" because they want something they can fill in themselves and open in Word.

The documents are personal: names, parents, addresses, dower amounts. So the constraint I set was that nothing typed into the tool leaves the browser. That ruled out the easy routes (server-side PDF rendering, a docx library on a backend) and led to a few small techniques worth sharing.

1. A valid .docx needs three files and a zip writer with no compression

A .docx is a zip with XML inside. Word opens a package with only:

  • [Content_Types].xml
  • _rels/.rels
  • word/document.xml

Zip's "stored" method (no compression) is simple to write by hand: a local header per file, the bytes, a central directory and an end record. The only real work is CRC-32:

const CRC = Array.from({ length: 256 }, (_, n) => {
  let c = n;
  for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
  return c >>> 0;
});
const crc32 = (b) => {
  let c = 0xFFFFFFFF;
  for (const x of b) c = CRC[(c ^ x) & 255] ^ (c >>> 8);
  return (c ^ 0xFFFFFFFF) >>> 0;
};
Enter fullscreen mode Exit fullscreen mode

Then each file is a 30-byte local header (signature PK\x03\x04, version 20, method 0, the CRC, sizes, name length), the name, and the body; the central directory repeats that with an offset; the end-of-central-directory record closes it. With a Blob of those Uint8Array chunks you have a download. I check the output with python-docx in a test: it opens, and the table has 26 rows (header plus 25 columns).

In document.xml, a three-column bordered table is a w:tbl with w:tblBorders and fixed w:gridCol widths in twentieths of a point. Line breaks inside a cell are w:br elements between runs, not \n.

2. Watermarks that survive "Save as PDF"

The free tier prints with a "DRAFT PREVIEW" watermark. A CSS background image does not work: Chrome drops backgrounds when printing unless the user ticks "Background graphics". What does work is a real element, position: fixed inside @media print, which Chrome repeats on every printed page:

@media print {
  .wm { position: fixed; inset: 0; }
  .wm span { transform: rotate(-28deg); color: rgb(155 63 102 / .16); }
}
Enter fullscreen mode Exit fullscreen mode

3. Nastaliq needs room

Urdu headings help users match columns on their paper form, and they have to be in Nastaliq, the script style Urdu readers expect, not the Naskh-style Arabic fallback a system font gives you. Noto Nastaliq Urdu (SIL OFL) is about 690 KB as TTF and 268 KB as WOFF2 after fontTools conversion. Two things bit me:

  • Nastaliq's descenders are deep and its stacking is tall: line-height under about 2 clips glyphs. I use 2.3 for body text.
  • A heading font set on h1 by the design system overrides inheritance from <html lang="ur">, so the Urdu page title fell back to a system Arabic font until I added an explicit html[lang="ur"] h1 rule.

4. Amounts in words, the Pakistani way

Pakistani documents group digits in lakhs (5,00,000) and write "Rupees five hundred thousand only". A small words() function plus a regex does the grouping:

const lakh = (n) => {
  const s = String(Math.floor(n));
  if (s.length <= 3) return s;
  return s.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ",") + "," + s.slice(-3);
};
Enter fullscreen mode Exit fullscreen mode

Gold dower is in tola, so the tool adds grams (1 tola = 11.6638 g).

5. A paid unlock without accounts

The paid step is a one-time card payment through Whop. After checkout Whop redirects to /unlock/?payment_id=.... A Cloudflare Pages worker looks the payment up with the Whop API, checks it is paid, for this product and not refunded, and returns payment_id + "." + HMAC(secret, payment_id). The browser stores that token; on load it asks the worker to verify it again, so a refund removes the unlock. The worker never sees the form contents, only the payment id.

What it does not do

It does not read the handwriting. A person who reads Urdu types each entry, and the tool does the layout, the printed column text in English, the translator's notes for struck-out columns, and the translator's statement worded to the USCIS rule (8 CFR 103.2(b)(3)). It is not a certified translation, and for UK and Canadian files a professional translator still signs.

If you work on tools for languages other than English, I would like to hear what broke for you in print and PDF.

Top comments (0)