1 ---
2 name: resume-maker
3 description: Build professional resumes with viewable HTML and downloadable PDF/DOCX export.
4 ---
5
6 # Resume Maker
7
8 Build a resume as a React web page backed by a generation script that produces pixel-perfect PDF and DOCX files from a single data source. The web page reads spacing values from the same JSON so the HTML preview closely matches the generated documents.
9
10 ## Before You Start Building — Gather Information First
11
12 **Do NOT start building the resume until you have enough information to write meaningful, specific content.** A resume with placeholder text or vague bullets is useless. You need real details to create something valuable.
13
14 If the user provides a complete resume or detailed background
15
16 Go ahead and start building immediately. You have what you need.
17
18 If the user asks to "make me a resume" without providing details
19
20 Before asking them to type everything out manually, **offer two fast-start options** to save them time:
21
22 "To save you time, you can get started in one of two ways:
23
24 - `**Upload your LinkedIn PDF**` — Go to your LinkedIn profile, click "More" → "Save to PDF", and upload that file here. I'll extract all your info automatically.
25 - **Copy-paste from LinkedIn** — Go to your LinkedIn profile page, select all the text (Ctrl/Cmd+A), copy it (Ctrl/Cmd+C), and paste it here. I'll parse through it and pull out your experience, education, and skills.
26
27 Or if you'd prefer, you can just tell me your details and we'll build it from scratch."
28
29 If the user uploads a LinkedIn PDF
30
31 Parse the PDF content to extract: name, headline, contact info, work experience (titles, companies, dates, descriptions), education, skills, certifications, and any other sections. LinkedIn PDFs follow a consistent format — use the structure to map content into the resume data model. After parsing, confirm what you extracted and ask if anything needs correction before building.
32
33 If the user pastes LinkedIn profile text
34
35 Parse the pasted text to extract the same fields. LinkedIn profile text typically follows this structure:
36
37 - Name and headline at the top
38 - "Experience" section with role titles, company names, date ranges, and descriptions
39
40 - "Education" section with degrees, schools, and dates
41 - "Skills" section with a list of skills
42
43 - Optional sections like "Certifications", "Publications", "Volunteer Experience"
44
45 Map each section into the resume data model. After parsing, confirm what you extracted and ask if anything needs correction.
46
47 If the user prefers to provide details manually
48
49 Ask clarifying questions before writing any code. Ask about:
50
51 - **Target role** — What job or type of role are they applying for? (This shapes the entire resume's framing)
52 - **Work experience** — For each role: job title, company name, dates, location, and what they accomplished (not just responsibilities — ask for specific achievements, numbers, outcomes)
53
54 - **Education** — Degree(s), school(s), graduation year(s), honors/GPA if notable
55 - **Skills** — Technical skills, tools, languages, frameworks, certifications
56
57 - **Contact info** — Full name, email, phone, LinkedIn, location, portfolio/website
58 - **Optional extras** — Publications, talks, awards, volunteer work, projects
59
60 How to ask
61
62 When offering the LinkedIn import options, lead with those first since they save the most time. Only fall back to manual questions if the user doesn't have LinkedIn or prefers to start fresh.
63
64 If going the manual route, don't dump all questions at once. Start with the most critical:
65
66 "No problem! Let's start with the basics:
67
68 - What's your full name and contact info (email, phone, LinkedIn, location)?
69 - What type of role are you targeting?
70
71 - Walk me through your work history — for each job, give me the title, company, dates, and your biggest accomplishments with specific numbers if possible."
72
73 Then follow up for education, skills, and anything else based on what they share. If they give vague bullets like "managed a team," push back and ask for specifics: "How large was the team? What did you deliver? Any measurable outcomes?"
74
75 Enhancing LinkedIn-imported content
76
77 Whether imported from PDF or pasted text, LinkedIn data often needs improvement for a strong resume:
78
79 - **Bullets are often weak** — LinkedIn descriptions tend to list responsibilities, not achievements. After importing, rewrite bullets to emphasize measurable outcomes and impact. Ask the user for specific numbers if the LinkedIn content is vague.
80 - **Skills may be bloated** — LinkedIn lets users add dozens of skills. Curate the list down to the most relevant ones for the target role.
81
82 - **Summary may be missing or generic** — LinkedIn summaries are often written in first person or as a pitch. Adapt it to resume conventions (third-person implied, concise, role-targeted).
83 - **Dates and locations may need cleanup** — LinkedIn sometimes uses inconsistent formats. Normalize them.
84
85 If the resume feels thin
86
87 If the user doesn't have enough experience to fill a page, ask about additional material: volunteer work, side projects, open-source contributions, certifications, coursework, publications, talks, or awards. Proactively suggest these categories — don't just leave the page half-empty.
88
89 If the user provides a job posting
90
91 Ask them for their background details, then tailor the resume to match the posting's keywords and requirements. Mirror the posting's language.
92
93 ## Build Order — Files First, Website Last
94
95 Follow this exact order so the user gets fast, tangible results:
96
97 - **Write the generation script** (generate-resume-files.ts) with all resume content and run it to produce the PDF, DOCX, and resume-data.json
98 - **Present the PDF to the user in chat** — show the generated PDF file immediately so they can see their resume right away
99
100 - **Present the DOCX file to the user in chat** — make the DOCX available for download so they can grab it
101 - **Build the web app last** — set up the React artifact and API routes that serve the resume page and download endpoints
102
103 The user cares most about seeing their resume quickly. The PDF and DOCX are the primary deliverables — get those into the user's hands first. The web app is a nice-to-have that comes after. Do NOT spend time building the web app before the user has seen their PDF and downloaded their DOCX.
104
105 ## Architecture
106
107 scripts/src/generate-resume-files.ts `← single source of truth for content + layout
108
109 └─ output/
110
111 `├─ resume-data.json ← consumed by the web frontend
112
113 `├─ <name>-resume.pdf ← downloadable PDF
114
115 `└─ <name>-resume.docx ← downloadable DOCX
116
117 artifacts/<name>/src/pages/resume.tsx `← React page, reads resume-data.json via API
118
119 artifacts/api-server/src/routes/download.ts ← serves PDF, DOCX, and JSON
120
121 Data flow
122
123 - generate-resume-files.ts holds all resume content in a getResumeData() function.
124 - The script renders the PDF with jsPDF, measures content height, and auto-adjusts spacing to fill exactly one US Letter page (612×792pt, 36pt margins).
125
126 - It writes the computed spacing values into resume-data.json alongside the content.
127 - The React page fetches resume-data.json at runtime and uses the spacing values (converted pt→px via 96/72) to render a matching HTML preview.
128
129 ## Content Rules
130
131 - **Summary/blurb**: If you include a personal summary at the top, keep it to 2 sentences max. Long plaintext paragraphs waste prime real estate and recruiters skip them.
132 - **One page hard limit**: The resume MUST fit on exactly one US Letter page (612×792pt). The auto-fit algorithm handles this, but be mindful when writing content — more bullets doesn't mean better.
133
134 ## Flag Guesses and Inferred Details
135
136 If you had to guess or infer any details — dates, job titles, specific contributions, metrics, technologies — you MUST tell the user what you made up. After presenting the first draft, explicitly list anything you weren't sure about and ask if they want to correct it. For example:
137
138 "I made a few assumptions I want to flag:
139
140 - I estimated your dates at Company X as 2021–2023 — are those right?
141 - I wrote that you 'reduced API latency by 40%' based on your mention of performance work — is that accurate, or should I adjust the number/framing?
142
143 - I guessed TypeScript and Python for your skills — anything to add or remove?"
144
145 Do NOT silently present fabricated details as fact. The user trusts you to be honest about what you know vs. what you inferred.
146
147 ## Iteration and Changes
148
149 When the user requests changes (rewording bullets, adding/removing sections, reordering, etc.):
150
151 - Make the requested changes in generate-resume-files.ts
152 - **Re-run the generation script** to produce updated PDF, DOCX, and JSON
153
154 - **Verify the output still fits on one page** — if the changes pushed content past the page boundary, the auto-fit algorithm should handle it, but visually confirm. If content is getting clipped or the scale factor is too aggressive (text becoming unreadably small), trim lower-priority bullets or reduce spacing before delivering.
155 - **Re-present the updated PDF and DOCX** to the user in chat
156
157 Never deliver an updated resume without re-running the generation script. The PDF/DOCX must always reflect the latest content. Every iteration cycle ends with the user seeing a fresh PDF and having a fresh DOCX to download.
158
159 ## Critical Unit Rules
160
161 DOCX (docx npm package)
162
163 The docx package uses two different unit systems — mixing them up causes 10× sizing bugs:
164
165 - **TextRun.size**`= **half-points**:`ptToHalfPt = pt * 2 (e.g., 11pt → 22)
166 - **Spacing, margins, page dimensions**=**twips**: ptToTwip = pt \* 20 (e.g., 36pt → 720)
167
168 Always define separate converter functions:
169
170 const ptToHalfPt = (pt: number) => Math.round(pt \* 2);
171
172 const ptToTwip = (pt: number) => Math.round(pt \* 20);
173
174 jsPDF baseline rule
175
176 doc.text(text, x, y) draws text with y as the **baseline** — the text body extends upward from y. When placing a horizontal rule after text:
177
178 - **Wrong**: y += 2 after drawing a rule — text overlaps the line
179 - **Right**: y += lineHeight after drawing a rule — full clearance before next text
180
181 doc.line(MARGIN, y, PAGE_W - MARGIN, y);
182
183 y += lineHeight; `// NOT y += 4`
184
185 Web CSS alignment
186
187 When using borderBottom as a section divider:
188
189 - Put the border directly on the element (e.g., h2) rather than a separate <div>
190 - Use paddingBottom to separate text from the line
191
192 - Use marginBottom with at least lineHeight to separate the line from content below
193
194 ## One-Page Auto-Fit Algorithm
195
196 The generation script measures content height, then adjusts spacing to fill the target:
197
198 `1.` Render with BASE_SPACING → measure finalY
199
200 1. If finalY > TARGET_Y: shorten bullets until it fits
201 2. If finalY < TARGET_Y: distribute slack across sectionGap, roleGap, bulletGap, lineHeight
202
203 `4.` Re-render with adjusted spacing → write final files
204
205 Slack distribution weights: sectionGap: 3, roleGap: 2, bulletGap: 1, lineHeight: 0.5
206
207 ## Skeleton: Generation Script
208
209 import { jsPDF } from "jspdf";
210
211 import`{`
212
213 Document, Packer, Paragraph, TextRun, BorderStyle,
214
215 TabStopPosition, TabStopType, AlignmentType,
216
217 `}`from` `"docx"`;`
218
219 import`fs`from` `"fs"`;`
220
221 import`path`from` `"path"`;`
222
223 // --- Interfaces ---
224
225 interface` `ResumeRole`{`
226
227 ` `title`:`string`;`
228
229 ` `company`:`string`;`
230
231 ` `location`:`string`;`
232
233 ` `startDate`:`string`;`
234
235 ` `endDate`:`string`;`
236
237 ` `bullets`:`string`[];`
238
239 }
240
241 interface` `Spacing`{`
242
243 ` `sectionGap`:`number`;`
244
245 ` `roleGap`:`number`;`
246
247 ` `bulletGap`:`number`;`
248
249 ` `lineHeight`:`number`;`
250
251 ` `bodyFontSize`:`number`;`
252
253 ` `headlineFontSize`:`number`;`
254
255 }
256
257 interface` `ResumeData`{`
258
259 ` `name`:`string`;`
260
261 ` `headline`:`string`;`
262
263 ` `contact`: {`email`:`string`;`phone`:`string`;`location`:`string`;`linkedin`:`string`;`website`:`string`};`
264
265 ` `summary`:`string`;`
266
267 ` `roles`:`ResumeRole`[];`
268
269 ` `skills`: {`category`:`string`;`items`:`string`}[];`
270
271 ` `education`: {`degree`:`string`;`school`:`string`;`location`:`string`;`dates`:`string`}[];`
272
273 ` `spacing`:`Spacing`;`
274
275 }
276
277 // --- Constants ---
278
279 const`PAGE_W`=` `612`;`// US Letter width in points
280
281 const`PAGE_H`=` `792`;`// US Letter height in points
282
283 const`MARGIN`=` `36`;`// 0.5 inch margins
284
285 const CONTENT*W = PAGE*W - 2 \* MARGIN;
286
287 const TARGET*Y = PAGE*H - MARGIN;
288
289 const BASE_SPACING: Spacing = {
290
291 ` `sectionGap`:`8`,`
292
293 ` `roleGap`:`4`,`
294
295 ` `bulletGap`:`1`,`
296
297 ` `lineHeight`:`14`,`
298
299 ` `bodyFontSize`:`11`,`
300
301 ` `headlineFontSize`:`11`,`
302
303 };
304
305 // --- Unit converters (DOCX) ---
306
307 const ptToHalfPt = (pt: number) => Math.round(pt \* 2); `// TextRun.size`
308
309 const ptToTwip = (pt: number) => Math.round(pt \* 20); `// spacing/margins`
310
311 // --- Resume data ---
312
313 function` `getResumeData`():`Omit`<`ResumeData`,`"spacing"`> {`
314
315 ` `return`{`
316
317 ` `name`:`"FULL NAME"`,`
318
319 ` `headline`:`"Title | Specialty"`,`
320
321 ` `contact`: {`
322
323 ` `email`:`"<email@example.com>"`,`
324
325 phone: "",
326
327 ` `location`:`"City, State"`,`
328
329 ` `linkedin`:`"linkedin.com/in/handle"`,`
330
331 ` `website`:`"example.com"`,`
332
333 },
334
335 ` `summary`:`"Professional summary paragraph."`,`
336
337 roles: [
338
339 {
340
341 ` `title`:`"Job Title"`,`
342
343 company: "Company",
344
345 ` `location`:`"City, State"`,`
346
347 startDate: "MM/YYYY",
348
349 endDate: "Present",
350
351 ` `bullets`: [`"Achievement or responsibility"`],`
352
353 },
354
355 ],
356
357 skills: [
358
359 ` `{ category`:`"Category"`,`items`:`"Skill1, Skill2, Skill3"`},`
360
361 ],
362
363 ` `education`: [`
364
365 {
366
367 degree: "Degree",
368
369 ` `school`:`"University"`,`
370
371 ` `location`:`"City, Country"`,`
372
373 ` `dates`:`"YYYY - YYYY"`,`
374
375 },
376
377 ],
378
379 };
380
381 }
382
383 // --- PDF rendering ---
384
385 function` `renderPDF`(doc:`jsPDF`, data:`Omit`<`ResumeData`,`"spacing"`>, spacing:`Spacing`):`number`{`
386
387 let y = MARGIN;
388
389 ` `const`{`bodyFontSize`,`headlineFontSize`,`lineHeight`,`sectionGap`,`roleGap`,`bulletGap`}`=`spacing;`
390
391 ` `// Header
392
393 ` `doc.setFont`(`"helvetica"`,`"bold"`);`
394
395 ` `doc.setFontSize`(`20`);`
396
397 doc.text(data.name, PAGE_W / 2, y, { align: "center" });
398
399 y += 16;
400
401 ` `doc.setFont`(`"helvetica"`,`"normal"`);`
402
403 doc.setFontSize(headlineFontSize);
404
405 doc.text(data.headline, PAGE_W / 2, y, { align: "center" });
406
407 y += lineHeight;
408
409 ` `// Contact line
410
411 ` `const`contactParts`=`[data`.contact.location`, data`.contact.email`, data`.contact.linkedin`, data`.contact.website`]`.filter`(Boolean);`
412
413 ` `doc.setFontSize`(`9`);`
414
415 doc.text(contactParts.join(" | "), PAGE_W / 2, y, { align: "center" });
416
417 y += lineHeight + 2;
418
419 ` `// Divider — use lineHeight gap after rule, NOT a small constant
420
421 ` `doc.setDrawColor`(`0`);`
422
423 ` `doc.setLineWidth`(`0.5`);`
424
425 doc.line(MARGIN, y, PAGE_W - MARGIN, y);
426
427 y += lineHeight;
428
429 ` `// Summary
430
431 ` `doc.setFont`(`"helvetica"`,`"normal"`);`
432
433 doc.setFontSize(bodyFontSize);
434
435 const summaryLines = doc.splitTextToSize(data.summary, CONTENT_W) as string[];
436
437 for (const line of summaryLines) {
438
439 doc.text(line, MARGIN, y);
440
441 y += lineHeight;
442
443 }
444
445 ` `// Section header helper
446
447 ` `function` `renderSectionHeader`(title:`string`) {`
448
449 y += sectionGap;
450
451 ` `doc.setFont`(`"helvetica"`,`"bold"`);`
452
453 ` `doc.setFontSize`(`11`);`
454
455 doc.text(title.toUpperCase(), MARGIN, y);
456
457 y += 3;
458
459 ` `doc.setLineWidth`(`0.3`);`
460
461 doc.line(MARGIN, y, PAGE_W - MARGIN, y);
462
463 ` `y +=`lineHeight;`// full lineHeight after rule
464
465 }
466
467 ` `// Experience, Skills, Education sections...
468
469 ` `// (render each section using the spacing values)
470
471 ` `return`y;`
472
473 }
474
475 // --- DOCX rendering ---
476
477 function` `buildDocx`(data:`Omit`<`ResumeData`,`"spacing"`>, spacing:`Spacing`):`Document`{`
478
479 ` `const`{`bodyFontSize`,`headlineFontSize`,`lineHeight`,`sectionGap`,`roleGap`,`bulletGap`}`=`spacing;`
480
481 ` `// Use ptToHalfPt() for ALL TextRun.size values
482
483 ` `// Use ptToTwip() for ALL spacing.before, spacing.after, margins, page dimensions
484
485 const doc = new Document({
486
487 ` `sections`: [{`
488
489 ` `properties`: {`
490
491 page: {
492
493 size: { width: ptToTwip(PAGE*W), height: ptToTwip(PAGE*H) },
494
495 margin: {
496
497 top: ptToTwip(MARGIN),
498
499 bottom: ptToTwip(MARGIN),
500
501 left: ptToTwip(MARGIN),
502
503 right: ptToTwip(MARGIN),
504
505 },
506
507 },
508
509 },
510
511 children: [
512
513 ` `// Build paragraphs here using the data
514
515 ],
516
517 }],
518
519 });
520
521 return doc;
522
523 }
524
525 // --- Auto-fit and generate ---
526
527 async` `function` `main`() {`
528
529 ` `const`data`=` `getResumeData`();`
530
531 const outputDir = path.resolve(import.meta.dirname, "..", "output");
532
533 if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
534
535 ` `// Measure with base spacing
536
537 let spacing = { ...BASE_SPACING };
538
539 let doc = new jsPDF({ unit: "pt", format: "letter" });
540
541 let finalY = renderPDF(doc, data, spacing);
542
543 ` `// Auto-fit: distribute slack
544
545 const slack = TARGET_Y - finalY;
546
547 if (slack > 0) {
548
549 ` `const`weights`=`{`sectionGap`:`3`,`roleGap`:`2`,`bulletGap`:`1`,`lineHeight`:`0.5`};`
550
551 const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
552
553 ` `// Count occurrences of each spacing type to distribute evenly
554
555 ` `// Adjust spacing values proportionally
556
557 }
558
559 ` `// Final render
560
561 doc = new jsPDF({ unit: "pt", format: "letter" });
562
563 finalY = renderPDF(doc, data, spacing);
564
565 ` `// Write files
566
567 const pdfBuffer = Buffer.from(doc.output("arraybuffer"));
568
569 fs.writeFileSync(path.join(outputDir, "person-name-resume.pdf"), pdfBuffer);
570
571 const docxDoc = buildDocx(data, spacing);
572
573 const docxBuffer = await Packer.toBuffer(docxDoc);
574
575 fs.writeFileSync(path.join(outputDir, "person-name-resume.docx"), docxBuffer);
576
577 const jsonData: ResumeData = { ...data, publications: [], spacing };
578
579 fs.writeFileSync(path.join(outputDir, "resume-data.json"), JSON.stringify(jsonData, null, 2));
580
581 ` `console.log`(`"All files generated successfully!"`);`
582
583 }
584
585 main().catch(console.error);
586
587 ## Skeleton: React Resume Page
588
589 import { useState, useEffect } from "react";
590
591 const PT*TO*PX = 96 / 72;
592
593 interface` `ResumeData`{`/*same interfaces as generation script*/`}`
594
595 function` `SectionHeader`({`title`,`lineHeightPx`}: {`title`:`string`;`lineHeightPx`:`number`}) {`
596
597 ` `return`(`
598
599 `<`h2 style={{
600
601 fontSize: "11pt",
602
603 ` `fontWeight`:`700`,`
604
605 ` `textTransform`:`"uppercase"`,`
606
607 ` `letterSpacing`:`"0.5px"`,`
608
609 margin: 0,
610
611 padding: 0,
612
613 ` `paddingBottom`:`"4px"`,`
614
615 marginBottom: `${lineHeightPx}px`,
616
617 ` `borderBottom`:`"1px solid \#1a1a2e"`,`
618
619 ` `color`:`"#1a1a2e"`,`
620
621 }}`>`
622
623 {title}
624
625 ` `</h2>
626
627 );
628
629 }
630
631 export` `default` `function` `ResumePage`() {`
632
633 const [data, setData] = useState<ResumeData | null>(null);
634
635 useEffect(() => {
636
637 ` `const`basePath`=` `import.meta.env.BASE_URL`;`
638
639 ` `fetch`(```${basePath}`api/resume-data``)`
640
641 .then((r) => r.json())
642
643 .then(setData);
644
645 }, []);
646
647 if (!data) return `<`p`>`Loading...`</`p`>`;
648
649 const sp = data.spacing;
650
651 const sectionGapPx = sp.sectionGap \* PT*TO*PX;
652
653 const roleGapPx = sp.roleGap \* PT*TO*PX;
654
655 const bulletGapPx = sp.bulletGap \* PT*TO*PX;
656
657 const lineHeightPx = sp.lineHeight \* PT*TO*PX;
658
659 const bodyFontPx = sp.bodyFontSize \* PT*TO*PX;
660
661 ` `return`(`
662
663 ` ``<`div` `style=`{{`background`:`"#f0f0f0"`,`minHeight`:`"100vh"`}}``>`
664
665 `<`div style={{
666
667 ` `width`:`"816px"`,`// 8.5in at 96dpi
668
669 ` `minHeight`:`"1056px"`,`// 11in at 96dpi
670
671 margin: "0 auto",
672
673 background: "#fff",
674
675 ` `padding`:`"48px"`,`// 0.5in margins at 96dpi
676
677 ` `fontFamily`:`"'Calibri', 'Arial', sans-serif"`,`
678
679 fontSize: `${bodyFontPx}px`,
680
681 lineHeight: `${lineHeightPx}px`,
682
683 }}`>`
684
685 ` `{/*Header, summary, experience, skills, education*/`}`
686
687 ` `{/*Use SectionHeader with lineHeightPx for each section*/`}`
688
689 ` `{/*Use spacing values from sp for all gaps*/`}`
690
691 `</`div`>`
692
693 ` `</div>
694
695 );
696
697 }
698
699 ## Skeleton: API Routes
700
701 import { Router } from "express";
702
703 import`path`from` `"path"`;`
704
705 import`fs`from` `"fs"`;`
706
707 const router = Router();
708
709 const outputDir = path.resolve(import.meta.dirname, "..", "..", "..", "..", "output");
710
711 router.get("/download/pdf", (_req, res) => {
712
713 const filePath = path.join(outputDir, "person-name-resume.pdf");
714
715 if (!fs.existsSync(filePath)) {
716
717 ` `res.status`(`404`)`.json`({`error`:`"PDF not found. Run the generation script first."`});`
718
719 ` `return`;`
720
721 }
722
723 ` `res.setHeader`(`"Content-Disposition"`,`"attachment; filename=person-name-resume.pdf"`);`
724
725 ` `res.setHeader`(`"Content-Type"`,`"application/pdf"`);`
726
727 res.sendFile(filePath);
728
729 });
730
731 router.get("/download/docx", (_req, res) => {
732
733 const filePath = path.join(outputDir, "person-name-resume.docx");
734
735 if (!fs.existsSync(filePath)) {
736
737 ` `res.status`(`404`)`.json`({`error`:`"DOCX not found. Run the generation script first."`});`
738
739 ` `return`;`
740
741 }
742
743 ` `res.setHeader`(`"Content-Disposition"`,`"attachment; filename=person-name-resume.docx"`);`
744
745 ` `res.setHeader`(`"Content-Type"`,`"application/vnd.openxmlformats-officedocument.wordprocessingml.document"`);`
746
747 res.sendFile(filePath);
748
749 });
750
751 router.get("/resume-data", (_req, res) => {
752
753 const filePath = path.join(outputDir, "resume-data.json");
754
755 if (!fs.existsSync(filePath)) {
756
757 ` `res.status`(`404`)`.json`({`error`:`"Resume data not found. Run the generation script first."`});`
758
759 ` `return`;`
760
761 }
762
763 res.json(JSON.parse(fs.readFileSync(filePath, "utf-8")));
764
765 });
766
767 export` `default`router;`
768
769 ## Common Pitfalls
770
771 | Problem | Cause | Fix |
772
773 |---|---|---|
774
775 | DOCX text is 10× too large | Used ptToTwip (×20) for TextRun.size | Use ptToHalfPt (×2) for font sizes |
776
777 | PDF text overlaps horizontal rule | Used y += 4 after doc.line() | Use y += lineHeight after any rule |
778
779 | Web section header line cuts through text | Border on a sibling `<div>`instead of the`<h2>`| Put borderBottom on the`<h2>` with paddingBottom |
780
781 | Downloads fail in Replit preview iframe | Proxy blocks file downloads in iframe | Use target="_blank" on download links, or present files directly |
782
783 | Web spacing doesn't match PDF | Hardcoded px values in CSS | Convert all pt values via PT_TO_PX = 96/72 |
784
785 ## Dependencies
786
787 - jspdf — PDF generation
788 - docx — DOCX generation
789
790 - tsx — TypeScript script runner (dev dependency)