feat: add adjust colors and scanner effect pages with corresponding types and configurations
This commit is contained in:
586
src/js/logic/scanner-effect-page.ts
Normal file
586
src/js/logic/scanner-effect-page.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import {
|
||||
downloadFile,
|
||||
formatBytes,
|
||||
readFileAsArrayBuffer,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { ScanSettings } from '../types/scanner-effect-type.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
let files: File[] = [];
|
||||
let cachedBaselineData: ImageData | null = null;
|
||||
let cachedBaselineWidth = 0;
|
||||
let cachedBaselineHeight = 0;
|
||||
let pdfjsDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||
|
||||
function getSettings(): ScanSettings {
|
||||
return {
|
||||
grayscale:
|
||||
(document.getElementById('setting-grayscale') as HTMLInputElement)
|
||||
?.checked ?? false,
|
||||
border:
|
||||
(document.getElementById('setting-border') as HTMLInputElement)
|
||||
?.checked ?? false,
|
||||
rotate: parseFloat(
|
||||
(document.getElementById('setting-rotate') as HTMLInputElement)?.value ??
|
||||
'0'
|
||||
),
|
||||
rotateVariance: parseFloat(
|
||||
(document.getElementById('setting-rotate-variance') as HTMLInputElement)
|
||||
?.value ?? '0'
|
||||
),
|
||||
brightness: parseInt(
|
||||
(document.getElementById('setting-brightness') as HTMLInputElement)
|
||||
?.value ?? '0'
|
||||
),
|
||||
contrast: parseInt(
|
||||
(document.getElementById('setting-contrast') as HTMLInputElement)
|
||||
?.value ?? '0'
|
||||
),
|
||||
blur: parseFloat(
|
||||
(document.getElementById('setting-blur') as HTMLInputElement)?.value ??
|
||||
'0'
|
||||
),
|
||||
noise: parseInt(
|
||||
(document.getElementById('setting-noise') as HTMLInputElement)?.value ??
|
||||
'10'
|
||||
),
|
||||
yellowish: parseInt(
|
||||
(document.getElementById('setting-yellowish') as HTMLInputElement)
|
||||
?.value ?? '0'
|
||||
),
|
||||
resolution: parseInt(
|
||||
(document.getElementById('setting-resolution') as HTMLInputElement)
|
||||
?.value ?? '150'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function applyEffects(
|
||||
sourceData: ImageData,
|
||||
canvas: HTMLCanvasElement,
|
||||
settings: ScanSettings,
|
||||
rotationAngle: number,
|
||||
scale: number = 1
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const w = sourceData.width;
|
||||
const h = sourceData.height;
|
||||
|
||||
const scaledBlur = settings.blur * scale;
|
||||
const scaledNoise = settings.noise * scale;
|
||||
|
||||
const workCanvas = document.createElement('canvas');
|
||||
workCanvas.width = w;
|
||||
workCanvas.height = h;
|
||||
const workCtx = workCanvas.getContext('2d')!;
|
||||
|
||||
if (scaledBlur > 0) {
|
||||
workCtx.filter = `blur(${scaledBlur}px)`;
|
||||
}
|
||||
|
||||
workCtx.putImageData(sourceData, 0, 0);
|
||||
if (scaledBlur > 0) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d')!;
|
||||
tempCtx.filter = `blur(${scaledBlur}px)`;
|
||||
tempCtx.drawImage(workCanvas, 0, 0);
|
||||
workCtx.filter = 'none';
|
||||
workCtx.clearRect(0, 0, w, h);
|
||||
workCtx.drawImage(tempCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const imageData = workCtx.getImageData(0, 0, w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrastFactor =
|
||||
settings.contrast !== 0
|
||||
? (259 * (settings.contrast + 255)) / (255 * (259 - settings.contrast))
|
||||
: 1;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
|
||||
if (settings.grayscale) {
|
||||
const grey = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
r = grey;
|
||||
g = grey;
|
||||
b = grey;
|
||||
}
|
||||
|
||||
if (settings.brightness !== 0) {
|
||||
r += settings.brightness;
|
||||
g += settings.brightness;
|
||||
b += settings.brightness;
|
||||
}
|
||||
|
||||
if (settings.contrast !== 0) {
|
||||
r = contrastFactor * (r - 128) + 128;
|
||||
g = contrastFactor * (g - 128) + 128;
|
||||
b = contrastFactor * (b - 128) + 128;
|
||||
}
|
||||
|
||||
if (settings.yellowish > 0) {
|
||||
const intensity = settings.yellowish / 50;
|
||||
r += 20 * intensity;
|
||||
g += 12 * intensity;
|
||||
b -= 15 * intensity;
|
||||
}
|
||||
|
||||
if (scaledNoise > 0) {
|
||||
const n = (Math.random() - 0.5) * scaledNoise;
|
||||
r += n;
|
||||
g += n;
|
||||
b += n;
|
||||
}
|
||||
|
||||
data[i] = Math.max(0, Math.min(255, r));
|
||||
data[i + 1] = Math.max(0, Math.min(255, g));
|
||||
data[i + 2] = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
|
||||
workCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
if (settings.border) {
|
||||
const borderSize = Math.max(w, h) * 0.02;
|
||||
const gradient1 = workCtx.createLinearGradient(0, 0, borderSize, 0);
|
||||
gradient1.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient1.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient1;
|
||||
workCtx.fillRect(0, 0, borderSize, h);
|
||||
|
||||
const gradient2 = workCtx.createLinearGradient(w, 0, w - borderSize, 0);
|
||||
gradient2.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient2.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient2;
|
||||
workCtx.fillRect(w - borderSize, 0, borderSize, h);
|
||||
|
||||
const gradient3 = workCtx.createLinearGradient(0, 0, 0, borderSize);
|
||||
gradient3.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient3.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient3;
|
||||
workCtx.fillRect(0, 0, w, borderSize);
|
||||
|
||||
const gradient4 = workCtx.createLinearGradient(0, h, 0, h - borderSize);
|
||||
gradient4.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient4.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient4;
|
||||
workCtx.fillRect(0, h - borderSize, w, borderSize);
|
||||
}
|
||||
|
||||
if (rotationAngle !== 0) {
|
||||
const rad = (rotationAngle * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rad));
|
||||
const sin = Math.abs(Math.sin(rad));
|
||||
const newW = Math.ceil(w * cos + h * sin);
|
||||
const newH = Math.ceil(w * sin + h * cos);
|
||||
|
||||
canvas.width = newW;
|
||||
canvas.height = newH;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, newW, newH);
|
||||
ctx.translate(newW / 2, newH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(workCanvas, -w / 2, -h / 2);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
} else {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.drawImage(workCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreview(): void {
|
||||
if (!cachedBaselineData) return;
|
||||
|
||||
const previewCanvas = document.getElementById(
|
||||
'preview-canvas'
|
||||
) as HTMLCanvasElement;
|
||||
if (!previewCanvas) return;
|
||||
|
||||
const settings = getSettings();
|
||||
const baselineCopy = new ImageData(
|
||||
new Uint8ClampedArray(cachedBaselineData.data),
|
||||
cachedBaselineWidth,
|
||||
cachedBaselineHeight
|
||||
);
|
||||
|
||||
applyEffects(baselineCopy, previewCanvas, settings, settings.rotate);
|
||||
}
|
||||
|
||||
async function renderPreview(): Promise<void> {
|
||||
if (!pdfjsDoc) return;
|
||||
|
||||
const page = await pdfjsDoc.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1.0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
cachedBaselineData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
cachedBaselineWidth = canvas.width;
|
||||
cachedBaselineHeight = canvas.height;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
const updateUI = () => {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const optionsPanel = document.getElementById('options-panel');
|
||||
|
||||
if (!fileDisplayArea || !optionsPanel) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (files.length > 0) {
|
||||
optionsPanel.classList.remove('hidden');
|
||||
|
||||
files.forEach((file) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`;
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className =
|
||||
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
files = [];
|
||||
pdfjsDoc = null;
|
||||
cachedBaselineData = null;
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
|
||||
readFileAsArrayBuffer(file)
|
||||
.then((buffer: ArrayBuffer) => {
|
||||
return getPDFDocument(buffer).promise;
|
||||
})
|
||||
.then((pdf: pdfjsLib.PDFDocumentProxy) => {
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`;
|
||||
})
|
||||
.catch(() => {
|
||||
metaSpan.textContent = formatBytes(file.size);
|
||||
});
|
||||
});
|
||||
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
optionsPanel.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
files = [];
|
||||
pdfjsDoc = null;
|
||||
cachedBaselineData = null;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
updateUI();
|
||||
};
|
||||
|
||||
async function processAllPages(): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Applying scanner effect...');
|
||||
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const pdfBytes = (await readFileAsArrayBuffer(files[0])) as ArrayBuffer;
|
||||
const doc = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const dpiScale = settings.resolution / 72;
|
||||
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
showLoader(`Processing page ${i} of ${doc.numPages}...`);
|
||||
|
||||
const page = await doc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: dpiScale });
|
||||
const renderCanvas = document.createElement('canvas');
|
||||
const renderCtx = renderCanvas.getContext('2d')!;
|
||||
renderCanvas.width = viewport.width;
|
||||
renderCanvas.height = viewport.height;
|
||||
|
||||
await page.render({
|
||||
canvasContext: renderCtx,
|
||||
viewport,
|
||||
canvas: renderCanvas,
|
||||
}).promise;
|
||||
|
||||
const baseData = renderCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
renderCanvas.width,
|
||||
renderCanvas.height
|
||||
);
|
||||
const baselineCopy = new ImageData(
|
||||
new Uint8ClampedArray(baseData.data),
|
||||
baseData.width,
|
||||
baseData.height
|
||||
);
|
||||
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
const pageRotation =
|
||||
settings.rotate +
|
||||
(settings.rotateVariance > 0
|
||||
? (Math.random() - 0.5) * 2 * settings.rotateVariance
|
||||
: 0);
|
||||
|
||||
applyEffects(
|
||||
baselineCopy,
|
||||
outputCanvas,
|
||||
settings,
|
||||
pageRotation,
|
||||
dpiScale
|
||||
);
|
||||
|
||||
const jpegBlob = await new Promise<Blob | null>((resolve) =>
|
||||
outputCanvas.toBlob(resolve, 'image/jpeg', 0.85)
|
||||
);
|
||||
|
||||
if (jpegBlob) {
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([
|
||||
outputCanvas.width,
|
||||
outputCanvas.height,
|
||||
]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: outputCanvas.width,
|
||||
height: outputCanvas.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'scanned.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
'Scanner effect applied successfully!',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to apply scanner effect. The file might be corrupted.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
const sliderDefaults: {
|
||||
id: string;
|
||||
display: string;
|
||||
suffix: string;
|
||||
defaultValue: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'setting-rotate',
|
||||
display: 'rotate-value',
|
||||
suffix: '°',
|
||||
defaultValue: '0',
|
||||
},
|
||||
{
|
||||
id: 'setting-rotate-variance',
|
||||
display: 'rotate-variance-value',
|
||||
suffix: '°',
|
||||
defaultValue: '0',
|
||||
},
|
||||
{
|
||||
id: 'setting-brightness',
|
||||
display: 'brightness-value',
|
||||
suffix: '',
|
||||
defaultValue: '0',
|
||||
},
|
||||
{
|
||||
id: 'setting-contrast',
|
||||
display: 'contrast-value',
|
||||
suffix: '',
|
||||
defaultValue: '0',
|
||||
},
|
||||
{
|
||||
id: 'setting-blur',
|
||||
display: 'blur-value',
|
||||
suffix: 'px',
|
||||
defaultValue: '0',
|
||||
},
|
||||
{
|
||||
id: 'setting-noise',
|
||||
display: 'noise-value',
|
||||
suffix: '',
|
||||
defaultValue: '10',
|
||||
},
|
||||
{
|
||||
id: 'setting-yellowish',
|
||||
display: 'yellowish-value',
|
||||
suffix: '',
|
||||
defaultValue: '0',
|
||||
},
|
||||
{
|
||||
id: 'setting-resolution',
|
||||
display: 'resolution-value',
|
||||
suffix: ' DPI',
|
||||
defaultValue: '150',
|
||||
},
|
||||
];
|
||||
|
||||
function resetSettings(): void {
|
||||
sliderDefaults.forEach(({ id, display, suffix, defaultValue }) => {
|
||||
const slider = document.getElementById(id) as HTMLInputElement;
|
||||
const label = document.getElementById(display);
|
||||
if (slider) slider.value = defaultValue;
|
||||
if (label) label.textContent = defaultValue + suffix;
|
||||
});
|
||||
|
||||
const grayscale = document.getElementById(
|
||||
'setting-grayscale'
|
||||
) as HTMLInputElement;
|
||||
const border = document.getElementById('setting-border') as HTMLInputElement;
|
||||
if (grayscale) grayscale.checked = false;
|
||||
if (border) border.checked = false;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function setupSettingsListeners(): void {
|
||||
sliderDefaults.forEach(({ id, display, suffix }) => {
|
||||
const slider = document.getElementById(id) as HTMLInputElement;
|
||||
const label = document.getElementById(display);
|
||||
if (slider && label) {
|
||||
slider.addEventListener('input', () => {
|
||||
label.textContent = slider.value + suffix;
|
||||
if (id !== 'setting-resolution') {
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const toggleIds = ['setting-grayscale', 'setting-border'];
|
||||
toggleIds.forEach((id) => {
|
||||
const toggle = document.getElementById(id) as HTMLInputElement;
|
||||
if (toggle) {
|
||||
toggle.addEventListener('change', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById('reset-settings-btn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', resetSettings);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
const handleFileSelect = async (newFiles: FileList | null) => {
|
||||
if (!newFiles || newFiles.length === 0) return;
|
||||
const validFiles = Array.from(newFiles).filter(
|
||||
(file) => file.type === 'application/pdf'
|
||||
);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
showAlert('Invalid File', 'Please upload a PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
files = [validFiles[0]];
|
||||
updateUI();
|
||||
|
||||
showLoader('Loading preview...');
|
||||
try {
|
||||
const buffer = await readFileAsArrayBuffer(validFiles[0]);
|
||||
pdfjsDoc = await getPDFDocument({ data: buffer }).promise;
|
||||
await renderPreview();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to load PDF for preview.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', processAllPages);
|
||||
}
|
||||
|
||||
setupSettingsListeners();
|
||||
});
|
||||
Reference in New Issue
Block a user