New Feature: Free-Form Page Formats for PDFs (#2595)

This commit is contained in:
Amruth Pillai
2026-01-25 23:39:16 +01:00
committed by GitHub
parent 4b2e3c0ded
commit 3a9d2e7652
68 changed files with 1053 additions and 277 deletions
@@ -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,
+1 -1
View File
@@ -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"
+43 -7
View File
@@ -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) => {
+8
View File
@@ -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;
+4 -1
View File
@@ -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.")