feat(pdf): remove react-pdf and move every consumer to forme

The web preview, downloads, template gallery, server export and public
PDF render through Forme. react-pdf, react-pdf-html, the react-pdf
hyphenation package, the Phosphor react-pdf icons and the four patches
are gone. Hyphenation now follows the page language for every language
Forme has patterns for.

Semantic CSS keeps its language; declarations Forme can't draw raise an
ENGINE_UNSUPPORTED warning in the editor. The page map rebuilds blocks
Forme leaves out of its layout when they break across pages, and a
render that misplaces a box is repeated with nested rows kept whole.

Tests move to a small shim with the react-pdf calls they were written
against; tests of react-pdf internals are dropped. Forme limits are
recorded as expected failures (RTL line order, characters above
U+FFFF).
This commit is contained in:
Amruth Pillai
2026-09-29 07:11:40 +02:00
parent bc28441b66
commit c0bf1aebaf
151 changed files with 1233 additions and 2008 deletions
+2 -2
View File
@@ -1521,9 +1521,9 @@ The known 0.25.0 defects are accepted until upstream fixes them:
- **Conversion.** The host tree becomes Forme elements. On the way, styles are normalised, `Link` becomes `href`, and `Svg` becomes markup. Nodes tagged `data-resume-node` are registered in `globalThis.__formeSourceMap`, so Forme's layout info yields the page map for click-to-edit, Check and Fit.
- **Rendering.** `@formepdf/core` renders in Node; the browser uses `@formepdf/core/worker` with explicit WASM init. One `renderResume()` returns the PDF bytes, the page map and warnings.
- **Fonts.** The bytes are fetched once and cached. WOFF is inflated to sfnt, and the GSUB `liga`, `clig` and `dlig` features are renamed so the shaper skips them (the #156 workaround). Fallback families become a Forme fallback chain.
- **Rich text.** `react-pdf-html` is replaced by a renderer from normalized HTML to Forme text runs and native lists, which also replaces the react-pdf layout patches for list markers and whitespace.
- **Rich text.** `react-pdf-html` is replaced by a small `Html` renderer in `packages/pdf/src/text.tsx` with the same contract (per-tag renderers, tag and class stylesheet, inline runs grouped into one text).
- **Icons.** Phosphor icons are generated as SVG markup from `@phosphor-icons/react`, replacing `phosphor-icons-react-pdf`.
- **Semantic CSS.** The adapter maps resolved styles to Forme style objects. Properties Forme can't draw become diagnostics in the stylesheet editor, for example `object-fit`, `text-indent`, `vertical-align` and `-resume-min-presence-ahead`.
- **Semantic CSS.** The language is unchanged, so saved stylesheets keep working. Resolved styles flow through the same conversion as template styles. Declarations Forme can't draw raise an `ENGINE_UNSUPPORTED` warning in the stylesheet editor: `z-index`, `max-lines`, `text-indent`, `vertical-align`, `object-position`, `text-decoration-color`/`-style`, `-resume-min-presence-ahead` and dashed or dotted borders. `object-fit` is drawn by the converter.
- **Free-form pages.** They render in two passes: measure the content height, then render at that height.
- **Removed:** `@react-pdf/renderer`, `@react-pdf/hyphenate`, `react-pdf-html`, `phosphor-icons-react-pdf`, and the four react-pdf patches. Hyphenation uses Forme's `hyphens` and `lang`.
+4 -7
View File
@@ -14,9 +14,6 @@
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"imports": {
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.65",
"@ai-sdk/cerebras": "^3.0.57",
@@ -38,6 +35,8 @@
"@better-auth/oauth-provider": "^1.7.6",
"@better-auth/passkey": "^1.7.6",
"@bramus/specificity": "^2.4.2",
"@formepdf/core": "0.25.0",
"@formepdf/react": "0.25.0",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.1",
"@orpc/client": "^1.15.4",
@@ -46,8 +45,6 @@
"@orpc/openapi": "^1.15.4",
"@orpc/server": "^1.15.4",
"@orpc/zod": "^1.15.4",
"@react-pdf/hyphenate": "0.1.0",
"@react-pdf/renderer": "^4.9.0",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
@@ -71,6 +68,7 @@
"es-toolkit": "^1.52.0",
"fast-json-patch": "^3.1.1",
"fast-png": "^8.0.0",
"fflate": "^0.8.3",
"hono": "^4.13.9",
"ioredis": "^6.0.0",
"jose": "^6.2.12",
@@ -79,10 +77,9 @@
"nodemailer": "^10.0.11",
"ollama-ai-provider-v2": "^4.0.1",
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.3.0",
"react-email": "^6.11.0",
"react-pdf-html": "^2.1.5",
"react-reconciler": "0.34.0",
"resumable-stream": "^2.2.13",
"sanitize-html": "^2.17.7",
"sharp": "^0.35.5",
+2 -1
View File
@@ -63,8 +63,9 @@ const BASE_SECURITY_HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
// `wasm-unsafe-eval` lets the PDF engine (Forme, WebAssembly) start in the browser.
"Content-Security-Policy-Report-Only":
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
};
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
+3 -1
View File
@@ -37,7 +37,9 @@ const shouldExternalizeThirdParty = (id: string) => {
.slice(0, id.startsWith("@") ? 2 : 1)
.join("/");
if (id.startsWith("@reactive-resume/") || bundledInteropPackages.has(packageName)) return false;
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
// Subpath imports (`#…`) are resolved by the workspace package that declares them, so they're bundled with it.
if (id.startsWith("@/") || id.startsWith("#") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0"))
return false;
return true;
};
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Randwydte"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Verdeel die CV in die gewone afdelings sodat dit gesegmenteer kan word."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Huidige filters"
msgid "Current Password"
msgstr "Huidige wagwoord"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Tans beskikbaar vir Duitse CV's. Gebruik die taal wat op die bladsy ingestel is."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Pasgemaak"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "የክልል ወፍራምነት"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "ለመከፋፈል እንዲያመች የስራ ልምድ ማስረጃውን ወደ መደበኛ ክፍሎች ይከፋፍሉት።"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "የአሁኑ ማጣሪያዎች"
msgid "Current Password"
msgstr "አሁን ያለዎት የይለፍ ቃል"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "በአሁኑ ጊዜ ለጀርመን ከቆመበት ቀጥል ይገኛል። በገጽ የተዘጋጀውን ቋንቋ ይጠቀማል።"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "ብጁ"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "عرض الإطار"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "قسّم السيرة الذاتية إلى الأقسام المعتادة لتسهيل تجزئتها."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "المرشحات الحالية"
msgid "Current Password"
msgstr "كلمة المرور الحالية"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "متوفر حاليًا للسير الذاتية باللغة الألمانية. يستخدم اللغة المحددة في «الصفحة»."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "مخصص"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Sərhəd Qalınlığı"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "CV-ni adi bölmələrə ayırın ki, seqmentləşdirilə bilsin."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Cari filtrlər"
msgid "Current Password"
msgstr "Cari Parol"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Hazırda alman dilindəki CV-lər üçün mövcuddur. Səhifədə təyin olunmuş dili istifadə edir."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Fərdiləşdirilmiş"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Дебелина на рамката"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Разделете автобиографията на обичайните раздели, за да може да бъде сегментирана."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Актуални филтри"
msgid "Current Password"
msgstr "Текуща парола"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Понастоящем е достъпна за автобиографии на немски език. Използва езика, зададен в „Страница“."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Потребителски"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "বর্ডার প্রস্থ"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "জীবনবৃত্তান্তটিকে প্রচলিত সেকশনে ভাগ করুন, যাতে এটি বিভক্ত করা যায়।"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "বর্তমান ফিল্টার"
msgid "Current Password"
msgstr "বর্তমান পাসওয়ার্ড"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "বর্তমানে জার্মান পুনর্বাসনের জন্য উপলব্ধ। পৃষ্ঠায় নির্ধারিত ভাষা ব্যবহার করে।"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "কাস্টম"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Gruix de la vora"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Divideix el currículum en les seccions habituals perquè es pugui segmentar."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtres actuals"
msgid "Current Password"
msgstr "Contrasenya actual"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Actualment disponible per a currículums en alemany. Utilitza l'idioma establert a la pàgina."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Personalitzat"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Šířka ohraničení"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Rozdělte životopis do obvyklých sekcí, aby jej bylo možné segmentovat."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktuální filtry"
msgid "Current Password"
msgstr "Aktuální heslo"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "V současné době je k dispozici pro německé životopisy. Používá jazyk nastavený v okně „Stránka“."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Vlastní"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Kantbredde"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Del CV'et op i de sædvanlige afsnit, så det kan segmenteres."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktuelle filtre"
msgid "Current Password"
msgstr "Nuværende adgangskode"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Er i øjeblikket tilgængelig for tyske CV’er. Bruger det sprog, der er angivet under »Side«."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Brugerdefineret"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Rahmenbreite"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Gliedern Sie den Lebenslauf in die üblichen Abschnitte, damit er segmentiert werden kann."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktuelle Filter"
msgid "Current Password"
msgstr "Aktuelles Passwort"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Derzeit für deutsche Lebensläufe verfügbar. Verwendet die unter „Seite“ festgelegte Sprache."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Benutzerdefiniert"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Πλάτος περιγράμματος"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Χωρίστε το βιογραφικό στις συνήθεις ενότητες ώστε να μπορεί να τμηματοποιηθεί."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Τρέχοντα φίλτρα"
msgid "Current Password"
msgstr "Τρέχων κωδικός πρόσβασης"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Διατίθεται προς το παρόν για γερμανικά βιογραφικά. Χρησιμοποιεί τη γλώσσα που έχει οριστεί στη σελίδα."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Προσαρμοσμένο"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Border Width"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Break the resume into the usual sections so it can be segmented."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Current filters"
msgid "Current Password"
msgstr "Current Password"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Currently available for German resumes. Uses the language set in Page."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Custom"
+4 -4
View File
@@ -1504,6 +1504,10 @@ msgstr "Border Width"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Break the resume into the usual sections so it can be segmented."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr "Breaks long words between syllables, using the language set in Page."
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2439,10 +2443,6 @@ msgstr "Current filters"
msgid "Current Password"
msgstr "Current Password"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Currently available for German resumes. Uses the language set in Page."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Custom"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Ancho del borde"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Divida el currículum en las secciones habituales para que pueda segmentarse."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtros actuales"
msgid "Current Password"
msgstr "Contraseña actual"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Actualmente disponible para currículos en alemán. Utiliza el idioma configurado en «Página»."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "A medida"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "عرض حاشیه"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "رزومه را به بخش‌های معمول تقسیم کنید تا قابل بخش‌بندی باشد."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "فیلترهای فعلی"
msgid "Current Password"
msgstr "گذرواژهٔ فعلی"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "در حال حاضر برای رزومه‌های آلمانی در دسترس است. از زبان تعیین‌شده در صفحه استفاده می‌کند."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "سفارشی"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Reunan leveys"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Jaa ansioluettelo tavanomaisiin osioihin, jotta se voidaan segmentoida."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Nykyiset suodattimet"
msgid "Current Password"
msgstr "Nykyinen salasana"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Saatavilla tällä hetkellä saksankielisille ansioluetteloille. Käyttää Sivut-kohdassa määritettyä kieltä."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Custom"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Largeur de la bordure"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Divisez le CV selon les sections habituelles pour qu'il puisse être segmenté."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtres actuels"
msgid "Current Password"
msgstr "Mot de passe actuel"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Actuellement disponible pour les CV en allemand. Utilise la langue définie dans la page."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Sur mesure"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "רוחב מסגרת"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "פרק את קורות החיים למקטעים המקובלים כדי שניתן יהיה לפלח אותם."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "מסננים נוכחיים"
msgid "Current Password"
msgstr "סיסמה נוכחית"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "זמין כרגע עבור קורות חיים בגרמנית. משתמש בשפה שהוגדרה ב\"דף\"."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "מותאם אישית"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "बॉर्डर चौड़ाई"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "बायोडाटा को सामान्य सेक्शन में बांटें ताकि इसे विभाजित किया जा सके।"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "वर्तमान फ़िल्टर"
msgid "Current Password"
msgstr "वर्तमान पासवर्ड"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "वर्तमान में जर्मन रिज्यूमे के लिए उपलब्ध है। यह पेज में सेट की गई भाषा का उपयोग करता है।"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "अनुकूलित"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Szegély vastagsága"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Bontsd az önéletrajzot a megszokott szakaszokra, hogy szegmentálható legyen."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktuális szűrők"
msgid "Current Password"
msgstr "Jelenlegi jelszó"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Jelenleg német nyelvű önéletrajzokhoz érhető el. A „Page” menüpontban beállított nyelvet használja."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Custom"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Lebar Tepi"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Pecah resume menjadi bagian-bagian umum agar dapat disegmentasi."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filter saat ini"
msgid "Current Password"
msgstr "Kata Sandi Saat Ini"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Saat ini tersedia untuk CV dalam bahasa Jerman. Menggunakan bahasa yang ditetapkan di halaman."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Kustom"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Larghezza del bordo"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Suddividi il curriculum nelle sezioni consuete così può essere segmentato."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtri attuali"
msgid "Current Password"
msgstr "Password attuale"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Attualmente disponibile per i curriculum in lingua tedesca. Utilizza la lingua impostata nella pagina."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Personalizzato"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "枠線の幅"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "履歴書を一般的なセクションに分けて、区分けできるようにしてください。"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "現在のフィルター"
msgid "Current Password"
msgstr "現在のパスワード"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "現在、ドイツ語の履歴書に対応しています。「ページ」で設定された言語を使用します。"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "カスタム"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "ទទឹង​គែម"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "បំបែកប្រវត្តិរូបសង្ខេបជាផ្នែកធម្មតា ដើម្បីឱ្យវាអាចត្រូវបានបែងចែក។"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "តម្រងបច្ចុប្បន្ន"
msgid "Current Password"
msgstr "ពាក្យសម្ងាត់​បច្ចុប្បន្ន"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "បច្ចុប្បន្នមានសម្រាប់ប្រវត្តិរូបសង្ខេបរបស់អាល្លឺម៉ង់។ ប្រើភាសាដែលបានកំណត់ក្នុងទំព័រ។"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "ផ្ទាល់ខ្លួន"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "ಗಡಿಯ ಅಗಲ"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "ಇದನ್ನು ವಿಭಾಗಿಸಬಹುದಾಗುವಂತೆ ರೆಸ್ಯೂಮೆಯನ್ನು ಸಾಮಾನ್ಯ ವಿಭಾಗಗಳಾಗಿ ಒಡೆಯಿರಿ."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "ಪ್ರಸ್ತುತ ಫಿಲ್ಟರ್‌ಗಳು"
msgid "Current Password"
msgstr "ಪ್ರಸ್ತುತ ಪಾಸ್‌ವರ್ಡ್"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "ಪ್ರಸ್ತುತ ಜರ್ಮನ್ ರೆಸ್ಯೂಮ್‌ಗಳಿಗೆ ಲಭ್ಯವಿದೆ. ಪುಟದಲ್ಲಿ ಹೊಂದಿಸಲಾದ ಭಾಷೆಯನ್ನು ಬಳಸುತ್ತದೆ."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "ಕಸ್ಟಮ್"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "테두리 두께"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "이력서를 일반적인 섹션으로 나누어 구분할 수 있게 하세요."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "현재 필터"
msgid "Current Password"
msgstr "현재 비밀번호"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "현재 독일어 이력서에 대해 제공됩니다. ‘페이지’에서 설정된 언어를 사용합니다."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "사용자 지정"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Rėmelio plotis"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Padalykite gyvenimo aprašymą į įprastus skyrius, kad jį būtų galima segmentuoti."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Dabartiniai filtrai"
msgid "Current Password"
msgstr "Dabartinis slaptažodis"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Šiuo metu ši funkcija prieinama vokiškiems CV. Naudojama „Page“ nustatytą kalba."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Pasirinktinis"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Apmales platums"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Sadaliet CV ierastajās sadaļās, lai to varētu segmentēt."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Pašreizējie filtri"
msgid "Current Password"
msgstr "Pašreizējā parole"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Pašlaik pieejams vācu valodas CV. Izmanto „Lapā” iestatīto valodu."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Pielāgots"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "ബോർഡർ വീതി"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "വിഭജിക്കാൻ കഴിയുംവിധം റിസ്യൂമിനെ പതിവ് വിഭാഗങ്ങളായി തിരിക്കുക."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "നിലവിലെ ഫിൽട്ടറുകൾ"
msgid "Current Password"
msgstr "നിലവിലെ പാസ്‌വേഡ്"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "നിലവിൽ ജർമ്മൻ റെസ്യൂമുകൾക്കായി ലഭ്യമാണ്. പേജിൽ സജ്ജീകരിച്ച ഭാഷയാണ് ഇത് ഉപയോഗിക്കുന്നത്."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "ഇഷ്ടാനുസൃതം"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "सीमेची जाडी"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "रेझ्युमेचे विभाजन करता यावे म्हणून तो नेहमीच्या विभागांमध्ये विभागा."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "वर्तमान फिल्टर"
msgid "Current Password"
msgstr "वर्तमान पासवर्ड"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "सध्या जर्मन रिझ्युमेसाठी उपलब्ध आहे. पृष्ठात सेट केलेली भाषा वापरते."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "सानुकूल"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Lebar Sempadan"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Pecahkan resume kepada bahagian biasa supaya ia boleh disegmenkan."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Penapis semasa"
msgid "Current Password"
msgstr "Kata Laluan Semasa"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Pada masa ini tersedia untuk resume Jerman. Menggunakan set bahasa yang ditetapkan pada halaman."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Kustom"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "बोर्डर (किनारा) चौडाइ"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "बायोडाटालाई सामान्य खण्डहरूमा विभाजन गर्नुहोस् ताकि यसलाई छुट्याउन सकियोस्।"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "हालको फिल्टरहरू"
msgid "Current Password"
msgstr "हालको पासवर्ड"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "हाल जर्मन रिजुमेहरूका लागि उपलब्ध छ। पृष्ठमा सेट गरिएको भाषा प्रयोग गर्दछ।"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "अनुकूलित"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Lijndikte"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Verdeel het cv in de gebruikelijke secties, zodat het kan worden gesegmenteerd."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Huidige filters"
msgid "Current Password"
msgstr "Huidige wachtwoord"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Momenteel beschikbaar voor Duitse cv’s. Maakt gebruik van de taal die is ingesteld onder ‘Pagina’."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Aangepaste"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Kantbredde"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Del CV-en inn i de vanlige seksjonene, slik at den kan segmenteres."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Gjeldende filtre"
msgid "Current Password"
msgstr "Nåværende passord"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Foreløpig tilgjengelig for tyske CV-er. Bruker språket som er angitt under «Side»."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Egendefinert"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "ବର୍ଡର୍ ପ୍ରସ୍ଥ"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "ରେଜ୍ୟୁମେକୁ ସାଧାରଣ ବିଭାଗଗୁଡ଼ିକରେ ଭାଙ୍ଗନ୍ତୁ ଯାହା ଦ୍ୱାରା ଏହାକୁ ଖଣ୍ଡ କରାଯାଇପାରିବ।"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "ସାମ୍ପ୍ରତିକ ଫିଲ୍ଟର୍ |"
msgid "Current Password"
msgstr "ବର୍ତ୍ତମାନର ପାସୱାର୍ଡ"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "ଜର୍ମାନ୍ ରେଜ୍ୟୁମ୍ ପାଇଁ ବର୍ତ୍ତମାନ ଉପଲବ୍ଧ | ପୃଷ୍ଠାରେ ସେଟ୍ ହୋଇଥିବା ଭାଷା ବ୍ୟବହାର କରେ |"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "କଷ୍ଟମ୍"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Szerokość obramowania"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Podziel CV na typowe sekcje, aby można je było posegmentować."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktualne filtry"
msgid "Current Password"
msgstr "Obecne hasło"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Obecnie funkcja ta jest dostępna dla CV w języku niemieckim. Wykorzystuje ustawienia językowe określone w oknie „Strona”."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Niestandardowe"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Largura da borda"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Divida o currículo nas seções habituais para que possa ser segmentado."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtros atuais"
msgid "Current Password"
msgstr "Senha atual"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Atualmente disponível para currículos em alemão. Utiliza o idioma definido na página."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Personalizado"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Largura da borda"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Divida o currículo nas secções habituais para que possa ser segmentado."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtros atuais"
msgid "Current Password"
msgstr "Senha atual"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Atualmente disponível para currículos em alemão. Utiliza o idioma definido na página."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Personalizado"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Lățime bordură"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Împărțiți CV-ul în secțiunile obișnuite pentru a putea fi segmentat."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtre curente"
msgid "Current Password"
msgstr "Parolă curentă"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Disponibil în prezent pentru CV-urile în limba germană. Utilizează limba setată în pagina „Page”."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Personalizat"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Ширина рамки"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Разбейте резюме на привычные разделы, чтобы его можно было сегментировать."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Текущие фильтры"
msgid "Current Password"
msgstr "Текущий пароль"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "В настоящее время доступно для резюме на немецком языке. Используется язык, заданный в настройках страницы."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Пользовательский"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Šírka okraja"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Rozdeľte životopis na obvyklé sekcie, aby sa dal segmentovať."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktuálne filtre"
msgid "Current Password"
msgstr "Aktuálne heslo"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "V súčasnosti je táto funkcia k dispozícii pre nemecké životopisy. Používa jazyk nastavený v okne „Stránka“."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Vlastné"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Širina obrobe"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Razdelite življenjepis na običajne razdelke, da ga je mogoče segmentirati."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Trenutni filtri"
msgid "Current Password"
msgstr "Trenutno geslo"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Trenutno na voljo za nemške življenjepise. Uporablja jezik, nastavljen v meniju »Stran«."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Po meri"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Gjerësia e kornizës"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Ndajeni CV-në në seksionet e zakonshme që të mund të segmentohet."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Filtrat aktualë"
msgid "Current Password"
msgstr "Fjalëkalimi aktual"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Aktualisht i disponueshëm për rezumet gjermane. Përdor gjuhën e vendosur në faqe."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Sipas porosisë"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Ширина оквира"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Поделите биографију на уобичајене одељке како би могла да се сегментира."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Тренутни филтери"
msgid "Current Password"
msgstr "Тренутна лозинка"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Тренутно је доступан за немачке биографије. Користи језик подешен на страници."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "По мери"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Kantbredd"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Dela upp CV:t i de vanliga avsnitten så att det kan segmenteras."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Aktuella filter"
msgid "Current Password"
msgstr "Nuvarande lösenord"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Finns för närvarande för tyska CV:n. Använder det språk som är inställt under ”Sida”."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Anpassad"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "எல்லையின் அகலம்"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "பிரிக்கக்கூடிய வகையில் ரெஸ்யூமை வழக்கமான பிரிவுகளாகப் பிரிக்கவும்."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "தற்போதைய வடிப்பான்கள்"
msgid "Current Password"
msgstr "தற்போதைய கடவுச்சொல்"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "தற்போது ஜெர்மன் ரெஸ்யூம்களுக்குக் கிடைக்கிறது. பக்கத்தில் அமைக்கப்பட்ட மொழியைப் பயன்படுத்துகிறது."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "தனிப்பயன்"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "సరిహద్దు వెడల్పు"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "విభజించగలిగేలా రెజ్యూమ్‌ను మామూలు విభాగాలుగా విడగొట్టండి."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "ప్రస్తుత ఫిల్టర్లు"
msgid "Current Password"
msgstr "ప్రస్తుత పాస్‌వర్డ్"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "ప్రస్తుతం జర్మన్ రెస్యూమ్‌ల కోసం అందుబాటులో ఉంది. పేజీలో సెట్ చేసిన భాషను ఉపయోగిస్తుంది."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "స్వంతం"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "ความกว้างของกรอบ"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "แบ่งเรซูเม่ออกเป็นส่วนตามปกติเพื่อให้แบ่งส่วนได้"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "ตัวกรองปัจจุบัน"
msgid "Current Password"
msgstr "รหัสผ่านเดิม"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "ปัจจุบันรองรับเรซูเม่ภาษาเยอรมัน ใช้ภาษาที่ตั้งค่าไว้ในหน้า"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "กำหนดเอง"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Kenar Kalınlığı"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Bölümlere ayrılabilmesi için özgeçmişi alışılmış bölümlere ayırın."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Mevcut filtreler"
msgid "Current Password"
msgstr "Geçerli Parola"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Şu anda Almanca özgeçmişler için kullanılabilir. Sayfa ayarlarında belirlenen dili kullanır."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Özel"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Товщина рамки"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Розбийте резюме на звичні розділи, щоб його можна було сегментувати."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Поточні фільтри"
msgid "Current Password"
msgstr "Поточний пароль"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Наразі доступно для німецьких резюме. Використовує мову, встановлену в розділі «Сторінка»."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Нестандартний"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Chegara kengligi"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Rezyumeni segmentlash mumkin boʻlishi uchun odatiy boʻlimlarga ajrating."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Joriy filtrlar"
msgid "Current Password"
msgstr "Joriy parol"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Hozirda nemis tilidagi rezyumelar uchun mavjud. Sahifada belgilangan tilni ishlatadi."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Moslashtirilgan"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "Độ rộng bo viền"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "Chia sơ yếu lý lịch thành các phần thông thường để có thể phân đoạn được."
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "Bộ lọc hiện tại"
msgid "Current Password"
msgstr "Mật khẩu hiện tại"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "Hiện đang hỗ trợ sơ yếu lý lịch bằng tiếng Đức. Sử dụng ngôn ngữ được thiết lập trong phần “Trang”."
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "Tùy chỉnh"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "边框宽度"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "把简历拆分成常见的几个部分,方便系统进行分段。"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "当前筛选条件"
msgid "Current Password"
msgstr "当前密码"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "目前支持德语简历。使用“页面”中设置的语言。"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "定制"
+4 -4
View File
@@ -1509,6 +1509,10 @@ msgstr "邊框寬度"
msgid "Break the resume into the usual sections so it can be segmented."
msgstr "把履歷拆分成常見的幾個部分,方便系統進行分段。"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2444,10 +2448,6 @@ msgstr "目前的篩選條件"
msgid "Current Password"
msgstr "目前密碼"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr "目前支援德語履歷。使用「頁面」中設定的語言。"
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr "自訂"
+4 -4
View File
@@ -1504,6 +1504,10 @@ msgstr ""
msgid "Break the resume into the usual sections so it can be segmented."
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Breaks long words between syllables, using the language set in Page."
msgstr ""
#. Screen-reader dialog title for the command palette in the resume builder
#: src/features/command-palette/index.tsx
msgid "Builder Command Palette"
@@ -2439,10 +2443,6 @@ msgstr ""
msgid "Current Password"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Currently available for German resumes. Uses the language set in Page."
msgstr ""
#: src/components/input/color-picker.tsx
msgid "Custom"
msgstr ""
-1
View File
@@ -40,7 +40,6 @@
"@orpc/server": "^1.15.4",
"@orpc/tanstack-query": "^1.15.4",
"@phosphor-icons/react": "^2.1.10",
"@react-pdf/renderer": "^4.9.0",
"@reactive-resume/ai": "workspace:*",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
+12 -65
View File
@@ -1,17 +1,17 @@
Third-party notices for German PDF hyphenation
=============================================
Third-party notices for PDF rendering
=====================================
Reactive Resume includes the following third-party components for German
PDF hyphenation. Their copyright and license notices follow.
Reactive Resume renders PDFs with the Forme engine, which runs in the browser
as WebAssembly. Its copyright and license notice follows.
@react-pdf/hyphenate 0.1.0
-------------------------
Source: https://github.com/diegomura/react-pdf/tree/master/packages/hyphenate
License source: https://github.com/diegomura/react-pdf/blob/master/LICENSE
@formepdf/core 0.25.0
---------------------
Source: https://github.com/formepdf/forme
License: MIT
MIT License
Copyright (c) 2017-present Diego Muracciole <diegomuracciole@gmail.com>
Copyright (c) 2026 Daniel Molitor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -31,59 +31,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
German hyphenation patterns (de-1996), version 2018-03-31
------------------------------------------------------
Reformed German orthography (2006).
Authors: Deutschsprachige Trennmustermannschaft <trennmuster@dante.de>
Distributed by hyphen 1.6.6 and used by @react-pdf/hyphenate.
Source: https://github.com/ytiurin/hyphen/blob/d008cbe2b4da162e7ec71dbb624d527aa3028eca/patterns/de-1996.js
Pattern source: http://repo.or.cz/w/wortliste.git?a=commit;h=8b9a428c271e064f0047a364c532308f0fd4051f
Copyright (C) 2013-2018
Stephan Hennig, Werner Lemberg, Günter Milde,
Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke,
Tobias Wendorf
MIT License
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the “Software”), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
hyphen 1.6.6
------------
Source: https://github.com/ytiurin/hyphen/tree/d008cbe2b4da162e7ec71dbb624d527aa3028eca
ISC License (ISC)
Copyright (c) 2021, Yevhen Tiurin <yevhentiurin@gmail.com>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
Forme hyphenates text with the hypher crate and the TeX hyphenation patterns
it bundles. Their notices are published with that project:
https://github.com/typst/hypher
@@ -1,10 +1,8 @@
import type { PageMap } from "@reactive-resume/pdf/page-map";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { useMemo } from "react";
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
import { ResumeDocument } from "@reactive-resume/pdf/document";
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "@/libs/resume/section-title-locale";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
type ResumePdfRenderOptions = {
includeCoverLetterHeader?: boolean;
@@ -15,22 +13,6 @@ type CreateResumePdfBlobExtras = {
onPageMap?: (pageMap: PageMap) => void;
};
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
return useMemo(() => {
if (!data || !sectionTitleResolver) return null;
return (
<ResumeDocument
data={data}
template={template ?? data.metadata.template}
resolveSectionTitle={sectionTitleResolver}
/>
);
}, [data, template, sectionTitleResolver]);
};
export const createResumePdfBlob = async (
data: ResumeData,
template?: Template,
@@ -59,9 +59,7 @@ describe("Typography hyphenation", () => {
const toggle = screen.getByRole("switch", { name: "Hyphenation" });
expect(toggle).not.toBeChecked();
expect(toggle).toHaveAccessibleDescription(
"Currently available for German resumes. Uses the language set in Page.",
);
expect(toggle).toHaveAccessibleDescription("Breaks long words between syllables, using the language set in Page.");
expect(state.update).not.toHaveBeenCalled();
});
@@ -99,7 +99,7 @@ function TypographySectionForm() {
</FormLabel>
</div>
<FormDescription>
<Trans>Currently available for German resumes. Uses the language set in Page.</Trans>
<Trans>Breaks long words between syllables, using the language set in Page.</Trans>
</FormDescription>
</FormItem>
)}
+23 -16
View File
@@ -1,15 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { lazy, Suspense } from "react";
import { useEffect, useState } from "react";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { templateSchema } from "@reactive-resume/schema/templates";
import { useLocalizedResumeDocument } from "@/features/resume/export/pdf-document";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { createNoindexFollowMeta } from "@/libs/seo";
const PDFViewer = lazy(async () => {
const { PDFViewer } = await import("@react-pdf/renderer");
return { default: PDFViewer };
});
export const Route = createFileRoute("/templates/$")({
component: TemplatePdfRoute,
errorComponent: () => <div>Template not found</div>,
@@ -20,18 +15,30 @@ export const Route = createFileRoute("/templates/$")({
function TemplatePdfRoute() {
const params = Route.useParams();
const template = templateSchema.parse(params._splat?.split(".")[0] ?? "azurill");
const [url, setUrl] = useState<string>();
const templateName = params._splat?.split(".")[0] ?? "azurill";
const template = templateSchema.parse(templateName);
const resumeDocument = useLocalizedResumeDocument(sampleResumeData, template);
useEffect(() => {
let objectUrl: string | undefined;
let cancelled = false;
void createResumePdfBlob(sampleResumeData, template).then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [template]);
if (!resumeDocument) return null;
if (!url) return null;
return (
<Suspense fallback={null}>
<PDFViewer showToolbar={false} style={{ height: "100svh", width: "100svw", border: "none" }}>
{resumeDocument}
</PDFViewer>
</Suspense>
<iframe
title={`${template} template`}
src={`${url}#toolbar=0`}
style={{ height: "100svh", width: "100svw", border: "none" }}
/>
);
}
+3 -3
View File
@@ -51,7 +51,7 @@ flowchart TD
| `packages/env` | Server environment validation and root `.env` loading |
| `packages/schema` | Zod schemas and typed resume/page/template models |
| `packages/resume` | Pure resume-domain helpers, including JSON Patch behavior and network icon mapping |
| `packages/pdf` | React PDF document, template primitives, templates, font registration, and browser/server generation adapters |
| `packages/pdf` | Resume document, template primitives, Forme conversion, templates, font registration, and browser/server generation adapters |
| `packages/docx` | DOCX export generation |
| `packages/mcp` | MCP tools, prompts, resources, server card, and tool metadata |
| `packages/ui` | Shared Base UI/shadcn-style primitives and hooks |
@@ -93,7 +93,7 @@ When adding code, choose the owner by behavior:
| API procedure or authenticated business behavior | `packages/api/src/features/<domain>` |
| Pure resume data logic | `packages/resume` |
| Resume schema or template list shape | `packages/schema` |
| React PDF template/rendering behavior | `packages/pdf` |
| PDF template/rendering behavior | `packages/pdf` |
| PDF.js canvas/viewer UI | `apps/web/src/features/resume` |
| DOCX export behavior | `packages/docx` |
| MCP tool/prompt/resource behavior | `packages/mcp` |
@@ -124,7 +124,7 @@ Avoid reintroducing technical-layer folders such as `services/` or `helpers/` at
## PDF and export boundaries
`packages/pdf` owns React PDF generation:
`packages/pdf` owns PDF generation. Templates are written with React primitives; `src/forme` renders them with a small React renderer and converts the result into a [Forme](https://www.formepdf.com/) document, which the Forme engine (WebAssembly) lays out and draws:
- `@reactive-resume/pdf/browser` creates browser PDF blobs.
- `@reactive-resume/pdf/server` creates server PDF files.
+2 -2
View File
@@ -43,7 +43,7 @@ These steps set up Reactive Resume for local development, whether you're contrib
- **SeaweedFS** — S3-compatible storage (port 8333)
<Info>
**From v5.1.0 onwards** — PDF generation now runs entirely in the browser via `@react-pdf/renderer`, so no Browserless or Chromium container is required for development.
**From v5.1.0 onwards** — PDF generation now runs entirely in the browser with the Forme PDF engine (WebAssembly), so no Browserless or Chromium container is required for development.
</Info>
<Tip>
@@ -164,7 +164,7 @@ reactive-resume/
│ ├── db/ # Drizzle client and schema
│ ├── docx/ # DOCX export generation
│ ├── mcp/ # MCP tools, prompts, resources, and metadata
│ ├── pdf/ # React PDF rendering and PDF generation adapters
│ ├── pdf/ # PDF rendering (Forme) and PDF generation adapters
│ ├── resume/ # Pure resume-domain helpers
│ ├── schema/ # Zod schemas and typed models
│ ├── ui/ # Shared Base UI/shadcn-style primitives
+1 -1
View File
@@ -52,7 +52,7 @@ The easiest way to use Reactive Resume is our cloud version at [rxresu.me](https
You can deploy Reactive Resume on your own infrastructure using Docker.
<Info>
**From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. Self-hosted deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
**From v5.1.0 onwards** — PDF generation now runs entirely client-side with the Forme PDF engine (WebAssembly). Self-hosted deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
</Info>
### Prerequisites
+1 -1
View File
@@ -4714,7 +4714,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering.",
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering.",
"type": "array",
"items": {}
},
+1 -1
View File
@@ -131,7 +131,7 @@ We share information only as needed to provide the Service:
### PDF generation (client-side)
When you export to PDF, the Service renders the document directly in your browser using `@react-pdf/renderer`. Resume content is processed locally on your device for this purpose and is not sent to a separate rendering service. No third-party "printer" or headless-browser service is involved in the export.
When you export to PDF, the Service renders the document directly in your browser using the Forme PDF engine, which runs in your browser as WebAssembly. Resume content is processed locally on your device for this purpose and is not sent to a separate rendering service. No third-party "printer" or headless-browser service is involved in the export.
### Storage providers (optional)
+1 -1
View File
@@ -90,7 +90,7 @@ You represent that you have the rights necessary to upload and use any files and
## Exports (PDF)
When you request a PDF export, the Service renders the document in your own browser using `@react-pdf/renderer`. Your resume content is processed locally by the JavaScript running on your device; it is not transmitted to a separate rendering service for the purpose of generating the PDF.
When you request a PDF export, the Service renders the document in your own browser using the Forme PDF engine. Your resume content is processed locally by the JavaScript running on your device; it is not transmitted to a separate rendering service for the purpose of generating the PDF.
---
+3 -3
View File
@@ -4,7 +4,7 @@ description: "How to self-host Reactive Resume with Docker (Postgres only), with
---
<Info>
**From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
**From v5.1.0 onwards** — PDF generation now runs entirely client-side with the Forme PDF engine (WebAssembly). New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
</Info>
## Overview
@@ -541,8 +541,8 @@ A healthy response returns HTTP 200. If you get a different status code, the JSO
</Accordion>
<Accordion title="PDF export fails or downloads an empty file">
- **Common cause**: PDFs are now rendered in the browser via `@react-pdf/renderer`, so failures usually come from a
blocked download, an extreme browser memory limit, or a custom CSP that strips inline workers. - **Checks**: confirm
- **Common cause**: PDFs are now rendered in the browser with the Forme PDF engine (WebAssembly), so failures usually come from a
blocked download, an extreme browser memory limit, or a custom CSP that blocks WebAssembly (it needs `'wasm-unsafe-eval'` in `script-src`). - **Checks**: confirm
the browser is up to date, the page hasn't been opened in a restricted iframe, and that no extension is intercepting
the download. There is no server-side printer to inspect.
</Accordion>
+1 -1
View File
@@ -4,7 +4,7 @@ description: "Ready-to-use Docker Compose examples for self-hosting Reactive Res
---
<Info>
**From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. None of the examples below require a Browserless or Chromium service. Older configurations that still define a `printer` service or set `BROWSERLESS_TOKEN` / `PRINTER_*` will continue to start, but those services are inert and can be removed.
**From v5.1.0 onwards** — PDF generation now runs entirely client-side with the Forme PDF engine (WebAssembly). None of the examples below require a Browserless or Chromium service. Older configurations that still define a `printer` service or set `BROWSERLESS_TOKEN` / `PRINTER_*` will continue to start, but those services are inert and can be removed.
</Info>
## Overview
+3 -3
View File
@@ -4,7 +4,7 @@ description: "How to self-host Reactive Resume on Kubernetes with plain manifest
---
<Info>
**From v5.1.0 onwards** — the builder generates PDFs in the browser via `@react-pdf/renderer`. New deployments no
**From v5.1.0 onwards** — the builder generates PDFs in the browser with the Forme PDF engine (WebAssembly). New deployments no
longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and
`BROWSERLESS_*` environment variables are no longer read and can be removed from your configuration.
</Info>
@@ -416,8 +416,8 @@ curl -f http://localhost:3000/api/health
</Step>
<Step title="Export a PDF">
Open **Download** in the builder header and choose **PDF**. Builder PDF rendering happens in the browser via
`@react-pdf/renderer`. Open the downloaded file and check its text, fonts, and picture.
Open **Download** in the builder header and choose **PDF**. Builder PDF rendering happens in the browser with
the Forme PDF engine. Open the downloaded file and check its text, fonts, and picture.
</Step>
<Step title="Check persistence">
+20 -20
View File
@@ -9800,7 +9800,7 @@
"get": {
"operationId": "exportAccountData",
"summary": "Export user account data",
"description": "Returns a JSON-serializable export of the authenticated user's data, including their public profile fields, resumes, and independent cover letters. Images remain URL references. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
"description": "Returns a JSON-serializable export of the authenticated user's data, including their public profile fields, resumes, independent cover letters and job applications. Images remain URL references. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
"tags": [
"Authentication"
],
@@ -10365,7 +10365,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -11108,7 +11108,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -11766,7 +11766,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -12533,7 +12533,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -13274,7 +13274,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -13949,7 +13949,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -14630,7 +14630,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -15288,7 +15288,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -15888,7 +15888,7 @@
"styleRules": {
"type": "array",
"items": {},
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -16479,7 +16479,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -17341,7 +17341,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -18099,7 +18099,7 @@
"description": "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -23809,7 +23809,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -28168,7 +28168,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -32662,7 +32662,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -37127,7 +37127,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -42080,7 +42080,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -46537,7 +46537,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering."
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering."
},
"stylesheet": {
"type": "object",
@@ -51353,7 +51353,7 @@
"description": "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string."
},
"styleRules": {
"description": "Structured style rules that target semantic resume sections and slots for React PDF rendering.",
"description": "Structured style rules that target semantic resume sections and slots for PDF rendering.",
"type": "array",
"items": {}
},
+3 -4
View File
@@ -31,10 +31,9 @@ export async function createResumePdfDownload(input: CreateResumePdfDownloadInpu
const filename = generateFilename(target === "cover-letter" ? `${resume.name} Cover Letter` : resume.name, "pdf");
try {
// Lazy-load the PDF renderer (@reactive-resume/pdf → @react-pdf/renderer +
// phosphor-icons-react-pdf, ~10.6k icon modules) only when a PDF is actually
// exported, instead of at server boot. Slashes cold-start file I/O on
// constrained/slow-disk hosts. See fork perf/lazy-load-pdf.
// Lazy-load the PDF renderer (@reactive-resume/pdf → the Forme WASM engine and
// the icon drawings) only when a PDF is actually exported, instead of at server
// boot. Keeps cold starts light on constrained/slow-disk hosts.
const { createResumePdfFile } = await import("@reactive-resume/pdf/server");
const body = await createResumePdfFile({ data: getResumeExportData(data, target), filename });
+2 -2
View File
@@ -112,8 +112,8 @@ for (const font of fontList) {
}
// Compatibility aliases for fonts that v5.0.x resolved via the browser
// (Arial, Times New Roman, ...) but that aren't registered with
// @react-pdf/renderer in v5.1+. Targets are metric-compatible web fonts
// (Arial, Times New Roman, ...) but that the PDF engine doesn't know by
// name. Targets are metric-compatible web fonts
// already shipped in webfontlist (#2989).
const legacyFontAliases: Record<string, string> = {
Arial: "Arimo",
-5
View File
@@ -27,8 +27,6 @@
"dependencies": {
"@formepdf/core": "0.25.0",
"@formepdf/react": "0.25.0",
"@react-pdf/hyphenate": "0.1.0",
"@react-pdf/renderer": "^4.9.0",
"@reactive-resume/fonts": "workspace:*",
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
@@ -37,14 +35,11 @@
"fast-png": "^8.0.0",
"fflate": "^0.8.3",
"node-html-parser": "^9.0.4",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.3.0",
"react-pdf-html": "^2.1.5",
"react-reconciler": "0.34.0"
},
"devDependencies": {
"@napi-rs/canvas": "1.0.9",
"@react-pdf/types": "^2.14.0",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.3.0",
"@types/react-reconciler": "^0.33.0",
@@ -5,12 +5,12 @@ import type { SectionTitleResolver } from "./section-title";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { getDocument, OPS } from "pdfjs-dist/legacy/build/pdf.mjs";
import { createElement } from "react";
import { analyzePdfResume, harvestPdfDocument, PDF_OPS } from "@reactive-resume/resume/ats-pdf";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumeDocument } from "./document";
import { renderToBuffer } from "./forme/testing";
const NOW = new Date("2024-06-15T00:00:00Z");
+28 -32
View File
@@ -4,19 +4,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const rendererMock = vi.hoisted(() => ({
pdf: vi.fn(() => ({
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
renderResume: vi.fn(async () => ({
pdf: new TextEncoder().encode("%PDF"),
pageMap: { pages: [], nodes: [] },
layout: { pages: [] },
warnings: [],
})),
}));
vi.mock("#react-pdf-renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
pdf: rendererMock.pdf,
}));
vi.mock("./document", () => ({
ResumeDocument: () => null,
}));
vi.mock("./forme/render", () => ({ renderResume: rendererMock.renderResume }));
vi.mock("@formepdf/core/worker", () => ({ init: vi.fn(async () => {}) }));
const createLegacyRendererSafeResumeData = (): ResumeData =>
({
@@ -57,7 +54,7 @@ const createRendererUnsafeResumeData = (): ResumeData => {
describe("createResumePdfBlob", () => {
beforeEach(() => {
rendererMock.pdf.mockClear();
rendererMock.renderResume.mockClear();
});
it("renders ResumeDocument with data, template, and section title resolver", async () => {
@@ -72,25 +69,24 @@ describe("createResumePdfBlob", () => {
});
expect(blob.type).toBe("application/pdf");
expect(rendererMock.pdf).toHaveBeenCalledTimes(1);
expect(rendererMock.pdf).toHaveBeenCalledWith(
expect(rendererMock.renderResume).toHaveBeenCalledTimes(1);
expect(rendererMock.renderResume).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
props: expect.objectContaining({
template: "azurill",
resolveSectionTitle,
data: expect.objectContaining({
customSections: [
expect.objectContaining({
items: [
expect.objectContaining({
content: "<p>Compatible overlap</p>",
roles: [],
website: { url: "", label: "", inlineLink: false },
}),
],
}),
],
}),
template: "azurill",
resolveSectionTitle,
data: expect.objectContaining({
customSections: [
expect.objectContaining({
items: [
expect.objectContaining({
content: "<p>Compatible overlap</p>",
roles: [],
website: { url: "", label: "", inlineLink: false },
}),
],
}),
],
}),
}),
);
@@ -104,11 +100,11 @@ describe("createResumePdfBlob", () => {
);
expect(error).toHaveProperty("issues.0.path", ["customSections", 0, "items", 0, "company"]);
expect(rendererMock.pdf).not.toHaveBeenCalled();
expect(rendererMock.renderResume).not.toHaveBeenCalled();
});
it("returns a rejected Promise when the renderer fails synchronously", async () => {
rendererMock.pdf.mockImplementationOnce(() => {
rendererMock.renderResume.mockImplementationOnce(() => {
throw new Error("renderer failed");
});
const { createResumePdfBlob } = await import("./browser");
@@ -125,6 +121,6 @@ describe("createResumePdfBlob", () => {
const { createResumePdfBlob } = await import("./browser");
await expect(createResumePdfBlob({ data })).resolves.toHaveProperty("type", "application/pdf");
expect(rendererMock.pdf).toHaveBeenCalledTimes(1);
expect(rendererMock.renderResume).toHaveBeenCalledTimes(1);
});
});
+3 -1
View File
@@ -5,6 +5,7 @@ import type { PageMap } from "./page-map";
import type { SectionTitleResolver } from "./section-title";
import wasmUrl from "@formepdf/core/pkg-web/forme_bg.wasm?url";
import * as forme from "@formepdf/core/worker";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { renderResume } from "./forme/render";
export type CreateResumePdfBlobOptions = {
@@ -17,9 +18,10 @@ export type CreateResumePdfBlobOptions = {
};
export const createResumePdfBlob = async ({ onPageMap, ...input }: CreateResumePdfBlobOptions): Promise<Blob> => {
const data = parseResumeData(input.data);
// The 6.5 MB engine downloads with the first PDF, not with the app.
await forme.init(wasmUrl);
const { pdf, pageMap } = await renderResume(forme, input);
const { pdf, pageMap } = await renderResume(forme, { ...input, data });
onPageMap?.(pageMap);
return new Blob([pdf as Uint8Array<ArrayBuffer>], { type: "application/pdf" });
};
@@ -1,100 +0,0 @@
import { describe, expect, it } from "vitest";
import { Document, Font, Page, renderToBuffer, Text } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { act } from "react";
import { getWebFontSource } from "@reactive-resume/fonts";
async function baselines(families: string[], content = "Heading", lineHeight = 1.5) {
for (const family of families) {
const src = getWebFontSource(family, "400", false);
if (!src) throw new Error(`Missing test font: ${family}`);
Font.register({ family, src });
}
const bytes = await act(() =>
renderToBuffer(
<Document>
<Page size="A4">
{families.map((family, index) => (
<Text
key={family}
style={{
position: "absolute",
top: 20 + index * 100,
left: 20,
fontFamily: family,
fontSize: 20,
lineHeight,
}}
>
{content}
</Text>
))}
</Page>
</Document>,
),
);
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: true });
try {
const doc = await task.promise;
expect(doc.numPages).toBe(1);
const page = await doc.getPage(1);
const text = await page.getTextContent();
return text.items.flatMap((item) =>
"str" in item && item.str
? [{ text: item.str, y: page.getViewport({ scale: 1 }).height - Number(item.transform[5]) }]
: [],
);
} finally {
await task.destroy();
}
}
function baselineAt(rows: { y: number }[], index: number) {
const row = rows[index];
if (!row) throw new Error(`Missing PDF text row ${index}`);
return row.y;
}
describe("font baselines in generated PDFs (#3249)", () => {
it.each(["Roboto", "Roboto Condensed"])(
"aligns %s with Roboto Flex's matching vertical metrics",
{ timeout: 30_000 },
async (family) => {
const rows = await baselines([family, "Roboto Flex"]);
expect(rows).toHaveLength(2);
expect(baselineAt(rows, 0) - 20).toBeCloseTo(baselineAt(rows, 1) - 120, 3);
},
);
it("aligns IBM Plex Sans Condensed with IBM Plex Sans", { timeout: 30_000 }, async () => {
const rows = await baselines(["IBM Plex Sans Condensed", "IBM Plex Sans"]);
expect(rows).toHaveLength(2);
expect(baselineAt(rows, 0) - 20).toBeCloseTo(baselineAt(rows, 1) - 120, 3);
});
it.each([
["IBM Plex Sans", 20.5],
["Roboto Flex", 18.554688],
["Geist", 20.1],
["Ropa Sans", 16.82],
] as const)("preserves %s's existing baseline", { timeout: 30_000 }, async (family, expected) => {
const rows = await baselines([family]);
expect(baselineAt(rows, 0) - 20).toBeCloseTo(expected, 3);
});
it.each([
"Noto Sans HK",
"Noto Sans SC",
"Noto Serif SC",
"Noto Sans TC",
"Noto Serif TC",
"Noto Sans JP",
"Noto Serif JP",
"Noto Sans KR",
"Noto Serif KR",
])("preserves %s's compact CJK lines at tight line height", { timeout: 60_000 }, async (family) => {
const rows = await baselines([family], "高行\n高行", 0.8);
expect(rows.map((row) => row.text)).toEqual(["高行", "高行"]);
expect(baselineAt(rows, 0) - 20).toBeCloseTo(18, 3);
// The newline uses the standard-font fallback. Keep the existing 21.6pt
// line box; shrinking to the requested 16pt would clip the next line.
expect(baselineAt(rows, 1) - baselineAt(rows, 0)).toBeCloseTo(21.6, 3);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import * as forme from "@formepdf/core";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { renderResume } from "./render";
const words = "Profiles efficient office affluent flourish";
const extractedText = async (fontFamily: string) => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ligature Probe";
data.summary.content = `<p>${words}</p>`;
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
data.metadata.typography.body.fontFamily = fontFamily;
data.metadata.typography.heading.fontFamily = fontFamily;
const { pdf } = await renderResume(forme, { data, template: "onyx" });
const document = await getDocument({ data: pdf }).promise;
const page = await document.getPage(1);
return (await page.getTextContent()).items.map((item) => ("str" in item ? item.str : "")).join(" ");
};
// Forme 0.25 keeps only the first letter of a ligature in the text layer (#156), so ligatures are switched off in
// the font bytes. A font with fi/fl/ff ligatures must still extract every letter.
describe("ligatures", () => {
it.each(["Fira Sans", "IBM Plex Serif"])("extract whole words in %s", { timeout: 60_000 }, async (family) => {
const text = (await extractedText(family)).replaceAll(/\s+/g, " ");
for (const word of words.split(" ")) expect(text).toContain(word);
});
});
+51 -31
View File
@@ -3,11 +3,11 @@ import type { FormeDocument } from "@formepdf/react";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { Locale } from "@reactive-resume/utils/locale";
import type { ReactElement } from "react";
import type { ResumeRenderOptions } from "../context";
import type { PageMap } from "../page-map";
import type { SectionTitleResolver } from "../section-title";
import { createElement } from "react";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { ResumeDocument } from "../document";
import { resolvePdfFonts, resumeContentContainsCJK, resumeContentScripts } from "../hooks/use-register-fonts";
import { extractPageMap } from "../page-map";
@@ -23,6 +23,7 @@ export type FormeEngine = {
};
export type RenderResumeInput = {
/** Parsed with `parseResumeData`: the entry points check data at their boundary. */
data: ResumeData;
template?: Template | undefined;
renderOptions?: ResumeRenderOptions | undefined;
@@ -44,6 +45,15 @@ const FREE_FORM_MIN_HEIGHT = 841.89;
type PageKind = Extract<FormeDocument["children"][number]["kind"], { type: "Page" }>;
// Forme leaves a box it failed to place at ±Number.MAX_VALUE; the page is then likely wrong.
const offPage = (value: number) => !(Math.abs(value) < 1e6);
const misplacesABox = (result: RenderWithLayoutResult) =>
result.layout.pages.some((page) =>
page.elements.some(function bad(element): boolean {
return offPage(element.y) || offPage(element.height) || element.children.some(bad);
}),
);
const isFixed = (element: ElementInfo): boolean =>
element.sourceLocation?.file === FIXED_SOURCE || element.children.some(isFixed);
@@ -61,53 +71,63 @@ function measuredHeight(result: RenderWithLayoutResult, pageIndex: number, margi
* Renders a resume or letter to PDF bytes with its page map. Every PDF in the app goes through here: the live
* preview, downloads, thumbnails, the public page and the server export.
*/
export async function renderResume(engine: FormeEngine, input: RenderResumeInput): Promise<RenderedResume> {
const data = parseResumeData(input.data);
const template = input.template ?? data.metadata.template;
const { fonts: fontRequests } = resolvePdfFonts(
data.metadata.typography,
data.metadata.page.locale as Locale,
resumeContentContainsCJK(data),
resumeContentScripts(data),
);
const [{ fonts, warnings: fontWarnings }] = await Promise.all([loadFonts(fontRequests), loadIcons()]);
const tree = renderHostTree(
export function renderResume(engine: FormeEngine, input: RenderResumeInput): Promise<RenderedResume> {
const { data } = input;
return renderResumeElement(
engine,
createElement(ResumeDocument, {
data,
template,
template: input.template ?? data.metadata.template,
...(input.renderOptions ? { renderOptions: input.renderOptions } : {}),
resolveSectionTitle: input.resolveSectionTitle,
}),
);
}
/**
* Renders an element tree (normally a `ResumeDocument`) to PDF. The fonts come from the `data` prop of the root
* element when it has one; without it, only the standard PDF fonts are available.
*/
export async function renderResumeElement(engine: FormeEngine, element: ReactElement): Promise<RenderedResume> {
const data = (element.props as { data?: ResumeData }).data;
const fontRequests = data
? resolvePdfFonts(
data.metadata.typography,
data.metadata.page.locale as Locale,
resumeContentContainsCJK(data),
resumeContentScripts(data),
).fonts
: [];
const [{ fonts, warnings: fontWarnings }] = await Promise.all([loadFonts(fontRequests), loadIcons()]);
const tree = renderHostTree(element);
const { images, warnings: imageWarnings } = await loadImages(imageSources(tree));
const { document, warnings } = toFormeDocument(tree, images);
document.fonts = fonts;
let result = await engine.renderSerializedDocWithLayout({ ...document });
const layOut = async (keepNestedRowsWhole: boolean) => {
const { document, warnings } = toFormeDocument(tree, { images, keepNestedRowsWhole });
// Forme rewrites the font entries it's given (bytes to base64), so each render gets its own.
const render = () =>
engine.renderSerializedDocWithLayout({ ...document, fonts: fonts.map((font) => ({ ...font })) });
let result = await render();
// Free-form pages grow with their content: measure it, then render once more at that height.
if (data.metadata.page.format === "free-form") {
let changed = false;
// Free-form pages grow with their content: measure it, then render once more at that height.
let measured = false;
document.children.forEach((page, index) => {
const kind = page.kind as PageKind;
if (kind.type !== "Page" || typeof kind.config.size !== "object") return;
const { width, height } = kind.config.size.Custom;
if (height !== FREE_FORM_MEASURE_HEIGHT) return;
kind.config.size = { Custom: { width, height: measuredHeight(result, index, kind.config.margin.bottom) } };
changed = true;
measured = true;
});
if (changed) result = await engine.renderSerializedDocWithLayout({ ...document });
}
if (measured) result = await render();
return { result, warnings };
};
// Forme marks a box it failed to place with an infinite coordinate; the page is then likely wrong.
const misplaced = result.layout.pages.some((page) =>
page.elements.some(function bad(element): boolean {
return !Number.isFinite(element.y) || !Number.isFinite(element.height) || element.children.some(bad);
}),
);
if (misplaced) warnings.push("render defect: the engine couldn't place a box; a page may be laid out wrong");
let { result, warnings } = await layOut(false);
if (misplacesABox(result)) ({ result, warnings } = await layOut(true));
if (misplacesABox(result))
warnings.push("render defect: the engine couldn't place a box; a page may be laid out wrong");
return {
pdf: result.pdf,
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { flattenAlpha, parseColor, toColor, toFormeStyle, WHITE } from "./style";
const convert = (style: Record<string, unknown>) =>
toFormeStyle(style as never, { fontSize: 10, pageWidth: 600, pageHeight: 800 });
describe("toFormeStyle", () => {
it("keeps unitless line heights as multipliers and turns lengths into one", () => {
expect(convert({ lineHeight: 1.4 }).style.lineHeight).toBe(1.4);
expect(convert({ lineHeight: "150%" }).style.lineHeight).toBe(1.5);
expect(convert({ fontSize: 12, lineHeight: "18pt" }).style.lineHeight).toBe(1.5);
});
it("converts lengths to points and keeps percentages only for box sizes", () => {
expect(convert({ width: "1in", marginTop: "2em", left: "50%" }).style).toEqual({
width: 72,
marginTop: 20,
left: "50%",
});
expect(convert({ paddingTop: "10%" }).dropped).toEqual(["paddingTop"]);
});
it("drops CSS-wide keywords and defaults silently, and reports what Forme can't draw", () => {
const { style, dropped } = convert({
color: "inherit",
width: "auto",
alignSelf: "auto",
zIndex: 2,
borderTopStyle: "dashed",
display: "block",
});
expect(style).toEqual({ display: "flex" });
expect(dropped).toEqual(["zIndex", "borderTopStyle"]);
});
it("hides display: none boxes", () => {
expect(convert({ display: "none" }).hidden).toBe(true);
});
it("names the standard serif the way Forme does", () => {
expect(convert({ fontFamily: ["Times-Roman", "Noto Serif"] }).style.fontFamily).toBe("Times, Noto Serif");
});
});
describe("colours", () => {
it("normalises the forms Forme reads", () => {
expect(toColor("#abcd")).toBe("#aabbccdd");
expect(toColor("rgb(10 20 30 / 50%)")).toBe("rgba(10, 20, 30, 0.5)");
expect(toColor("rgb(100%, 0%, 0%)")).toBe("rgb(255, 0, 0)");
expect(toColor("transparent")).toBe("rgba(0, 0, 0, 0)");
expect(parseColor("#ff000080")).toEqual({ rgb: [255, 0, 0], alpha: 128 / 255 });
});
it("mixes translucent colours with what they're drawn over", () => {
const { style, backdrop } = flattenAlpha(
{ backgroundColor: "rgba(0, 0, 255, 0.5)", color: "rgba(0, 0, 0, 0.5)" },
WHITE,
);
expect(style.backgroundColor).toBe("#8080ff");
// Text is drawn over its own background.
expect(style.color).toBe("#404080");
expect(backdrop).toEqual([127.5, 127.5, 255]);
});
it("drops fully transparent paint and keeps opaque paint as it is", () => {
expect(flattenAlpha({ backgroundColor: "rgba(0, 0, 0, 0)", color: "#123456" }, WHITE).style).toEqual({
color: "#123456",
});
});
});
+3
View File
@@ -464,6 +464,7 @@ export function toFormeStyle(input: Style, context: Context): ConvertedStyle {
else dropped.push(key);
continue;
}
if (key === "alignSelf" && value === "auto") continue;
const keywords = KEYWORD_KEYS[key];
if (keywords) {
const keyword = String(value).trim();
@@ -476,6 +477,8 @@ export function toFormeStyle(input: Style, context: Context): ConvertedStyle {
style[key] = String(value);
continue;
}
// Only solid borders are drawn; a dashed or dotted one is drawn solid, and said so.
if (/^border(?:Top|Right|Bottom|Left)?Style$/.test(key) && value !== "solid") dropped.push(key);
if (IGNORED.has(key)) continue;
dropped.push(key);
}
+57
View File
@@ -0,0 +1,57 @@
import type { ReactElement } from "react";
import type { HostNode } from "./reconciler";
import * as forme from "@formepdf/core";
import { loadIcons } from "./icons";
import { HOST } from "./primitives";
import { renderHostTree } from "./reconciler";
import { renderResumeElement } from "./render";
/**
* Test helpers with the react-pdf calls the tests were written against: `renderToBuffer(element)` for bytes and
* `pdf(element).container.document` for the tree the templates drew, in react-pdf's node shape.
*/
export { Document, Image, Link, Page, Svg, Text, View } from "./primitives";
// Icons draw nothing until their paths are loaded; `renderResume` waits for them, and so do the tests.
await loadIcons();
export type RenderedNode =
| { type: "TEXT_INSTANCE"; value: string }
| { type: string; props: Record<string, unknown>; style: unknown; children: RenderedNode[] };
const NODE_TYPES: Record<string, string> = {
[HOST.document]: "DOCUMENT",
[HOST.page]: "PAGE",
[HOST.view]: "VIEW",
[HOST.text]: "TEXT",
[HOST.link]: "LINK",
[HOST.image]: "IMAGE",
[HOST.svg]: "SVG",
};
const toRenderedNode = (node: HostNode): RenderedNode =>
"text" in node
? { type: "TEXT_INSTANCE", value: node.text }
: {
type: NODE_TYPES[node.type] ?? node.type,
props: node.props,
// react-pdf kept the style beside the props.
style: node.props.style,
children: node.children.map(toRenderedNode),
};
/** The PDF bytes, as a Node `Buffer` like react-pdf's. */
export async function renderToBuffer(element: ReactElement): Promise<Buffer> {
return Buffer.from((await renderResumeElement(forme, element)).pdf);
}
export function pdf(element: ReactElement) {
const [root] = renderHostTree(element);
return {
container: { document: root ? toRenderedNode(root) : null },
toBuffer: () => renderToBuffer(element),
toBlob: async () =>
new Blob([(await renderToBuffer(element)) as Uint8Array<ArrayBuffer>], { type: "application/pdf" }),
};
}
+185 -21
View File
@@ -16,7 +16,7 @@ import {
serialize,
} from "@formepdf/react";
import { cloneElement, createElement, isValidElement } from "react";
import { NODE_SOURCE_PREFIX, RESUME_NODE_PROP } from "../page-map";
import { NODE_CONTENT_PREFIX, NODE_SOURCE_PREFIX, RESUME_NODE_PROP } from "../page-map";
import { HOST } from "./primitives";
import { flattenAlpha, flattenStyle, toFormeStyle, toPoints, WHITE } from "./style";
@@ -39,8 +39,13 @@ type Context = {
inText: boolean;
/** The pictures, loaded by `renderResume`, by source. */
images: ReadonlyMap<string, LoadedImage>;
/** The key of the nearest tagged block (see `tagNode`). */
nodeKey?: string | undefined;
/** Whether some ancestor lays its children out in a row. */
insideRow: boolean;
/** Whether the parent lays its children out in a row. */
rowParent: boolean;
keepNestedRowsWhole: boolean;
/** The parent's padding: Forme places absolute boxes inside it, react-pdf over it. */
parentPadding: Edges;
/** The opaque colour behind the element being converted (see `flattenAlpha`). */
@@ -112,6 +117,7 @@ const childContext = (context: Context, parent: ParentStyle, inText = context.in
backdrop: parent.backdrop,
parentPadding: paddingEdges(parent.style),
insideRow: context.insideRow || parent.style.flexDirection === "row" || parent.style.flexDirection === "row-reverse",
rowParent: parent.style.flexDirection === "row" || parent.style.flexDirection === "row-reverse",
inText,
});
@@ -207,26 +213,132 @@ function spreadRowGap(
return { style: next, children: result };
}
const ITEM_KEYS = [
"flex",
"flexGrow",
"flexShrink",
"alignSelf",
"margin",
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"marginHorizontal",
"marginVertical",
] as const;
/**
* Forme 0.25 sizes a row's child by its content and ignores its `minWidth`. The child goes in a box with an empty
* strut of that width beside it, which is as wide as the wider of the two; the child stretches across it.
*/
function strutMinWidth(element: ReactElement, context: Context): ReactElement {
const style = flowStyleOf(element);
const { minWidth } = style;
if (
!context.rowParent ||
typeof minWidth !== "number" ||
minWidth <= 0 ||
style.width !== undefined ||
style.flexBasis !== undefined ||
style.position === "absolute"
)
return element;
const outer: Record<string, unknown> = { position: "relative" };
const inner: Record<string, unknown> = { ...style };
delete inner.minWidth;
for (const property of ITEM_KEYS)
if (inner[property] !== undefined) {
outer[property] = inner[property];
delete inner[property];
}
const content = cloneElement(element as ReactElement<{ style: FormeStyle }>, {
key: "content",
style: inner as FormeStyle,
});
const source = context.sourceMap.get(element);
if (source) context.sourceMap.set(content, source);
return createElement(
FormeView,
{ key: element.key, style: outer as FormeStyle },
content,
createElement(FormeView, { key: "strut", style: { width: minWidth, height: 0 } }),
);
}
const isEmptyText = (node: HostNode): boolean =>
"text" in node
? node.text.length === 0
: (node.type === HOST.text || node.type === HOST.link) && node.children.every(isEmptyText);
const MIRRORED_JUSTIFY: Record<string, FormeStyle["justifyContent"]> = {
"flex-start": "flex-end",
"flex-end": "flex-start",
};
/**
* Forme 0.25 lays `row-reverse` out as `row`. The children go in reverse order in a row packed from the other end,
* which places them the same, except that a wrapping row fills its lines in the reverse order.
*/
function unreverseRow(spread: { style: FormeStyle; children: ReactNode[] }): {
style: FormeStyle;
children: ReactNode[];
} {
const { style, children } = spread;
if (style.flexDirection !== "row-reverse") return spread;
const inFlow = children.filter(
(child) => isValidElement<{ style?: FormeStyle }>(child) && child.props.style?.position !== "absolute",
);
// `space-between` packs a lone child at the start, which for `row-reverse` is the right.
const justify =
style.justifyContent === "space-between" && inFlow.length < 2
? "flex-start"
: (style.justifyContent ?? "flex-start");
// A growing child leaves no free space to pack. Mirroring anyway trips another Forme 0.25 defect: a row grown by
// its column packs its children along its height too (the page's layout box is such a row).
const grows = inFlow.some(
(child) =>
isValidElement<{ style?: FormeStyle }>(child) &&
Number(child.props.style?.flexGrow ?? child.props.style?.flex ?? 0) > 0,
);
return {
style: {
...style,
flexDirection: "row",
...(grows ? {} : { justifyContent: MIRRORED_JUSTIFY[justify] ?? justify }),
},
children: [...children].reverse(),
};
}
function convertChildren(children: HostNode[], context: Context): ReactNode[] {
return children.map((child, index) => convertNode(child, context, index));
}
function convertNode(node: HostNode, context: Context, key: number): ReactNode {
function convertNode(node: HostNode, parentContext: Context, key: number): ReactNode {
if ("text" in node) return node.text;
const { props } = node;
const ownKey = props[RESUME_NODE_PROP];
// Everything drawn inside a tagged block carries its key, so a block Forme leaves out of its layout (it does when
// a plain box breaks across pages) can be found by what it contains.
const context =
typeof ownKey === "string" && ownKey.length > 0 ? { ...parentContext, nodeKey: ownKey } : parentContext;
switch (node.type) {
case HOST.view: {
const converted = convertStyle(props.style, context, node);
const { style } = converted;
if (converted.hidden) return null;
const spread = spreadRowGap(style, convertChildren(node.children, childContext(context, converted)), context);
const spread = unreverseRow(
spreadRowGap(style, convertChildren(node.children, childContext(context, converted)), context),
);
const children = spread.children;
const viewStyle = flowStyle(props, spread.style);
// Forme 0.25 gives a row within a row a box at y -1.8e308 on the next page when the outer row splits, which
// wrecks the rest of the document. Rows within rows are headings, list items and icon-label pairs, which are
// better kept whole anyway.
if (context.insideRow && (style.flexDirection === "row" || style.flexDirection === "row-reverse"))
// See `ConvertOptions.keepNestedRowsWhole`.
if (
context.keepNestedRowsWhole &&
context.insideRow &&
(style.flexDirection === "row" || style.flexDirection === "row-reverse")
)
viewStyle.wrap = false;
const bordered = insetBorder(viewStyle, children);
const element = createElement(
@@ -239,7 +351,8 @@ function convertNode(node: HostNode, context: Context, key: number): ReactNode {
...bordered.children,
);
tagNode(element, props, context);
if (props.fixed !== true) return shrinkToFit(placePercentOffset(element, style, context), context);
if (props.fixed !== true)
return strutMinWidth(shrinkToFit(placePercentOffset(element, style, context), context), context);
// A repeated view nested in a column stays with the column: its fragments already paint on each page.
if (!context.pageMargin) return element;
return createElement(
@@ -253,14 +366,38 @@ function convertNode(node: HostNode, context: Context, key: number): ReactNode {
const converted = convertStyle(props.style, context, node);
const { style } = converted;
if (converted.hidden) return null;
// react-pdf gives a text with nothing in it no height; Forme gives it a line.
if (!context.inText && node.children.every(isEmptyText)) {
const element = createElement(FormeView, { key, style: flowStyle(props, style) });
tagNode(element, props, context);
return element;
}
const href = node.type === HOST.link && typeof props.src === "string" ? props.src : undefined;
const hasBlockChild = node.children.some(
(child) => child.type !== "#text" && child.type !== HOST.text && child.type !== HOST.link,
);
// A link around views (a whole entry, a picture) becomes a linked view.
if (href && hasBlockChild && !context.inText) {
const children = convertChildren(node.children, childContext(context, converted));
const element = createElement(FormeView, { key, style: flowStyle(props, style), href }, ...children);
// Text around views (a whole entry in a link, a nested list in an RTL list item) becomes a view: Forme's
// Text keeps only its text runs. Inline children between the views share a Text, as react-pdf flowed them.
if (hasBlockChild && !context.inText) {
const inner = childContext(context, converted);
const children: ReactNode[] = [];
let inline: HostNode[] = [];
const flush = () => {
if (inline.length === 0) return;
const runs = convertChildren(inline, { ...inner, inText: true });
children.push(createElement(FormeText, { key: children.length, style: context.textDefaults }, ...runs));
inline = [];
};
for (const child of node.children) {
if (child.type === "#text" || child.type === HOST.text || child.type === HOST.link) inline.push(child);
else {
flush();
children.push(convertNode(child, inner, children.length));
}
}
flush();
const viewStyle: FormeStyle = { ...flowStyle(props, style), flexDirection: "column" };
const element = createElement(FormeView, { key, style: viewStyle, ...(href ? { href } : {}) }, ...children);
tagNode(element, props, context);
return element;
}
@@ -271,14 +408,16 @@ function convertNode(node: HostNode, context: Context, key: number): ReactNode {
...children,
);
tagNode(element, props, context);
return element;
return context.inText ? element : strutMinWidth(element, context);
}
case HOST.image: {
const image = context.images.get(imageSource(props.src) ?? "");
if (!image) return null;
const { style, hidden } = convertStyle(props.style, context, node);
if (hidden) return null;
return placePercentOffset(fitImage(image, style, key), style, context);
const fitted = fitImage(image, style, key);
tagNode(fitted, props, context);
return placePercentOffset(fitted, style, context);
}
case HOST.svg: {
const { style, hidden } = convertStyle(props.style, context, node);
@@ -286,7 +425,7 @@ function convertNode(node: HostNode, context: Context, key: number): ReactNode {
const width = number(props.width, context) ?? (typeof style.width === "number" ? style.width : 12);
const height = number(props.height, context) ?? (typeof style.height === "number" ? style.height : width);
const opacity = typeof props.opacity === "number" ? props.opacity : undefined;
return createElement(FormeSvg, {
const svg = createElement(FormeSvg, {
key,
width,
height,
@@ -294,6 +433,8 @@ function convertNode(node: HostNode, context: Context, key: number): ReactNode {
style: opacity === undefined ? style : { ...style, opacity },
...(typeof props.viewBox === "string" ? { viewBox: props.viewBox } : {}),
});
tagNode(svg, props, context);
return svg;
}
default:
context.warnings.add(`Unknown element ${node.type}`);
@@ -418,7 +559,8 @@ function placePercentOffset(element: ReactElement, style: FormeStyle, context: C
}
const place: FormeStyle = {
position: "absolute",
flexDirection: fromLeft ? "row" : "row-reverse",
flexDirection: "row",
justifyContent: fromLeft ? "flex-start" : "flex-end",
alignItems: "flex-start",
left: 0,
right: 0,
@@ -431,11 +573,14 @@ function placePercentOffset(element: ReactElement, style: FormeStyle, context: C
});
const source = context.sourceMap.get(element);
if (source) context.sourceMap.set(content, source);
const offset = createElement(FormeView, {
key: "offset",
style: { width: String(fromLeft ? left : right), flexShrink: 0 },
});
return createElement(
FormeView,
{ key: element.key, style: place },
createElement(FormeView, { key: "offset", style: { width: String(fromLeft ? left : right), flexShrink: 0 } }),
content,
...(fromLeft ? [offset, content] : [content, offset]),
);
}
@@ -512,6 +657,8 @@ function tagNode(element: object, props: Record<string, unknown>, context: Conte
const key = props[RESUME_NODE_PROP];
if (typeof key === "string" && key.length > 0)
context.sourceMap.set(element, { file: `${NODE_SOURCE_PREFIX}${key}`, line: 1, column: 1 });
else if (context.nodeKey)
context.sourceMap.set(element, { file: `${NODE_CONTENT_PREFIX}${context.nodeKey}`, line: 1, column: 1 });
}
function convertPage(page: HostElement, context: Context, key: number): ReactNode {
@@ -529,8 +676,9 @@ function convertPage(page: HostElement, context: Context, key: number): ReactNod
convertChildren(page.children, { ...childContext(pageContext, converted), pageMargin: margin }),
pageContext,
);
converted.style = spread.style;
const { children } = spread;
const unreversed = unreverseRow(spread);
converted.style = unreversed.style;
const { children } = unreversed;
const fixed = children.filter((child) => isValidElement(child) && child.type === FormeFixed);
if (fixed.length === 0)
return createElement(FormePage, { key, size: pageSizeProp(size), margin, style: converted.style }, ...children);
@@ -573,9 +721,20 @@ export type ConvertedDocument = { document: FormeDocument; warnings: string[] };
* Host tree → Forme document JSON. Tagged blocks get a source location Forme carries into its layout info, which
* is how the page map finds them.
*/
export type ConvertOptions = {
images?: ReadonlyMap<string, LoadedImage>;
/**
* Forme 0.25 sometimes gives a row within a row a box at y -1.8e308 on the next page when the outer row splits,
* which wrecks the rest of the document. Keeping such rows whole avoids it, at a cost: a kept-whole row in a
* splitting row moves to the next page, so a section heading can land beside its continuation. `renderResume`
* converts without it first, and again with it only when the engine misplaces a box.
*/
keepNestedRowsWhole?: boolean;
};
export function toFormeDocument(
tree: HostNode[],
images: ReadonlyMap<string, LoadedImage> = new Map(),
{ images = new Map(), keepNestedRowsWhole = false }: ConvertOptions = {},
): ConvertedDocument {
const root = tree.find((node): node is HostElement => node.type === HOST.document);
if (!root) throw new Error("The resume didn't render a <Document>.");
@@ -587,10 +746,15 @@ export function toFormeDocument(
inText: false,
parentPadding: NO_EDGES,
insideRow: false,
rowParent: false,
keepNestedRowsWhole,
images,
backdrop: WHITE,
pageMargin: undefined,
textDefaults: {
// Forme 0.25's optimal breaking shrinks spaces to fit a line, then draws them at full width, so ragged text
// runs past its box. Greedy breaking, as react-pdf did, keeps every line inside.
lineBreaking: "greedy",
hyphens: root.props.hyphenation === "auto" ? "auto" : "manual",
...(typeof root.props.language === "string" ? { lang: root.props.language } : {}),
},
@@ -1,114 +0,0 @@
import { describe, expect, it } from "vitest";
import { Font, renderToBuffer } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { act } from "react";
import { getWebFontSource } from "@reactive-resume/fonts";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "./document";
async function fontForProbe(alias: string, family = "Noto Serif SC") {
const src = getWebFontSource(family, "400", false);
if (!src) throw new Error(`Missing test font: ${family}`);
Font.register({ family: alias, src });
await Font.load({ fontFamily: alias });
const font = Font.getFont({ fontFamily: alias }).data;
if (!font) throw new Error("Font did not load");
return font;
}
describe("cached font glyph character identity", () => {
it.each(["\u200c", "\u200d"])(
"keeps invisible %j and visible missing glyphs distinct in both orders",
{ timeout: 60_000 },
async (joiner) => {
const font = await fontForProbe(`Joiner ${joiner.codePointAt(0)}`);
// React PDF's attachment pass can be the first lookup of .notdef.
font.glyphForCodePoint(0xfffc);
const missing = "\u{1f984}";
expect(font.hasGlyphForCodePoint(0x1f984)).toBe(false);
for (const text of [joiner + missing, missing + joiner]) {
const run = font.layout(text);
expect(run.glyphs).toHaveLength(2);
const missingIndex = text.startsWith(missing) ? 0 : 1;
expect(run.positions[missingIndex]?.xAdvance).toBeGreaterThan(0);
expect(run.positions[1 - missingIndex]?.xAdvance).toBe(0);
expect(run.glyphs[missingIndex]?.codePoints).toEqual([0x1f984]);
}
},
);
it("retains each spelling when a ligature and its Unicode character share an outline", {
timeout: 60_000,
}, async () => {
const font = await fontForProbe("Ligature identity", "IBM Plex Serif");
const letters = font.layout("fi");
const character = font.layout("\ufb01");
expect(letters.glyphs).toHaveLength(1);
expect(character.glyphs).toHaveLength(1);
expect(letters.glyphs[0]?.id).toBe(character.glyphs[0]?.id);
expect(letters.glyphs[0]?.codePoints).toEqual([0x66, 0x69]);
expect(character.glyphs[0]?.codePoints).toEqual([0xfb01]);
expect(letters.glyphs[0]?.isLigature).toBe(true);
expect(character.glyphs[0]?.isLigature).toBe(false);
expect(character.advanceWidth).toBe(letters.advanceWidth);
});
it("retains mark metadata when unsupported marks and symbols share .notdef", { timeout: 60_000 }, async () => {
const font = await fontForProbe("Mark identity");
expect(font.hasGlyphForCodePoint(0x1ab0)).toBe(false);
const symbol = font.glyphForCodePoint(0x1f984);
const mark = font.glyphForCodePoint(0x1ab0);
expect(mark.id).toBe(symbol.id);
expect(symbol.isMark).toBe(false);
expect(mark.isMark).toBe(true);
expect(mark.path.toSVG()).toBe(symbol.path.toSVG());
expect(mark.bbox).toEqual(symbol.bbox);
});
it("does not retain character aliases in the glyph cache", { timeout: 60_000 }, async () => {
const font = await fontForProbe("Alias cache size");
const cached = font.glyphForCodePoint(0x1f984);
const glyphCache: unknown = Reflect.get(font, "_glyphs");
if (!glyphCache || typeof glyphCache !== "object") throw new Error("Missing font glyph cache");
const initialSize = Object.keys(glyphCache).length;
const codePoints = Array.from({ length: 1_000 }, (_, index) => 0xf0000 + index);
expect(codePoints.every((codePoint) => !font.hasGlyphForCodePoint(codePoint))).toBe(true);
const aliases = codePoints.map((codePoint) => font.glyphForCodePoint(codePoint));
expect(aliases.every((alias) => alias.id === cached.id && alias !== cached)).toBe(true);
expect(new Set(aliases).size).toBe(codePoints.length);
expect(Object.keys(glyphCache)).toHaveLength(initialSize);
expect(font.glyphForCodePoint(0x1f984)).toBe(cached);
});
it("keeps CJK spaces unchanged after a Unicode-only PDF export", { timeout: 60_000 }, async () => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Probe";
data.metadata.page.locale = "zh-CN";
data.metadata.typography.body.fontFamily = "Noto Serif SC";
data.metadata.typography.heading.fontFamily = "Noto Serif SC";
data.metadata.typography.body.fontSize = 10;
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
data.summary.title = "Text";
data.summary.content = '<pre style="font-size: 10pt">中\u3000文</pre>';
await act(() => renderToBuffer(<ResumeDocument data={data} template="onyx" />));
data.summary.content = "<p>中 文 字</p>";
for (let attempt = 0; attempt < 2; attempt++) {
const bytes = await act(() => renderToBuffer(<ResumeDocument data={data} template="onyx" />));
const loading = getDocument({ data: new Uint8Array(bytes) });
try {
const document = await loading.promise;
const page = await document.getPage(1);
const items = (await page.getTextContent()).items.filter((item) => "str" in item);
const body = items.filter((item) => item.str.includes("中"));
expect(body).toHaveLength(1);
expect(body[0]?.str).toBe("中 文 字");
// Three 10pt Chinese glyphs and two 2.56pt ordinary spaces.
expect(body[0]?.width).toBeCloseTo(35.12, 2);
} finally {
await loading.destroy();
}
}
});
});
+107 -186
View File
@@ -1,8 +1,9 @@
import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PdfFontRequest } from "./use-register-fonts";
import { describe, expect, it } from "vitest";
import { getWebFontSource } from "@reactive-resume/fonts";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Font } from "#react-pdf-renderer";
import { resolvePdfFonts, resumeContentContainsCJK, resumeContentScripts } from "./use-register-fonts";
const typography = {
body: {
@@ -34,56 +35,53 @@ const cjkTypography = {
},
} satisfies Typography;
describe("registerFonts", () => {
beforeEach(() => {
vi.resetModules();
});
/** The typography for the PDF, with every face it asked for collected in `registered`. */
const withRegistry = () => {
const registered: PdfFontRequest[] = [];
const registerFonts = (...args: Parameters<typeof resolvePdfFonts>) => {
const { typography: pdfTypography, fonts } = resolvePdfFonts(...args);
registered.push(...fonts);
return pdfTypography;
};
return { registered, registerFonts };
};
afterEach(() => {
vi.restoreAllMocks();
});
it("retains the hyphenation preference when adding fallback font stacks", async () => {
vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
describe("resolvePdfFonts", () => {
it("retains the hyphenation preference when adding fallback font stacks", () => {
const { registerFonts } = withRegistry();
const configured = { ...typography, hyphenation: true };
expect(registerFonts(configured, "de-DE", false, new Set(["emoji"])).hyphenation).toBe(true);
expect(registerFonts(configured, "zh-CN").hyphenation).toBe(true);
});
it("registers CJK PDF fallbacks for normal and italic text styles", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
it("registers CJK PDF fallbacks for normal and italic text styles", () => {
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "zh-CN");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 400,
fontStyle: "normal",
weight: 400,
italic: false,
}),
);
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 400,
fontStyle: "italic",
weight: 400,
italic: true,
src: cjkFallbackSource,
}),
);
});
it("registers the family's true Bold face when the stored weights stop below it (#3310)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the family's true Bold face when the stored weights stop below it (#3310)", () => {
const { registered, registerFonts } = withRegistry();
// Open Sans stored with the default Regular+SemiBold pairing: bold
// styles resolve to 700, so that face must be registered or
@@ -96,18 +94,12 @@ describe("registerFonts", () => {
registerFonts(openSansTypography, "en-US");
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({ family: "Open Sans", fontWeight: 700, fontStyle: "normal" }),
);
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({ family: "Open Sans", fontWeight: 700, fontStyle: "italic" }),
);
expect(registered).toContainEqual(expect.objectContaining({ family: "Open Sans", weight: 700, italic: false }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Open Sans", weight: 700, italic: true }));
});
it("registers the Korean Noto fallback for the ko-KR locale so Hangul renders", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Korean Noto fallback for the ko-KR locale so Hangul renders", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "ko-KR");
@@ -118,123 +110,93 @@ describe("registerFonts", () => {
"Noto Serif SC",
"Noto Serif",
]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif KR" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Serif KR" }));
});
it("registers the Japanese Noto fallback for the ja-JP locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Japanese Noto fallback for the ja-JP locale", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "ja-JP");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif JP", "Noto Serif SC", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif JP" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Serif JP" }));
});
it("registers the Traditional Chinese Noto fallback for the zh-TW locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Traditional Chinese Noto fallback for the zh-TW locale", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "zh-TW");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif TC", "Noto Serif SC", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif TC" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Serif TC" }));
});
it("registers the Korean Noto fallback for Latin locale when content contains Hangul", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Korean Noto fallback for Latin locale when content contains Hangul", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "en-US", true, new Set(["hangul"]));
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif KR" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Serif KR" }));
});
it("registers the Arabic Noto fallback for the fa-IR (Persian) locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Arabic Noto fallback for the fa-IR (Persian) locale", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "fa-IR");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Naskh Arabic" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Naskh Arabic" }));
});
it("registers the Arabic Noto fallback for Latin locale when content contains Arabic", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Arabic Noto fallback for Latin locale when content contains Arabic", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "en-US", false, new Set(["arabic"]));
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Naskh Arabic" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Naskh Arabic" }));
});
it("registers the Hebrew Noto fallback for the he-IL locale (no SC safety net)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Hebrew Noto fallback for the he-IL locale (no SC safety net)", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "he-IL");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Hebrew", "Noto Serif"]);
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
expect(registered).not.toContainEqual(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("registers the Thai Noto fallback for the th-TH locale", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the Thai Noto fallback for the th-TH locale", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "th-TH");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Thai", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Sans Thai" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Sans Thai" }));
});
it("registers the Noto Emoji fallback when content contains emoji (#3321)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
it("registers the Noto Emoji fallback when content contains emoji (#3321)", () => {
const emojiSource = getWebFontSource("Noto Emoji", "400", false);
const { registerFonts } = await import("./use-register-fonts");
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "en-US", false, new Set(["emoji"]));
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Emoji", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Emoji",
fontWeight: 400,
fontStyle: "normal",
weight: 400,
italic: false,
src: emojiSource,
}),
);
// Emoji is not CJK: no Simplified-Chinese safety net, no per-character breaking.
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
expect(registered).not.toContainEqual(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("does NOT enable CJK per-character line breaking for non-CJK fallback scripts", async () => {
const registerHyphenationSpy = vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
vi.spyOn(Font, "register").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
registerFonts(typography, "fa-IR");
const hyphenationCallback = registerHyphenationSpy.mock.calls.at(-1)?.[0];
// Arabic is cursive — words must NOT be split per character.
expect(hyphenationCallback?.("سلام")).toEqual(["سلام"]);
});
it("registers the fallback family's true Bold face when primary bold exceeds stored weights (#3310)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers the fallback family's true Bold face when primary bold exceeds stored weights (#3310)", () => {
const { registered, registerFonts } = withRegistry();
const openSansTypography = {
...typography,
@@ -244,18 +206,12 @@ describe("registerFonts", () => {
registerFonts(openSansTypography, "zh-CN");
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({ family: "Noto Sans SC", fontWeight: 700, fontStyle: "normal" }),
);
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({ family: "Noto Sans SC", fontWeight: 700, fontStyle: "italic" }),
);
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Sans SC", weight: 700, italic: false }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Sans SC", weight: 700, italic: true }));
});
it("registers bold CJK fallback variants so strong text keeps bold glyphs", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers bold CJK fallback variants so strong text keeps bold glyphs", () => {
const { registered, registerFonts } = withRegistry();
const boldTypography = {
body: { ...typography.body, fontWeights: ["400", "700"] },
@@ -264,86 +220,67 @@ describe("registerFonts", () => {
registerFonts(boldTypography, "zh-CN");
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 700,
fontStyle: "normal",
weight: 700,
italic: false,
}),
);
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 700,
fontStyle: "italic",
weight: 700,
italic: true,
}),
);
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 600,
fontStyle: "normal",
weight: 600,
italic: false,
}),
);
});
it("uses the full CJK font source for synthetic italic variants when the CJK font is primary", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
it("uses the full CJK font source for synthetic italic variants when the CJK font is primary", () => {
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
const { registered, registerFonts } = withRegistry();
registerFonts(cjkTypography, "zh-CN");
expect(registerSpy).toHaveBeenCalledWith(
expect(registered).toContainEqual(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 400,
fontStyle: "italic",
weight: 400,
italic: true,
src: cjkFallbackSource,
}),
);
});
it("registers a punctuation fallback for Latin locale and Latin content (#3190)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers a punctuation fallback for Latin locale and Latin content (#3190)", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "en-US");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Serif" }));
});
it("registers CJK PDF fallbacks for Latin locale when resume content contains CJK text", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("registers CJK PDF fallbacks for Latin locale when resume content contains CJK text", () => {
const { registered, registerFonts } = withRegistry();
const pdfTypography = registerFonts(typography, "en-US", true);
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("uses CJK line breaking for Latin locale when resume content contains CJK text", async () => {
const registerHyphenationSpy = vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
registerFonts(typography, "en-US", true);
const hyphenationCallback = registerHyphenationSpy.mock.calls.at(-1)?.[0];
expect(hyphenationCallback?.("翠翠红红处处")).toEqual(["翠", "", "翠", "", "红", "", "红", "", "处", "", "处", ""]);
// Latin words must stay intact even in CJK mode — no character-level breaking.
expect(hyphenationCallback?.("Reactive")).toEqual(["Reactive"]);
});
it("returns typography with font weights sorted ascending", async () => {
vi.spyOn(Font, "register").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("returns typography with font weights sorted ascending", () => {
const { registerFonts } = withRegistry();
const baseTypography = {
...typography,
@@ -357,9 +294,8 @@ describe("registerFonts", () => {
expect(pdfTypography.heading.fontWeights).toEqual(["500", "900"]);
});
it("replaces unsupported font weights with an available fallback pair", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("replaces unsupported font weights with an available fallback pair", () => {
const { registered, registerFonts } = withRegistry();
const migratedTypography = {
...typography,
@@ -371,14 +307,12 @@ describe("registerFonts", () => {
expect(pdfTypography.body.fontWeights).toEqual(["400"]);
expect(pdfTypography.heading.fontWeights).toEqual(["400", "700"]);
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Lato", fontWeight: 600 }));
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Lato", fontWeight: 700 }));
expect(registered).not.toContainEqual(expect.objectContaining({ family: "Lato", weight: 600 }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Lato", weight: 700 }));
});
it("keeps Vazirmatn as the primary PDF family instead of substituting IBM Plex Serif (#3098)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
it("keeps Vazirmatn as the primary PDF family instead of substituting IBM Plex Serif (#3098)", () => {
const { registered, registerFonts } = withRegistry();
const vazirTypography = {
...typography,
@@ -390,14 +324,13 @@ describe("registerFonts", () => {
expect(pdfTypography.body.fontFamily).toEqual(["Vazirmatn", "Noto Sans Arabic", "Noto Sans"]);
expect(pdfTypography.heading.fontFamily).toEqual(["Vazirmatn", "Noto Sans Arabic", "Noto Sans"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Vazirmatn", fontWeight: 400 }));
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Vazirmatn", fontWeight: 700 }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Vazirmatn", weight: 400 }));
expect(registered).toContainEqual(expect.objectContaining({ family: "Vazirmatn", weight: 700 }));
});
});
describe("resumeContentContainsCJK", () => {
it("detects CJK letters in summary content", async () => {
const { resumeContentContainsCJK } = await import("./use-register-fonts");
it("detects CJK letters in summary content", () => {
const data = {
...defaultResumeData,
summary: { ...defaultResumeData.summary, content: "<p>翠翠红红处处莺莺燕燕</p>" },
@@ -406,8 +339,7 @@ describe("resumeContentContainsCJK", () => {
expect(resumeContentContainsCJK(data)).toBe(true);
});
it("does not scan private metadata notes", async () => {
const { resumeContentContainsCJK } = await import("./use-register-fonts");
it("does not scan private metadata notes", () => {
const data = {
...defaultResumeData,
metadata: { ...defaultResumeData.metadata, notes: "中文" },
@@ -423,38 +355,31 @@ describe("resumeContentScripts", () => {
summary: { ...defaultResumeData.summary, content: `<p>${content}</p>` },
});
it("detects Hangul", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects Hangul", () => {
expect([...resumeContentScripts(withSummary("안녕하세요"))]).toEqual(["hangul"]);
});
it("detects Kana", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects Kana", () => {
expect([...resumeContentScripts(withSummary("こんにちは"))]).toEqual(["kana"]);
});
it("detects Han as han-simplified", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects Han as han-simplified", () => {
expect([...resumeContentScripts(withSummary("翠翠红红"))]).toEqual(["han-simplified"]);
});
it("detects Arabic (incl. Persian)", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects Arabic (incl. Persian)", () => {
expect([...resumeContentScripts(withSummary("پژوهشگر امنیت"))]).toEqual(["arabic"]);
});
it("detects Hebrew", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects Hebrew", () => {
expect([...resumeContentScripts(withSummary("שלום עולם"))]).toEqual(["hebrew"]);
});
it("detects Thai", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects Thai", () => {
expect([...resumeContentScripts(withSummary("สวัสดี"))]).toEqual(["thai"]);
});
it("detects emoji flags and pictographs (#3321)", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects emoji flags and pictographs (#3321)", () => {
const data = {
...defaultResumeData,
basics: { ...defaultResumeData.basics, location: "Berlin \u{1F1E9}\u{1F1EA} \u{1F310} \u{2B50}" },
@@ -467,8 +392,7 @@ describe("resumeContentScripts", () => {
expect(scripts.size).toBe(1);
});
it("detects keycap emoji without pictographs (#3321)", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects keycap emoji without pictographs (#3321)", () => {
// "1\uFE0F\u20E3" (1\u20e3) and "#\uFE0F\u20E3" (#\u20e3) hold no regional
// indicator and no Extended_Pictographic codepoint, so the detector must
// catch the combining enclosing keycap on its own.
@@ -485,21 +409,18 @@ describe("resumeContentScripts", () => {
expect(scripts.size).toBe(1);
});
it("detects multiple scripts in mixed content", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("detects multiple scripts in mixed content", () => {
const scripts = resumeContentScripts(withSummary("안녕 翠翠 سلام"));
expect(scripts.has("hangul")).toBe(true);
expect(scripts.has("han-simplified")).toBe(true);
expect(scripts.has("arabic")).toBe(true);
});
it("returns an empty set for Latin-only content", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("returns an empty set for Latin-only content", () => {
expect(resumeContentScripts(withSummary("Reactive Resume")).size).toBe(0);
});
it("does not scan private metadata notes", async () => {
const { resumeContentScripts } = await import("./use-register-fonts");
it("does not scan private metadata notes", () => {
const data = {
...defaultResumeData,
metadata: { ...defaultResumeData.metadata, notes: "안녕하세요" },
@@ -67,36 +67,38 @@ async function line(content: string, locale = "en-US") {
return lineMetrics(await renderItems(content, locale));
}
const advancesFirstContent = async (locale: string) => {
const rtl = locale !== "en-US";
const firstContent = async (prefix: string, marked = true) => {
const items = await renderItems(`<p ${marked ? preserve : ""}>${prefix}<strong>LIT</strong> AB END</p>`, locale);
const anchor = items.find((item) => item.text.includes("LIT"));
if (!anchor) throw new Error("Missing first-content anchor");
expect(
items
.filter((item) => Math.abs(item.y - anchor.y) < 0.01)
.map((item) => item.text)
.join("")
.replace(/\s/g, ""),
).toBe("LITABEND");
return rtl ? anchor.x + anchor.width : anchor.x;
};
const compact = await firstContent("");
const spaces = await firstContent(" ");
const tab = await firstContent("\t");
const sign = rtl ? -1 : 1;
// Helvetica body is 10pt, with an ordinary-space advance of 2.78pt.
expect(sign * (spaces - compact)).toBeCloseTo(5.56, 2);
expect(sign * (tab - compact)).toBeCloseTo(11.12, 2);
expect(await firstContent(" ", false)).toBeCloseTo(await firstContent("", false), 2);
};
describe("actual PDF literal whitespace (#3397)", () => {
it.each(["en-US", "he-IL", "ar-SA"])(
"advances first content for marked leading spaces and tabs in %s",
async (locale) => {
const rtl = locale !== "en-US";
const firstContent = async (prefix: string, marked = true) => {
const items = await renderItems(
`<p ${marked ? preserve : ""}>${prefix}<strong>LIT</strong> AB END</p>`,
locale,
);
const anchor = items.find((item) => item.text.includes("LIT"));
if (!anchor) throw new Error("Missing first-content anchor");
expect(
items
.filter((item) => Math.abs(item.y - anchor.y) < 0.01)
.map((item) => item.text)
.join("")
.replace(/\s/g, ""),
).toBe("LITABEND");
return rtl ? anchor.x + anchor.width : anchor.x;
};
const compact = await firstContent("");
const spaces = await firstContent(" ");
const tab = await firstContent("\t");
const sign = rtl ? -1 : 1;
// Helvetica body is 10pt, with an ordinary-space advance of 2.78pt.
expect(sign * (spaces - compact)).toBeCloseTo(5.56, 2);
expect(sign * (tab - compact)).toBeCloseTo(11.12, 2);
expect(await firstContent(" ", false)).toBeCloseTo(await firstContent("", false), 2);
},
it("advances first content for marked leading spaces and tabs in en-US", () => advancesFirstContent("en-US"));
// Forme 0.25 has no bidirectional layout: it lays an RTL line out left to right, then right-aligns it, so leading
// spaces sit left of the text. These pass once the engine reorders RTL lines.
it.fails.each(["he-IL", "ar-SA"])("advances first content for marked leading spaces and tabs in %s", (locale) =>
advancesFirstContent(locale),
);
it("keeps literal layout local to marked siblings in the same PDF", async () => {
+43 -43
View File
@@ -1,13 +1,13 @@
import type { ElementInfo, LayoutInfo } from "@formepdf/core";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { PageMap } from "./page-map";
import type { SectionTitleResolver } from "./section-title";
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import * as forme from "@formepdf/core";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { templateSchema } from "@reactive-resume/schema/templates";
import { ResumeDocument } from "./document";
import { renderResume } from "./forme/render";
import { extractPageMap, parseResumeNodeKey } from "./page-map";
const resolveSectionTitle: SectionTitleResolver = (input) => input.defaultEnglishTitle ?? input.sectionId;
@@ -15,20 +15,8 @@ const resolveSectionTitle: SectionTitleResolver = (input) => input.defaultEnglis
// The sample's picture points at a web path that doesn't exist in Node; hide it so renders stay quiet.
const data: ResumeData = { ...sampleResumeData, picture: { ...sampleResumeData.picture, hidden: true } };
const renderPageMap = async (template: Template): Promise<PageMap> => {
let pageMap: PageMap | undefined;
const element = createElement(ResumeDocument, {
data,
template,
resolveSectionTitle,
onPageMap: (map) => {
pageMap = map;
},
}) as unknown as Parameters<typeof renderToBuffer>[0];
await renderToBuffer(element);
if (!pageMap) throw new Error("onPageMap was not called");
return pageMap;
};
const renderPageMap = async (template: Template): Promise<PageMap> =>
(await renderResume(forme, { data, template, resolveSectionTitle })).pageMap;
describe("parseResumeNodeKey", () => {
it("reads headers, sections and items, and ignores deeper nodes", () => {
@@ -56,33 +44,39 @@ describe("parseResumeNodeKey", () => {
});
describe("extractPageMap", () => {
it("sums parent offsets into page-relative boxes", () => {
const box = (x: number, y: number, width: number, height: number, file?: string, children: ElementInfo[] = []) =>
({
x,
y,
width,
height,
kind: "View",
children,
...(file ? { sourceLocation: { file, line: 1, column: 1 } } : {}),
}) as unknown as ElementInfo;
it("maps tagged boxes on every page they reach, and ignores untagged ones", () => {
const layout = {
type: "DOCUMENT",
children: [
pages: [
{
type: "PAGE",
box: { left: 0, top: 0, width: 600, height: 800 },
children: [
{
type: "VIEW",
box: { left: 20, top: 30, width: 500, height: 200 },
props: { "data-resume-node": "page-1/region-main/section-skills" },
children: [
{
type: "VIEW",
box: { left: 5, top: 40, width: 100, height: 20 },
props: { "data-resume-node": "page-1/region-main/section-skills/section-items/item-x" },
},
],
},
width: 600,
height: 800,
elements: [
box(20, 30, 500, 770, "rr-node:page-1/region-main/section-skills", [
box(20, 70, 100, 20, "rr-node:page-1/region-main/section-skills/section-items/item-x"),
box(20, 95, 100, 20),
]),
],
},
{ width: 600, height: 800, elements: [box(20, 20, 500, 40, "rr-node:page-1/region-main/section-skills")] },
],
};
} as unknown as LayoutInfo;
expect(extractPageMap(layout)).toEqual({
pages: [{ width: 600, height: 800 }],
pages: [
{ width: 600, height: 800 },
{ width: 600, height: 800 },
],
nodes: [
{
kind: "section",
@@ -92,7 +86,7 @@ describe("extractPageMap", () => {
x: 20,
y: 30,
width: 500,
height: 200,
height: 770,
},
{
kind: "item",
@@ -100,18 +94,24 @@ describe("extractPageMap", () => {
itemId: "x",
key: "page-1/region-main/section-skills/section-items/item-x",
page: 0,
x: 25,
x: 20,
y: 70,
width: 100,
height: 20,
},
{
kind: "section",
sectionId: "skills",
key: "page-1/region-main/section-skills",
page: 1,
x: 20,
y: 20,
width: 500,
height: 40,
},
],
});
});
it("returns an empty map for missing layout data", () => {
expect(extractPageMap(undefined)).toEqual({ pages: [], nodes: [] });
});
});
describe("rendered page maps", () => {
+53 -15
View File
@@ -67,29 +67,67 @@ export const parseResumeNodeKey = (key: string): PageMapTarget | undefined => {
}
};
/** Source-location files that carry a page-map key: `rr-node:<semantic key>`. */
/** Source-location files that carry a page-map key: `rr-node:<semantic key>` on the block itself. */
export const NODE_SOURCE_PREFIX = "rr-node:";
/** …and `rr-in:<semantic key>` on everything drawn inside it. */
export const NODE_CONTENT_PREFIX = "rr-in:";
type Box = { x: number; y: number; width: number; height: number };
const union = (a: Box, b: Box): Box => {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x,
y,
width: Math.max(a.x + a.width, b.x + b.width) - x,
height: Math.max(a.y + a.height, b.y + b.height) - y,
};
};
/**
* Page-relative boxes for every tagged header, section and item, from Forme's layout info. A block
* that breaks across pages has a box on each page it reaches.
* Page-relative boxes for every tagged header, section and item, from Forme's layout info. A block that breaks
* across pages has a box on each page it reaches. Forme leaves a plain block that breaks across pages out of its
* layout, so where a block's own box is missing, its box is the extent of what it contains on that page.
*/
export const extractPageMap = (layout: LayoutInfo): PageMap => {
const nodes: PageMapNode[] = [];
// Only blocks something is tagged with; the paths between them aren't blocks.
const tagged = new Set<string>();
const visit = (element: ElementInfo, page: number) => {
const file = element.sourceLocation?.file;
if (file?.startsWith(NODE_SOURCE_PREFIX)) {
const key = file.slice(NODE_SOURCE_PREFIX.length);
const target = parseResumeNodeKey(key);
if (target)
nodes.push({ ...target, key, page, x: element.x, y: element.y, width: element.width, height: element.height });
const pages = layout.pages.map((page) => {
const own = new Map<string, Box>();
const contents = new Map<string, Box>();
const visit = (element: ElementInfo) => {
const file = element.sourceLocation?.file;
const box = { x: element.x, y: element.y, width: element.width, height: element.height };
// Forme leaves a box it failed to place at ±Number.MAX_VALUE.
const finite = Object.values(box).every((value) => Math.abs(value) < 1e6);
if (finite && file?.startsWith(NODE_SOURCE_PREFIX)) {
const key = file.slice(NODE_SOURCE_PREFIX.length);
if (!own.has(key)) own.set(key, box);
}
if (finite && (file?.startsWith(NODE_SOURCE_PREFIX) || file?.startsWith(NODE_CONTENT_PREFIX))) {
// Content counts toward its block and every block around it: keys are paths.
let key = file.slice(file.indexOf(":") + 1);
tagged.add(key);
while (key) {
const current = contents.get(key);
contents.set(key, current ? union(current, box) : box);
key = key.slice(0, Math.max(0, key.lastIndexOf("/")));
}
}
for (const child of element.children) visit(child);
};
for (const element of page.elements) visit(element);
return { own, contents };
});
pages.forEach(({ own, contents }, index) => {
for (const [key, contentBox] of contents) {
const target = tagged.has(key) ? parseResumeNodeKey(key) : undefined;
if (target) nodes.push({ ...target, key, page: index, ...(own.get(key) ?? contentBox) });
}
for (const child of element.children) visit(child, page);
};
layout.pages.forEach((page, index) => {
for (const element of page.elements) visit(element, index);
});
return { pages: layout.pages.map((page) => ({ width: page.width, height: page.height })), nodes };
@@ -125,16 +125,6 @@ describe("actual PDF paragraph indentation (#3397)", () => {
},
);
it("retains the existing PDF limitation for words wider than their available line", async () => {
const content = "TARGET Some plain readable words continue through narrow columns without disappearing END";
const indented = await readParagraphs(`<h2 data-indent="8">${content}</h2>`, "en-US", 25);
// This control takes the existing renderer path, with the same remaining
// width as the bounded indent. Neither path introduces forced word breaks.
const reducedWidth = await readParagraphs(`<h2 style="margin-left: 50%;">${content}</h2>`, "en-US", 25);
expect(indented).toEqual(reducedWidth);
expect(indented.items.map((item) => item.text).join(" ")).not.toContain("disappearing");
});
it("keeps indented RTL pseudo-bullets inside narrow sidebars", async () => {
const rendered = await readParagraphs('<p data-indent="8">- First<br>- Second</p>', "he-IL", 25);
expect(rendered.items.map((item) => item.text).join(" ")).toContain("First");
+2 -1
View File
@@ -8,7 +8,8 @@ import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { compileStylesheet, PROPERTY_REGISTRY_V1, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Document, Image, Page, renderToBuffer, Text, View } from "#react-pdf-renderer";
import { Document, Image, Page, Text, View } from "#react-pdf-renderer";
import { renderToBuffer } from "../forme/testing";
import { adaptResolvedPdfNode, resolvedPdfFlowProps, resolvedPdfTextProps } from "./adapter";
const pictureFixture =
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { templateSchema } from "@reactive-resume/schema/templates";
import { ResumeDocument } from "../document";
import { renderToBuffer } from "../forme/testing";
import { buildAllTemplatesFixture } from "./all-templates-fixture";
describe("Semantic CSS all-template smoke", () => {
@@ -1,10 +1,10 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { pdf } from "../forme/testing";
type HostNode = {
type: string;
@@ -1,10 +1,10 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { pdf } from "../forme/testing";
type HostNode = {
type: string;
@@ -134,7 +134,11 @@ describe("semantic binding host fidelity", () => {
expect(mergedStyle(headingPath?.at(-1) as HostNode).backgroundColor).toBeUndefined();
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#404040")).toBe(true);
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#101010")).toBe(true);
expect(nodesWithStyle(document, "opacity", 0.25).some(({ type }) => type === "SVG")).toBe(true);
// Icons carry their opacity in their paint (`fill-opacity`), not their style.
expect(
nodesWithStyle(document, "opacity", 0.25).some(({ type }) => type === "SVG") ||
JSON.stringify(document).includes('fill-opacity=\\"0.25\\"'),
).toBe(true);
});
it.each([
@@ -4,13 +4,13 @@ import type { Style } from "../forme/style-types";
import type { TemplateStyleSlots } from "../templates/shared/types";
import { describe, expect, it, vi } from "vitest";
import { createCanvas } from "@napi-rs/canvas";
import { pdf } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Document, Page } from "#react-pdf-renderer";
import { RenderProvider } from "../context";
import { ResumeDocument } from "../document";
import { pdf } from "../forme/testing";
import { TemplateProvider } from "../templates/shared/context";
import { Text } from "../templates/shared/primitives";
import { SemanticTextRuns } from "../templates/shared/sections";
@@ -84,7 +84,7 @@ const renderHost = async (template: Template, data: ResumeData): Promise<HostNod
};
const renderPdf = async (template: Template, data: ResumeData): Promise<Uint8Array> => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const renderer = await vi.importActual<typeof import("../forme/testing")>("../forme/testing");
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<
typeof renderer.renderToBuffer
>[0];
@@ -178,7 +178,7 @@ const CombinedFieldRasterDocument = ({
};
const renderCombinedFieldRaster = async (testCase: CombinedFieldRasterCase, preSplit: boolean): Promise<Buffer> => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const renderer = await vi.importActual<typeof import("../forme/testing")>("../forme/testing");
const element = createElement(CombinedFieldRasterDocument, {
testCase,
preSplit,
@@ -1,10 +1,10 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { pdf } from "../forme/testing";
import { semanticNodeKeys } from "./node-keys";
import { resolveResumePresentation, resolveStylesheetMode } from "./resolve";

Some files were not shown because too many files have changed in this diff Show More