feat(pdf-tools): add posterize tool to split pages into grid
Add new posterize feature that allows splitting PDF pages into a grid of smaller pages. The tool includes: - Configurable rows and columns - Page size and orientation options - Content scaling modes - Overlap settings for assembly - Page range selection
This commit is contained in:
@@ -5,7 +5,7 @@ export const singlePdfLoadTools = [
|
|||||||
'extract-pages', 'add-watermark', 'add-header-footer', 'invert-colors', 'view-metadata',
|
'extract-pages', 'add-watermark', 'add-header-footer', 'invert-colors', 'view-metadata',
|
||||||
'reverse-pages', 'crop', 'redact', 'pdf-to-bmp', 'pdf-to-tiff', 'split-in-half',
|
'reverse-pages', 'crop', 'redact', 'pdf-to-bmp', 'pdf-to-tiff', 'split-in-half',
|
||||||
'page-dimensions', 'n-up', 'duplicate-organize', 'combine-single-page', 'fix-dimensions', 'change-background-color',
|
'page-dimensions', 'n-up', 'duplicate-organize', 'combine-single-page', 'fix-dimensions', 'change-background-color',
|
||||||
'change-text-color', 'ocr-pdf', 'sign-pdf', 'remove-annotations', 'cropper', 'form-filler',
|
'change-text-color', 'ocr-pdf', 'sign-pdf', 'remove-annotations', 'cropper', 'form-filler', 'posterize'
|
||||||
];
|
];
|
||||||
|
|
||||||
export const simpleTools = [
|
export const simpleTools = [
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const categories = [
|
|||||||
{ id: 'edit-metadata', name: 'Edit Metadata', icon: 'file-cog', subtitle: 'Change the author, title, and other properties.' },
|
{ id: 'edit-metadata', name: 'Edit Metadata', icon: 'file-cog', subtitle: 'Change the author, title, and other properties.' },
|
||||||
{ id: 'pdf-to-zip', name: 'PDFs to ZIP', icon: 'stretch-horizontal', subtitle: 'Package multiple PDF files into a ZIP archive.' },
|
{ id: 'pdf-to-zip', name: 'PDFs to ZIP', icon: 'stretch-horizontal', subtitle: 'Package multiple PDF files into a ZIP archive.' },
|
||||||
{ id: 'compare-pdfs', name: 'Compare PDFs', icon: 'git-compare', subtitle: 'Compare two PDFs side by side.' },
|
{ id: 'compare-pdfs', name: 'Compare PDFs', icon: 'git-compare', subtitle: 'Compare two PDFs side by side.' },
|
||||||
|
{ id: 'posterize', name: 'Posterize PDF', icon: 'layout-grid', subtitle: 'Split a large page into multiple smaller pages.' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { applyAndSaveSignatures, setupSignTool } from './sign-pdf.js';
|
|||||||
import { removeAnnotations, setupRemoveAnnotationsTool } from './remove-annotations.js';
|
import { removeAnnotations, setupRemoveAnnotationsTool } from './remove-annotations.js';
|
||||||
import { setupCropperTool } from './cropper.js';
|
import { setupCropperTool } from './cropper.js';
|
||||||
import { processAndDownloadForm, setupFormFiller } from './form-filler.js';
|
import { processAndDownloadForm, setupFormFiller } from './form-filler.js';
|
||||||
|
import { posterize, setupPosterizeTool } from './posterize.js';
|
||||||
|
|
||||||
export const toolLogic = {
|
export const toolLogic = {
|
||||||
merge: { process: merge, setup: setupMergeTool },
|
merge: { process: merge, setup: setupMergeTool },
|
||||||
@@ -107,4 +108,5 @@ export const toolLogic = {
|
|||||||
'remove-annotations': { process: removeAnnotations, setup: setupRemoveAnnotationsTool },
|
'remove-annotations': { process: removeAnnotations, setup: setupRemoveAnnotationsTool },
|
||||||
'cropper': { setup: setupCropperTool },
|
'cropper': { setup: setupCropperTool },
|
||||||
'form-filler': { process: processAndDownloadForm, setup: setupFormFiller},
|
'form-filler': { process: processAndDownloadForm, setup: setupFormFiller},
|
||||||
|
'posterize': { process: posterize, setup: setupPosterizeTool },
|
||||||
};
|
};
|
||||||
158
src/js/logic/posterize.ts
Normal file
158
src/js/logic/posterize.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||||
|
import { downloadFile, parsePageRanges } from '../utils/helpers.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { PDFDocument, rgb, PageSizes } from 'pdf-lib';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
||||||
|
let pdfJsDoc = null;
|
||||||
|
let pageSnapshot = null;
|
||||||
|
|
||||||
|
async function renderPosterizePreview() {
|
||||||
|
if (!pdfJsDoc) return;
|
||||||
|
showLoader('Rendering preview...');
|
||||||
|
|
||||||
|
const canvas = document.getElementById('posterize-preview-canvas') as HTMLCanvasElement;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const page = await pdfJsDoc.getPage(1); // Always preview the first page
|
||||||
|
const viewport = page.getViewport({ scale: 1.5 });
|
||||||
|
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
|
|
||||||
|
pageSnapshot = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
drawGridOverlay();
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGridOverlay() {
|
||||||
|
if (!pageSnapshot) return;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('posterize-preview-canvas') as HTMLCanvasElement;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const rows = parseInt((document.getElementById('posterize-rows') as HTMLInputElement).value) || 1;
|
||||||
|
const cols = parseInt((document.getElementById('posterize-cols') as HTMLInputElement).value) || 1;
|
||||||
|
|
||||||
|
context.putImageData(pageSnapshot, 0, 0);
|
||||||
|
|
||||||
|
context.strokeStyle = 'rgba(239, 68, 68, 0.9)'; // Grid line color
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.setLineDash([10, 5]);
|
||||||
|
|
||||||
|
const cellWidth = canvas.width / cols;
|
||||||
|
const cellHeight = canvas.height / rows;
|
||||||
|
|
||||||
|
for (let i = 1; i < cols; i++) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(i * cellWidth, 0);
|
||||||
|
context.lineTo(i * cellWidth, canvas.height);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < rows; i++) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, i * cellHeight);
|
||||||
|
context.lineTo(canvas.width, i * cellHeight);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupPosterizeTool() {
|
||||||
|
if (state.pdfDoc) {
|
||||||
|
document.getElementById('total-pages').textContent = state.pdfDoc.getPageCount().toString();
|
||||||
|
|
||||||
|
const pdfBytes = await state.pdfDoc.save();
|
||||||
|
pdfJsDoc = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
|
||||||
|
|
||||||
|
await renderPosterizePreview();
|
||||||
|
|
||||||
|
// Add event listeners to update the grid on change
|
||||||
|
document.getElementById('posterize-rows').addEventListener('input', drawGridOverlay);
|
||||||
|
document.getElementById('posterize-cols').addEventListener('input', drawGridOverlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function posterize() {
|
||||||
|
showLoader('Posterizing PDF...');
|
||||||
|
try {
|
||||||
|
const rows = parseInt((document.getElementById('posterize-rows') as HTMLInputElement).value) || 1;
|
||||||
|
const cols = parseInt((document.getElementById('posterize-cols') as HTMLInputElement).value) || 1;
|
||||||
|
const pageSizeKey = (document.getElementById('output-page-size') as HTMLSelectElement).value;
|
||||||
|
const orientation = (document.getElementById('output-orientation') as HTMLSelectElement).value;
|
||||||
|
const scalingMode = (document.querySelector('input[name="scaling-mode"]:checked') as HTMLInputElement).value;
|
||||||
|
const overlap = parseFloat((document.getElementById('overlap') as HTMLInputElement).value) || 0;
|
||||||
|
const overlapUnits = (document.getElementById('overlap-units') as HTMLSelectElement).value;
|
||||||
|
const pageRangeInput = (document.getElementById('page-range') as HTMLInputElement).value;
|
||||||
|
|
||||||
|
let overlapInPoints = overlap;
|
||||||
|
if (overlapUnits === 'in') {
|
||||||
|
overlapInPoints = overlap * 72;
|
||||||
|
} else if (overlapUnits === 'mm') {
|
||||||
|
overlapInPoints = overlap * (72 / 25.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDoc = state.pdfDoc;
|
||||||
|
const newDoc = await PDFDocument.create();
|
||||||
|
const totalPages = sourceDoc.getPageCount();
|
||||||
|
const pageIndicesToProcess = parsePageRanges(pageRangeInput, totalPages);
|
||||||
|
|
||||||
|
if (pageIndicesToProcess.length === 0) {
|
||||||
|
throw new Error("Invalid page range specified. Please check your input (e.g., '1-3, 5').");
|
||||||
|
}
|
||||||
|
|
||||||
|
let [targetWidth, targetHeight] = PageSizes[pageSizeKey];
|
||||||
|
if (orientation === 'landscape' && targetWidth < targetHeight) {
|
||||||
|
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||||
|
} else if (orientation === 'portrait' && targetWidth > targetHeight) {
|
||||||
|
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndicesToProcess) {
|
||||||
|
const sourcePage = sourceDoc.getPages()[pageIndex as number];
|
||||||
|
const { width: sourceWidth, height: sourceHeight } = sourcePage.getSize();
|
||||||
|
|
||||||
|
const embeddedPage = await newDoc.embedPage(sourcePage);
|
||||||
|
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const newPage = newDoc.addPage([targetWidth, targetHeight]);
|
||||||
|
|
||||||
|
const tileWidth = sourceWidth / cols;
|
||||||
|
const tileHeight = sourceHeight / rows;
|
||||||
|
|
||||||
|
const scaleX = targetWidth / tileWidth;
|
||||||
|
const scaleY = targetHeight / tileHeight;
|
||||||
|
const scale = scalingMode === 'fit' ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
|
||||||
|
|
||||||
|
const scaledTileWidth = tileWidth * scale;
|
||||||
|
const scaledTileHeight = tileHeight * scale;
|
||||||
|
|
||||||
|
const offsetX = (targetWidth - scaledTileWidth) / 2;
|
||||||
|
const offsetY = (targetHeight - scaledTileHeight) / 2;
|
||||||
|
|
||||||
|
newPage.drawPage(embeddedPage, {
|
||||||
|
x: -c * scaledTileWidth + offsetX - (c * overlapInPoints),
|
||||||
|
y: r * scaledTileHeight + offsetY - ((rows - 1 - r) * overlapInPoints),
|
||||||
|
width: sourceWidth * scale,
|
||||||
|
height: sourceHeight * scale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPdfBytes = await newDoc.save();
|
||||||
|
downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'posterized.pdf');
|
||||||
|
showAlert('Success', 'Your PDF has been posterized.');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showAlert('Error', e.message || 'Could not posterize the PDF.');
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/js/ui.ts
94
src/js/ui.ts
@@ -1694,4 +1694,98 @@ cropper: () => `
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
posterize: () => `
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-4">Posterize PDF</h2>
|
||||||
|
<p class="mb-6 text-gray-400">Split a single page into multiple smaller pages to print as a poster. Specify a page range or leave it blank to process all pages.</p>
|
||||||
|
${createFileInputHTML()}
|
||||||
|
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||||
|
|
||||||
|
<div id="posterize-options" class="hidden mt-6 space-y-6">
|
||||||
|
|
||||||
|
<div id="posterize-preview-container" class="relative w-full max-w-xl mx-auto bg-gray-900 rounded-lg border-2 border-gray-600">
|
||||||
|
<canvas id="posterize-preview-canvas" class="w-full h-auto"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-gray-900 border border-gray-700 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-3">Grid Layout</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="posterize-rows" class="block mb-2 text-sm font-medium text-gray-300">Rows</label>
|
||||||
|
<input type="number" id="posterize-rows" value="1" min="1" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="posterize-cols" class="block mb-2 text-sm font-medium text-gray-300">Columns</label>
|
||||||
|
<input type="number" id="posterize-cols" value="2" min="1" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-gray-900 border border-gray-700 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-3">Output Page Settings</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="output-page-size" class="block mb-2 text-sm font-medium text-gray-300">Page Size</label>
|
||||||
|
<select id="output-page-size" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||||
|
<option value="A4" selected>A4</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
<option value="Legal">Legal</option>
|
||||||
|
<option value="A3">A3</option>
|
||||||
|
<option value="A5">A5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="output-orientation" class="block mb-2 text-sm font-medium text-gray-300">Orientation</label>
|
||||||
|
<select id="output-orientation" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||||
|
<option value="portrait" selected>Portrait</option>
|
||||||
|
<option value="landscape">Landscape</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-gray-900 border border-gray-700 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-3">Advanced Options</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-300">Content Scaling</label>
|
||||||
|
<div class="flex gap-4 p-2 rounded-lg bg-gray-800">
|
||||||
|
<label class="flex-1 flex items-center gap-2 p-3 rounded-md hover:bg-gray-700 cursor-pointer has-[:checked]:bg-indigo-600">
|
||||||
|
<input type="radio" name="scaling-mode" value="fit" checked class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-white">Fit</span>
|
||||||
|
<p class="text-xs text-gray-400">Preserves all content, may add margins.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex-1 flex items-center gap-2 p-3 rounded-md hover:bg-gray-700 cursor-pointer has-[:checked]:bg-indigo-600">
|
||||||
|
<input type="radio" name="scaling-mode" value="fill" class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-white">Fill (Crop)</span>
|
||||||
|
<p class="text-xs text-gray-400">Fills the page, may crop content.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="overlap" class="block mb-2 text-sm font-medium text-gray-300">Overlap (for assembly)</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="number" id="overlap" value="0" min="0" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||||
|
<select id="overlap-units" class="bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||||
|
<option value="pt">Points</option>
|
||||||
|
<option value="in">Inches</option>
|
||||||
|
<option value="mm">mm</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="page-range" class="block mb-2 text-sm font-medium text-gray-300">Page Range (optional)</label>
|
||||||
|
<input type="text" id="page-range" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" placeholder="e.g., 1-3, 5">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Total pages: <span id="total-pages">0</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="process-btn" class="btn-gradient w-full mt-6" disabled>Posterize PDF</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user