Alihformat, deployed at alihformat.rominur.com, is an Indonesian-language file converter of eighty tools on seven shelves: images, PDF, archives, spreadsheets and data, documents, audio and video, and AI. Seventy-six of them run entirely in the user’s browser, so the hosting platform serves only static pages; the remaining four — PDF to Markdown, OCR, table photo to CSV and invoice to JSON — send the file through a single server route to a language model. This paper describes the catalogue that generates both the search-engine pages and the client dispatcher from one data file; the browser limits each engine has to work around, from iOS Safari’s silent canvas ceiling to EXIF rotation that pdf-lib ignores; a 7Z writer that only works by compressing the stream and then decompressing it; a serialised queue in front of a shared ffmpeg.wasm instance; the constraints on the AI route; and the limits that remain.
Search for “JPG ke PDF” or “RAR ke ZIP” and the results are the same kind of site: upload the file, wait, download the result. For a holiday photo that is merely slow; for a scanned identity card, a payslip or a signed contract it means handing a private document to a server the user knows nothing about. Almost none of these conversions need a server. A modern browser can decode and re-encode images, build and render PDFs, unpack RAR and 7Z through WebAssembly, read and write Excel workbooks, and run ffmpeg.
Alihformat is built on that observation. Its home page leads with a sentence that is also its design constraint — Ubah format file tanpa mengunggahnya, change a file’s format without uploading it — and every tool page carries one of two badges: a green Diproses di perangkatmu, tanpa upload or an amber Diproses AI lewat OpenRouter, maksimal 3 MB. Beneath each page a short section answers the question the user should be asking, Ke mana file saya dikirim?, with “nowhere” for seventy-six tools and a plain disclosure of the third parties for the other four.
Every converter is a record in lib/converters.ts: a slug, source and target labels, a title and description in Indonesian, a category, an accept filter, optional flags for multiple files, a minimum file count and heavy engines, and an engine value from a discriminated union of thirteen kinds. The file deliberately contains data only, because it has two readers. On the server, Next.js calls generateStaticParams over the list and pre-renders one static page per converter, with its own title, description, canonical URL and Open Graph tags, and the sitemap is generated from the same list. In the browser, a dispatcher switches on the engine kind and dynamically imports only the module that kind needs, so opening “CSV ke JSON” never downloads the PDF or ffmpeg code.
Most tools are generated rather than written. Twenty image pairs, eight archive pairs, ten spreadsheet pairs, six JSON/YAML/XML pairs, five document pairs and thirteen media presets are arrays of tuples expanded in loops; the README’s instruction for adding WEBP to BMP is a single tuple, after which the page, the sitemap entry and the index card appear on the next build.
| Shelf | Tools | Engine | Where it runs |
|---|---|---|---|
| Gambar | 20 | Canvas; heic2any; hand-written BMP and ICO encoders | Browser |
| 11 | pdf-lib (create, merge, split, text to PDF); pdf.js (render, extract text) | Browser | |
| Arsip | 11 | fflate (ZIP); libarchive.js in WebAssembly (RAR, 7Z, TAR, GZ) | Browser |
| Spreadsheet & data | 16 | SheetJS 0.20.3; js-yaml; fast-xml-parser | Browser |
| Dokumen | 5 | mammoth (DOCX); marked; turndown | Browser |
| Audio & video | 13 | ffmpeg.wasm 0.12, single-threaded core | Browser |
| Dengan AI | 4 | One Next.js route → OpenRouter chat completions | Server → model provider |
The WebAssembly workers cannot be bundled by webpack in the usual way, so a post-install script copies the pdf.js legacy build, the libarchive worker and the ffmpeg worker into public/vendor/ under a directory named after the three library versions — for example p4.10.38-l2.0.2-f0.12.15. That path is served with a one-year immutable cache header, which is safe precisely because an upgrade changes the directory name and therefore every URL.
Image conversion is decode, draw, encode: load the file into an <img>, draw it onto a canvas, and ask the canvas for a blob of the target type. Each of the three steps has a trap. Decoding: only Safari reads HEIC, so HEIC files are first converted to PNG with heic2any; an SVG without absolute width and height is rasterised by browsers at 300×150, so the SVG is parsed, sized from its viewBox with the long side at least 1,024 px, and re-serialised; and the image is awaited through onload rather than img.decode(), because Chrome defers decode() while the tab is hidden and a user who switches tabs would otherwise find the job frozen.
Drawing: canvases have a pixel budget, and iOS Safari enforces a lower one by returning an empty canvas instead of throwing. The engine caps the canvas at 16 million pixels on iOS (detected, for iPadOS, by a Mac platform string with touch points) and 50 million elsewhere, scales larger images down by the square root of the ratio, and attaches a note to the result stating the original and new dimensions so the reduction is never silent. Encoding: toBlob falls back to PNG when a browser cannot write the requested type, so the returned blob’s type is checked and a mismatch becomes an error naming the browsers that can. JPEG output is drawn over white so transparency does not turn black.
Browsers cannot encode BMP or ICO at all, so both are written by hand. The BMP encoder emits a 54-byte header and 24-bit bottom-up rows padded to four bytes, compositing each pixel’s alpha onto white. The ICO encoder renders the image at 16, 32, 48, 64, 128 and 256 px, encodes each as PNG, and writes the ICONDIR and six sixteen-byte directory entries in front of them — with the width and height bytes of the 256 px entry set to zero, which is how the format spells 256.
Images to PDF places each image on an A4 page with 24-point margins, turning the page to landscape when the image is wider than tall and never scaling an image up. pdf-lib can embed JPEG and PNG bytes directly, which is fast and lossless, but it ignores the EXIF Orientation tag — and a phone photo taken in portrait is usually stored sideways with a tag telling viewers to rotate it. The engine therefore reads the tag itself: it walks the JPEG markers until the start of scan, finds the APP1 Exif segment, honours the TIFF byte order, and reads tag 0x0112 from the first directory. Only a JPEG with orientation 1 is embedded as-is; anything else, and any file whose content does not match its extension, is drawn through a canvas, where the browser applies the rotation, and embedded from there.
PDF to image renders each page with pdf.js at twice its natural scale, reduced when a page is large enough (a poster or an A0 drawing) to exceed 16 million pixels or 16,000 px on a side. Rendering uses the print intent, which avoids requestAnimationFrame — so rendering continues in a background tab — and hides annotations flagged not to print. Text to PDF uses the standard Helvetica font, which covers only WinAnsi, so unsupported characters are replaced rather than crashing the encoder, and long words are broken at the page width character by character.
A plain ZIP is opened with fflate, a small pure-JavaScript inflater, because it is faster than starting a WebAssembly worker; if that fails — an encrypted ZIP or an unusual method — the file falls through to libarchive.js, which handles RAR3, RAR5, 7Z, TAR and GZ. Encrypted archives are detected before extraction and answered with a clear message instead of a corrupted result. Repacking preserves folder structure, and when two entries collide the second gets a numbered suffix on the file name only, not on a folder whose name happens to contain a dot.
Writing 7Z exposed a defect in libarchive.js: with compression set to none, it returns an empty file. The workaround is to ask it for a 7Z archive with gzip compression — which it produces correctly, wrapped in a gzip stream — and then gunzip that stream with fflate, leaving a pure .7z. TAR.GZ uses the same call and keeps the wrapper. RAR remains input-only: the format is proprietary and there is no free encoder.
SheetJS is installed from its own CDN at version 0.20.3, because the last release published to npm (0.18.5) is years old and carries known prototype-pollution and ReDoS advisories. A workbook with several sheets becomes several CSV files, or one JSON object keyed by sheet name; in the other direction, a JSON object whose values are all arrays becomes one sheet per key, with names cleaned of the characters Excel forbids, cut to 31 characters and made unique case-insensitively. Every CSV starts with a UTF-8 byte-order mark, without which Excel on Windows reads Indonesian names with diacritics as mojibake. Excel dates carry no time zone, so they are written as the date or local timestamp that was typed rather than shifted to UTC.
For JSON, YAML and XML, the XML parser is configured with parseTagValue: false, so values such as a phone number 08123, a code 007 or 0x1F remain text rather than becoming numbers and losing their leading zeros. Since XML requires a single root element, a value with more than one top-level key or an array is wrapped in <root> before it is written.
Audio and video conversion runs on ffmpeg.wasm. Its core, about 30 MB, is downloaded from jsDelivr the first time any media tool runs, turned into blob URLs, and cached by the browser; the page states the size before the download starts. The core is single-threaded: the multi-threaded build needs cross-origin isolation headers, which would complicate embedding third-party content, and the trade is slower video. The presets are fixed: MP3 as LAME VBR quality 2; MP4 as H.264 veryfast at CRF 23 in yuv420p with dimensions rounded to even numbers and +faststart; GIF at 12 fps and 480 px wide through a generated palette.
One ffmpeg instance is shared by the whole session, and its progress and log events are global to that instance. If a user starts a job on one tool page and then opens another, two jobs would interleave their progress reports and could collide on temporary file names. Every media job is therefore chained onto a single promise queue, gets a random identifier in its temporary names, and removes its listeners and files in a finally block. The last thirty log lines are kept so that a failure can be explained — a video without an audio track is reported as such rather than as a generic error.
The four AI tools post a data URL to /api/ai, a Next.js function that holds the OpenRouter key in an environment variable. The route accepts only a known task name and only PDF, PNG, JPEG or WebP data URLs of at most 4.2 million characters — about 3 MB of file after base64 — because the platform limits a function’s request body to 4.5 MB. The client enforces the same limit first: a PDF over 3 MB is refused with advice to split it with the PDF splitter, while a photo is redrawn as JPEG with its long side at no more than 2,400 px and reduced by a fifth at a time until it fits, giving up at 800 px so that text is never shrunk past legibility.
Each task has a fixed English prompt that asks for output only — Markdown, verbatim text, CSV with a stated quoting rule, or a JSON invoice schema that uses null for missing fields and says do not guess. Requests run at temperature zero on google/gemini-2.5-flash by default, with JSON mode for invoices and an optional OpenRouter file-parser engine for scanned PDFs. Replies wrapped in a code fence are unwrapped, invoice JSON is re-indented, and CSV gets the same byte-order mark as the local converters. Without a key, the route answers 503 with a message naming the missing variable, and every non-AI tool continues to work.
The AI route is unauthenticated. Its only brake is a rate limiter of twenty requests per hour per client address, stored in a map in the function’s memory, so the bound holds per warm instance rather than globally and resets on a cold start; anyone who finds the endpoint can spend the site’s key within that allowance. A shared store such as Redis, and ideally sign-in or per-account quotas, would be needed before the AI shelf is promoted. Files sent to the AI tools pass through OpenRouter to a model provider, which is disclosed on the page but is still a transfer the rest of the site avoids.
The local engines have their own boundaries. Everything is held in browser memory, so the practical ceiling is about 500 MB for media and archives, and single-threaded ffmpeg makes long videos slow. Password-protected archives are not supported, RAR cannot be written, and 7Z output may mangle non-ASCII file names, whereas ZIP and TAR.GZ do not. Layout-faithful DOCX to PDF and clean PDF to DOCX are absent because both need an office engine on a server. The bundled ffmpeg core includes libx264, which is GPL-licensed. The README records fifty-one end-to-end scenarios passing in Chromium, including RAR3, RAR5, 7Z, TAR.GZ and the error paths; HEIC conversion and live AI calls had not been tested at the time of writing.
Alihformat is eighty tools because its catalogue makes a tool cheap: a tuple becomes a static page, an index card and a lazily loaded engine. The work that is not cheap sits where the browser’s promises run out — a canvas that fails silently on a phone, a PDF library that does not rotate phone photos, an archive library that writes empty files unless asked to compress, a shared ffmpeg that must be taken in turns. Solving those in the browser is what lets the site keep the sentence on its front page, and confine the network to the four tools that genuinely need a model.