Write a resume, see exactly what an applicant tracking system will read, and export a real PDF. Everything stays in your browser.
pnpm install
pnpm devMost resume builders show you a preview and hope. A preview proves nothing — it's pixels. The only question that matters is whether a parser can pull the text back out of the PDF, so Rezuma ships a test that does exactly that.
- A real PDF, not a screenshot. Every glyph is drawn as vector text with the font embedded, so it stays selectable, searchable and extractable.
- Proof, not promises.
pnpm verify:pdfgenerates the PDF, extracts the text with pdf.js, and asserts every name, employer, date, bullet and skill survived the round trip — accents included. - Live ATS checks. Parseable dates, timeline gaps, quantified bullets, passive openers, keyword coverage. Each finding links to the field to fix.
- English, French and Arabic, the last of them properly right-to-left — shaped and reordered in the PDF, not just mirrored on screen. See Arabic and right-to-left.
- Four document layouts, all of which still export a linear text stream. See Layouts.
- Nothing leaves your browser. See Privacy.
Rezuma has no server and no account. It is a static site. Everything you type
is held in your browser's localStorage under the key my-resume:v1, and it
stays on your own machine.
- No account, no email address, no sign-in.
- No upload — the PDF is generated locally by your browser and downloaded directly. It is never transmitted anywhere.
- No analytics, no trackers, no cookies, no third-party requests at runtime.
- Closing the tab keeps your work. Clearing your browser data removes it, and nobody else ever had a copy to recover it from.
- Save as JSON exports your document as a file you control; Open JSON reads one back. That is the only way data enters or leaves the app.
Because storage is per-browser and per-device, your resume will not follow you to another machine. That is the trade: no sync, no server, no copies.
| Command | What it does |
|---|---|
pnpm dev |
Dev server |
pnpm build |
Type-check and build |
pnpm preview |
Serve the production build |
pnpm preview:cf |
Serve it the way Cloudflare will (wrangler) |
pnpm deploy |
Build and deploy to Cloudflare |
pnpm fonts |
Re-download the vendored TTFs into src/assets/fonts/ |
pnpm verify:pdf |
Generate PDFs and assert the text extracts back out |
src/services/pdf/ draws the document with jsPDF rather than rasterising the preview.
fonts.tsembeds Source Serif 4 or Source Sans 3 — or, for Arabic, Noto Naskh Arabic or Noto Sans Arabic — as subsetted TrueType, fetched on demand at export time so they stay out of the initial bundle. Two base-14 families (times,helvetica) are also selectable for maximum compatibility.layout.tsis a two-pass typesetter: everything is measured into groups first, then paginated. That's what keeps a job title with its dates, a section heading with its first entry, and paragraphs free of orphans and widows. It paginates flows, so a layout with a side column is two of them sharing one page sequence.exportPdf.tsturns a resume, a theme and a layout into those groups. It is written entirely in logical coordinates — offsets from the leading margin, which is the left one in English and the right one in Arabic — so one set of layout code serves both directions.
The preview loads the same families as webfonts, so what you see is what gets exported.
jsPDF can only embed raw TrueType. @fontsource ships woff2, which it cannot
read, so scripts/fetch-fonts.mjs pulls static TTFs from the Google Fonts CSS
API (which serves TrueType when the request carries no User-Agent) into
src/assets/fonts/. They are committed so builds are hermetic. All four
families are SIL Open Font License; the license travels with them in that
directory.
Arabic adds two requirements the script enforces. The CSS API answers a
UA-less request with the latin slice unless you ask for subset=arabic —
24 KB with an empty Arabic cmap, which embeds without complaint and then draws
nothing. And the face has to carry Arabic Presentation Forms-B (U+FE70–FEFF),
because jsPDF shapes by substituting those codepoints rather than by running
OpenType. pnpm fonts reads the cmap and refuses any face that fails either.
Switching the interface to العربية sets dir="rtl" on <html>, and everything
downstream follows from that one attribute: the stylesheets are written in
logical properties, so the editor panel, the toolbar and the document all mirror
without a direction-specific rule anywhere.
The PDF is the harder half, and it works:
- Shaping. jsPDF substitutes Arabic Presentation Forms-B before drawing, so
letters join.
getTextWidthruns the same pass, which means measurement matches what is drawn. - Line breaking.
splitTextToSizedoesn't: it sums per-character widths of the unshaped string, and isolated forms are much wider than joined ones, so it ends lines ~20% short of the measure. Arabic gets a measured greedy wrap instead. - Extraction. The text comes back as presentation forms — ﻣﻬﻨﺪﺱ, not مهندس.
String.normalize('NFKC')folds them back to the base letters, which is exactly what a parser's own normaliser does.pnpm verify:pdfasserts it. - What bidi does cost you. Reordering moves whole runs: a bullet mixing
Arabic with
Reactextracts with the Latin run displaced, and paired brackets come back mirrored. Every token survives, contiguity does not — so the verification checks Arabic token by token and says so.
Dates stay in Latin digits in Arabic (ar-u-nu-latn). Arabic-Indic digits are
correct typography and unreadable to a date parser.
The interface is Arabic; the ATS check messages are not. They are English in all three locales — see Interface language.
Four arrangements, orthogonal to the nine styles: any layout can wear any style.
They live in src/config/layouts.ts, and both the preview and the exporter read
that one file — the preview turns rail and gutter into a CSS grid, the
exporter turns them into column geometry.
| Layout | What moves |
|---|---|
| Stacked | Nothing. One column, headings above their content. |
| Side headings | Section headings sit in a rail beside the body. |
| Timeline | Each role's place and dates sit in a rail beside the entry. |
| Sidebar | Skills, education and certifications move to a second column. |
Only Sidebar prints two columns, and it is the one layout the ATS panel
flags. The text stream stays linear — the main column is written before the side
column, so a parser reads name → summary → experience → skills → education, and
verify:pdf asserts that order — but an applicant tracking system that rebuilds
lines from geometry rather than from the content stream will interleave the two
anyway. The check says so rather than quietly passing.
The landing page carries a set of Vue Bits-style effects — an animated aurora and light-ray field, a parallax dot grid, split-text and glitch headings, scramble-decrypt on the extracted-text panel, a typed terminal, counting figures, spotlight and tilt cards, magnetic buttons, a click-spark cursor, and an SVG diagram that draws itself.
None of them adds a dependency. They live in src/components/effects/ and
are written against the platform: the aurora is one fragment shader on a
fullscreen triangle in raw WebGL2, and the dot grid and sparks are canvas 2D.
Reaching for ogl or three would have meant 150 kB+ of scene graph the page
never uses, on the one route that is not lazily loaded — and Vue Bits ships
under MIT + Commons Clause, which does not belong in an MIT repository. The
aurora is the only piece heavy enough to matter (5 kB), and it is an async chunk
loaded after mount.
Two rules hold for everything in that directory:
prefers-reduced-motionis the finished state, not a hidden one. Every effect renders its settled appearance and adds animation; nothing becomes unreachable when a reader turns motion off. The aurora paints one static frame, the dot grid stops tracking the pointer, and every reveal starts revealed.- Nothing changes what is announced. Split, scrambled and counted text each
keep an intact copy in the accessibility tree with the animated copy marked
aria-hidden. The one exception is the extracted-text panel, which renders a single node on purpose — selecting and copying that block is the demonstration, and a hidden duplicate would put every line in the clipboard twice.
Loops are tied to an IntersectionObserver and to visibilitychange, so a
background tab or a scrolled-past section costs nothing.
AR / classic / sidebar
file ar-classic-sidebar.pdf (65 KB, 1 page)
extracted 2563 chars
checks 51/51 found
order summary → experience → skills → education
arabic 1554 letters (61% of the text)
Nine cases: three languages, two styles, four layouts. pnpm verify:pdf exits
non-zero on any miss, so a regression in the exporter fails loudly rather than
silently shipping an unreadable file. The expected section order is derived from
layouts.ts rather than hard-coded, which keeps it a test of the exporter
instead of a copy of it.
Static output — no server, no runtime, no environment variables.
The repo is set up for Workers static assets, which is what the dashboard creates today for a Vite project. Connect the repo under Workers & Pages → Create → Workers → Import a repository and use:
| Setting | Value |
|---|---|
| Build command | pnpm build |
| Deploy command | npx wrangler deploy |
| Output directory | dist |
wrangler.jsonc supplies the rest. It declares a static-asset-only Worker — no
main, so no server-side code runs — and sets not_found_handling to
single-page-application, which is what stops a hard refresh on /app from
404ing.
Files that matter to a deployment:
wrangler.jsonc— the Worker definition. It must be committed, otherwisewrangler deploydrops into an interactive setup wizard, which fails in CI.public/_headers— immutable caching for hashed assets and fonts, plusX-Content-Type-Options,Referrer-Policy,X-Frame-Optionsand aPermissions-Policythat switches off APIs this app never uses. Honoured by Workers static assets, Pages and Netlify alike..node-version/packageManager— pin Node 22 and pnpm 11. The lockfile is version 9.0, which a build image defaulting to pnpm 8 cannot read.pnpm-workspace.yaml—allowBuildsmust listesbuildandworkerd. pnpm 11 treats an ignored install script as a hard error, and wrangler depends on both, so omitting them fails the deploy step.
There is deliberately no public/_redirects. The usual SPA rule
(/* /index.html 200) is rejected by the Workers deploy API with
Infinite loop detected in this rule, because static assets already strip
/index and .html from paths, so redirecting to /index.html would re-enter
the same rule. not_found_handling is the supported mechanism here. Note that
wrangler dev accepts the file even though the deploy API refuses it, so this
only surfaces on a real deploy.
Check the built output the way Cloudflare will actually serve it:
pnpm build
pnpm preview:cf # wrangler dev, using wrangler.jsoncOr deploy by hand from your machine with pnpm deploy.
vercel.json is kept for Vercel (same rewrite and headers, different syntax).
For Cloudflare Pages or Netlify, add a public/_redirects containing:
/* /index.html 200
Those platforms want the rule that Workers rejects. Any static host works
provided unmatched routes fall back to index.html.
Source lives under src/, grouped by the role a file plays rather than by
feature:
src/
components/ appearance/ · ats/ · editor/ · effects/ · preview/
composables/ useAppearance · useResume · useTheme · useReducedMotion · useRaf
config/ themes.ts — the nine styles · layouts.ts — the four layouts
data/ resume-{en,fr,ar}.json (the samples) · landing-demo.ts
i18n/ index.ts + locales/{en,fr,ar}.ts
router/
services/ ats.ts (the checks) · pdf/ (the exporter)
styles/
types/
utils/ format.ts · id.ts · normalise.ts
views/
@/ resolves to src/, so imports read the same wherever they sit in the tree.
src/data/ holds one fictional sample document per language. English and French
share a person (Alex Moreau, who does not exist); the Arabic sample is Mohammed
Al-Qasimi, who also does not exist, with the same career relocated to Saudi
Arabia — a market where the document would actually be read in Arabic. Reset
to sample restores them and is undoable.
English, French and Arabic, via vue-i18n.
Messages live in src/i18n/locales/; en.ts is the reference and the other two
are typed against it, so a key added without a translation is a type error
rather than a silent fallback. The active locale is the single source of truth
for language: setLocale writes lang and dir onto <html>, the resume
store reads the locale to pick which of the three documents is on screen, and
the date formatters read it to pick an Intl locale.
Each language keeps its own document and its own undo history, all three in
localStorage under the same key. Switching language switches documents; it
does not translate anything.
The landing page is translated too, and carries its own language picker so a
visitor who arrives in Arabic never has to open the editor to find one. Two
things on it stay left-to-right by design: the verify:pdf transcript, because
terminal output is a left-to-right artefact in every locale, and the Latin type
specimen, because it is showing a Latin face on purpose. The privacy diagram
does mirror — it is a sentence about where your data goes, not a fixed picture.
The ATS check messages are English in all three locales. The detection is
trilingual (Arabic units and Arabic-Indic digits count as quantified results;
تم and جرى are flagged as agentless passives the way "responsible for" is),
but the prose explaining each finding is not yet translated.
Vue 3, TypeScript, Vite, vue-router, vue-i18n, jsPDF, pdf.js. No backend.
MIT — see LICENSE.