feat: add TIFF conversion options and integrate wasm-vips for image processing
- Updated README.md to include new dependencies: wasm-vips, pixelmatch, diff, and microdiff. - Added wasm-vips to package.json and package-lock.json for advanced TIFF encoding. - Enhanced localization files with new options for DPI, compression, color mode, and multi-page TIFF saving. - Implemented UI changes in pdf-to-tiff.html to allow users to select DPI, compression type, color mode, and multi-page options. - Refactored pdf-to-tiff-page.ts to utilize wasm-vips for TIFF encoding, replacing previous UTIF implementation. - Introduced TiffOptions interface in pdf-to-tiff-type.ts for better type management. - Updated Vite configuration to exclude wasm-vips from dependency optimization.
This commit is contained in:
@@ -9,9 +9,11 @@ import {
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import JSZip from 'jszip';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import UTIF from 'utif';
|
||||
import { PDFPageProxy } from 'pdfjs-dist';
|
||||
import { t } from '../i18n/i18n';
|
||||
import type Vips from 'wasm-vips';
|
||||
import wasmUrl from 'wasm-vips/vips.wasm?url';
|
||||
import type { TiffOptions } from '@/types';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -19,6 +21,42 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
).toString();
|
||||
|
||||
let files: File[] = [];
|
||||
let vipsInstance: typeof Vips | null = null;
|
||||
|
||||
async function getVips(): Promise<typeof Vips> {
|
||||
if (vipsInstance) return vipsInstance;
|
||||
const VipsInit = (await import('wasm-vips')).default;
|
||||
vipsInstance = await VipsInit({
|
||||
dynamicLibraries: [],
|
||||
locateFile: (fileName: string) => {
|
||||
if (fileName.endsWith('.wasm')) {
|
||||
return wasmUrl;
|
||||
}
|
||||
return fileName;
|
||||
},
|
||||
});
|
||||
return vipsInstance;
|
||||
}
|
||||
|
||||
function getOptions(): TiffOptions {
|
||||
const dpiInput = document.getElementById('tiff-dpi') as HTMLInputElement;
|
||||
const compressionInput = document.getElementById(
|
||||
'tiff-compression'
|
||||
) as HTMLSelectElement;
|
||||
const colorModeInput = document.getElementById(
|
||||
'tiff-color-mode'
|
||||
) as HTMLSelectElement;
|
||||
const multiPageInput = document.getElementById(
|
||||
'tiff-multipage'
|
||||
) as HTMLInputElement;
|
||||
|
||||
return {
|
||||
dpi: dpiInput ? parseInt(dpiInput.value, 10) : 300,
|
||||
compression: compressionInput ? compressionInput.value : 'lzw',
|
||||
colorMode: colorModeInput ? colorModeInput.value : 'rgb',
|
||||
multiPage: multiPageInput ? multiPageInput.checked : false,
|
||||
};
|
||||
}
|
||||
|
||||
const updateUI = () => {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
@@ -46,7 +84,7 @@ const updateUI = () => {
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`; // Initial state
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • ${t('common.loadingPageCount')}`;
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
@@ -62,7 +100,6 @@ const updateUI = () => {
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
|
||||
// Fetch page count asynchronously
|
||||
readFileAsArrayBuffer(file)
|
||||
.then((buffer) => {
|
||||
return getPDFDocument(buffer).promise;
|
||||
@@ -76,7 +113,6 @@ const updateUI = () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize icons immediately after synchronous render
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
optionsPanel.classList.add('hidden');
|
||||
@@ -90,6 +126,82 @@ const resetState = () => {
|
||||
updateUI();
|
||||
};
|
||||
|
||||
async function renderPageToRgba(
|
||||
page: PDFPageProxy,
|
||||
dpi: number
|
||||
): Promise<{ rgba: Uint8ClampedArray; width: number; height: number }> {
|
||||
const scale = dpi / 72;
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
canvas,
|
||||
}).promise;
|
||||
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return { rgba: imageData.data, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
function encodePageToTiff(
|
||||
vips: typeof Vips,
|
||||
rgba: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
options: TiffOptions
|
||||
): Uint8Array {
|
||||
let image = vips.Image.newFromMemory(
|
||||
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
|
||||
width,
|
||||
height,
|
||||
4,
|
||||
vips.BandFormat.uchar
|
||||
);
|
||||
|
||||
image = image.copy();
|
||||
const pixelsPerMm = options.dpi / 25.4;
|
||||
image.setDouble('xres', pixelsPerMm);
|
||||
image.setDouble('yres', pixelsPerMm);
|
||||
|
||||
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
|
||||
if (image.bands === 4) {
|
||||
image = image.flatten({ background: [255, 255, 255] });
|
||||
}
|
||||
image = image.colourspace(vips.Interpretation.b_w);
|
||||
} else {
|
||||
if (image.bands === 4) {
|
||||
image = image.flatten({ background: [255, 255, 255] });
|
||||
}
|
||||
}
|
||||
|
||||
const tiffOptions: Parameters<typeof image.tiffsaveBuffer>[0] = {
|
||||
compression: options.compression as Vips.Enum,
|
||||
resunit: vips.ForeignTiffResunit.inch,
|
||||
xres: options.dpi / 25.4,
|
||||
yres: options.dpi / 25.4,
|
||||
predictor:
|
||||
options.compression === 'lzw' || options.compression === 'deflate'
|
||||
? vips.ForeignTiffPredictor.horizontal
|
||||
: vips.ForeignTiffPredictor.none,
|
||||
};
|
||||
|
||||
if (options.colorMode === 'bw') {
|
||||
tiffOptions.bitdepth = 1;
|
||||
}
|
||||
|
||||
if (options.compression === 'jpeg') {
|
||||
tiffOptions.Q = 85;
|
||||
}
|
||||
|
||||
const buffer = image.tiffsaveBuffer(tiffOptions);
|
||||
image.delete();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function convert() {
|
||||
if (files.length === 0) {
|
||||
showAlert(
|
||||
@@ -98,26 +210,110 @@ async function convert() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
showLoader(t('tools:pdfToTiff.loader.converting'));
|
||||
showLoader(t('tools:pdfToTiff.loadingVips'));
|
||||
|
||||
let vips: typeof Vips;
|
||||
try {
|
||||
vips = await getVips();
|
||||
} catch (e) {
|
||||
console.error('Failed to load wasm-vips:', e);
|
||||
hideLoader();
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to load the image processor. Please ensure your browser supports SharedArrayBuffer (requires HTTPS or localhost).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader(t('tools:pdfToTiff.converting'));
|
||||
|
||||
try {
|
||||
const options = getOptions();
|
||||
const pdf = await getPDFDocument(await readFileAsArrayBuffer(files[0]))
|
||||
.promise;
|
||||
|
||||
if (pdf.numPages === 1) {
|
||||
if (options.multiPage && pdf.numPages > 1) {
|
||||
const pages: Vips.Image[] = [];
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const { rgba, width, height } = await renderPageToRgba(
|
||||
page,
|
||||
options.dpi
|
||||
);
|
||||
|
||||
let img = vips.Image.newFromMemory(
|
||||
new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
|
||||
width,
|
||||
height,
|
||||
4,
|
||||
vips.BandFormat.uchar
|
||||
);
|
||||
|
||||
if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
|
||||
if (img.bands === 4) {
|
||||
img = img.flatten({ background: [255, 255, 255] });
|
||||
}
|
||||
img = img.colourspace(vips.Interpretation.b_w);
|
||||
} else {
|
||||
if (img.bands === 4) {
|
||||
img = img.flatten({ background: [255, 255, 255] });
|
||||
}
|
||||
}
|
||||
|
||||
pages.push(img);
|
||||
}
|
||||
|
||||
const firstPage = pages[0];
|
||||
let joined = firstPage;
|
||||
if (pages.length > 1) {
|
||||
joined = vips.Image.arrayjoin(pages, { across: 1 });
|
||||
}
|
||||
|
||||
const tiffOptions: Parameters<typeof joined.tiffsaveBuffer>[0] = {
|
||||
compression: options.compression as Vips.Enum,
|
||||
resunit: vips.ForeignTiffResunit.inch,
|
||||
xres: options.dpi / 25.4,
|
||||
yres: options.dpi / 25.4,
|
||||
page_height: firstPage.height,
|
||||
predictor:
|
||||
options.compression === 'lzw' || options.compression === 'deflate'
|
||||
? vips.ForeignTiffPredictor.horizontal
|
||||
: vips.ForeignTiffPredictor.none,
|
||||
};
|
||||
|
||||
if (options.colorMode === 'bw') {
|
||||
tiffOptions.bitdepth = 1;
|
||||
}
|
||||
|
||||
if (options.compression === 'jpeg') {
|
||||
tiffOptions.Q = 85;
|
||||
}
|
||||
|
||||
const buffer = joined.tiffsaveBuffer(tiffOptions);
|
||||
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/tiff' });
|
||||
downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
|
||||
|
||||
joined.delete();
|
||||
for (const p of pages) {
|
||||
if (!p.isDeleted()) p.delete();
|
||||
}
|
||||
} else if (pdf.numPages === 1) {
|
||||
const page = await pdf.getPage(1);
|
||||
const blob = await renderPage(page, 1);
|
||||
downloadFile(
|
||||
blob.blobData,
|
||||
getCleanPdfFilename(files[0].name) + '.' + blob.ending
|
||||
);
|
||||
const { rgba, width, height } = await renderPageToRgba(page, options.dpi);
|
||||
const buffer = encodePageToTiff(vips, rgba, width, height, options);
|
||||
const blob = new Blob([new Uint8Array(buffer)], { type: 'image/tiff' });
|
||||
downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
|
||||
} else {
|
||||
const zip = new JSZip();
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const blob = await renderPage(page, i);
|
||||
if (blob.blobData) {
|
||||
zip.file(`page_${i}.` + blob.ending, blob.blobData);
|
||||
}
|
||||
const { rgba, width, height } = await renderPageToRgba(
|
||||
page,
|
||||
options.dpi
|
||||
);
|
||||
const buffer = encodePageToTiff(vips, rgba, width, height, options);
|
||||
zip.file(`page_${i}.tiff`, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
@@ -140,64 +336,19 @@ async function convert() {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(
|
||||
page: PDFPageProxy,
|
||||
pageNumber: number
|
||||
): Promise<{ blobData: Blob | null; ending: string }> {
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
await page.render({
|
||||
canvasContext: context!,
|
||||
viewport: viewport,
|
||||
canvas,
|
||||
}).promise;
|
||||
|
||||
const imageData = context!.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const rgba = imageData.data;
|
||||
|
||||
try {
|
||||
const tiffData = UTIF.encodeImage(
|
||||
new Uint8Array(rgba),
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
const tiffBlob = new Blob([tiffData], { type: 'image/tiff' });
|
||||
return {
|
||||
blobData: tiffBlob,
|
||||
ending: 'tiff',
|
||||
};
|
||||
} catch (encodeError: any) {
|
||||
console.warn(
|
||||
`TIFF encoding failed for page ${pageNumber}, using PNG fallback:`,
|
||||
encodeError
|
||||
);
|
||||
// Fallback to PNG if TIFF encoding fails (e.g., PackBits compression issues)
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
);
|
||||
if (pngBlob) {
|
||||
return {
|
||||
blobData: pngBlob,
|
||||
ending: 'png',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blobData: null,
|
||||
ending: 'tiff',
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
const dpiSlider = document.getElementById('tiff-dpi') as HTMLInputElement;
|
||||
const dpiValue = document.getElementById('tiff-dpi-value');
|
||||
const compressionSelect = document.getElementById(
|
||||
'tiff-compression'
|
||||
) as HTMLSelectElement;
|
||||
const colorModeSelect = document.getElementById(
|
||||
'tiff-color-mode'
|
||||
) as HTMLSelectElement;
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
@@ -205,6 +356,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (dpiSlider && dpiValue) {
|
||||
dpiSlider.addEventListener('input', () => {
|
||||
dpiValue.textContent = dpiSlider.value;
|
||||
});
|
||||
}
|
||||
|
||||
if (compressionSelect && colorModeSelect) {
|
||||
compressionSelect.addEventListener('change', () => {
|
||||
if (compressionSelect.value === 'ccittfax4') {
|
||||
colorModeSelect.value = 'bw';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleFileSelect = (newFiles: FileList | null) => {
|
||||
if (!newFiles || newFiles.length === 0) return;
|
||||
const validFiles = Array.from(newFiles).filter(
|
||||
|
||||
Reference in New Issue
Block a user