Tdut90^>L0R`!!GN4Fp(NVGm-`m&!cY
zmKizbZ1om(WVGo6iP3m}`iOq$^h1t?wU&X6jy7&X^`o^s3z*{?isL-P_Qmhju~OpV
zQ}N|1*DL^5T(D9TeBAO*0`TfdJ0`k?`f8Db@srHq$d|VflSX0UClx_a&JF{(>SBrK
z0QDIto2uD8`J6izJBW^AJ7t4UL+q|$=&z^pA-)opE5KEoKVV@5<2dmTz(>Xc@)kj!
zSCsQt$k7gqn^rsrVTbW8aBadB-Gn?fC1UM>(Lk_}U*5r&Kg%MF#?CroJ2ZrQY(<5C
z@brPO>L+2XixQq!xRWfz)Em^i?Qgh7t6*k*lo5K
zFwY6)8Tiix|F>QGoPa*!ZDXN_K4RBb)f)`sVW62<+}Uu}#xL;4K-X&B$gxQ9F+M+L
zh>SLxPGT%xQ#c!d6;>?Q?htee1bs&%L}UDpMfKVkz)b_V^5H714P8S2!5R>LK{s?6vE*huJ|0=?CXwV>MwI^oCN=7AfS12-_vDI;H!lVcw3
zLm#)t3_u~Gk<*{xpaV(toYDxRqKw0t#Fi;zv=$2mzlDO&h&~5*B;degVQIXe-9U*+
zUpS}cE3neTWuUa~g!4rRknyGQ5k-JbPx93kY~|}Y7PI?d+KIQg3Q&7tQzL@+|N^jPs>b7{^?U%fogw7GfefC$m|pX?}h-a#P<8mvn!v
zH-xB)YAqJPW`PnpX7CLPzYn1%IxRM;H+*&RO}8M~*=^QXC5>|f))ya6e}Tmo`)QAH
zu-d=EswV{^23*Q%xRllc*z4*Fa>rme{!dHYc1B}Y1UvPro`=O=0BsGIrQsxtiA39P
z!*fi$7OKe9ukojtG`U~Oo
z$0+@778`Bcj=nCU55g~n1~^n0p-~F9MTRyZGcnCUK8RUL+4-^AIH8gpU*-M?|9U
zsIwrDeX|wOsY5WI9BJeRj>dPfxD#Bw&WZ<<@nE)!wuAa&;`QE?jfjr!reo3;ZJ#@^
zrg2D3l;D@|u-lsF=H~jeTBK)BqeShGu#1hwt%JptOIlG#!B|bE
zXe0fEzvXMxc!)PB^mm7ieUg`#eIEWU#BP_W<38C4t)(gxdlU!}P~;qxRHE`vumVxy
z9q<%XWEYrcshpT|{yBEaAGBi-z{VngIp36Wj&H2Y;v1{tD}@3Wqp_BO*m5Y*QK5x@
zZKTaZ{S^ozwF4k~ltKjRr{^$gg}a_2UWjv%LFoSk&ktf@X69KeTfKp<4Y;EElB*3A
z30)*kTfChSlw`tTJ;guj9_ZCvT|IzaC2do&2MSdMJsb>vvW4qwSwnpGUydh5On!o=)D?SIvua1tA~Wju0yyD2a<7dBc=o>*kVs`rb9kT!vxlz@_^tw
zZs&>q^5#&Z@hQ~Di{YR;DfPU<3-hD2WXaYd{#HSOS!5(78e+&)=rc~?@8J_^hCr-E1<1fjtayLFM5gAVO@1ANvuupIe2Pxzvs
zF5~)Bsx0+8j}46`C9%JZ?edbhD2|HjS9-l+1$?Laq3+#WZ!AmTm8OdfEUfS>tgwzU
z5dI8bAo6b!dBr(v4E$j=M8@aD8mMDP)WzB2+5a~$2*|+V3Qsd!;Sp6+Odu%11?)yT
z@&d;CJjx4%Pgx$LI$#8t3dqSO!*LFR^?JYgOC(&ybhwI-%Q7hULpQHkfe2;@=Gv>-
q+^XXlh^)dF`#QM3AM%tL9{&sN=EcQKbKued0000X1^@s6b9lc2000T~Nkl-CbRB>aSzfhaR$~wFGvk1PPj3F%y2c=Z`{w>h;h%=D82;fh5?U49
z*G*>^XdV71*SChhvCIGtg1VQ<7|$0zdid+%4{b8$$LIY6ChB~+EzretrICvM+uHr(
zK_H7GbBM=GXM4k^XrMmJvYl@g+*eIU&kdh>#vPXJ9AW0Nr#-p3b$jr%T<_h=
z0DiRa-@>!NUF|$Mh3(p)tIFa3`Tft*`Dw$y44-Bg$enWk`S2I#4R9XFu8|L4&>DVd
zu8(OAJw4ZZwDS9M;*Vl)+Syu>CXx|zJaH-sr-7&(z`sMIf7Z~L2Ql~UE
z1wm01?Mi`_LlnWo!<5YtXbRkXoM(k^)Ya}a;;Zuf=Ix!u?CXskSP+w%N9WGV5{k0E
zfO;ewNnd|yFSs^uoYOEZ$@28XngEhH_B3kCFXiP0TLpcZC*u0lSZ$0`wEebX+fW
z&}+lrYh^$+el!Jc&)*y7?x9zPZ`I1j&ef(mtlih;Cn;Em3REc+eIk~X`&ZP1#r$c$
zW#}`h48&YfN~hYU@PMykh+0darZoE4qWXJt3bzbT0-+nZL`?h&&p_yf50qu+zc3<5&Ws@wj8)@rTjE(1h5pa)VV1`Z=2C%<|FWKjFt?wRf3
zEO3+ki(4J;&KX*n>~G)7-_F${-XhFp0ub}EiH;loU6>1m$dJ2;UKhSeD_<$s&4S`q
zb>F4HJw6}rADjn|Yh(Cru4MVSHqL$>pPFa)nbDZJ+1v>B9yL+Vx`|&{j3U^$S9o7w
zUcfqQ9ung2uChVenSY;Xoc1aWRj>D^05G&%X{`R!Mcoow0jTXrM1ukL-{O#S!5?s9kbq#_GyJjPYB
zopO0>(9?za-O{0G)+X!6|HRarN~unbk;bOwYh78j!?z0V>cGHn6XvP_W6@OBVi9l~
zoSi7O*8{Mh&{n{0lg!(!)jt1G5V)x3W@>wFy9&5>cZ<(*NpW%!XPNIQnecG|N6aFN
zCBUbs00rq|Duvm%HC0{~++q=9xB17oNBb;VDXYG}ujEFckF-JaFG6C7$A&ArwI8^-
zB{VTJTQtutnd;CYm{8G4Qs@sR76CVH?<5L2FnN2};Sf{9TA;0fd-VNhEildYp75eR
zH*-jn7p($rX9N65RAw6}D=oM;rQi~ox4gMPwGV*1>I)T@1-I;0rG>dq
zWNQ5%(R!N#_cimbvP+9v7%`QsRk=9YrNuXV_7NzeegZ8Z5y(TrFBwF@=cFNeqPhFP
zYh3t6z}?rOxw#N%Z@2=iam*LWyK*Du`Eq^R@}}hr4%67U?fvlHKkr>2V-nH#vz)_d0V7_Hp$iR0!Eemd7(>|pljabB;
zAQPML=iPHDG>eD?!Oj>;8}{QM2kwCQ!GA8Oa_Ym7H*T##xs?LuF;%(@xNV~P1%vDb
z!Q@IwyrFgOK(J2}I(Mj4X`d0B8mrC68kJik)hrcQ-|zcoD$iXn9Y)X^CJN9`2-8%(
z8J_Cgv#W=hqMoFQ>L93pPGitEs1MfM%MVv3qrRzh)7VD88S7mSK#M08SlI$;G#Vd}
z2#W9Ar>pwR!#GdrKLKEEoO-`3xW{%aI-pl7%zN6XKg0lf%A-=4)~PmPwFe+JLJ{zC
zQKqO@4)vZq_G!SBmjO3zTET9$H?@xak-Cgi9hAw!QG~OIE5bq}DQE|bQyINY|L3Ou
zMXW$lWKn^=@}~VD_{oQbXB~_3r39Zn0Tm5oVI366F!aF&|5*Pt@2*yN+@*l~vTz-O
zRQqG}&KfvL8~=%6F>IPW-N0yn{_D7DEQGm_rcfFFK3FTU;2_rxvUJvf!3Wx8ZD@B4cIgxK|VmkV^6PDNx_k3GbZYZg>eW
zHo8yyeKSvU&uiQ}zEqv!x#ymUb~huS0W$6YKXd#Wjz7<(Kr}%8$08*N{PUuH-vX{b
z`0Vakr(k?L<3QYvdH*Qb%$2#Z0V`j<**v%Vy)hU|5$#roG~3aa-1$+%a{;qNM-)!E
zM8;X#*t+ZMBdslpid%xjPyIpL65SoK>MGz~spIv>YY8+7_>YFg5+{t7cO@aM!M&3B
z)*o-91f-68KY!(>*Wg}RgzE+lR0*uXeW0$)dR!|@U=8k-MYwL@K$XD%0N&I|IBTtE
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,