From 12c30009970862ac405fc4acd955337063dd4a63 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 9 Dec 2025 17:50:13 +0100 Subject: [PATCH 01/11] Create user-report file that handles definition of pdf doc types/interfaces, helpers for data formatting to human-readable form & PDF generator funciton --- src/lib/pdf/user-report.ts | 182 +++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/lib/pdf/user-report.ts diff --git a/src/lib/pdf/user-report.ts b/src/lib/pdf/user-report.ts new file mode 100644 index 0000000..504cd33 --- /dev/null +++ b/src/lib/pdf/user-report.ts @@ -0,0 +1,182 @@ +import type { PDFFont } from "pdf-lib"; +// src/lib/pdf/user-report.ts +import { + PDFDocument, + rgb, + StandardFonts + +} from "pdf-lib"; + +export interface UserReportItem { + id: string; + label: string; + answer: string; +} + +export interface UserReportSection { + id: string; + name: string; + description?: string; + items: UserReportItem[]; +} + +export interface UserReportPayload { + userId: string; + submittedAt: string | null; + sections: UserReportSection[]; +} + +/** + * Wrap a long text into multiple lines that fit a given width. + */ +function wrapText( + text: string, + maxWidth: number, + font: PDFFont, + fontSize: number +): string[] { + const words = text.split(" "); + const lines: string[] = []; + let currentLine = ""; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const width = font.widthOfTextAtSize(testLine, fontSize); + + if (width > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } + else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + +/** + * Generate and trigger download of a per-user survey report PDF. + * Created entirely client-side using pdf-lib. + */ +export async function generateUserReportPdf( + payload: UserReportPayload +): Promise { + const pdfDoc = await PDFDocument.create(); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const marginX = 50; + const marginY = 50; + const lineGap = 4; + + let page = pdfDoc.addPage(); + let { width, height } = page.getSize(); + let cursorY = height - marginY; + + const contentWidth = width - marginX * 2; + + const drawLine = ( + text: string, + size: number, + bold = false, + color = rgb(0, 0, 0) + ): void => { + const usedFont = bold ? fontBold : font; + const textHeight = size; + + if (cursorY - textHeight < marginY) { + page = pdfDoc.addPage(); + ({ width, height } = page.getSize()); + cursorY = height - marginY; + } + + page.drawText(text, { + x: marginX, + y: cursorY - textHeight, + size, + font: usedFont, + color + }); + + cursorY -= textHeight + lineGap; + }; + + // --- Cover / header ------------------------------------------------------ + drawLine("StateOfDev.ma · Geeksblabla", 10, true, rgb(0.3, 0.3, 0.3)); + drawLine("State of Dev in Morocco – My Survey Report", 18, true); + + if (payload.submittedAt) { + drawLine( + `Submitted: ${payload.submittedAt}`, + 10, + false, + rgb(0.3, 0.3, 0.3) + ); + } + + drawLine(`User ID: ${payload.userId}`, 10, false, rgb(0.3, 0.3, 0.3)); + drawLine("Website: https://stateofdev.ma", 10, false, rgb(0.3, 0.3, 0.3)); + + cursorY -= 10; + + const intro + = "This document contains the answers you submitted to the State of Dev in Morocco survey. It is intended for your personal reference."; + + const introLines = wrapText(intro, contentWidth, font, 11); + for (const line of introLines) { + drawLine(line, 11); + } + + cursorY -= 14; + + // --- Sections ------------------------------------------------------------ + + for (const section of payload.sections) { + cursorY -= 8; + drawLine(section.name, 14, true); + + if (section.description) { + const descLines = wrapText(section.description, contentWidth, font, 10); + for (const line of descLines) { + drawLine(line, 10, false, rgb(0.3, 0.3, 0.3)); + } + } + + cursorY -= 4; + + for (const item of section.items) { + const qLines = wrapText(item.label, contentWidth, fontBold, 11); + for (const line of qLines) { + drawLine(line, 11, true); + } + + const answerText = item.answer || "Not answered"; + const aLines = wrapText(answerText, contentWidth, font, 10); + for (const line of aLines) { + drawLine(line, 10, false, rgb(0.2, 0.2, 0.2)); + } + + cursorY -= 6; + } + + cursorY -= 8; + } + + const pdfBytes = await pdfDoc.save(); + const byteArray = new Uint8Array(pdfBytes); + const blob = new Blob([byteArray], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = "stateofdev-my-survey-report.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} From 4092926546fca25835cb1b664fbc50733aaefc9c Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 9 Dec 2025 17:51:07 +0100 Subject: [PATCH 02/11] Add `View & Download my survey` in the thanking page --- src/pages/thanks.astro | 68 +++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/src/pages/thanks.astro b/src/pages/thanks.astro index 871002c..021b4c4 100644 --- a/src/pages/thanks.astro +++ b/src/pages/thanks.astro @@ -133,6 +133,20 @@ const posts: Post[] = [

+
+

Get a copy of your answers

+

+ You can view your survey responses and download a personal PDF + report. +

+ + View & download my report + +
+
{posts.map(post => )}
@@ -142,35 +156,35 @@ const posts: Post[] = [ From 5a1ff49b35dd9e54c81e061c7d19caf86a4e6b35 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 9 Dec 2025 17:59:25 +0100 Subject: [PATCH 03/11] PDF_DOWNLOAD: Install pdf-lib --- package.json | 1 + pnpm-lock.yaml | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c4922d0..3612cbd 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "firebase-admin": "^12.5.0", "js-yaml": "^4.1.0", "lucide-react": "^0.436.0", + "pdf-lib": "^1.17.1", "query-string": "^9.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 355080f..6ac1308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: lucide-react: specifier: ^0.436.0 version: 0.436.0(react@18.3.1) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 query-string: specifier: ^9.1.0 version: 9.1.0 @@ -140,7 +143,7 @@ importers: version: 5.62.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.5.4) '@vitejs/plugin-react': specifier: ^5.0.4 - version: 5.0.4(vite@6.4.0(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1)) + version: 5.0.4(vite@7.1.10(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -1707,6 +1710,12 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4578,6 +4587,7 @@ packages: libsql@0.5.22: resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} + cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lilconfig@2.1.0: @@ -5212,6 +5222,9 @@ packages: pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5289,6 +5302,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -8015,7 +8031,7 @@ snapshots: '@firebase/component@0.6.8': dependencies: '@firebase/util': 1.9.7 - tslib: 2.7.0 + tslib: 2.8.1 '@firebase/database-compat@1.0.7': dependencies: @@ -8120,7 +8136,7 @@ snapshots: '@firebase/logger@0.4.2': dependencies: - tslib: 2.7.0 + tslib: 2.8.1 '@firebase/messaging-compat@0.2.10(@firebase/app-compat@0.2.39)(@firebase/app@0.10.9)': dependencies: @@ -8683,6 +8699,14 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@pkgjs/parseargs@0.11.0': optional: true @@ -9373,7 +9397,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.0.4(vite@6.4.0(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.4(vite@7.1.10(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -9381,7 +9405,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.38 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.0(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1) + vite: 7.1.10(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -13105,6 +13129,8 @@ snapshots: pako@0.2.9: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13187,6 +13213,13 @@ snapshots: pathval@2.0.1: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + pend@1.2.0: {} picocolors@1.0.1: {} From df9af080dd8409b2bae6db42731a055e488bf25c Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 11:44:06 +0100 Subject: [PATCH 04/11] Update the user-report file to fix the download and textWrapping functionalities --- src/lib/pdf/user-report.ts | 64 ++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/src/lib/pdf/user-report.ts b/src/lib/pdf/user-report.ts index 504cd33..936ecd3 100644 --- a/src/lib/pdf/user-report.ts +++ b/src/lib/pdf/user-report.ts @@ -1,21 +1,19 @@ import type { PDFFont } from "pdf-lib"; -// src/lib/pdf/user-report.ts import { PDFDocument, rgb, StandardFonts - } from "pdf-lib"; export interface UserReportItem { id: string; - label: string; - answer: string; + label?: string; + answer?: string; } export interface UserReportSection { id: string; - name: string; + name?: string; description?: string; items: UserReportItem[]; } @@ -26,6 +24,16 @@ export interface UserReportPayload { sections: UserReportSection[]; } +/** + * Replace characters that can't be encoded by WinAnsi (Helvetica) + */ + +function sanitizeForPdf(text: string): string { + return Array.from(text) + .map(ch => (ch.charCodeAt(0) <= 0xFF ? ch : "?")) + .join(""); +} + /** * Wrap a long text into multiple lines that fit a given width. */ @@ -35,7 +43,8 @@ function wrapText( font: PDFFont, fontSize: number ): string[] { - const words = text.split(" "); + const safeText = sanitizeForPdf(text); + const words = safeText.split(" "); const lines: string[] = []; let currentLine = ""; @@ -88,6 +97,7 @@ export async function generateUserReportPdf( ): void => { const usedFont = bold ? fontBold : font; const textHeight = size; + const safeText = sanitizeForPdf(text); if (cursorY - textHeight < marginY) { page = pdfDoc.addPage(); @@ -95,7 +105,7 @@ export async function generateUserReportPdf( cursorY = height - marginY; } - page.drawText(text, { + page.drawText(safeText, { x: marginX, y: cursorY - textHeight, size, @@ -135,13 +145,15 @@ export async function generateUserReportPdf( cursorY -= 14; // --- Sections ------------------------------------------------------------ - for (const section of payload.sections) { cursorY -= 8; - drawLine(section.name, 14, true); - if (section.description) { - const descLines = wrapText(section.description, contentWidth, font, 10); + const sectionTitle = section.name || section.id || "Section"; + drawLine(sectionTitle, 14, true); + + const sectionDescription = section.description; + if (sectionDescription) { + const descLines = wrapText(sectionDescription, contentWidth, font, 10); for (const line of descLines) { drawLine(line, 10, false, rgb(0.3, 0.3, 0.3)); } @@ -150,7 +162,8 @@ export async function generateUserReportPdf( cursorY -= 4; for (const item of section.items) { - const qLines = wrapText(item.label, contentWidth, fontBold, 11); + const questionLabel = item.label || "Question"; + const qLines = wrapText(questionLabel, contentWidth, fontBold, 11); for (const line of qLines) { drawLine(line, 11, true); } @@ -167,16 +180,27 @@ export async function generateUserReportPdf( cursorY -= 8; } + // --- Export & open PDF --------------------------------------------------- + const pdfBytes = await pdfDoc.save(); + + // Re-wrap to get a clean ArrayBuffer for Blob (TS-safe) const byteArray = new Uint8Array(pdfBytes); - const blob = new Blob([byteArray], { type: "application/pdf" }); + const blob = new Blob([byteArray.buffer], { type: "application/pdf" }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "stateofdev-my-survey-report.pdf"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + const opened = window.open(url, "_blank"); + + if (!opened) { + const a = document.createElement("a"); + a.href = url; + a.download = "stateofdev-my-survey-report.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + } + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 10000); } From 9930fe4cbcfa4ede1823eed6fe93a77e08b5ea04 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 11:44:51 +0100 Subject: [PATCH 05/11] Enhance styles of the download and view results button --- src/pages/thanks.astro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/thanks.astro b/src/pages/thanks.astro index 021b4c4..722643d 100644 --- a/src/pages/thanks.astro +++ b/src/pages/thanks.astro @@ -133,7 +133,7 @@ const posts: Post[] = [

-
+

Get a copy of your answers

You can view your survey responses and download a personal PDF @@ -141,7 +141,7 @@ const posts: Post[] = [

View & download my report From f60ac5a03cc3511bd6e38175b8faf75a7727fea7 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 11:45:55 +0100 Subject: [PATCH 06/11] Create a script file to handle the PDF payload and download button --- src/scripts/my-responses.client.ts | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/scripts/my-responses.client.ts diff --git a/src/scripts/my-responses.client.ts b/src/scripts/my-responses.client.ts new file mode 100644 index 0000000..db777a5 --- /dev/null +++ b/src/scripts/my-responses.client.ts @@ -0,0 +1,57 @@ +import type { UserReportPayload } from "../lib/pdf/user-report"; +import { generateUserReportPdf } from "../lib/pdf/user-report"; + +function getPayload(): UserReportPayload | null { + const dataEl = document.getElementById("my-responses-data"); + + if (!dataEl) { + console.warn("[my-responses] #my-responses-data not found"); + return null; + } + + const raw = dataEl.getAttribute("data-payload") || "{}"; + + try { + const parsed = JSON.parse(raw) as UserReportPayload; + return parsed; + } + catch (error) { + console.error("[my-responses] Failed to parse PDF payload", error); + return null; + } +} + +function setupDownloadButton(): void { + const button = document.getElementById("download-pdf"); + const errorEl = document.getElementById("pdf-error"); + + if (!button) { + console.warn("[my-responses] #download-pdf not found"); + return; + } + + const payload = getPayload(); + if (!payload) { + if (errorEl) { + errorEl.classList.remove("hidden"); + } + return; + } + + button.addEventListener("click", () => { + if (errorEl) { + errorEl.classList.add("hidden"); + } + + void generateUserReportPdf(payload).catch((error) => { + console.error("[my-responses] Failed to generate PDF report", error); + if (errorEl) { + errorEl.classList.remove("hidden"); + } + }); + }); +} + +window.addEventListener("DOMContentLoaded", () => { + setupDownloadButton(); +}); From 9e8a6e5bb87caef15ec2a052e938114f4004d1fa Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 11:47:38 +0100 Subject: [PATCH 07/11] Add My Responses page with per-user PDF download --- src/pages/my-responses.astro | 263 +++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/pages/my-responses.astro diff --git a/src/pages/my-responses.astro b/src/pages/my-responses.astro new file mode 100644 index 0000000..c528aa8 --- /dev/null +++ b/src/pages/my-responses.astro @@ -0,0 +1,263 @@ +--- +import { getAuth } from "firebase-admin/auth"; +import { getFirestore } from "firebase-admin/firestore"; +import BaseLayout from "@/components/layout.astro"; +import { getActiveApp } from "@/lib/firebase/server"; +import { validateSurveyFile } from "@/lib/validators/survey-schema"; +import myResponsesScriptUrl from "@/scripts/my-responses.client?url"; +import profileQuestionsRaw from "@/survey/1-profile.yml"; +import learningQuestionsRaw from "@/survey/2-learning-and-education.yml"; +import workQuestionsRaw from "@/survey/3-work.yml"; +import aiQuestionsRaw from "@/survey/4-ai.yml"; +import techQuestionsRaw from "@/survey/5-tech.yml"; +import communityQuestionsRaw from "@/survey/6-community.yml"; + +export const prerender = false; + +// --------------------------------------------------------------------------- +// 1. Authenticate user based on the existing anonymous session cookie +// --------------------------------------------------------------------------- + +const app = getActiveApp(); +const auth = getAuth(app); +const db = getFirestore(app); + +if (!Astro.cookies.has("__session")) { + return Astro.redirect("/before-start"); +} + +const sessionCookie = Astro.cookies.get("__session")?.value; +if (!sessionCookie) { + return Astro.redirect("/before-start"); +} + +const decoded = await auth.verifySessionCookie(sessionCookie); +const user = await auth.getUser(decoded.uid); +if (!user) { + return Astro.redirect("/before-start"); +} + +const userId = user.uid; + +// --------------------------------------------------------------------------- +// 2. Load this user's answers from Firestore +// --------------------------------------------------------------------------- + +const RESULTS_COLLECTION = "results"; + +const docRef = db.collection(RESULTS_COLLECTION).doc(userId); +const snapshot = await docRef.get(); + +if (!snapshot.exists) { + return Astro.redirect("/survey"); +} + +const rawData = snapshot.data() as Record; + +const METADATA_KEYS = new Set([ + "creationTime", + "lastSignInTime", + "lastUpdated", + "userId" +]); + +const answers = Object.fromEntries( + Object.entries(rawData).filter(([key]) => !METADATA_KEYS.has(key)) +) as Record; + +// --------------------------------------------------------------------------- +// 3. Load survey definition from YAML +// --------------------------------------------------------------------------- + +const sectionsRaw = [ + validateSurveyFile(profileQuestionsRaw, "1-profile.yml"), + validateSurveyFile(learningQuestionsRaw, "2-learning-and-education.yml"), + validateSurveyFile(workQuestionsRaw, "3-work.yml"), + validateSurveyFile(aiQuestionsRaw, "4-ai.yml"), + validateSurveyFile(techQuestionsRaw, "5-tech.yml"), + validateSurveyFile(communityQuestionsRaw, "6-community.yml") +]; + +// --------------------------------------------------------------------------- +// 4. Helpers: map stored indices -> human-readable labels +// --------------------------------------------------------------------------- + +interface QuestionDefinition { + label: string; + choices?: string[]; + multiple?: boolean; +} + +function normalizeIndices(value: unknown): number[] { + if (Array.isArray(value)) { + return value + .map(x => (typeof x === "number" ? x : Number(x))) + .filter(x => Number.isFinite(x)); + } + + if (typeof value === "number") { + return [value]; + } + + return []; +} + +function formatAnswer( + questionKey: string, + question: QuestionDefinition +): string { + const value = answers[questionKey]; + + if (value === null || typeof value === "undefined") { + return "Not answered"; + } + + const { choices, multiple } = question; + + if (!choices || !Array.isArray(choices)) { + return String(value); + } + + if (multiple) { + const indices = normalizeIndices(value); + const labels = indices + .map(i => choices[i]) + .filter((x): x is string => Boolean(x)); + + const otherKey = `${questionKey}-other`; + const otherValue = answers[otherKey]; + + if (typeof otherValue === "string" && otherValue.trim()) { + labels.push(`Other: ${otherValue.trim()}`); + } + + return labels.length ? labels.join(", ") : "Not answered"; + } + + const indices = normalizeIndices(value); + const idx = indices[0]; + + if (typeof idx === "number" && typeof choices[idx] === "string") { + let base = choices[idx]; + + const otherKey = `${questionKey}-other`; + const otherValue = answers[otherKey]; + + if (typeof otherValue === "string" && otherValue.trim()) { + base += ` – Other: ${otherValue.trim()}`; + } + + return base; + } + + return String(value); +} + +// --------------------------------------------------------------------------- +// 5. Build a view model used by both UI and PDF generator +// --------------------------------------------------------------------------- + +export interface ViewQuestionItem { + id: string; + label: string; + answer: string; +} + +export interface ViewSection { + id: string; + name: string; + description?: string; + items: ViewQuestionItem[]; +} + +const viewSections: ViewSection[] = sectionsRaw.map((section: any) => ({ + id: section.label, + name: section.name, + description: section.description, + items: section.questions.map((q: QuestionDefinition, index: number) => { + const questionId = `${section.label}-q-${index}`; + + return { + id: questionId, + label: q.label, + answer: formatAnswer(questionId, q) + }; + }) +})); + +const submittedAt + = (rawData.lastUpdated as string | undefined) + ?? (rawData.creationTime as string | undefined) + ?? null; +--- + + +
+ +
+

Your survey report

+

+ This page shows the answers you submitted to the State of Dev in Morocco + survey. You can download a PDF copy for your own records. +

+ +
+ + + +
+
+ + +
+ { + viewSections.map(section => ( +
+

{section.name}

+ {section.description && ( +

+ {section.description} +

+ )} + +
+ {section.items.map((item: ViewQuestionItem) => ( +
+
{item.label}
+
+ {item.answer} +
+
+ ))} +
+
+ )) + } +
+ + +
+
+ + + +
+
From 59ccf2b7916e5a17efe7630ca466abbbd02a30c4 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 13:50:12 +0100 Subject: [PATCH 08/11] Add GeeksBlaBla and StateOfDev PNG Logos for PDF generation --- src/assets/geeksblabla-logo.png | Bin 0 -> 4764 bytes src/assets/stateOfDev-logo.png | Bin 0 -> 2606 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/assets/geeksblabla-logo.png create mode 100644 src/assets/stateOfDev-logo.png diff --git a/src/assets/geeksblabla-logo.png b/src/assets/geeksblabla-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9505d48e76ab00e8184076a236f28fd5a763a421 GIT binary patch literal 4764 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 QjQ{`u07*qoM6N<$f)=YZUjP6A literal 0 HcmV?d00001 From fed35224bca99bda2dbe7cc419601e7f5ac62769 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 14:16:17 +0100 Subject: [PATCH 09/11] Update my-responses client file, and remove debugging lines --- src/scripts/my-responses.client.ts | 53 +++++++++++++++--------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/scripts/my-responses.client.ts b/src/scripts/my-responses.client.ts index db777a5..0fca529 100644 --- a/src/scripts/my-responses.client.ts +++ b/src/scripts/my-responses.client.ts @@ -1,37 +1,30 @@ -import type { UserReportPayload } from "../lib/pdf/user-report"; -import { generateUserReportPdf } from "../lib/pdf/user-report"; +import type { UserReportPayload } from "@/lib/pdf/user-report"; +import { + generateUserReportPdf -function getPayload(): UserReportPayload | null { +} from "@/lib/pdf/user-report"; + +function setupDownloadButton(): void { + const button = document.getElementById("download-pdf"); const dataEl = document.getElementById("my-responses-data"); + const errorEl = document.getElementById("pdf-error"); - if (!dataEl) { - console.warn("[my-responses] #my-responses-data not found"); - return null; + if (!button || !dataEl) { + console.warn("[my-responses] Missing button or data element", { + button, + dataEl + }); + return; } - const raw = dataEl.getAttribute("data-payload") || "{}"; + let payload: UserReportPayload | null = null; try { - const parsed = JSON.parse(raw) as UserReportPayload; - return parsed; + const raw = dataEl.getAttribute("data-payload") || "{}"; + payload = JSON.parse(raw) as UserReportPayload; } catch (error) { console.error("[my-responses] Failed to parse PDF payload", error); - return null; - } -} - -function setupDownloadButton(): void { - const button = document.getElementById("download-pdf"); - const errorEl = document.getElementById("pdf-error"); - - if (!button) { - console.warn("[my-responses] #download-pdf not found"); - return; - } - - const payload = getPayload(); - if (!payload) { if (errorEl) { errorEl.classList.remove("hidden"); } @@ -39,6 +32,10 @@ function setupDownloadButton(): void { } button.addEventListener("click", () => { + if (!payload) { + return; + } + if (errorEl) { errorEl.classList.add("hidden"); } @@ -52,6 +49,8 @@ function setupDownloadButton(): void { }); } -window.addEventListener("DOMContentLoaded", () => { - setupDownloadButton(); -}); +if (typeof window !== "undefined") { + window.addEventListener("DOMContentLoaded", () => { + setupDownloadButton(); + }); +} From 9ecab3b695208c1b791d119faea8ba0e77d4820b Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 15:52:30 +0100 Subject: [PATCH 10/11] Enhance the appearance of the generated document fro user-report.ts file --- src/lib/pdf/user-report.ts | 382 +++++++++++++++++++++++++++---------- 1 file changed, 286 insertions(+), 96 deletions(-) diff --git a/src/lib/pdf/user-report.ts b/src/lib/pdf/user-report.ts index 936ecd3..4534417 100644 --- a/src/lib/pdf/user-report.ts +++ b/src/lib/pdf/user-report.ts @@ -1,14 +1,13 @@ import type { PDFFont } from "pdf-lib"; -import { - PDFDocument, - rgb, - StandardFonts -} from "pdf-lib"; +import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; + +import geeksLogo from "@/assets/geeksblabla-logo.png"; +import stateOfDevLogo from "@/assets/stateOfDev-logo.png"; export interface UserReportItem { id: string; - label?: string; - answer?: string; + label: string; + answer: string; } export interface UserReportSection { @@ -25,17 +24,24 @@ export interface UserReportPayload { } /** - * Replace characters that can't be encoded by WinAnsi (Helvetica) + * Strip characters that WinAnsi / built-in fonts can’t encode (emojis etc.) + * to avoid runtime errors like “WinAnsi cannot encode ...”. */ - function sanitizeForPdf(text: string): string { + if (!text) + return ""; return Array.from(text) - .map(ch => (ch.charCodeAt(0) <= 0xFF ? ch : "?")) + .map((ch) => { + const code = ch.codePointAt(0) ?? 0; + if (code < 32 || code > 255) + return ""; + return ch; + }) .join(""); } /** - * Wrap a long text into multiple lines that fit a given width. + * Wrap text into multiple lines that fit a given width. */ function wrapText( text: string, @@ -43,69 +49,80 @@ function wrapText( font: PDFFont, fontSize: number ): string[] { - const safeText = sanitizeForPdf(text); - const words = safeText.split(" "); + const clean = sanitizeForPdf(text); + const words = clean.split(/\s+/).filter(Boolean); const lines: string[] = []; - let currentLine = ""; + let current = ""; for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; + const testLine = current ? `${current} ${word}` : word; const width = font.widthOfTextAtSize(testLine, fontSize); - if (width > maxWidth && currentLine) { - lines.push(currentLine); - currentLine = word; + if (width > maxWidth && current) { + lines.push(current); + current = word; } else { - currentLine = testLine; + current = testLine; } } - if (currentLine) { - lines.push(currentLine); - } - + if (current) + lines.push(current); return lines; } /** * Generate and trigger download of a per-user survey report PDF. - * Created entirely client-side using pdf-lib. */ export async function generateUserReportPdf( payload: UserReportPayload ): Promise { const pdfDoc = await PDFDocument.create(); - const font = await pdfDoc.embedFont(StandardFonts.Helvetica); - const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - const marginX = 50; - const marginY = 50; + // ---- Fonts ---------------------------------------------------------------- + // Monospace dev vibe: Courier everywhere. + const bodyFont = await pdfDoc.embedFont(StandardFonts.Courier); + const headingFont = await pdfDoc.embedFont(StandardFonts.CourierBold); + const titleFont = headingFont; // big bold Courier for title + + // ---- Colors & layout ------------------------------------------------------ + const brandBlue = rgb(0.18, 0.29, 0.66); + const softGrey = rgb(0.55, 0.55, 0.55); + const borderGrey = rgb(0.82, 0.82, 0.84); + const rowDivider = rgb(0.9, 0.9, 0.92); + const textDark = rgb(0.12, 0.12, 0.12); + + const marginX = 56; + const marginY = 56; const lineGap = 4; let page = pdfDoc.addPage(); let { width, height } = page.getSize(); let cursorY = height - marginY; - const contentWidth = width - marginX * 2; + const ensureSpace = (needed: number) => { + if (cursorY - needed < marginY) { + page = pdfDoc.addPage(); + ({ width, height } = page.getSize()); + cursorY = height - marginY; + } + }; + const drawLine = ( text: string, size: number, - bold = false, - color = rgb(0, 0, 0) - ): void => { - const usedFont = bold ? fontBold : font; + opts?: { font?: PDFFont; color?: ReturnType } + ) => { + const safe = sanitizeForPdf(text); + const usedFont = opts?.font ?? bodyFont; + const color = opts?.color ?? textDark; const textHeight = size; - const safeText = sanitizeForPdf(text); - if (cursorY - textHeight < marginY) { - page = pdfDoc.addPage(); - ({ width, height } = page.getSize()); - cursorY = height - marginY; - } + ensureSpace(textHeight + lineGap); - page.drawText(safeText, { + page.drawText(safe, { x: marginX, y: cursorY - textHeight, size, @@ -116,91 +133,264 @@ export async function generateUserReportPdf( cursorY -= textHeight + lineGap; }; - // --- Cover / header ------------------------------------------------------ - drawLine("StateOfDev.ma · Geeksblabla", 10, true, rgb(0.3, 0.3, 0.3)); - drawLine("State of Dev in Morocco – My Survey Report", 18, true); - - if (payload.submittedAt) { - drawLine( - `Submitted: ${payload.submittedAt}`, - 10, - false, - rgb(0.3, 0.3, 0.3) - ); - } + // ---- 1. Logos + title block ---------------------------------------------- - drawLine(`User ID: ${payload.userId}`, 10, false, rgb(0.3, 0.3, 0.3)); - drawLine("Website: https://stateofdev.ma", 10, false, rgb(0.3, 0.3, 0.3)); + try { + const [leftBytes, rightBytes] = await Promise.all([ + fetch(stateOfDevLogo.src).then(async res => res.arrayBuffer()), + fetch(geeksLogo.src).then(async res => res.arrayBuffer()) + ]); - cursorY -= 10; + const leftImg = await pdfDoc.embedPng(leftBytes); + const rightImg = await pdfDoc.embedPng(rightBytes); - const intro + const logoHeight = 15; + const leftScale = logoHeight / leftImg.height; + const rightScale = (logoHeight + 7) / rightImg.height; + + page.drawImage(leftImg, { + x: marginX, + y: cursorY - logoHeight, + width: leftImg.width * leftScale, + height: logoHeight + }); + + page.drawImage(rightImg, { + x: width - marginX - rightImg.width * rightScale, + y: cursorY - logoHeight, + width: rightImg.width * rightScale, + height: logoHeight + 7 + }); + + cursorY -= logoHeight + 18; + } + catch { + cursorY -= 12; + } + + // Title: terminal-style banner, centered + const title = "=== SURVEY REPORT ==="; + const titleSize = 16; + const titleWidth = titleFont.widthOfTextAtSize(title, titleSize); + const titleX = marginX + (contentWidth - titleWidth) / 2; + const titleY = cursorY - titleSize; + + page.drawText(sanitizeForPdf(title), { + x: titleX, + y: titleY, + size: titleSize, + font: titleFont, + color: textDark + }); + + // Short underline (40% width) + const underlineWidth = contentWidth * 0; + const underlineX = marginX + (contentWidth - underlineWidth) / 2; + + page.drawRectangle({ + x: underlineX, + y: titleY - 6, + width: underlineWidth, + height: 1, + color: brandBlue + }); + + cursorY = titleY - 18; + + // Subtitle in softer grey + const subtitle = "This document contains the answers you submitted to the State of Dev in Morocco survey. It is intended for your personal reference."; - const introLines = wrapText(intro, contentWidth, font, 11); - for (const line of introLines) { - drawLine(line, 11); + const subtitleLines = wrapText(subtitle, contentWidth, bodyFont, 10); + for (const line of subtitleLines) { + drawLine(line, 10, { font: bodyFont, color: softGrey }); } - cursorY -= 14; + cursorY -= 8; + + // ---- 2. Metadata “card” (compact) ---------------------------------------- + + const cardTop = cursorY; + const cardPaddingX = 10; + const cardPaddingY = 8; + const labelSize = 10; + const valueSize = 10; + const rowHeight = labelSize + 4; + + const metaRows: Array<[string, string]> = [ + ["Submitted", payload.submittedAt ?? "N/A"], + ["User ID", payload.userId], + ["Website", "https://stateofdev.ma"] + ]; + + const cardHeight = cardPaddingY * 2 + metaRows.length * rowHeight; + + ensureSpace(cardHeight + 12); + + page.drawRectangle({ + x: marginX, + y: cursorY - cardHeight, + width: contentWidth, + height: cardHeight, + borderColor: borderGrey, + borderWidth: 0.8, + color: rgb(1, 1, 1) + }); + + let metaCursorY = cursorY - cardPaddingY - labelSize; + + metaRows.forEach(([label, value]) => { + const labelText = `${label}:`; + + page.drawText(sanitizeForPdf(labelText), { + x: marginX + cardPaddingX, + y: metaCursorY, + size: labelSize, + font: headingFont, + color: textDark + }); + + const valueX + = marginX + + cardPaddingX + + headingFont.widthOfTextAtSize(labelText, labelSize) + + 6; + + page.drawText(sanitizeForPdf(value), { + x: valueX, + y: metaCursorY, + size: valueSize, + font: bodyFont, + color: softGrey + }); + + metaCursorY -= rowHeight; + }); + + cursorY = cardTop - cardHeight - 18; + + // ---- 3. Sections as blocks (minimal + terminal flair) -------------------- + + const rowGap = 10; + const qaGap = 2; + const questionColor = textDark; + const answerColor = rgb(0.25, 0.25, 0.25); - // --- Sections ------------------------------------------------------------ for (const section of payload.sections) { - cursorY -= 8; + cursorY -= 16; + ensureSpace(90); + + // Section header bar (left) with terminal-style label + const barHeight = 18; + // const barWidth = contentWidth * 0.35; + const barWidth = contentWidth; + const barY = cursorY - barHeight; + + page.drawRectangle({ + x: marginX, + y: barY, + width: barWidth, + height: barHeight, + color: brandBlue + }); + + const rawLabel = section.name ?? section.id ?? ""; + const sectionLabel = `> ${rawLabel.toUpperCase()}`; + + page.drawText(sanitizeForPdf(sectionLabel), { + x: marginX + 8, + y: barY + 4, + size: 11, + font: headingFont, + color: rgb(1, 1, 1) + }); - const sectionTitle = section.name || section.id || "Section"; - drawLine(sectionTitle, 14, true); + cursorY = barY - 12; - const sectionDescription = section.description; - if (sectionDescription) { - const descLines = wrapText(sectionDescription, contentWidth, font, 10); + if (section.description) { + const descLines = wrapText( + section.description, + contentWidth, + bodyFont, + 9.5 + ); for (const line of descLines) { - drawLine(line, 10, false, rgb(0.3, 0.3, 0.3)); + drawLine(line, 9.5, { font: bodyFont, color: softGrey }); } + cursorY -= 4; } - cursorY -= 4; - for (const item of section.items) { - const questionLabel = item.label || "Question"; - const qLines = wrapText(questionLabel, contentWidth, fontBold, 11); + const qLines = wrapText(item.label, contentWidth, headingFont, 10.5); for (const line of qLines) { - drawLine(line, 11, true); + drawLine(line, 10.5, { font: headingFont, color: questionColor }); } + cursorY -= qaGap; + const answerText = item.answer || "Not answered"; - const aLines = wrapText(answerText, contentWidth, font, 10); + const aLines = wrapText(` ${answerText}`, contentWidth, bodyFont, 10); for (const line of aLines) { - drawLine(line, 10, false, rgb(0.2, 0.2, 0.2)); + drawLine(line, 10, { font: bodyFont, color: answerColor }); } - cursorY -= 6; + const dividerHeight = 0.5; + ensureSpace(dividerHeight + rowGap); + page.drawRectangle({ + x: marginX, + y: cursorY - dividerHeight - 2, + width: contentWidth, + height: dividerHeight, + color: rowDivider + }); + + cursorY -= rowGap; } - - cursorY -= 8; } - // --- Export & open PDF --------------------------------------------------- + // ---- 4. Footer on every page --------------------------------------------- - const pdfBytes = await pdfDoc.save(); + const pages = pdfDoc.getPages(); + const totalPages = pages.length; - // Re-wrap to get a clean ArrayBuffer for Blob (TS-safe) - const byteArray = new Uint8Array(pdfBytes); - const blob = new Blob([byteArray.buffer], { type: "application/pdf" }); - const url = URL.createObjectURL(blob); + pages.forEach((p, index) => { + const footerY = marginY - 30; + const footerCenterX = marginX + (p.getSize().width - marginX * 2) / 2; - const opened = window.open(url, "_blank"); + const signature = "StateOfDev.ma · Geeksblabla"; + const sigWidth = bodyFont.widthOfTextAtSize(signature, 9); - if (!opened) { - const a = document.createElement("a"); - a.href = url; - a.download = "stateofdev-my-survey-report.pdf"; - document.body.appendChild(a); - a.click(); - a.remove(); - } + p.drawText(sanitizeForPdf(signature), { + x: footerCenterX - sigWidth / 2, + y: footerY, + size: 9, + font: bodyFont, + color: softGrey + }); + + const pageText = `Page ${index + 1} / ${totalPages}`; + const pageWidth = bodyFont.widthOfTextAtSize(pageText, 9); + + p.drawText(pageText, { + x: p.getSize().width - marginX - pageWidth, + y: footerY, + size: 9, + font: bodyFont, + color: softGrey + }); + }); + + // ---- 5. Export & download ------------------------------------------------- + const pdfBytes = await pdfDoc.save(); + const byteArray = new Uint8Array(pdfBytes); + const blob = new Blob([byteArray], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); - setTimeout(() => { - URL.revokeObjectURL(url); - }, 10000); + const a = document.createElement("a"); + a.href = url; + a.download = "stateofdev-my-survey-report.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); } From 6d26239c497c54b4042b73a7197af239d8049188 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Wed, 10 Dec 2025 16:02:50 +0100 Subject: [PATCH 11/11] Tests: added & ran unit tests for the user-report functionalities #all passed --- src/lib/pdf/user-report.test.ts | 141 ++++++++++++++++++++++++++++++++ src/lib/pdf/user-report.ts | 4 +- 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/lib/pdf/user-report.test.ts diff --git a/src/lib/pdf/user-report.test.ts b/src/lib/pdf/user-report.test.ts new file mode 100644 index 0000000..eb1c4e7 --- /dev/null +++ b/src/lib/pdf/user-report.test.ts @@ -0,0 +1,141 @@ +import type { PDFFont } from "pdf-lib"; +import type { UserReportPayload } from "./user-report"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + generateUserReportPdf, + sanitizeForPdf, + wrapText + +} from "./user-report"; + +/** + * Small fake font object – only widthOfTextAtSize is needed by wrapText. + */ +const fakeFont: PDFFont = { + widthOfTextAtSize: (text: string, size: number) => + text.length * size * 0.5 +} as unknown as PDFFont; + +describe("sanitizeForPdf", () => { + it("keeps regular ASCII text as-is", () => { + expect(sanitizeForPdf("Hello World! 123")).toBe("Hello World! 123"); + }); + + it("strips emojis and non-WinAnsi characters", () => { + const input = "Hello 😊 World 🌍"; + const output = sanitizeForPdf(input); + + // We expect emojis to be removed but basic letters/spaces kept + expect(output).toBe("Hello World "); + }); + + it("returns empty string for empty input", () => { + expect(sanitizeForPdf("")).toBe(""); + }); +}); + +describe("wrapText", () => { + it("wraps long text into multiple lines based on max width", () => { + const text = "one two three four five six seven eight"; + const lines = wrapText(text, 40, fakeFont, 10); + + expect(lines.length).toBeGreaterThan(1); + const joined = lines.join(" "); + expect(joined).toContain("one"); + expect(joined).toContain("eight"); + }); + + it("returns a single line when text fits in max width", () => { + const text = "short text"; + const lines = wrapText(text, 1000, fakeFont, 10); + + expect(lines.length).toBe(1); + expect(lines[0]).toBe("short text"); + }); +}); + +describe("generateUserReportPdf", () => { + let originalDocument: Document | undefined; + let originalURL: typeof URL | undefined; + + let createElementMock: ReturnType; + let appendChildMock: ReturnType; + let createObjectURLMock: ReturnType; + let revokeObjectURLMock: ReturnType; + + beforeEach(() => { + // Save originals to restore later + originalDocument = globalThis.document; + originalURL = globalThis.URL; + + const fakeLink = { + href: "", + download: "", + click: vi.fn(), + remove: vi.fn() + } as unknown as HTMLAnchorElement; + + createElementMock = vi.fn(() => fakeLink); + appendChildMock = vi.fn(); + createObjectURLMock = vi.fn(() => "blob:fake"); + revokeObjectURLMock = vi.fn(); + + globalThis.document = { + createElement: createElementMock, + body: { + appendChild: appendChildMock + } + } as unknown as Document; + + globalThis.URL = { + createObjectURL: createObjectURLMock, + revokeObjectURL: revokeObjectURLMock + } as unknown as typeof URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + + if (originalDocument) { + globalThis.document = originalDocument; + } + + if (originalURL) { + globalThis.URL = originalURL; + } + }); + + it("generates a PDF and triggers a download without throwing", async () => { + const payload: UserReportPayload = { + userId: "test-user", + submittedAt: "2025-01-01T00:00:00Z", + sections: [ + { + id: "profile", + name: "Profile", + items: [ + { + id: "profile-q-0", + label: "What is your gender?", + answer: "Male" + }, + { + id: "profile-q-1", + label: "What is your age?", + answer: "25 to 34 years" + } + ] + } + ] + }; + + await expect(generateUserReportPdf(payload)).resolves.toBeUndefined(); + + // Assert that our DOM/URL mocks were actually used + expect(createElementMock).toHaveBeenCalledWith("a"); + expect(appendChildMock).toHaveBeenCalled(); + expect(createObjectURLMock).toHaveBeenCalled(); + expect(revokeObjectURLMock).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/pdf/user-report.ts b/src/lib/pdf/user-report.ts index 4534417..071d100 100644 --- a/src/lib/pdf/user-report.ts +++ b/src/lib/pdf/user-report.ts @@ -27,7 +27,7 @@ export interface UserReportPayload { * Strip characters that WinAnsi / built-in fonts can’t encode (emojis etc.) * to avoid runtime errors like “WinAnsi cannot encode ...”. */ -function sanitizeForPdf(text: string): string { +export function sanitizeForPdf(text: string): string { if (!text) return ""; return Array.from(text) @@ -43,7 +43,7 @@ function sanitizeForPdf(text: string): string { /** * Wrap text into multiple lines that fit a given width. */ -function wrapText( +export function wrapText( text: string, maxWidth: number, font: PDFFont,