Astro i18n Dostępność

Jak sprawić, żeby CV na stronie i CV w PDF-ie nigdy się nie rozjechały?

CV na stronie i trzy pliki PDF (en, pl, es) powstają z jednego pliku TypeScript. Playwright drukuje zaprojektowany arkusz A4, a test łapie każdy rozjazd.

Autor: Mateusz Muszarski Zaktualizowano 5 min czytania In English

Trzymaj CV jako dane, a nie jako dwa dokumenty. Na tej stronie jeden plik TypeScript zasila stronę /cv/ i zaprojektowany arkusz A4, który Playwright drukuje do PDF-ów po angielsku, polsku i hiszpańsku. Test jednostkowy pada, gdy dane się zmienią, a PDF-ów nikt nie wydrukuje na nowo, więc strona i plik nie mogą po cichu mówić dwóch różnych rzeczy.

TL;DR

  • Jeden plik, src/data/cv.ts, trzyma wszystkie fakty z CV w trzech językach. Czytają go dwa układy: CvSheet.astro na papier i CvWeb.astro na ekran.
  • bun run cv:sheet drukuje arkusz przez page.pdf() z Playwrighta, mieści go na dwóch stronach A4 i sprawdza tekst PDF-a, zanim podmieni plik na stronie.
  • Arkusz niesie skrót (digest) danych, które pokazuje. cv.test.ts pada, gdy dane przestają pasować do PDF-ów.

Skąd w ogóle bierze się rozjazd?

Stąd, że CV zwykle istnieje dwa razy: jako PDF z edytora dokumentów i jako strona przepisana ręcznie. Każdą zmianę trzeba wprowadzić w obu miejscach i prędzej czy później o jednym się zapomina: strona dostaje nową pracę, a PDF zostaje ze starym stanowiskiem. Dorzuć trzy języki i masz sześć kopii.

W wiki mojego projektu jest zasada na jedną linijkę: nigdy nie edytuj samej strony ani samego PDF-a. Zasady trzymają się tylko wtedy, gdy coś ich pilnuje, więc ten układ zamienia rozjazd w czerwony test, a nie w kwestię pamięci.

Co siedzi w pliku z danymi?

cv.ts eksportuje jeden obiekt. Każdy tekst, który zależy od języka, to rekord z osobną wartością dla każdego locale:

export type Text = Record<Locale, string>; // { en, pl, es }

export interface CvEntry {
  id: string;
  title: Text;
  org?: Text | undefined;
  when: Text; // pokazywane tak, jak zapisane: "2026", "Od 2022"
  bullets: Text[];
  links?: { label: string; href: string }[];
  short?: { name: string; note: Text }; // jedna linijka na stronę główną
  current?: boolean; // rola, którą pełnię teraz
  placeholder?: boolean;
}

Dalej idą sekcje, od podsumowania i doświadczenia aż po języki i jednozdaniową klauzulę rekrutacyjną, której oczekują polscy rekruterzy. Wartość, która nie jest jeszcze ostateczna, ma flagę placeholder, a skrypt drukujący ostrzega o niej.

Treść CV to dłuższy tekst, więc siedzi tu osobno dla każdego języka. Krótkie napisy interfejsu, jak nagłówek „Doświadczenie”, przechodzą przez Lingui, jak każda inna etykieta na stronie (dlaczego Lingui, a nie i18next). Z tego pliku czyta nie tylko samo CV:

Kto czyta Co bierze
/<locale>/cv/ wszystko, w układzie na ekran
/<locale>/cv/print/, a z niej PDF-y wszystko, na arkuszu A4
Sekcja Kompetencje na stronie głównej kwalifikacje i oś czasu z linijek short
Dane strukturalne osoby (Person) stanowisko i podsumowanie
llms.txt stanowisko, podsumowanie, lokalizację i języki

Dlaczego PDF ma własny układ?

Bo papier i ekran mają innych czytelników. Pierwsza wersja renderowała jeden komponent dwa razy: jako plakat na ekran i jako prostą kolumnę na papier. Zastąpiłem ją 8 października 2026 zaprojektowanym arkuszem na dwie strony, a strona dostała własny układ: kopia PDF-a na ekranie byłaby po prostu nudna.

Strona /cv/: dopasowane imię i nazwisko na kobaltowym nagłówku, zdjęcie, przycisk Pobierz PDF z rozmiarem pliku i dwie strony PDF-a rozłożone jak kartki Strona /cv/
Góra pierwszej strony polskiego PDF-a: kobaltowy pas ze zdjęciem i nazwiskiem, granatowy pasek kontaktu, potem Profil i pierwsza praca z datą po lewej PDF, strona 1
Jeden plik z danymi, dwa układy. Kartki rozłożone na stronie to prawdziwe strony PDF-a, wyrenderowane przez to samo uruchomienie skryptu, które go drukuje.

Arkusz obsługuje dwóch czytelników naraz: rekrutera, który z pierwszej strony chce wyczytać, kim jestem, co robię, skąd jestem i jak się ze mną skontaktować, oraz system ATS, który potrzebuje prawdziwego tekstu w kolejności czytania. Ekran zachowuje te same sekcje, kolejność, słowa i linki, a dokłada kolor, prawdziwe media projektów i przyciski kopiowania. Test end-to-end sprawdza, czy polska strona ma sekcje w kolejności z arkusza.

Jak Playwright zamienia arkusz w PDF?

  1. 01 cv.ts Każdy fakt z CV, raz dla każdego języka.
  2. 02 Arkusz Trasa do druku renderuje CvSheet.astro.
  3. 03 Druk Playwright drukuje go i mieści na dwóch stronach A4.
  4. 04 Kontrole Fonty, kolejność czytania, linki i daty w tekście PDF-a.
  5. 05 Pliki PDF, podglądy jego stron i skrót danych.
  6. 06 cv.test.ts Pada, gdy dane zmieniły się od ostatniego druku.

bun run cv:sheet buduje stronę, serwuje ją na wolnym porcie i dla każdego języka drukuje /<locale>/cv/print/, gdy fonty są już na miejscu:

await page.goto(`${base}/${locale}/cv/print/`, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);

// --d idzie od 0 (najgęściej) do 1 (najluźniej); czyta go każdy rozmiar i odstęp arkusza
const pdfAt = async (d) => {
  await page.evaluate((v) => document.documentElement.style.setProperty("--d", String(v)), d);
  return page.pdf({ format: "A4", printBackground: true, preferCSSPageSize: true, tagged: true, outline: true });
};

Według dokumentacji Playwrighta page.pdf() renderuje z mediami CSS print i domyślnie bierze format Letter, więc ustawiam A4. preferCSSPageSize daje pierwszeństwo rozmiarowi z @page, printBackground zostawia kobaltowy pas, a tagged i outline dają dostępny PDF z zakładkami.

Czekanie na document.fonts.ready ma znaczenie przy własnym foncie: według MDN ta obietnica rozwiązuje się, gdy fonty są załadowane, a układ gotowy. Potem skrypt szuka binarnie najluźniejszego --d, przy którym arkusz wciąż mieści się na dwóch stronach, a pdf-lib dopisuje tytuł, autora i język.

PDF-y trafiają do repozytorium, do public/files/, bo build wdrożeniowy nie ma przeglądarki, którą mógłby je wydrukować. Patrzyłem też na react-pdf i Typst: dobre narzędzia, ale oba oznaczały opisanie układu drugi raz, czyli dokładnie ten rozjazd, który chciałem usunąć.

Co robi CSS do druku?

Trasa do druku nie ma nawigacji ani ruchu i sama ustawia papier (w uproszczeniu):

@page {
  size: A4;
  margin: 18mm 0 14mm;
}
@page :first {
  margin: 0 0 14mm; /* stronę 1 otwiera kobaltowy pas */
}
:root {
  color-scheme: light;
}
.cs-entry {
  break-inside: avoid;
}
.cs-sec h2 {
  break-after: avoid;
}

Strona 1 nie ma górnego marginesu, więc tekst PDF-a zaczyna się od mojego imienia i nazwiska. Na marginesie strony 2 jest pagina z nazwiskiem i numer strony, narysowane jako kontury, żeby do tekstu nie trafiło drugie nazwisko. Jasny color-scheme nadpisuje ciemny ze strony. Dwie reguły break- trzymają wpis na jednej stronie i nie zostawiają nagłówka samego na dole.

Strona /cv/ ma własny przycisk Drukuj. Wywołuje print(), a blok @media print chowa elementy serwisu i wybiela stronę.

Co się zepsuło przy pierwszym druku?

Każda kontrola w skrypcie istnieje, bo to raz poszło źle:

  • Tekst wyszedł w złej kolejności. Chrome zapisuje tekst PDF-a w kolejności malowania, a element pozycjonowany maluje się po treści w normalnym przepływie: pasek kontaktu wylądował przed moim imieniem. Teraz nic, co zawiera tekst, nie jest pozycjonowane ani transformowane.
  • Font zmienny wydrukował się jako glify Type 3, z którymi część parserów sobie nie radzi. Arkusz używa statycznych wariantów tego samego kroju, generowanych przez bun run cv:fonts.
  • Szeroki odstęp między literami rozbijał słowa. Rozstrzelony nagłówek wychodził z ekstraktora jako „E D U C A T I O N”. Teraz rozstrzelenie to najwyżej 0,05 em.

Dlatego cv:sheet czyta każdy PDF z powrotem przez pdf.js. Odrzuca wydruk, gdy imię nie jest pierwszym tekstem, nagłówek jest nie na swoim miejscu, font nie jest osadzonym TrueType, linku nie da się kliknąć albo data zjeżdża z linii bazowej swojego tytułu. Odrzucony wydruk nigdy nie zastępuje PDF-a na stronie.

Jak test łapie nieaktualny PDF?

Odciskiem palca danych. cvDigestSource(locale) zostawia z obiektu CV tylko teksty danego języka i zamienia je na JSON. Trasa do druku liczy z tego skrót (SHA-256, pierwsze 16 znaków szesnastkowych) i wstawia go do znacznika <meta name="cv-digest">, a skrypt przepisuje tę wartość do cv-digest.json obok PDF-ów. Test liczy skrót od nowa z aktualnych danych:

describe.each(["en", "pl", "es"] as const)("CV PDF (%s)", (locale) => {
  it("was printed from the current CV data", () => {
    const recorded = JSON.parse(readFileSync(new URL("cv-digest.json", files), "utf8")) as Record<string, string>;
    expect(recorded[locale], "run `bun run cv:sheet` (in apps/web)").toBe(digest(locale));
  });

  it("has its page previews for the /cv/ page", () => {
    expect(existsSync(new URL(`mateusz-muszarski-cv-${locale}-p1.webp`, files))).toBe(true);
  });
});

Skrót jest osobny dla każdego języka, więc literówka po hiszpańsku wywala tylko hiszpański test, a komunikat błędu od razu mówi, co zrobić. Obok stoją jeszcze dwa zabezpieczenia: build produkcyjny staje, gdy brakuje któregoś PDF-a, a test end-to-end sprawdza, czy link „Pobierz PDF” na polskiej stronie ma atrybut download i zwraca 200.

Co dostaje osoba, która wchodzi na stronę?

Na /cv/ pierwszy przycisk to PDF, z rozmiarem odczytanym z pliku w czasie builda. To zwykły link z atrybutem download, który według MDN działa tylko dla adresów z tego samego źródła (same-origin): jeszcze jeden powód, żeby pliki leżały na stronie. Przycisk CV w serwisie (nagłówek, pierwsza sekcja, menu, stopka) zaczyna to pobieranie w ramach kliknięcia, a potem otwiera /cv/, które je potwierdza.

Tekst w PDF-ie to prawdziwy tekst: pdftotext na polskim pliku zaczyna od mojego imienia i nazwiska, z kompletem ogonków. Pliki mają tagi i deklarują swój język.

Ograniczenia

  • Skrót obejmuje tylko cv.ts. Po zmianie arkusza, trasy do druku albo nagłówków CV uruchamiam bun run cv:sheet ręcznie.
  • Kontrole czytają tekst tak jak parser (pdf.js, pdftotext), ale żadna z nich nie jest prawdziwym systemem ATS.
  • Sprawdzają warstwę tekstu, nie wygląd: po każdym druku i tak oglądam podglądy stron.
  • Hiszpański tekst powstał z pomocą maszyny i wciąż czeka na przegląd native speakera.

Efekt znajdziesz pod /pl/cv/, o jedno kliknięcie od PDF-a. Imię i nazwisko na górze tej strony ustawia dopasowanie tekstu w czasie builda, a resztę historii strony opisałem w studium przypadku.

Źródła

  1. page.pdf() (Playwright docs, class Page) (otwiera się w nowej karcie) (dostęp: )
  2. @page (MDN) (otwiera się w nowej karcie) (dostęp: )
  3. Printing (MDN, CSS media queries guide) (otwiera się w nowej karcie) (dostęp: )
  4. break-inside (MDN) (otwiera się w nowej karcie) (dostęp: )
  5. FontFaceSet: ready property (MDN) (otwiera się w nowej karcie) (dostęp: )
  6. <a>: the download attribute (MDN) (otwiera się w nowej karcie) (dostęp: )
  7. react-pdf (otwiera się w nowej karcie) (dostęp: )
  8. Typst documentation (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy