mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 14:57:00 +10:00
New Feature: Free-Form Page Formats for PDFs (#2595)
This commit is contained in:
@@ -1,18 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import type z from "zod";
|
||||
import { pageDimensionsAsMillimeters } from "@/schema/page";
|
||||
import type { resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
const pageDimensions = {
|
||||
a4: {
|
||||
width: "210mm",
|
||||
height: "297mm",
|
||||
},
|
||||
letter: {
|
||||
width: "216mm",
|
||||
height: "279mm",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type UseCssVariablesProps = Pick<z.infer<typeof resumeDataSchema>, "picture" | "metadata">;
|
||||
|
||||
export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) => {
|
||||
@@ -28,8 +18,8 @@ export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) =>
|
||||
|
||||
return {
|
||||
"--picture-border-radius": `${picture.borderRadius}pt`,
|
||||
"--page-width": pageDimensions[metadata.page.format].width,
|
||||
"--page-height": pageDimensions[metadata.page.format].height,
|
||||
"--page-width": pageDimensionsAsMillimeters[metadata.page.format].width,
|
||||
"--page-height": pageDimensionsAsMillimeters[metadata.page.format].height,
|
||||
"--page-sidebar-width": `${metadata.layout.sidebarWidth}%`,
|
||||
"--page-text-color": metadata.design.colors.text,
|
||||
"--page-primary-color": metadata.design.colors.primary,
|
||||
|
||||
@@ -160,7 +160,7 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
|
||||
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
|
||||
</div>
|
||||
|
||||
{pageHeight > maxPageHeight && (
|
||||
{metadata.page.format !== "free-form" && pageHeight > maxPageHeight && (
|
||||
<div className="absolute start-0 top-full mt-4 print:hidden">
|
||||
<a
|
||||
rel="noopener"
|
||||
|
||||
@@ -115,18 +115,49 @@ export const printerService = {
|
||||
|
||||
// Step 5: Adjust the DOM for proper PDF pagination
|
||||
// This runs in the browser context to modify CSS before PDF generation
|
||||
await page.evaluate(
|
||||
(marginY: number, minPageHeight: number) => {
|
||||
// For free-form: measure actual content height, don't add page breaks
|
||||
// For A4/Letter: adjust page height for margins, add page breaks
|
||||
const isFreeForm = format === "free-form";
|
||||
|
||||
const contentHeight = await page.evaluate(
|
||||
(marginY: number, isFreeForm: boolean, minPageHeight: number) => {
|
||||
const root = document.documentElement;
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||
|
||||
if (isFreeForm) {
|
||||
// For free-form: add visual gaps between pages, then measure total height
|
||||
// Convert marginY from PDF points to CSS pixels (1pt = 0.75px)
|
||||
const marginYAsPixels = marginY * 0.75;
|
||||
const numberOfPages = pageElements.length;
|
||||
|
||||
// Add margin between pages (except the last one)
|
||||
for (let i = 0; i < numberOfPages - 1; i++) {
|
||||
const pageEl = pageElements[i] as HTMLElement;
|
||||
pageEl.style.marginBottom = `${marginYAsPixels}px`;
|
||||
}
|
||||
|
||||
// Now measure the total height (margins are now part of the DOM)
|
||||
let totalHeight = 0;
|
||||
for (const el of pageElements) {
|
||||
const pageEl = el as HTMLElement;
|
||||
// offsetHeight includes padding and border, but not margin
|
||||
const style = getComputedStyle(pageEl);
|
||||
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
|
||||
totalHeight += pageEl.offsetHeight + marginBottom;
|
||||
}
|
||||
|
||||
return Math.max(totalHeight, minPageHeight);
|
||||
}
|
||||
|
||||
// For A4/Letter: existing behavior
|
||||
// The --page-height CSS variable controls the height of each resume page.
|
||||
// We need to reduce it by the PDF margins so content fits within the printable area.
|
||||
// Without this, content would overflow and create empty pages.
|
||||
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
|
||||
const containerHeight = container
|
||||
? getComputedStyle(container).getPropertyValue("--page-height").trim()
|
||||
: null;
|
||||
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
|
||||
const currentHeight = containerHeight || rootHeight;
|
||||
const heightValue = Math.max(Number.parseFloat(currentHeight), minPageHeight);
|
||||
|
||||
@@ -139,8 +170,6 @@ export const printerService = {
|
||||
|
||||
// Add page break CSS to each resume page element (identified by data-page-index attribute)
|
||||
// This ensures each visual resume page starts a new PDF page
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||
@@ -152,22 +181,29 @@ export const printerService = {
|
||||
// (e.g., if a single page has more content than fits on one PDF page)
|
||||
element.style.breakInside = "auto";
|
||||
}
|
||||
|
||||
return null; // Fixed height from pageDimensionsAsPixels for A4/Letter
|
||||
},
|
||||
marginY,
|
||||
isFreeForm,
|
||||
pageDimensionsAsPixels[format].height,
|
||||
);
|
||||
|
||||
// Step 6: Generate the PDF with the specified dimensions and margins
|
||||
// For free-form: use measured content height (with minimum constraint)
|
||||
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
|
||||
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
width: `${pageDimensionsAsPixels[format].width}px`,
|
||||
height: `${pageDimensionsAsPixels[format].height}px`,
|
||||
height: `${pdfHeight}px`,
|
||||
tagged: true, // Adds accessibility tags to the PDF
|
||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||
printBackground: true, // Includes background colors and images
|
||||
margin: {
|
||||
bottom: 0,
|
||||
top: marginY,
|
||||
right: marginX,
|
||||
// bottom: marginY,
|
||||
left: marginX,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
@@ -71,13 +72,14 @@ function PageSectionForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans context="Page Format (A4 or Letter)">Format</Trans>
|
||||
<Trans context="Page Format (A4, Letter or Free-Form)">Format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "a4", label: "A4" },
|
||||
{ value: "letter", label: "Letter" },
|
||||
{ value: "a4", label: t`A4` },
|
||||
{ value: "letter", label: t`Letter` },
|
||||
{ value: "free-form", label: t`Free-Form` },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ export const pageDimensionsAsPixels = {
|
||||
width: 816,
|
||||
height: 1056,
|
||||
},
|
||||
"free-form": {
|
||||
width: 794,
|
||||
height: 1123, // used as minimum height
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const pageDimensionsAsMillimeters = {
|
||||
@@ -18,4 +22,8 @@ export const pageDimensionsAsMillimeters = {
|
||||
width: "216mm",
|
||||
height: "279mm",
|
||||
},
|
||||
"free-form": {
|
||||
width: "210mm",
|
||||
height: "297mm", // used as minimum height
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -390,7 +390,10 @@ export const pageSchema = z.object({
|
||||
gapY: z.number().min(0).describe("The vertical gap between the sections of the page, defined in points (pt)."),
|
||||
marginX: z.number().min(0).describe("The horizontal margin of the page, defined in points (pt)."),
|
||||
marginY: z.number().min(0).describe("The vertical margin of the page, defined in points (pt)."),
|
||||
format: z.enum(["a4", "letter"]).describe("The format of the page. Can be 'a4' or 'letter'."),
|
||||
format: z
|
||||
.enum(["a4", "letter", "free-form"])
|
||||
.describe("The format of the page. Can be 'a4', 'letter' or 'free-form'.")
|
||||
.catch("a4"),
|
||||
locale: z
|
||||
.string()
|
||||
.describe("The locale of the page. Used for displaying pre-translated section headings, if not overridden.")
|
||||
|
||||
Reference in New Issue
Block a user