feat: add Deskew PDF and Font to Outline tools with improved issue templates
New Features: - Add Deskew PDF tool for straightening scanned/skewed PDF pages - Add Font to Outline tool for converting text to vector paths - Add translations for new tools in all supported locales (de, en, id, it, tr, vi, zh) Improvements: - Migrate GitHub issue templates from markdown to YAML forms - Separate templates for bug reports, feature requests, and questions - Add config.yml for issue template chooser - Update sitemap.xml with new tool pages - Update ghostscript loader and helper utilities
This commit is contained in:
@@ -42,7 +42,7 @@ export async function convertToPdfA(
|
||||
gs = cachedGsModule;
|
||||
} else {
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript');
|
||||
gs = await loadWASM({
|
||||
gs = (await loadWASM({
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return gsBaseUrl + 'gs.wasm';
|
||||
@@ -51,7 +51,7 @@ export async function convertToPdfA(
|
||||
},
|
||||
print: (text: string) => console.log('[GS]', text),
|
||||
printErr: (text: string) => console.error('[GS Error]', text),
|
||||
}) as GhostscriptModule;
|
||||
})) as GhostscriptModule;
|
||||
cachedGsModule = gs;
|
||||
}
|
||||
|
||||
@@ -76,16 +76,24 @@ export async function convertToPdfA(
|
||||
const response = await fetchWasmFile('ghostscript', iccFileName);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.`);
|
||||
throw new Error(
|
||||
`Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.`
|
||||
);
|
||||
}
|
||||
|
||||
const iccData = new Uint8Array(await response.arrayBuffer());
|
||||
console.log('[Ghostscript] sRGB v2 ICC profile loaded:', iccData.length, 'bytes');
|
||||
console.log(
|
||||
'[Ghostscript] sRGB v2 ICC profile loaded:',
|
||||
iccData.length,
|
||||
'bytes'
|
||||
);
|
||||
|
||||
gs.FS.writeFile(iccPath, iccData);
|
||||
console.log('[Ghostscript] sRGB ICC profile written to FS:', iccPath);
|
||||
|
||||
const iccHex = Array.from(iccData).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const iccHex = Array.from(iccData)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
console.log('[Ghostscript] ICC profile hex length:', iccHex.length);
|
||||
|
||||
const pdfaSubtype = level === 'PDF/A-1b' ? '/GTS_PDFA1' : '/GTS_PDFA';
|
||||
@@ -114,7 +122,9 @@ export async function convertToPdfA(
|
||||
`;
|
||||
|
||||
gs.FS.writeFile(pdfaDefPath, pdfaPS);
|
||||
console.log('[Ghostscript] PDFA PostScript created with embedded ICC hex data');
|
||||
console.log(
|
||||
'[Ghostscript] PDFA PostScript created with embedded ICC hex data'
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Ghostscript] Failed to setup PDF/A assets:', e);
|
||||
throw new Error('Conversion failed: could not create PDF/A definition');
|
||||
@@ -163,10 +173,26 @@ export async function convertToPdfA(
|
||||
console.log('[Ghostscript] Exit code:', exitCode);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
try { gs.FS.unlink(inputPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(outputPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(iccPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(pdfaDefPath); } catch { /* ignore */ }
|
||||
try {
|
||||
gs.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gs.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gs.FS.unlink(iccPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gs.FS.unlink(pdfaDefPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
@@ -182,14 +208,32 @@ export async function convertToPdfA(
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try { gs.FS.unlink(inputPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(outputPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(iccPath); } catch { /* ignore */ }
|
||||
try { gs.FS.unlink(pdfaDefPath); } catch { /* ignore */ }
|
||||
try {
|
||||
gs.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gs.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gs.FS.unlink(iccPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gs.FS.unlink(pdfaDefPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (level !== 'PDF/A-1b') {
|
||||
onProgress?.('Post-processing for transparency compliance...');
|
||||
console.log('[Ghostscript] Adding Group dictionaries to pages for transparency compliance...');
|
||||
console.log(
|
||||
'[Ghostscript] Adding Group dictionaries to pages for transparency compliance...'
|
||||
);
|
||||
|
||||
try {
|
||||
output = await addPageGroupDictionaries(output);
|
||||
@@ -202,10 +246,12 @@ export async function convertToPdfA(
|
||||
return output;
|
||||
}
|
||||
|
||||
async function addPageGroupDictionaries(pdfData: Uint8Array): Promise<Uint8Array> {
|
||||
async function addPageGroupDictionaries(
|
||||
pdfData: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfData, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false
|
||||
updateMetadata: false,
|
||||
});
|
||||
|
||||
const catalog = pdfDoc.catalog;
|
||||
@@ -227,12 +273,22 @@ async function addPageGroupDictionaries(pdfData: Uint8Array): Promise<Uint8Array
|
||||
|
||||
if (currentCS instanceof PDFName) {
|
||||
const csName = currentCS.decodeText();
|
||||
if (csName === 'DeviceRGB' || csName === 'DeviceGray' || csName === 'DeviceCMYK') {
|
||||
const iccColorSpace = pdfDoc.context.obj([PDFName.of('ICCBased'), iccProfileRef]);
|
||||
if (
|
||||
csName === 'DeviceRGB' ||
|
||||
csName === 'DeviceGray' ||
|
||||
csName === 'DeviceCMYK'
|
||||
) {
|
||||
const iccColorSpace = pdfDoc.context.obj([
|
||||
PDFName.of('ICCBased'),
|
||||
iccProfileRef,
|
||||
]);
|
||||
groupDict.set(PDFName.of('CS'), iccColorSpace);
|
||||
}
|
||||
} else if (!currentCS) {
|
||||
const iccColorSpace = pdfDoc.context.obj([PDFName.of('ICCBased'), iccProfileRef]);
|
||||
const iccColorSpace = pdfDoc.context.obj([
|
||||
PDFName.of('ICCBased'),
|
||||
iccProfileRef,
|
||||
]);
|
||||
groupDict.set(PDFName.of('CS'), iccColorSpace);
|
||||
}
|
||||
};
|
||||
@@ -247,7 +303,10 @@ async function addPageGroupDictionaries(pdfData: Uint8Array): Promise<Uint8Array
|
||||
updateGroupCS(existingGroup);
|
||||
}
|
||||
} else if (iccProfileRef) {
|
||||
const colorSpace = pdfDoc.context.obj([PDFName.of('ICCBased'), iccProfileRef]);
|
||||
const colorSpace = pdfDoc.context.obj([
|
||||
PDFName.of('ICCBased'),
|
||||
iccProfileRef,
|
||||
]);
|
||||
const groupDict = pdfDoc.context.obj({
|
||||
Type: 'Group',
|
||||
S: 'Transparency',
|
||||
@@ -261,8 +320,12 @@ async function addPageGroupDictionaries(pdfData: Uint8Array): Promise<Uint8Array
|
||||
|
||||
if (iccProfileRef) {
|
||||
pdfDoc.context.enumerateIndirectObjects().forEach(([ref, obj]) => {
|
||||
if (obj instanceof PDFDict || (obj && typeof obj === 'object' && 'dict' in obj)) {
|
||||
const dict = 'dict' in obj ? (obj as { dict: PDFDict }).dict : obj as PDFDict;
|
||||
if (
|
||||
obj instanceof PDFDict ||
|
||||
(obj && typeof obj === 'object' && 'dict' in obj)
|
||||
) {
|
||||
const dict =
|
||||
'dict' in obj ? (obj as { dict: PDFDict }).dict : (obj as PDFDict);
|
||||
|
||||
const subtype = dict.get(PDFName.of('Subtype'));
|
||||
if (subtype instanceof PDFName && subtype.decodeText() === 'Form') {
|
||||
@@ -290,8 +353,100 @@ export async function convertFileToPdfA(
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfData = new Uint8Array(arrayBuffer);
|
||||
const result = await convertToPdfA(pdfData, level, onProgress);
|
||||
// Copy to regular ArrayBuffer to avoid SharedArrayBuffer issues
|
||||
const copy = new Uint8Array(result.length);
|
||||
copy.set(result);
|
||||
return new Blob([copy], { type: 'application/pdf' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertFontsToOutlines(
|
||||
pdfData: Uint8Array,
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<Uint8Array> {
|
||||
onProgress?.('Loading Ghostscript...');
|
||||
|
||||
let gs: GhostscriptModule;
|
||||
|
||||
if (cachedGsModule) {
|
||||
gs = cachedGsModule;
|
||||
} else {
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript');
|
||||
gs = (await loadWASM({
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return gsBaseUrl + 'gs.wasm';
|
||||
}
|
||||
return path;
|
||||
},
|
||||
print: (text: string) => console.log('[GS]', text),
|
||||
printErr: (text: string) => console.error('[GS Error]', text),
|
||||
})) as GhostscriptModule;
|
||||
cachedGsModule = gs;
|
||||
}
|
||||
|
||||
const inputPath = '/tmp/input.pdf';
|
||||
const outputPath = '/tmp/output.pdf';
|
||||
|
||||
gs.FS.writeFile(inputPath, pdfData);
|
||||
|
||||
onProgress?.('Converting fonts to outlines...');
|
||||
|
||||
const args = [
|
||||
'-dNOSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
'-dNoOutputFonts',
|
||||
'-dCompressPages=true',
|
||||
'-dAutoRotatePages=/None',
|
||||
`-sOutputFile=${outputPath}`,
|
||||
inputPath,
|
||||
];
|
||||
|
||||
let exitCode: number;
|
||||
try {
|
||||
exitCode = gs.callMain(args);
|
||||
} catch (e) {
|
||||
try {
|
||||
gs.FS.unlink(inputPath);
|
||||
} catch {}
|
||||
throw new Error(`Ghostscript threw an exception: ${e}`);
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
try {
|
||||
gs.FS.unlink(inputPath);
|
||||
} catch {}
|
||||
try {
|
||||
gs.FS.unlink(outputPath);
|
||||
} catch {}
|
||||
throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
let output: Uint8Array;
|
||||
try {
|
||||
output = gs.FS.readFile(outputPath);
|
||||
} catch (e) {
|
||||
throw new Error('Ghostscript did not produce output file');
|
||||
}
|
||||
|
||||
try {
|
||||
gs.FS.unlink(inputPath);
|
||||
} catch {}
|
||||
try {
|
||||
gs.FS.unlink(outputPath);
|
||||
} catch {}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function convertFileToOutlines(
|
||||
file: File,
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfData = new Uint8Array(arrayBuffer);
|
||||
const result = await convertFontsToOutlines(pdfData, onProgress);
|
||||
const copy = new Uint8Array(result.length);
|
||||
copy.set(result);
|
||||
return new Blob([copy], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
@@ -306,3 +306,157 @@ export function escapeHtml(text: string): string {
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
const CHUNK_SIZE = 0x8000;
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
||||
const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
|
||||
chunks.push(String.fromCharCode(...chunk));
|
||||
}
|
||||
return btoa(chunks.join(''));
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(html: string): string {
|
||||
if (!html) return html;
|
||||
|
||||
let sanitized = html;
|
||||
|
||||
sanitized = sanitized.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '');
|
||||
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
sanitized = sanitized.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
sanitized = sanitized.replace(/<link[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, '');
|
||||
sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, '');
|
||||
sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, '');
|
||||
sanitized = sanitized.replace(
|
||||
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
|
||||
''
|
||||
);
|
||||
sanitized = sanitized.replace(
|
||||
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
|
||||
(match, encodedUrl) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||
return `href="${decodedUrl}"`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
);
|
||||
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
|
||||
sanitized = sanitized.replace(
|
||||
/href=["']([^"']{500,})["']/gi,
|
||||
(match, url) => {
|
||||
const baseUrl = url.split('?')[0];
|
||||
if (baseUrl && baseUrl.length < 200) {
|
||||
return `href="${baseUrl}"`;
|
||||
}
|
||||
return `href="${url.substring(0, 200)}"`;
|
||||
}
|
||||
);
|
||||
|
||||
sanitized = sanitized.replace(
|
||||
/\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi,
|
||||
''
|
||||
);
|
||||
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
|
||||
sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
|
||||
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
|
||||
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
|
||||
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
|
||||
sanitized = sanitized.replace(/<\/td>/gi, ' </span>');
|
||||
sanitized = sanitized.replace(/<th[^>]*>/gi, '<strong> ');
|
||||
sanitized = sanitized.replace(/<\/th>/gi, ' </strong>');
|
||||
sanitized = sanitized.replace(/<div>\s*<\/div>/gi, '');
|
||||
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
|
||||
sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
|
||||
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
|
||||
sanitized = sanitized.replace(
|
||||
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
|
||||
'$1'
|
||||
);
|
||||
|
||||
const MAX_HTML_SIZE = 100000;
|
||||
if (sanitized.length > MAX_HTML_SIZE) {
|
||||
const truncateAt = sanitized.lastIndexOf('</div>', MAX_HTML_SIZE);
|
||||
if (truncateAt > MAX_HTML_SIZE / 2) {
|
||||
sanitized = sanitized.substring(0, truncateAt) + '</div></body></html>';
|
||||
} else {
|
||||
sanitized = sanitized.substring(0, MAX_HTML_SIZE) + '...</body></html>';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw RFC 2822 date string into a nicer human-readable format,
|
||||
* while preserving the original timezone and time.
|
||||
* Example input: "Sun, 8 Jan 2017 20:37:44 +0200"
|
||||
* Example output: "Sunday, January 8, 2017 at 8:37 PM (+0200)"
|
||||
*/
|
||||
export function formatRawDate(raw: string): string {
|
||||
try {
|
||||
const match = raw.match(
|
||||
/([A-Za-z]{3}),\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+([+-]\d{4})/
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const [
|
||||
,
|
||||
dayAbbr,
|
||||
dom,
|
||||
monthAbbr,
|
||||
year,
|
||||
hoursStr,
|
||||
minsStr,
|
||||
secsStr,
|
||||
timezone,
|
||||
] = match;
|
||||
|
||||
const days: Record<string, string> = {
|
||||
Sun: 'Sunday',
|
||||
Mon: 'Monday',
|
||||
Tue: 'Tuesday',
|
||||
Wed: 'Wednesday',
|
||||
Thu: 'Thursday',
|
||||
Fri: 'Friday',
|
||||
Sat: 'Saturday',
|
||||
};
|
||||
const months: Record<string, string> = {
|
||||
Jan: 'January',
|
||||
Feb: 'February',
|
||||
Mar: 'March',
|
||||
Apr: 'April',
|
||||
May: 'May',
|
||||
Jun: 'June',
|
||||
Jul: 'July',
|
||||
Aug: 'August',
|
||||
Sep: 'September',
|
||||
Oct: 'October',
|
||||
Nov: 'November',
|
||||
Dec: 'December',
|
||||
};
|
||||
|
||||
const fullDay = days[dayAbbr] || dayAbbr;
|
||||
const fullMonth = months[monthAbbr] || monthAbbr;
|
||||
|
||||
let hours = parseInt(hoursStr, 10);
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
const tzSign = timezone.substring(0, 1);
|
||||
const tzHours = timezone.substring(1, 3);
|
||||
const tzMins = timezone.substring(3, 5);
|
||||
const formattedTz = `UTC${tzSign}${tzHours}:${tzMins}`;
|
||||
|
||||
return `${fullDay}, ${fullMonth} ${dom}, ${year} at ${hours}:${minsStr} ${ampm} (${formattedTz})`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to raw string if parsing fails
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user