import inquirer from "inquirer"; import { PDFDocument, degrees } from "pdf-lib"; import * as fs from "fs"; import * as path from "path"; // Extend the orientation type to include "stacked" and "grid" type OrientationType = "horizontal" | "vertical" | "stacked" | "grid"; interface Answers { operation: string; pdfPath: string; copies: number; orientation: OrientationType; specifyPaperSize: boolean; selectedSize: [number, number]; rotateGridPages?: boolean; // New property to track if grid pages should be rotated } // -------------- Utility function to ensure unique filenames -------------- function getUniqueFilePath(filePath: string): string { if (!fs.existsSync(filePath)) { return filePath; } const dir = path.dirname(filePath); const ext = path.extname(filePath); const baseName = path.basename(filePath, ext); // Check if the filename already ends with a pattern like (1), (2), etc. const match = baseName.match(/^(.*?)(\(\d+\))?$/); const nameWithoutCounter = match ? match[1].trim() : baseName; let counter = 1; let newPath = filePath; // Keep incrementing counter until we find a filename that doesn't exist while (fs.existsSync(newPath)) { newPath = path.join(dir, `${nameWithoutCounter}(${counter})${ext}`); counter++; } return newPath; } // -------------- Existing duplicate function -------------- async function duplicatePages( inputPath: string, copies: number ): Promise { const pdfBytes = fs.readFileSync(inputPath); const pdfDoc = await PDFDocument.load(pdfBytes); const pageCount = pdfDoc.getPageCount(); for (let i = 0; i < pageCount; i++) { // For each page, insert (copies-1) additional copies after it for (let j = 0; j < copies - 1; j++) { const [copiedPage] = await pdfDoc.copyPages(pdfDoc, [i]); pdfDoc.insertPage(i + 1 + j, copiedPage); } } return pdfDoc.save(); } // -------------- Updated 2-up function -------------- async function combinePages2In1( inputPath: string, orientation: OrientationType, paperSize?: [number, number], rotateGridPages?: boolean // New parameter to track if grid pages should be rotated ): Promise { // 1) Load the input PDF const pdfBytes = fs.readFileSync(inputPath); const originalPdf = await PDFDocument.load(pdfBytes); // 2) Create a new PDFDocument for the 2-up output const newPdf = await PDFDocument.create(); // 3) Decide final page size (portrait) let finalWidth: number; let finalHeight: number; if (paperSize) { [finalWidth, finalHeight] = paperSize; // e.g. [595.28, 841.89] for A4 } else { // Default to A4 if none chosen finalWidth = 595.28; finalHeight = 841.89; } // 4) For each page in the original PDF, add a new 2-up page const pageCount = originalPdf.getPageCount(); for (let i = 0; i < pageCount; i++) { // Add a blank page in the new PDF const newPage = newPdf.addPage([finalWidth, finalHeight]); // Copy the same page twice (or four times for grid) const [origPage1] = await newPdf.copyPages(originalPdf, [i]); const [origPage2] = await newPdf.copyPages(originalPdf, [i]); // Embed the pages const embedded1 = await newPdf.embedPage(origPage1); const embedded2 = await newPdf.embedPage(origPage2); // Original size const { width: w, height: h } = origPage1.getSize(); if (orientation === "vertical") { // --------------------------------------------------- // "VERTICAL" = side-by-side (left/right) // --------------------------------------------------- // // ┌─────────┬─────────┐ // │ Page1 │ Page2 │ // └─────────┴─────────┘ // // Each half is finalWidth/2 wide, finalHeight tall. // Use "contain" scaling so each page fits entirely. const slotWidth = finalWidth / 2; const slotHeight = finalHeight; const scale = Math.min(slotWidth / w, slotHeight / h); const scaledW = w * scale; const scaledH = h * scale; // Left slot offsets const offsetX1 = (slotWidth - scaledW) / 2; const offsetY1 = (slotHeight - scaledH) / 2; newPage.drawPage(embedded1, { x: offsetX1, y: offsetY1, xScale: scale, yScale: scale, }); // Right slot offsets const offsetX2 = slotWidth + (slotWidth - scaledW) / 2; const offsetY2 = offsetY1; newPage.drawPage(embedded2, { x: offsetX2, y: offsetY2, xScale: scale, yScale: scale, }); } else if (orientation === "horizontal") { // --------------------------------------------------- // "HORIZONTAL" = top & bottom WITH rotation // --------------------------------------------------- // // ┌─────────┐ (top half) // │ Page1 │ rotated +90 // ├─────────┤ // │ Page2 │ rotated +90 // └─────────┘ (bottom half) // // Each half is finalWidth wide, finalHeight/2 tall. // We rotate each original page so it appears landscape. const slotWidth = finalWidth; const slotHeight = finalHeight / 2; // After a +90 rotation, the bounding box is effectively (h × w). const scale = Math.min(slotWidth / h, slotHeight / w); // We'll shift x by + h*scale so the rotated page remains in view // and center it within the slot. // Page1 in the TOP half const offsetX1 = (slotWidth - h * scale) / 2; const offsetY1 = slotHeight + (slotHeight - w * scale) / 2; newPage.drawPage(embedded1, { x: offsetX1 + h * scale, y: offsetY1, xScale: scale, yScale: scale, rotate: degrees(90), }); // Page2 in the BOTTOM half const offsetX2 = (slotWidth - h * scale) / 2; const offsetY2 = (slotHeight - w * scale) / 2; newPage.drawPage(embedded2, { x: offsetX2 + h * scale, y: offsetY2, xScale: scale, yScale: scale, rotate: degrees(90), }); } else if (orientation === "grid") { // --------------------------------------------------- // "GRID" = 2x2 grid with 4 copies // --------------------------------------------------- // // ┌─────────┬─────────┐ // │ Page1 │ Page2 │ // ├─────────┼─────────┤ // │ Page3 │ Page4 │ // └─────────┴─────────┘ // // Each cell is finalWidth/2 wide, finalHeight/2 tall. // We need two more copies of the page for the grid layout const [origPage3] = await newPdf.copyPages(originalPdf, [i]); const [origPage4] = await newPdf.copyPages(originalPdf, [i]); const embedded3 = await newPdf.embedPage(origPage3); const embedded4 = await newPdf.embedPage(origPage4); const slotWidth = finalWidth / 2; const slotHeight = finalHeight / 2; if (rotateGridPages) { // When rotated 90 degrees, width and height are swapped for scaling calculation const scale = Math.min(slotWidth / h, slotHeight / w); // Calculate offsets with rotated dimensions // Top-left cell (Page1) const offsetX1 = (slotWidth - h * scale) / 2; const offsetY1 = slotHeight + (slotHeight - w * scale) / 2; // Top-right cell (Page2) const offsetX2 = slotWidth + (slotWidth - h * scale) / 2; const offsetY2 = offsetY1; // Bottom-left cell (Page3) const offsetX3 = offsetX1; const offsetY3 = (slotHeight - w * scale) / 2; // Bottom-right cell (Page4) const offsetX4 = offsetX2; const offsetY4 = offsetY3; // Draw all four copies with rotation newPage.drawPage(embedded1, { x: offsetX1 + h * scale, y: offsetY1, xScale: scale, yScale: scale, rotate: degrees(90), }); newPage.drawPage(embedded2, { x: offsetX2 + h * scale, y: offsetY2, xScale: scale, yScale: scale, rotate: degrees(90), }); newPage.drawPage(embedded3, { x: offsetX3 + h * scale, y: offsetY3, xScale: scale, yScale: scale, rotate: degrees(90), }); newPage.drawPage(embedded4, { x: offsetX4 + h * scale, y: offsetY4, xScale: scale, yScale: scale, rotate: degrees(90), }); } else { // No rotation, standard grid placement const scale = Math.min(slotWidth / w, slotHeight / h); const scaledW = w * scale; const scaledH = h * scale; // Calculate offsets for top-left cell (Page1) const offsetX1 = (slotWidth - scaledW) / 2; const offsetY1 = slotHeight + (slotHeight - scaledH) / 2; // Calculate offsets for top-right cell (Page2) const offsetX2 = slotWidth + (slotWidth - scaledW) / 2; const offsetY2 = offsetY1; // Calculate offsets for bottom-left cell (Page3) const offsetX3 = offsetX1; const offsetY3 = (slotHeight - scaledH) / 2; // Calculate offsets for bottom-right cell (Page4) const offsetX4 = offsetX2; const offsetY4 = offsetY3; // Draw all four copies without rotation newPage.drawPage(embedded1, { x: offsetX1, y: offsetY1, xScale: scale, yScale: scale, }); newPage.drawPage(embedded2, { x: offsetX2, y: offsetY2, xScale: scale, yScale: scale, }); newPage.drawPage(embedded3, { x: offsetX3, y: offsetY3, xScale: scale, yScale: scale, }); newPage.drawPage(embedded4, { x: offsetX4, y: offsetY4, xScale: scale, yScale: scale, }); } } else { // --------------------------------------------------- // "STACKED" = top & bottom WITHOUT rotation // --------------------------------------------------- // // ┌─────────┐ (top half) // │ Page1 │ normal orientation // ├─────────┤ // │ Page2 │ normal orientation // └─────────┘ (bottom half) // // Each half is finalWidth wide, finalHeight/2 tall. // We do not rotate. We simply scale to fit. const slotWidth = finalWidth; const slotHeight = finalHeight / 2; const scale = Math.min(slotWidth / w, slotHeight / h); const scaledW = w * scale; const scaledH = h * scale; // Place Page1 in the top half // top half's y-range is [slotHeight, finalHeight] const offsetX1 = (slotWidth - scaledW) / 2; const offsetY1 = slotHeight + (slotHeight - scaledH) / 2; newPage.drawPage(embedded1, { x: offsetX1, y: offsetY1, xScale: scale, yScale: scale, }); // Place Page2 in the bottom half const offsetX2 = (slotWidth - scaledW) / 2; const offsetY2 = (slotHeight - scaledH) / 2; newPage.drawPage(embedded2, { x: offsetX2, y: offsetY2, xScale: scale, yScale: scale, }); } } // 5) Save and return return newPdf.save(); } // -------------- Updated main() -------------- async function main() { try { // 1) Check if PDF path is provided as a command-line argument let pdfPath: string | undefined = process.argv[2]; // 2) If not provided, prompt for PDF path if (!pdfPath) { const answer = await inquirer.prompt>({ type: "input", name: "pdfPath", message: "Enter the path to your PDF file:", validate: async (input: string) => { try { fs.existsSync(input); return ( path.extname(input).toLowerCase() === ".pdf" || "Please provide a PDF file" ); } catch { return "File does not exist"; } }, }); pdfPath = answer.pdfPath; } else { // Validate the provided PDF path if ( !fs.existsSync(pdfPath) || path.extname(pdfPath).toLowerCase() !== ".pdf" ) { console.error("Invalid PDF path provided as argument."); return; } } // 3) Ask user what operation to perform const { operation } = await inquirer.prompt>({ type: "list", name: "operation", message: "What operation would you like to perform?", choices: ["Duplicate PDF pages", "Combine pages 2 in 1"], }); // 4) Perform the chosen operation if (operation === "Duplicate PDF pages") { // Ask how many copies const { copies } = await inquirer.prompt>({ type: "number", name: "copies", message: "How many copies of each page do you want?", validate: (input?: number) => (input && input > 0) || "Please enter a number greater than 0", }); // Output path for duplicated pages const outputPath = getUniqueFilePath( path.join( path.dirname(pdfPath), `${path.basename(pdfPath, ".pdf")}_duplicated.pdf` ) ); // Duplicate const pdfBytes = await duplicatePages(pdfPath, copies); fs.writeFileSync(outputPath, pdfBytes); console.log(`Success! Output saved to: ${outputPath}`); } else { // "Combine pages 2 in 1" // 4.1) Ask orientation // - "vertical" => side by side // - "horizontal" => top/bottom with rotation // - "stacked" => top/bottom no rotation // - "grid" => 2x2 grid with 4 copies const { orientation } = await inquirer.prompt< Pick >({ type: "list", name: "orientation", message: "Select arrangement (affects content placement):", choices: [ { name: "Vertical (side-by-side)", value: "vertical" }, { name: "Horizontal (top/bottom, rotated)", value: "horizontal" }, { name: "Stacked (top/bottom, no rotation)", value: "stacked" }, { name: "Grid (2x2)", value: "grid" }, ], }); // If grid layout is selected, ask if user wants to rotate pages 90 degrees let rotateGridPages = false; if (orientation === "grid") { const { shouldRotate } = await inquirer.prompt<{ shouldRotate: boolean; }>({ type: "confirm", name: "shouldRotate", message: "Would you like to rotate each copy by 90 degrees (horizontally) in the grid?", default: false, }); rotateGridPages = shouldRotate; } // 4.2) Ask if user wants to specify a paper size const { specifyPaperSize } = await inquirer.prompt< Pick >({ type: "confirm", name: "specifyPaperSize", message: "Would you like to choose a common paper size for the output?", default: false, }); let chosenPaperSize: [number, number] | undefined = undefined; if (specifyPaperSize) { const { selectedSize } = await inquirer.prompt< Pick >({ type: "list", name: "selectedSize", message: "Select a paper size (output will be portrait):", choices: [ { name: "A4 (210 × 297 mm)", value: [595.28, 841.89] }, { name: "Letter (8.5 × 11 in)", value: [612, 792] }, { name: "Legal (8.5 × 14 in)", value: [612, 1008] }, { name: "A3 (297 × 420 mm)", value: [841.89, 1190.55] }, { name: "Tabloid (11 × 17 in)", value: [792, 1224] }, ], }); chosenPaperSize = selectedSize; } // 4.3) Output path const outputPath = getUniqueFilePath( path.join( path.dirname(pdfPath), `${path.basename(pdfPath, ".pdf")}_2up.pdf` ) ); // 4.4) Generate the 2-up PDF const pdfBytes = await combinePages2In1( pdfPath, orientation, chosenPaperSize, rotateGridPages // Pass the rotation preference to the function ); fs.writeFileSync(outputPath, pdfBytes); console.log(`Success! 2-up PDF saved to: ${outputPath}`); } } catch (error) { console.error("An error occurred:", error); } } main();