mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 11:52:48 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c79b0ae144 | ||
|
|
649aec046d | ||
|
|
f213d9dc27 | ||
|
|
b39d2c267c | ||
|
|
09436a689d | ||
|
|
c5764e4172 | ||
|
|
2741b29ad1 | ||
|
|
41a35586c7 |
+4
-1
@@ -13,7 +13,10 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
|
||||
<img src="images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<span class="text-white font-bold text-xl ml-2">
|
||||
<a href="index.html">BentoPDF</a>
|
||||
</span>
|
||||
|
||||
+4
-1
@@ -13,7 +13,10 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
|
||||
<img src="images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<span class="text-white font-bold text-xl ml-2">
|
||||
<a href="index.html">BentoPDF</a>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
|
||||
<img src="images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<span class="text-white font-bold text-xl ml-2">
|
||||
<a href="index.html">BentoPDF</a>
|
||||
</span>
|
||||
|
||||
+5
-2
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BentoPDF - The Privacy First PDF Toolkit</title>
|
||||
<link rel="icon" type="image/png" href="./images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="/src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
@@ -277,7 +280,7 @@
|
||||
<kbd id="shortcut"
|
||||
class="bg-gray-800 px-2.5 py-1.5 rounded-md text-xs font-semibold text-gray-300 border border-gray-700 shadow-sm"></kbd>
|
||||
<button id="open-shortcuts-btn"
|
||||
class="p-1 hover:bg-gray-600 rounded-md text-gray-400 hover:text-white transition-colors hidden-on-touch"
|
||||
class="p-1 hover:bg-gray-600 rounded-md text-gray-400 hover:text-white transition-colors"
|
||||
title="Keyboard Shortcuts">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
</button>
|
||||
|
||||
+15
-5
@@ -13,7 +13,10 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
|
||||
<img src="images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<span class="text-white font-bold text-xl ml-2">
|
||||
<a href="index.html">BentoPDF</a>
|
||||
</span>
|
||||
@@ -192,7 +195,8 @@
|
||||
<div
|
||||
class="mt-8 bg-gradient-to-r from-indigo-900/40 to-purple-900/40 rounded-lg p-6 border border-indigo-500/50">
|
||||
<div class="text-center">
|
||||
<div class="inline-block bg-red-500 text-white text-xs font-bold px-3 py-1 rounded-full mb-3 animate-pulse">
|
||||
<div
|
||||
class="inline-block bg-red-500 text-white text-xs font-bold px-3 py-1 rounded-full mb-3 animate-pulse">
|
||||
LIMITED TIME OFFER
|
||||
</div>
|
||||
<p class="text-white text-2xl font-bold mb-2">Lifetime License</p>
|
||||
@@ -202,7 +206,7 @@
|
||||
<span class="text-lg font-normal text-gray-400">one-time</span>
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm mb-4">Includes all feature updates forever</p>
|
||||
|
||||
|
||||
<!-- Unlimited Usage Info -->
|
||||
<div class="bg-indigo-900/30 rounded-lg p-4 mb-4 border border-indigo-500/30">
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
@@ -210,12 +214,18 @@
|
||||
<div>
|
||||
<h4 class="text-white font-semibold text-sm mb-2">Unlimited Usage</h4>
|
||||
<p class="text-gray-300 text-xs leading-relaxed">
|
||||
The BentoPDF commercial license permits use on an <strong class="text-white">unrestricted, unlimited number of devices, servers, and user machines</strong> within your organization. There are <strong class="text-white">no per-user, per-machine, or per-seat limitations</strong>. Once you purchase the license, all your internal users may use BentoPDF through your browser-based system without requiring additional licenses.
|
||||
The BentoPDF commercial license permits use on an <strong
|
||||
class="text-white">unrestricted, unlimited number of devices, servers,
|
||||
and user machines</strong> within your organization. There are <strong
|
||||
class="text-white">no per-user, per-machine, or per-seat
|
||||
limitations</strong>. Once you purchase the license, all your internal
|
||||
users may use BentoPDF through your browser-based system without requiring
|
||||
additional licenses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a href="https://ko-fi.com/s/f32ca4cb75" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-block px-8 py-3 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition-colors shadow-lg hover:shadow-indigo-500/50 transform hover:scale-105 transition-all duration-200">
|
||||
Purchase Lifetime License
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ http {
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
@@ -37,4 +38,4 @@ http {
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Resource-Policy "cross-origin" always;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bento-pdf",
|
||||
"version": "1.7.3",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bento-pdf",
|
||||
"version": "1.7.3",
|
||||
"version": "1.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource/cedarville-cursive": "^5.2.7",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@fontsource/lato": "^5.2.7",
|
||||
"@fontsource/merriweather": "^5.2.11",
|
||||
"@neslinesli93/qpdf-wasm": "^0.3.0",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"archiver": "^7.0.1",
|
||||
"blob-stream": "^0.1.3",
|
||||
@@ -3088,6 +3089,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/fontkit": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
|
||||
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bento-pdf",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.1",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -50,6 +50,7 @@
|
||||
"@fontsource/lato": "^5.2.7",
|
||||
"@fontsource/merriweather": "^5.2.11",
|
||||
"@neslinesli93/qpdf-wasm": "^0.3.0",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"archiver": "^7.0.1",
|
||||
"blob-stream": "^0.1.3",
|
||||
|
||||
+4
-1
@@ -13,7 +13,10 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
|
||||
<img src="images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<span class="text-white font-bold text-xl ml-2">
|
||||
<a href="index.html">BentoPDF</a>
|
||||
</span>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -1,5 +1,7 @@
|
||||
<svg width="86" height="166" viewBox="0 0 86 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(40, 0)">
|
||||
<path d="M56.7848 80.9116L0 25.6211L24.8434 1.43151C26.8037 -0.47717 29.9814 -0.47717 31.9414 1.43151L85.177 53.2662L56.7848 80.9116Z" fill="#A5B4FC"/>
|
||||
<path d="M-1.30805e-05 80.7335L21.2939 101.467L42.5878 80.7335L21.2939 60L-1.30805e-05 80.7335Z" fill="#E0E7FF"/>
|
||||
<path d="M56.7848 83L81.6279 107.19C83.5881 109.098 83.5881 112.192 81.6279 114.101L28.3925 165.936L0 138.29L56.7848 83Z" fill="#6366F1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 510 B After Width: | Height: | Size: 550 B |
+23
@@ -0,0 +1,23 @@
|
||||
declare const coherentpdf: typeof import('../../src/types/coherentpdf.global').coherentpdf;
|
||||
|
||||
interface InterleaveFile {
|
||||
name: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface InterleaveMessage {
|
||||
command: 'interleave';
|
||||
files: InterleaveFile[];
|
||||
}
|
||||
|
||||
interface InterleaveSuccessResponse {
|
||||
status: 'success';
|
||||
pdfBytes: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface InterleaveErrorResponse {
|
||||
status: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
type InterleaveResponse = InterleaveSuccessResponse | InterleaveErrorResponse;
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
declare const coherentpdf: typeof import('../../src/types/coherentpdf.global').coherentpdf;
|
||||
|
||||
interface MergeJob {
|
||||
fileName: string;
|
||||
rangeType: 'all' | 'specific' | 'single' | 'range';
|
||||
rangeString?: string;
|
||||
pageIndex?: number;
|
||||
startPage?: number;
|
||||
endPage?: number;
|
||||
}
|
||||
|
||||
interface MergeFile {
|
||||
name: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface MergeMessage {
|
||||
command: 'merge';
|
||||
files: MergeFile[];
|
||||
jobs: MergeJob[];
|
||||
}
|
||||
|
||||
interface MergeSuccessResponse {
|
||||
status: 'success';
|
||||
pdfBytes: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface MergeErrorResponse {
|
||||
status: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
type MergeResponse = MergeSuccessResponse | MergeErrorResponse;
|
||||
+12
-17
@@ -291,9 +291,6 @@ footer a {
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(79, 70, 229, 0.3),
|
||||
0 4px 6px -4px rgba(79, 70, 229, 0.3);
|
||||
/* hover:shadow-xl hover:shadow-indigo-500/30 */
|
||||
transform: translateY(-0.25rem);
|
||||
/* hover:-translate-y-1 */
|
||||
}
|
||||
|
||||
.btn-gradient:focus {
|
||||
@@ -571,20 +568,6 @@ button:disabled,
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Hide elements on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.hidden-on-touch {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Also hide on very small screens just in case */
|
||||
@media (max-width: 640px) {
|
||||
.hidden-on-touch {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll to Top Button Visibility */
|
||||
#scroll-to-top-btn.visible {
|
||||
opacity: 1;
|
||||
@@ -603,4 +586,16 @@ button:disabled,
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hide spin buttons for number inputs */
|
||||
input[type="number"] {
|
||||
appearance: none;
|
||||
-moz-appearance: textfield; /* Firefox */
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none; /* Chrome, Safari, Edge */
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Font mappings for OCR text layer rendering
|
||||
* Maps Tesseract language codes to appropriate Noto Sans font families and their CDN URLs
|
||||
*/
|
||||
|
||||
export const languageToFontFamily: Record<string, string> = {
|
||||
// CJK Languages
|
||||
jpn: 'Noto Sans JP',
|
||||
chi_sim: 'Noto Sans SC',
|
||||
chi_tra: 'Noto Sans TC',
|
||||
kor: 'Noto Sans KR',
|
||||
|
||||
// Arabic Script
|
||||
ara: 'Noto Sans Arabic',
|
||||
fas: 'Noto Sans Arabic',
|
||||
urd: 'Noto Sans Arabic',
|
||||
pus: 'Noto Sans Arabic',
|
||||
kur: 'Noto Sans Arabic',
|
||||
|
||||
// Devanagari Script
|
||||
hin: 'Noto Sans Devanagari',
|
||||
mar: 'Noto Sans Devanagari',
|
||||
san: 'Noto Sans Devanagari',
|
||||
nep: 'Noto Sans Devanagari',
|
||||
|
||||
// Bengali Script
|
||||
ben: 'Noto Sans Bengali',
|
||||
asm: 'Noto Sans Bengali',
|
||||
|
||||
// Tamil Script
|
||||
tam: 'Noto Sans Tamil',
|
||||
|
||||
// Telugu Script
|
||||
tel: 'Noto Sans Telugu',
|
||||
|
||||
// Kannada Script
|
||||
kan: 'Noto Sans Kannada',
|
||||
|
||||
// Malayalam Script
|
||||
mal: 'Noto Sans Malayalam',
|
||||
|
||||
// Gujarati Script
|
||||
guj: 'Noto Sans Gujarati',
|
||||
|
||||
// Gurmukhi Script (Punjabi)
|
||||
pan: 'Noto Sans Gurmukhi',
|
||||
|
||||
// Oriya Script
|
||||
ori: 'Noto Sans Oriya',
|
||||
|
||||
// Sinhala Script
|
||||
sin: 'Noto Sans Sinhala',
|
||||
|
||||
// Thai Script
|
||||
tha: 'Noto Sans Thai',
|
||||
|
||||
// Lao Script
|
||||
lao: 'Noto Sans Lao',
|
||||
|
||||
// Khmer Script
|
||||
khm: 'Noto Sans Khmer',
|
||||
|
||||
// Myanmar Script
|
||||
mya: 'Noto Sans Myanmar',
|
||||
|
||||
// Tibetan Script
|
||||
bod: 'Noto Serif Tibetan',
|
||||
|
||||
// Georgian Script
|
||||
kat: 'Noto Sans Georgian',
|
||||
kat_old: 'Noto Sans Georgian',
|
||||
|
||||
// Armenian Script
|
||||
hye: 'Noto Sans Armenian',
|
||||
|
||||
// Hebrew Script
|
||||
heb: 'Noto Sans Hebrew',
|
||||
yid: 'Noto Sans Hebrew',
|
||||
|
||||
// Ethiopic Script
|
||||
amh: 'Noto Sans Ethiopic',
|
||||
tir: 'Noto Sans Ethiopic',
|
||||
|
||||
// Cherokee Script
|
||||
chr: 'Noto Sans Cherokee',
|
||||
|
||||
// Syriac Script
|
||||
syr: 'Noto Sans Syriac',
|
||||
|
||||
// Cyrillic Script (Noto Sans includes Cyrillic)
|
||||
bel: 'Noto Sans',
|
||||
bul: 'Noto Sans',
|
||||
mkd: 'Noto Sans',
|
||||
rus: 'Noto Sans',
|
||||
srp: 'Noto Sans',
|
||||
srp_latn: 'Noto Sans',
|
||||
ukr: 'Noto Sans',
|
||||
kaz: 'Noto Sans',
|
||||
kir: 'Noto Sans',
|
||||
tgk: 'Noto Sans',
|
||||
uzb: 'Noto Sans',
|
||||
uzb_cyrl: 'Noto Sans',
|
||||
aze_cyrl: 'Noto Sans',
|
||||
|
||||
// Latin Script (covered by base Noto Sans)
|
||||
afr: 'Noto Sans',
|
||||
aze: 'Noto Sans',
|
||||
bos: 'Noto Sans',
|
||||
cat: 'Noto Sans',
|
||||
ceb: 'Noto Sans',
|
||||
ces: 'Noto Sans',
|
||||
cym: 'Noto Sans',
|
||||
dan: 'Noto Sans',
|
||||
deu: 'Noto Sans',
|
||||
ell: 'Noto Sans',
|
||||
eng: 'Noto Sans',
|
||||
enm: 'Noto Sans',
|
||||
epo: 'Noto Sans',
|
||||
est: 'Noto Sans',
|
||||
eus: 'Noto Sans',
|
||||
fin: 'Noto Sans',
|
||||
fra: 'Noto Sans',
|
||||
frk: 'Noto Sans',
|
||||
frm: 'Noto Sans',
|
||||
gle: 'Noto Sans',
|
||||
glg: 'Noto Sans',
|
||||
grc: 'Noto Sans',
|
||||
hat: 'Noto Sans',
|
||||
hrv: 'Noto Sans',
|
||||
hun: 'Noto Sans',
|
||||
iku: 'Noto Sans',
|
||||
ind: 'Noto Sans',
|
||||
isl: 'Noto Sans',
|
||||
ita: 'Noto Sans',
|
||||
ita_old: 'Noto Sans',
|
||||
jav: 'Noto Sans',
|
||||
lat: 'Noto Sans',
|
||||
lav: 'Noto Sans',
|
||||
lit: 'Noto Sans',
|
||||
mlt: 'Noto Sans',
|
||||
msa: 'Noto Sans',
|
||||
nld: 'Noto Sans',
|
||||
nor: 'Noto Sans',
|
||||
pol: 'Noto Sans',
|
||||
por: 'Noto Sans',
|
||||
ron: 'Noto Sans',
|
||||
slk: 'Noto Sans',
|
||||
slv: 'Noto Sans',
|
||||
spa: 'Noto Sans',
|
||||
spa_old: 'Noto Sans',
|
||||
sqi: 'Noto Sans',
|
||||
swa: 'Noto Sans',
|
||||
swe: 'Noto Sans',
|
||||
tgl: 'Noto Sans',
|
||||
tur: 'Noto Sans',
|
||||
vie: 'Noto Sans',
|
||||
dzo: 'Noto Sans',
|
||||
uig: 'Noto Sans',
|
||||
};
|
||||
|
||||
export const fontFamilyToUrl: Record<string, string> = {
|
||||
'Noto Sans JP': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
|
||||
'Noto Sans SC': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
|
||||
'Noto Sans TC': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
|
||||
'Noto Sans KR': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
|
||||
'Noto Sans Arabic': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf',
|
||||
'Noto Sans Devanagari': 'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
|
||||
'Noto Sans Bengali': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
|
||||
'Noto Sans Gujarati': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
|
||||
'Noto Sans Kannada': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
|
||||
'Noto Sans Malayalam': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
|
||||
'Noto Sans Oriya': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
|
||||
'Noto Sans Gurmukhi': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
|
||||
'Noto Sans Tamil': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
|
||||
'Noto Sans Telugu': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
|
||||
'Noto Sans Sinhala': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
|
||||
'Noto Sans Thai': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
|
||||
'Noto Sans Khmer': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
|
||||
'Noto Sans Lao': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
|
||||
'Noto Sans Myanmar': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
|
||||
'Noto Sans Hebrew': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
|
||||
'Noto Sans Georgian': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
|
||||
'Noto Sans Ethiopic': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
|
||||
'Noto Serif Tibetan': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
|
||||
'Noto Sans Cherokee': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
|
||||
'Noto Sans Armenian': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
|
||||
'Noto Sans Syriac': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
|
||||
'Noto Sans': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
|
||||
};
|
||||
@@ -69,4 +69,5 @@ export const multiFileTools = [
|
||||
'compress',
|
||||
'extract-attachments',
|
||||
'flatten',
|
||||
'repair-pdf',
|
||||
];
|
||||
|
||||
@@ -468,6 +468,12 @@ export const categories = [
|
||||
subtitle:
|
||||
'Remove password protection and security restrictions associated with digitally signed PDF files.',
|
||||
},
|
||||
{
|
||||
href: '/src/pages/repair-pdf.html',
|
||||
name: 'Repair PDF',
|
||||
icon: 'wrench',
|
||||
subtitle: 'Recover data from corrupted or damaged PDF files.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -117,7 +117,7 @@ async function handleSinglePdfUpload(toolId, file) {
|
||||
.toString();
|
||||
}
|
||||
|
||||
if (toolId === 'organize' || toolId === 'rotate') {
|
||||
if (toolId === 'organize' || toolId === 'rotate' || toolId === 'delete-pages') {
|
||||
await renderPageThumbnails(toolId, state.pdfDoc);
|
||||
|
||||
if (toolId === 'rotate') {
|
||||
@@ -134,14 +134,19 @@ async function handleSinglePdfUpload(toolId, file) {
|
||||
const rotateAllRightBtn = document.getElementById(
|
||||
'rotate-all-right-btn'
|
||||
);
|
||||
const rotateAllCustomBtn = document.getElementById('rotate-all-custom-btn');
|
||||
const rotateAllCustomInput = document.getElementById('custom-rotate-all-input') as HTMLInputElement;
|
||||
const rotateAllDecrementBtn = document.getElementById('rotate-all-decrement-btn');
|
||||
const rotateAllIncrementBtn = document.getElementById('rotate-all-increment-btn');
|
||||
|
||||
|
||||
rotateAllControls.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
|
||||
const rotateAll = (direction) => {
|
||||
const rotateAll = (angle: number) => {
|
||||
// Update rotation state for ALL pages (including unrendered ones)
|
||||
for (let i = 0; i < rotationState.length; i++) {
|
||||
rotationState[i] = (rotationState[i] + direction * 90 + 360) % 360;
|
||||
rotationState[i] = (rotationState[i] + angle);
|
||||
}
|
||||
|
||||
// Update DOM for currently rendered pages
|
||||
@@ -149,15 +154,44 @@ async function handleSinglePdfUpload(toolId, file) {
|
||||
const pageIndex = parseInt((item as HTMLElement).dataset.pageIndex || '0');
|
||||
const newRotation = rotationState[pageIndex];
|
||||
(item as HTMLElement).dataset.rotation = newRotation.toString();
|
||||
|
||||
const thumbnail = item.querySelector('canvas, img');
|
||||
if (thumbnail) {
|
||||
(thumbnail as HTMLElement).style.transform =
|
||||
`rotate(${newRotation}deg)`;
|
||||
}
|
||||
|
||||
const input = item.querySelector('input');
|
||||
if (input) {
|
||||
input.value = newRotation.toString();
|
||||
}
|
||||
});
|
||||
};
|
||||
rotateAllLeftBtn.onclick = () => rotateAll(-1);
|
||||
rotateAllRightBtn.onclick = () => rotateAll(1);
|
||||
rotateAllLeftBtn.onclick = () => rotateAll(-90);
|
||||
rotateAllRightBtn.onclick = () => rotateAll(90);
|
||||
|
||||
if (rotateAllCustomBtn && rotateAllCustomInput) {
|
||||
rotateAllCustomBtn.onclick = () => {
|
||||
const angle = parseInt(rotateAllCustomInput.value);
|
||||
if (!isNaN(angle) && angle !== 0) {
|
||||
rotateAll(angle);
|
||||
}
|
||||
};
|
||||
|
||||
if (rotateAllDecrementBtn) {
|
||||
rotateAllDecrementBtn.onclick = () => {
|
||||
let current = parseInt(rotateAllCustomInput.value) || 0;
|
||||
rotateAllCustomInput.value = (current - 1).toString();
|
||||
};
|
||||
}
|
||||
|
||||
if (rotateAllIncrementBtn) {
|
||||
rotateAllIncrementBtn.onclick = () => {
|
||||
let current = parseInt(rotateAllCustomInput.value) || 0;
|
||||
rotateAllCustomInput.value = (current + 1).toString();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,24 +473,24 @@ async function handleSinglePdfUpload(toolId, file) {
|
||||
|
||||
addBtn.onclick = () => {
|
||||
const fieldWrapper = document.createElement('div');
|
||||
fieldWrapper.className = 'flex items-center gap-2 custom-field-wrapper';
|
||||
fieldWrapper.className = 'flex flex-col sm:flex-row items-stretch sm:items-center gap-2 custom-field-wrapper';
|
||||
|
||||
const keyInput = document.createElement('input');
|
||||
keyInput.type = 'text';
|
||||
keyInput.placeholder = 'Key (e.g., Department)';
|
||||
keyInput.className =
|
||||
'custom-meta-key w-1/3 bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
|
||||
'custom-meta-key w-full sm:w-1/3 bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
|
||||
|
||||
const valueInput = document.createElement('input');
|
||||
valueInput.type = 'text';
|
||||
valueInput.placeholder = 'Value (e.g., Marketing)';
|
||||
valueInput.className =
|
||||
'custom-meta-value flex-grow bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
|
||||
'custom-meta-value w-full sm:flex-grow bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className =
|
||||
'btn p-2 text-red-500 hover:bg-gray-700 rounded-full';
|
||||
'btn p-2 text-red-500 hover:bg-gray-700 rounded-full self-center sm:self-auto';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2"></i>';
|
||||
removeBtn.addEventListener('click', () => fieldWrapper.remove());
|
||||
|
||||
|
||||
@@ -3,9 +3,14 @@ import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/he
|
||||
import { state } from '../state.js';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
const alternateMergeState = {
|
||||
pdfDocs: {} as Record<string, any>,
|
||||
pdfBytes: {} as Record<string, ArrayBuffer>,
|
||||
interface AlternateMergeState {
|
||||
pdfDocs: Record<string, any>;
|
||||
pdfBytes: Record<string, ArrayBuffer>;
|
||||
}
|
||||
|
||||
const alternateMergeState: AlternateMergeState = {
|
||||
pdfDocs: {},
|
||||
pdfBytes: {},
|
||||
};
|
||||
|
||||
const alternateMergeWorker = new Worker('/workers/alternate-merge.worker.js');
|
||||
@@ -98,7 +103,7 @@ export async function alternateMerge() {
|
||||
(li) => (li as HTMLElement).dataset.fileName
|
||||
).filter(Boolean) as string[];
|
||||
|
||||
const filesToMerge: { name: string; data: ArrayBuffer }[] = [];
|
||||
const filesToMerge: InterleaveFile[] = [];
|
||||
for (const name of sortedFileNames) {
|
||||
const bytes = alternateMergeState.pdfBytes[name];
|
||||
if (bytes) {
|
||||
@@ -112,12 +117,14 @@ export async function alternateMerge() {
|
||||
return;
|
||||
}
|
||||
|
||||
alternateMergeWorker.postMessage({
|
||||
const message: InterleaveMessage = {
|
||||
command: 'interleave',
|
||||
files: filesToMerge
|
||||
}, filesToMerge.map(f => f.data));
|
||||
};
|
||||
|
||||
alternateMergeWorker.onmessage = (e) => {
|
||||
alternateMergeWorker.postMessage(message, filesToMerge.map(f => f.data));
|
||||
|
||||
alternateMergeWorker.onmessage = (e: MessageEvent<InterleaveResponse>) => {
|
||||
hideLoader();
|
||||
if (e.data.status === 'success') {
|
||||
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
|
||||
|
||||
+188
-361
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck
|
||||
// TODO: @ALAM - remove ts-nocheck and fix types later, possibly convert this into an npm package
|
||||
|
||||
import { PDFDocument, PDFName, PDFString, PDFNumber, PDFArray } from 'pdf-lib';
|
||||
import { PDFDocument, PDFName, PDFString, PDFNumber, PDFArray, PDFHexString } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import Sortable from 'sortablejs';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
@@ -37,19 +37,19 @@ function showInputModal(title, fields = [], defaultValues = {}) {
|
||||
.map((field) => {
|
||||
if (field.type === 'text') {
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<input type="text" id="modal-${field.name}" value="${escapeHTML(defaultValues[field.name] || '')}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="${field.placeholder || ''}" />
|
||||
</div>
|
||||
`;
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<input type="text" id="modal-${field.name}" value="${escapeHTML(defaultValues[field.name] || '')}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="${field.placeholder || ''}" />
|
||||
</div>
|
||||
`;
|
||||
} else if (field.type === 'select') {
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
${field.options
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
${field.options
|
||||
.map(
|
||||
(opt) => `
|
||||
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
||||
@@ -57,91 +57,92 @@ function showInputModal(title, fields = [], defaultValues = {}) {
|
||||
</option>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</select>
|
||||
.join('')
|
||||
}
|
||||
</select>
|
||||
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden w-full h-10 mt-2 rounded cursor-pointer border border-gray-300" value="#000000" />' : ''}
|
||||
</div>
|
||||
`;
|
||||
</div>
|
||||
`;
|
||||
} else if (field.type === 'destination') {
|
||||
const hasDestination =
|
||||
defaultValues.destX !== null && defaultValues.destX !== undefined;
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-1 text-xs">
|
||||
<input type="checkbox" id="modal-use-destination" class="w-4 h-4" ${hasDestination ? 'checked' : ''}>
|
||||
<span>Set custom destination</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="destination-controls" class="${hasDestination ? '' : 'hidden'} space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Page</label>
|
||||
<input type="number" id="modal-dest-page" min="1" max="${field.maxPages || 1}" value="${defaultValues.destPage || field.page || 1}"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" step="1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Zoom (%)</label>
|
||||
<select id="modal-dest-zoom" class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="">Inherit</option>
|
||||
<option value="0">Fit Page</option>
|
||||
<option value="50">50%</option>
|
||||
<option value="75">75%</option>
|
||||
<option value="100">100%</option>
|
||||
<option value="125">125%</option>
|
||||
<option value="150">150%</option>
|
||||
<option value="200">200%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">X Position</label>
|
||||
<input type="number" id="modal-dest-x" value="0" step="10"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Y Position</label>
|
||||
<input type="number" id="modal-dest-y" value="0" step="10"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<button id="modal-pick-destination" class="w-full px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs flex items-center justify-center gap-1">
|
||||
<i data-lucide="crosshair" class="w-3 h-3"></i> Click on PDF to Pick Location
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 italic">Click the button above, then click on the PDF where you want the bookmark to jump to</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-1 text-xs">
|
||||
<input type="checkbox" id="modal-use-destination" class="w-4 h-4" ${hasDestination ? 'checked' : ''}>
|
||||
<span>Set custom destination </span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="destination-controls" class="${hasDestination ? '' : 'hidden'} space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Page</label>
|
||||
<input type="number" id="modal-dest-page" min="1" max="${field.maxPages || 1}" value="${defaultValues.destPage || field.page || 1}"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" step="1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Zoom(%)</label>
|
||||
<select id="modal-dest-zoom" class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
<option value="">Inherit</option>
|
||||
<option value="0">Fit Page</option>
|
||||
<option value="50">50%</option>
|
||||
<option value="75">75%</option>
|
||||
<option value="100">100%</option>
|
||||
<option value="125">125%</option>
|
||||
<option value="150">150%</option>
|
||||
<option value="200">200%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">X Position</label>
|
||||
<input type="number" id="modal-dest-x" value="0" step="10"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Y Position</label>
|
||||
<input type="number" id="modal-dest-y" value="0" step="10"
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<button id="modal-pick-destination" class="w-full px-3 py-2 btn-gradient text-white rounded text-xs !flex items-center justify-center gap-1">
|
||||
<i data-lucide="crosshair" class="w-3 h-3"></i> Click on PDF to Pick Location
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 italic">Click the button above, then click on the PDF where you want the bookmark to jump to</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (field.type === 'preview') {
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<div id="modal-preview" class="style-preview bg-gray-50">
|
||||
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<div id="modal-preview" class="style-preview bg-gray-50">
|
||||
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<div class="mb-6">
|
||||
${fieldsHTML}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button id="modal-cancel" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
|
||||
<button id="modal-confirm" class="px-4 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<div class="mb-6">
|
||||
${fieldsHTML}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300">Cancel</button>
|
||||
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(modal);
|
||||
modalContainer.appendChild(overlay);
|
||||
@@ -476,7 +477,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
canvasWrapper.appendChild(coordTooltip);
|
||||
}
|
||||
|
||||
coordTooltip.textContent = `X: ${Math.round(x)}, Y: ${Math.round(y)}`;
|
||||
coordTooltip.textContent = `X: ${Math.round(x)}, Y: ${Math.round(y)} `;
|
||||
coordTooltip.style.left = e.clientX - rect.left + 15 + 'px';
|
||||
coordTooltip.style.top = e.clientY - rect.top + 15 + 'px';
|
||||
});
|
||||
@@ -525,12 +526,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
destinationMarker = document.createElement('div');
|
||||
destinationMarker.className = 'destination-marker';
|
||||
destinationMarker.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" fill="#3b82f6" fill-opacity="0.2"/>
|
||||
<path d="M12 2 L12 22 M2 12 L22 12"/>
|
||||
<circle cx="12" cy="12" r="2" fill="#3b82f6"/>
|
||||
</svg>
|
||||
`;
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" fill="#3b82f6" fill-opacity="0.2" />
|
||||
<path d="M12 2 L12 22 M2 12 L22 12" />
|
||||
<circle cx="12" cy="12" r="2" fill="#3b82f6" />
|
||||
</svg>
|
||||
`;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const wrapperRect = canvasWrapper.getBoundingClientRect();
|
||||
destinationMarker.style.position = 'absolute';
|
||||
@@ -549,7 +550,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
canvasX + canvasRect.left - wrapperRect.left + 20 + 'px';
|
||||
coordDisplay.style.top =
|
||||
canvasY + canvasRect.top - wrapperRect.top - 30 + 'px';
|
||||
coordDisplay.textContent = `X: ${Math.round(pdfX)}, Y: ${Math.round(pdfY)}`;
|
||||
coordDisplay.textContent = `X: ${Math.round(pdfX)}, Y: ${Math.round(pdfY)} `;
|
||||
canvasWrapper.appendChild(coordDisplay);
|
||||
|
||||
// Call callback with PDF coordinates
|
||||
@@ -577,14 +578,14 @@ function showConfirmModal(message) {
|
||||
modal.className = 'modal-content';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button id="modal-cancel" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
|
||||
<button id="modal-confirm" class="px-4 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300">Cancel</button>
|
||||
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(modal);
|
||||
@@ -618,14 +619,14 @@ function showAlertModal(title, message) {
|
||||
modal.className = 'modal-content';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<div class="flex justify-end">
|
||||
<button id="modal-ok" class="px-4 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white">OK</button>
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<div class="flex justify-end">
|
||||
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(modal);
|
||||
modalContainer.appendChild(overlay);
|
||||
@@ -832,7 +833,7 @@ deleteAllBtn.addEventListener('click', async () => {
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmModal(
|
||||
`Delete all ${bookmarkTree.length} bookmark(s)?`
|
||||
`Delete all ${bookmarkTree.length} bookmark(s) ? `
|
||||
);
|
||||
if (confirmed) {
|
||||
bookmarkTree = [];
|
||||
@@ -949,7 +950,7 @@ batchDeleteBtn.addEventListener('click', async () => {
|
||||
if (selectedBookmarks.size === 0) return;
|
||||
|
||||
const confirmed = await showConfirmModal(
|
||||
`Delete ${selectedBookmarks.size} bookmark(s)?`
|
||||
`Delete ${selectedBookmarks.size} bookmark(s) ? `
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1173,7 +1174,7 @@ async function renderPage(num, zoom = null, destX = null, destY = null) {
|
||||
ctx.stroke();
|
||||
|
||||
// Draw coordinate text background
|
||||
const text = `X: ${Math.round(destX)}, Y: ${Math.round(destY)}`;
|
||||
const text = `X: ${Math.round(destX)}, Y: ${Math.round(destY)} `;
|
||||
ctx.font = 'bold 12px monospace';
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const textWidth = textMetrics.width;
|
||||
@@ -1532,7 +1533,7 @@ function createNodeElement(node, level = 0) {
|
||||
if (result && result.title) {
|
||||
node.children.push({
|
||||
id: Date.now() + Math.random(),
|
||||
title: result.title,
|
||||
title: cleanTitle(result.title),
|
||||
page: currentPage,
|
||||
children: [],
|
||||
color: null,
|
||||
@@ -1608,7 +1609,7 @@ function createNodeElement(node, level = 0) {
|
||||
);
|
||||
|
||||
if (result) {
|
||||
node.title = result.title;
|
||||
node.title = cleanTitle(result.title);
|
||||
node.color = result.color || null;
|
||||
node.style = result.style || null;
|
||||
|
||||
@@ -1756,7 +1757,7 @@ function parseCSV(text) {
|
||||
const [, title, page, level] = match;
|
||||
const bookmark = {
|
||||
id: Date.now() + Math.random(),
|
||||
title: title.replace(/""/g, '"'),
|
||||
title: cleanTitle(title.replace(/""/g, '"')),
|
||||
page: parseInt(page),
|
||||
children: [],
|
||||
color: null,
|
||||
@@ -1787,6 +1788,15 @@ jsonImportHidden.addEventListener('change', async (e) => {
|
||||
const text = await file.text();
|
||||
try {
|
||||
const imported = JSON.parse(text);
|
||||
// Recursively clean titles in imported JSON
|
||||
function cleanImportedTree(nodes) {
|
||||
if (!nodes) return;
|
||||
for (const node of nodes) {
|
||||
if (node.title) node.title = cleanTitle(node.title);
|
||||
if (node.children) cleanImportedTree(node.children);
|
||||
}
|
||||
}
|
||||
cleanImportedTree(imported);
|
||||
bookmarkTree = imported;
|
||||
saveState();
|
||||
renderBookmarkTree();
|
||||
@@ -1834,254 +1844,84 @@ extractExistingBtn.addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// function cleanTitle(title) {
|
||||
// // @TODO@ALAM: visit this for encoding issues later
|
||||
// if (typeof title === 'string') {
|
||||
// if (title.includes('€') && !title.includes(' ')) {
|
||||
// return title.replace(/€/g, ' ');
|
||||
// }
|
||||
// return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim();
|
||||
// }
|
||||
// return title;
|
||||
// }
|
||||
|
||||
function cleanTitle(title) {
|
||||
// @TODO@ALAM: check for other encoding issues
|
||||
if (typeof title === 'string') {
|
||||
return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
async function extractExistingBookmarks(doc) {
|
||||
try {
|
||||
const outlines = doc.catalog.lookup(PDFName.of('Outlines'));
|
||||
if (!outlines) return [];
|
||||
|
||||
const pages = doc.getPages();
|
||||
|
||||
// Helper to resolve references
|
||||
function resolveRef(obj) {
|
||||
if (!obj) return null;
|
||||
if (obj.lookup) return obj;
|
||||
if (obj.objectNumber !== undefined && doc.context) {
|
||||
return doc.context.lookup(obj);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Build named destinations map (support full name-tree with Kids and Catalog-level Dests)
|
||||
const namedDests = new Map();
|
||||
try {
|
||||
function addNamePair(nameObj, destObj) {
|
||||
try {
|
||||
const key = nameObj.decodeText
|
||||
? nameObj.decodeText()
|
||||
: String(nameObj);
|
||||
namedDests.set(key, resolveRef(destObj));
|
||||
} catch (_) {
|
||||
// ignore malformed entry
|
||||
}
|
||||
}
|
||||
|
||||
function traverseNamesNode(node) {
|
||||
if (!node) return;
|
||||
node = resolveRef(node);
|
||||
if (!node) return;
|
||||
|
||||
const namesArray = node.lookup
|
||||
? node.lookup(PDFName.of('Names'))
|
||||
: null;
|
||||
if (namesArray && namesArray.array) {
|
||||
for (let i = 0; i < namesArray.array.length; i += 2) {
|
||||
const n = namesArray.array[i];
|
||||
const d = namesArray.array[i + 1];
|
||||
addNamePair(n, d);
|
||||
}
|
||||
}
|
||||
|
||||
const kidsArray = node.lookup ? node.lookup(PDFName.of('Kids')) : null;
|
||||
if (kidsArray && kidsArray.array) {
|
||||
for (const kid of kidsArray.array) traverseNamesNode(kid);
|
||||
}
|
||||
}
|
||||
|
||||
// Names tree under Catalog/Names/Dests
|
||||
const names = doc.catalog.lookup(PDFName.of('Names'));
|
||||
if (names) {
|
||||
const destsTree = names.lookup(PDFName.of('Dests'));
|
||||
if (destsTree) traverseNamesNode(destsTree);
|
||||
}
|
||||
|
||||
// Some PDFs store Dests directly under the Catalog's Dests as a dictionary
|
||||
const catalogDests = doc.catalog.lookup(PDFName.of('Dests'));
|
||||
if (catalogDests && catalogDests.dict) {
|
||||
const entries = catalogDests.dict.entries();
|
||||
for (const [key, value] of entries) {
|
||||
// keys are PDFName; convert to string
|
||||
const keyStr = key.decodeText ? key.decodeText() : key.toString();
|
||||
namedDests.set(keyStr, resolveRef(value));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error building named destinations:', e);
|
||||
}
|
||||
|
||||
function findPageIndex(pageRef, resolvedPageRef = null) {
|
||||
if (!pageRef) return 0;
|
||||
|
||||
try {
|
||||
// Method 1: Try the resolved page ref first (if provided)
|
||||
if (resolvedPageRef) {
|
||||
// Check if resolved is a page dictionary - compare to each page's dictionary
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const pageDict = doc.context.lookup(pages[i].ref);
|
||||
if (pageDict === resolvedPageRef) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Direct PDFRef comparison
|
||||
const directMatch = pages.findIndex((p) => p.ref === pageRef);
|
||||
if (directMatch !== -1) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
// Method 3: Try resolving if not already resolved
|
||||
if (!resolvedPageRef) {
|
||||
const resolved = resolveRef(pageRef);
|
||||
if (resolved) {
|
||||
// Check if the resolved object matches any page's dictionary
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const pageDict = doc.context.lookup(pages[i].ref);
|
||||
if (pageDict === resolved) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Compare by string representation
|
||||
if (pageRef.toString) {
|
||||
const target = pageRef.toString();
|
||||
const stringMatch = pages.findIndex(
|
||||
(p) => p.ref && p.ref.toString() === target
|
||||
);
|
||||
if (stringMatch !== -1) {
|
||||
return stringMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 5: Try numeric value (for edge cases)
|
||||
if (pageRef.numberValue !== undefined) {
|
||||
const numericIndex = pageRef.numberValue | 0;
|
||||
if (numericIndex >= 0 && numericIndex < pages.length) {
|
||||
return numericIndex;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error finding page index:', e);
|
||||
}
|
||||
|
||||
// If we couldn't find a match, return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getDestination(item) {
|
||||
if (!item) return null;
|
||||
|
||||
// Try Dest entry first
|
||||
let dest = item.lookup(PDFName.of('Dest'));
|
||||
|
||||
// If no Dest, try Action/D
|
||||
if (!dest) {
|
||||
const action = resolveRef(item.lookup(PDFName.of('A')));
|
||||
if (action) {
|
||||
dest = action.lookup(PDFName.of('D'));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle named destinations
|
||||
if (dest && !dest.array) {
|
||||
try {
|
||||
const name = dest.decodeText ? dest.decodeText() : dest.toString();
|
||||
if (namedDests.has(name)) {
|
||||
const namedDest = namedDests.get(name);
|
||||
|
||||
// Named destinations can be:
|
||||
// 1. A direct array [pageRef, /XYZ, ...]
|
||||
// 2. A dictionary with a 'D' entry containing the array
|
||||
if (namedDest.array) {
|
||||
dest = namedDest;
|
||||
} else if (namedDest.lookup) {
|
||||
// It's a dictionary - extract the 'D' entry
|
||||
const destFromDict = namedDest.lookup(PDFName.of('D'));
|
||||
if (destFromDict) {
|
||||
dest = destFromDict;
|
||||
} else {
|
||||
dest = namedDest;
|
||||
}
|
||||
} else {
|
||||
dest = namedDest;
|
||||
}
|
||||
} else if (dest.lookup) {
|
||||
// Some named destinations resolve to a dictionary with 'D' entry
|
||||
const maybeDict = resolveRef(dest);
|
||||
const dictD =
|
||||
maybeDict && maybeDict.lookup
|
||||
? maybeDict.lookup(PDFName.of('D'))
|
||||
: null;
|
||||
if (dictD) dest = resolveRef(dictD);
|
||||
}
|
||||
} catch (_) {
|
||||
// leave dest as-is if it can't be decoded
|
||||
}
|
||||
}
|
||||
|
||||
// dest is typically an array like [pageRef, /XYZ, x, y, zoom]
|
||||
// Resolving it would corrupt the array structure
|
||||
return dest;
|
||||
}
|
||||
|
||||
function traverse(item) {
|
||||
if (!item) return null;
|
||||
item = resolveRef(item);
|
||||
if (!item) return null;
|
||||
|
||||
const title = item.lookup(PDFName.of('Title'));
|
||||
const dest = getDestination(item);
|
||||
const colorObj = item.lookup(PDFName.of('C'));
|
||||
const flagsObj = item.lookup(PDFName.of('F'));
|
||||
const outline = await pdfJsDoc.getOutline();
|
||||
console.log(outline);
|
||||
if (!outline) return [];
|
||||
|
||||
async function processOutlineItem(item) {
|
||||
let pageIndex = 0;
|
||||
let destX = null;
|
||||
let destY = null;
|
||||
let zoom = null;
|
||||
|
||||
if (dest && dest.array) {
|
||||
const pageRef = dest.array[0];
|
||||
const resolvedPageRef = resolveRef(pageRef);
|
||||
pageIndex = findPageIndex(pageRef, resolvedPageRef);
|
||||
try {
|
||||
let dest = item.dest;
|
||||
if (typeof dest === 'string') {
|
||||
dest = await pdfJsDoc.getDestination(dest);
|
||||
}
|
||||
|
||||
if (dest.array.length > 2) {
|
||||
const xObj = resolveRef(dest.array[2]);
|
||||
const yObj = resolveRef(dest.array[3]);
|
||||
const zoomObj = resolveRef(dest.array[4]);
|
||||
if (Array.isArray(dest)) {
|
||||
const destRef = dest[0];
|
||||
pageIndex = await pdfJsDoc.getPageIndex(destRef);
|
||||
|
||||
if (xObj && xObj.numberValue !== undefined) destX = xObj.numberValue;
|
||||
if (yObj && yObj.numberValue !== undefined) destY = yObj.numberValue;
|
||||
if (zoomObj && zoomObj.numberValue !== undefined) {
|
||||
zoom = String(Math.round(zoomObj.numberValue * 100));
|
||||
if (dest.length > 2) {
|
||||
const x = dest[2];
|
||||
const y = dest[3];
|
||||
const z = dest[4];
|
||||
|
||||
if (typeof x === 'number') destX = x;
|
||||
if (typeof y === 'number') destY = y;
|
||||
if (typeof z === 'number') zoom = String(Math.round(z * 100));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error resolving destination:', e);
|
||||
}
|
||||
|
||||
// Rest of the color and style processing remains the same
|
||||
let color = null;
|
||||
if (colorObj && colorObj.array) {
|
||||
const [r, g, b] = colorObj.array;
|
||||
if (r > 0.8 && g < 0.3 && b < 0.3) color = 'red';
|
||||
else if (r < 0.3 && g < 0.3 && b > 0.8) color = 'blue';
|
||||
else if (r < 0.3 && g > 0.8 && b < 0.3) color = 'green';
|
||||
else if (r > 0.8 && g > 0.8 && b < 0.3) color = 'yellow';
|
||||
else if (r > 0.5 && g < 0.5 && b > 0.5) color = 'purple';
|
||||
if (item.color) {
|
||||
const [r, g, b] = item.color;
|
||||
const rN = r / 255;
|
||||
const gN = g / 255;
|
||||
const bN = b / 255;
|
||||
|
||||
if (rN > 0.8 && gN < 0.3 && bN < 0.3) color = 'red';
|
||||
else if (rN < 0.3 && gN < 0.3 && bN > 0.8) color = 'blue';
|
||||
else if (rN < 0.3 && gN > 0.8 && bN < 0.3) color = 'green';
|
||||
else if (rN > 0.8 && gN > 0.8 && bN < 0.3) color = 'yellow';
|
||||
else if (rN > 0.5 && gN < 0.5 && bN > 0.5) color = 'purple';
|
||||
}
|
||||
|
||||
// Map style
|
||||
let style = null;
|
||||
if (flagsObj) {
|
||||
const flags = flagsObj.numberValue || 0;
|
||||
const isBold = (flags & 2) !== 0;
|
||||
const isItalic = (flags & 1) !== 0;
|
||||
if (isBold && isItalic) style = 'bold-italic';
|
||||
else if (isBold) style = 'bold';
|
||||
else if (isItalic) style = 'italic';
|
||||
}
|
||||
if (item.bold && item.italic) style = 'bold-italic';
|
||||
else if (item.bold) style = 'bold';
|
||||
else if (item.italic) style = 'italic';
|
||||
|
||||
const bookmark = {
|
||||
id: Date.now() + Math.random(),
|
||||
title: title ? title.decodeText() : 'Untitled',
|
||||
title: cleanTitle(item.title),
|
||||
page: pageIndex + 1,
|
||||
children: [],
|
||||
color,
|
||||
@@ -2091,21 +1931,10 @@ async function extractExistingBookmarks(doc) {
|
||||
zoom,
|
||||
};
|
||||
|
||||
// Process children (make sure to resolve refs)
|
||||
let child = resolveRef(item.lookup(PDFName.of('First')));
|
||||
while (child) {
|
||||
const childBookmark = traverse(child);
|
||||
if (childBookmark) bookmark.children.push(childBookmark);
|
||||
child = resolveRef(child.lookup(PDFName.of('Next')));
|
||||
}
|
||||
|
||||
if (pageIndex === 0 && bookmark.children.length > 0) {
|
||||
const firstChild = bookmark.children[0];
|
||||
if (firstChild) {
|
||||
bookmark.page = firstChild.page;
|
||||
bookmark.destX = firstChild.destX;
|
||||
bookmark.destY = firstChild.destY;
|
||||
bookmark.zoom = firstChild.zoom;
|
||||
if (item.items && item.items.length > 0) {
|
||||
for (const childItem of item.items) {
|
||||
const childBookmark = await processOutlineItem(childItem);
|
||||
bookmark.children.push(childBookmark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2113,11 +1942,9 @@ async function extractExistingBookmarks(doc) {
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let first = resolveRef(outlines.lookup(PDFName.of('First')));
|
||||
while (first) {
|
||||
const bookmark = traverse(first);
|
||||
if (bookmark) result.push(bookmark);
|
||||
first = resolveRef(first.lookup(PDFName.of('Next')));
|
||||
for (const item of outline) {
|
||||
const bookmark = await processOutlineItem(item);
|
||||
result.push(bookmark);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -2153,7 +1980,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||
const itemDict = pdfLibDoc.context.obj({});
|
||||
const itemRef = pdfLibDoc.context.register(itemDict);
|
||||
|
||||
itemDict.set(PDFName.of('Title'), PDFString.of(node.title));
|
||||
itemDict.set(PDFName.of('Title'), PDFHexString.fromText(node.title));
|
||||
itemDict.set(PDFName.of('Parent'), parentRef);
|
||||
|
||||
// Always map bookmark page to zero-based index consistently
|
||||
|
||||
@@ -180,7 +180,8 @@ async function performFlatteningCrop(cropData: any) {
|
||||
|
||||
// Load the original PDF with pdf-lib to copy un-cropped pages from
|
||||
const sourcePdfDocForCopying = await PDFLibDocument.load(
|
||||
cropperState.originalPdfBytes
|
||||
cropperState.originalPdfBytes,
|
||||
{ignoreEncryption: true, throwOnInvalidObject: false}
|
||||
);
|
||||
const totalPages = cropperState.pdfDoc.numPages;
|
||||
|
||||
@@ -321,7 +322,8 @@ export async function setupCropperTool() {
|
||||
finalPdfBytes = await newPdfDoc.save();
|
||||
} else {
|
||||
const pdfToModify = await PDFLibDocument.load(
|
||||
cropperState.originalPdfBytes
|
||||
cropperState.originalPdfBytes,
|
||||
{ignoreEncryption: true, throwOnInvalidObject: false}
|
||||
);
|
||||
await performMetadataCrop(pdfToModify, finalCropData);
|
||||
finalPdfBytes = await pdfToModify.save();
|
||||
|
||||
@@ -68,3 +68,44 @@ export async function deletePages() {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
export function setupDeletePagesTool() {
|
||||
const input = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
if (!input) return;
|
||||
|
||||
const updateHighlights = () => {
|
||||
const val = input.value;
|
||||
const pagesToDelete = new Set<number>();
|
||||
|
||||
const parts = val.split(',');
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.includes('-')) {
|
||||
const [start, end] = trimmed.split('-').map(Number);
|
||||
if (!isNaN(start) && !isNaN(end) && start <= end) {
|
||||
for (let i = start; i <= end; i++) pagesToDelete.add(i);
|
||||
}
|
||||
} else {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num)) pagesToDelete.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnails = document.querySelectorAll('#delete-pages-preview .page-thumbnail');
|
||||
thumbnails.forEach((thumb) => {
|
||||
const pageNum = parseInt((thumb as HTMLElement).dataset.pageNumber || '0');
|
||||
const innerContainer = thumb.querySelector('div.relative');
|
||||
|
||||
if (pagesToDelete.has(pageNum)) {
|
||||
innerContainer?.classList.add('border-red-500');
|
||||
innerContainer?.classList.remove('border-gray-600');
|
||||
} else {
|
||||
innerContainer?.classList.remove('border-red-500');
|
||||
innerContainer?.classList.add('border-gray-600');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
input.addEventListener('input', updateHighlights);
|
||||
updateHighlights();
|
||||
}
|
||||
|
||||
+179
-56
@@ -8,50 +8,14 @@ import 'pdfjs-dist/web/pdf_viewer.css'
|
||||
// Initialize PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
|
||||
|
||||
interface FormField {
|
||||
id: string
|
||||
type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
name: string
|
||||
defaultValue: string
|
||||
fontSize: number
|
||||
alignment: 'left' | 'center' | 'right'
|
||||
textColor: string
|
||||
required: boolean
|
||||
readOnly: boolean
|
||||
tooltip: string
|
||||
combCells: number
|
||||
maxLength: number
|
||||
options?: string[]
|
||||
checked?: boolean
|
||||
exportValue?: string
|
||||
groupName?: string
|
||||
label?: string
|
||||
pageIndex: number
|
||||
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
|
||||
actionUrl?: string
|
||||
jsScript?: string
|
||||
targetFieldName?: string
|
||||
visibilityAction?: 'show' | 'hide' | 'toggle'
|
||||
dateFormat?: string
|
||||
multiline?: boolean
|
||||
}
|
||||
import { FormField, PageData } from '../types/index.js'
|
||||
|
||||
interface PageData {
|
||||
index: number
|
||||
width: number
|
||||
height: number
|
||||
pdfPageData?: string
|
||||
}
|
||||
|
||||
let fields: FormField[] = []
|
||||
let selectedField: FormField | null = null
|
||||
let fieldCounter = 0
|
||||
let existingFieldNames: Set<string> = new Set()
|
||||
let existingRadioGroups: Set<string> = new Set()
|
||||
let existingFieldNames: Set<string> = new Set()
|
||||
let existingRadioGroups: Set<string> = new Set()
|
||||
let draggedElement: HTMLElement | null = null
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
@@ -59,7 +23,7 @@ let offsetY = 0
|
||||
let pages: PageData[] = []
|
||||
let currentPageIndex = 0
|
||||
let uploadedPdfDoc: PDFDocument | null = null
|
||||
let uploadedPdfjsDoc: any = null
|
||||
let uploadedPdfjsDoc: any = null
|
||||
let pageSize: { width: number; height: number } = { width: 612, height: 792 }
|
||||
let currentScale = 1.333
|
||||
let pdfViewerOffset = { x: 0, y: 0 }
|
||||
@@ -99,6 +63,132 @@ const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement
|
||||
const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement
|
||||
const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement
|
||||
const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement | null
|
||||
const gotoPageInput = document.getElementById('gotoPageInput') as HTMLInputElement
|
||||
const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement
|
||||
|
||||
const gridVInput = document.getElementById('gridVInput') as HTMLInputElement
|
||||
const gridHInput = document.getElementById('gridHInput') as HTMLInputElement
|
||||
const toggleGridBtn = document.getElementById('toggleGridBtn') as HTMLButtonElement
|
||||
const enableGridCheckbox = document.getElementById('enableGridCheckbox') as HTMLInputElement
|
||||
let gridV = 2
|
||||
let gridH = 2
|
||||
let gridAlwaysVisible = false
|
||||
let gridEnabled = true
|
||||
|
||||
if (gridVInput && gridHInput) {
|
||||
gridVInput.value = '2'
|
||||
gridHInput.value = '2'
|
||||
|
||||
const updateGrid = () => {
|
||||
let v = parseInt(gridVInput.value) || 2
|
||||
let h = parseInt(gridHInput.value) || 2
|
||||
|
||||
if (v < 2) { v = 2; gridVInput.value = '2' }
|
||||
if (h < 2) { h = 2; gridHInput.value = '2' }
|
||||
if (v > 14) { v = 14; gridVInput.value = '14' }
|
||||
if (h > 14) { h = 14; gridHInput.value = '14' }
|
||||
|
||||
gridV = v
|
||||
gridH = h
|
||||
|
||||
if (gridAlwaysVisible && gridEnabled) {
|
||||
renderGrid()
|
||||
}
|
||||
}
|
||||
|
||||
gridVInput.addEventListener('input', updateGrid)
|
||||
gridHInput.addEventListener('input', updateGrid)
|
||||
}
|
||||
|
||||
if (enableGridCheckbox) {
|
||||
enableGridCheckbox.addEventListener('change', (e) => {
|
||||
gridEnabled = (e.target as HTMLInputElement).checked
|
||||
|
||||
if (!gridEnabled) {
|
||||
removeGrid()
|
||||
if (gridVInput) gridVInput.disabled = true
|
||||
if (gridHInput) gridHInput.disabled = true
|
||||
if (toggleGridBtn) toggleGridBtn.disabled = true
|
||||
} else {
|
||||
if (gridVInput) gridVInput.disabled = false
|
||||
if (gridHInput) gridHInput.disabled = false
|
||||
if (toggleGridBtn) toggleGridBtn.disabled = false
|
||||
if (gridAlwaysVisible) renderGrid()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (toggleGridBtn) {
|
||||
toggleGridBtn.addEventListener('click', () => {
|
||||
gridAlwaysVisible = !gridAlwaysVisible
|
||||
|
||||
if (gridAlwaysVisible) {
|
||||
toggleGridBtn.classList.add('bg-indigo-600')
|
||||
toggleGridBtn.classList.remove('bg-gray-600')
|
||||
if (gridEnabled) renderGrid()
|
||||
} else {
|
||||
toggleGridBtn.classList.remove('bg-indigo-600')
|
||||
toggleGridBtn.classList.add('bg-gray-600')
|
||||
removeGrid()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const existingGrid = document.getElementById('pdfGrid')
|
||||
if (existingGrid) existingGrid.remove()
|
||||
|
||||
const gridContainer = document.createElement('div')
|
||||
gridContainer.id = 'pdfGrid'
|
||||
gridContainer.className = 'absolute inset-0 pointer-events-none'
|
||||
gridContainer.style.zIndex = '1'
|
||||
|
||||
if (gridV > 0) {
|
||||
const stepX = canvas.offsetWidth / gridV
|
||||
for (let i = 0; i <= gridV; i++) {
|
||||
const line = document.createElement('div')
|
||||
line.className = 'absolute top-0 bottom-0 border-l-2 border-indigo-500 opacity-60'
|
||||
line.style.left = (i * stepX) + 'px'
|
||||
gridContainer.appendChild(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (gridH > 0) {
|
||||
const stepY = canvas.offsetHeight / gridH
|
||||
for (let i = 0; i <= gridH; i++) {
|
||||
const line = document.createElement('div')
|
||||
line.className = 'absolute left-0 right-0 border-t-2 border-indigo-500 opacity-60'
|
||||
line.style.top = (i * stepY) + 'px'
|
||||
gridContainer.appendChild(line)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.insertBefore(gridContainer, canvas.firstChild)
|
||||
}
|
||||
|
||||
function removeGrid() {
|
||||
const existingGrid = document.getElementById('pdfGrid')
|
||||
if (existingGrid) existingGrid.remove()
|
||||
}
|
||||
|
||||
if (gotoPageBtn && gotoPageInput) {
|
||||
gotoPageBtn.addEventListener('click', () => {
|
||||
const pageNum = parseInt(gotoPageInput.value)
|
||||
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) {
|
||||
currentPageIndex = pageNum - 1
|
||||
renderCanvas()
|
||||
updatePageNavigation()
|
||||
} else {
|
||||
alert(`Please enter a valid page number between 1 and ${pages.length}`)
|
||||
}
|
||||
})
|
||||
|
||||
gotoPageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
gotoPageBtn.click()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Tool item interactions
|
||||
const toolItems = document.querySelectorAll('.tool-item')
|
||||
@@ -109,10 +199,13 @@ toolItems.forEach(item => {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
const type = (item as HTMLElement).dataset.type || 'text'
|
||||
e.dataTransfer.setData('text/plain', type)
|
||||
if (gridEnabled) renderGrid()
|
||||
}
|
||||
})
|
||||
|
||||
// Click to select tool for placement
|
||||
item.addEventListener('dragend', () => {
|
||||
if (!gridAlwaysVisible && gridEnabled) removeGrid()
|
||||
})
|
||||
item.addEventListener('click', () => {
|
||||
const type = (item as HTMLElement).dataset.type || 'text'
|
||||
|
||||
@@ -191,8 +284,9 @@ canvas.addEventListener('dragover', (e) => {
|
||||
|
||||
canvas.addEventListener('drop', (e) => {
|
||||
e.preventDefault()
|
||||
if (!gridAlwaysVisible) removeGrid()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left - 75 // Center the field on drop point
|
||||
const x = e.clientX - rect.left - 75
|
||||
const y = e.clientY - rect.top - 15
|
||||
const type = e.dataTransfer?.getData('text/plain') || 'text'
|
||||
createField(type as any, x, y)
|
||||
@@ -246,7 +340,9 @@ function createField(type: FormField['type'], x: number, y: number): void {
|
||||
visibilityAction: type === 'button' ? 'toggle' : undefined,
|
||||
dateFormat: type === 'date' ? 'mm/dd/yyyy' : undefined,
|
||||
pageIndex: currentPageIndex,
|
||||
multiline: type === 'text' ? false : undefined
|
||||
multiline: type === 'text' ? false : undefined,
|
||||
borderColor: '#000000',
|
||||
hideBorder: false
|
||||
}
|
||||
|
||||
fields.push(field)
|
||||
@@ -263,6 +359,7 @@ function renderField(field: FormField): void {
|
||||
fieldWrapper.style.top = field.y + 'px'
|
||||
fieldWrapper.style.width = field.width + 'px'
|
||||
fieldWrapper.style.overflow = 'visible'
|
||||
fieldWrapper.style.zIndex = '10' // Ensure fields are above grid and PDF
|
||||
|
||||
// Create label - hidden by default, shown on group hover or selection
|
||||
const label = document.createElement('div')
|
||||
@@ -398,6 +495,7 @@ function renderField(field: FormField): void {
|
||||
offsetX = e.clientX - rect.left - field.x
|
||||
offsetY = e.clientY - rect.top - field.y
|
||||
selectField(field)
|
||||
if (gridEnabled) renderGrid()
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
@@ -559,9 +657,9 @@ document.addEventListener('mouseup', () => {
|
||||
draggedElement = null
|
||||
resizing = false
|
||||
resizeField = null
|
||||
if (!gridAlwaysVisible) removeGrid()
|
||||
})
|
||||
|
||||
// Touch move for dragging and resizing
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
const touch = e.touches[0]
|
||||
if (resizing && resizeField) {
|
||||
@@ -866,6 +964,14 @@ function showProperties(field: FormField): void {
|
||||
<input type="checkbox" id="propReadOnly" ${field.readOnly ? 'checked' : ''} class="mr-2">
|
||||
<label for="propReadOnly" class="text-xs font-semibold text-gray-300">Read Only</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
|
||||
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
|
||||
<label for="propHideBorder" class="text-xs font-semibold text-gray-300">Hide Border</label>
|
||||
</div>
|
||||
<button id="deleteBtn" class="w-full bg-red-600 text-white py-2 rounded hover:bg-red-700 transition text-sm font-semibold">
|
||||
Delete Field
|
||||
</button>
|
||||
@@ -963,6 +1069,17 @@ function showProperties(field: FormField): void {
|
||||
field.readOnly = (e.target as HTMLInputElement).checked
|
||||
})
|
||||
|
||||
const propBorderColor = document.getElementById('propBorderColor') as HTMLInputElement
|
||||
const propHideBorder = document.getElementById('propHideBorder') as HTMLInputElement
|
||||
|
||||
propBorderColor.addEventListener('input', (e) => {
|
||||
field.borderColor = (e.target as HTMLInputElement).value
|
||||
})
|
||||
|
||||
propHideBorder.addEventListener('change', (e) => {
|
||||
field.hideBorder = (e.target as HTMLInputElement).checked
|
||||
})
|
||||
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
deleteField(field)
|
||||
})
|
||||
@@ -1424,14 +1541,15 @@ downloadBtn.addEventListener('click', async () => {
|
||||
if (field.type === 'text') {
|
||||
const textField = form.createTextField(field.name)
|
||||
const rgbColor = hexToRgb(field.textColor)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
|
||||
textField.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b),
|
||||
})
|
||||
@@ -1474,13 +1592,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'checkbox') {
|
||||
const checkBox = form.createCheckBox(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
checkBox.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
})
|
||||
if (field.checked) checkBox.check()
|
||||
@@ -1512,13 +1631,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
})
|
||||
if (field.checked) radioGroup.select(field.exportValue || 'Yes')
|
||||
@@ -1532,13 +1652,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'dropdown') {
|
||||
const dropdown = form.createDropdown(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
dropdown.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams
|
||||
})
|
||||
if (field.options) dropdown.setOptions(field.options)
|
||||
@@ -1561,13 +1682,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'optionlist') {
|
||||
const optionList = form.createOptionList(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
optionList.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
})
|
||||
if (field.options) optionList.setOptions(field.options)
|
||||
@@ -1590,13 +1712,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'button') {
|
||||
const button = form.createButton(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
button.addToPage(field.label || 'Button', pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { pdfToPng } from './pdf-to-png.js';
|
||||
import { pngToPdf } from './png-to-pdf.js';
|
||||
import { pdfToWebp } from './pdf-to-webp.js';
|
||||
import { webpToPdf } from './webp-to-pdf.js';
|
||||
import { deletePages } from './delete-pages.js';
|
||||
import { deletePages, setupDeletePagesTool } from './delete-pages.js';
|
||||
import { addBlankPage } from './add-blank-page.js';
|
||||
import { extractPages } from './extract-pages.js';
|
||||
import { addWatermark, setupWatermarkUI } from './add-watermark.js';
|
||||
@@ -67,6 +67,7 @@ import { extractAttachments } from './extract-attachments.js';
|
||||
import { editAttachments, setupEditAttachmentsTool } from './edit-attachments.js';
|
||||
import { sanitizePdf } from './sanitize-pdf.js';
|
||||
import { removeRestrictions } from './remove-restrictions.js';
|
||||
import { repairPdf } from './repair-pdf.js';
|
||||
|
||||
export const toolLogic = {
|
||||
merge: { process: merge, setup: setupMergeTool },
|
||||
@@ -74,6 +75,7 @@ export const toolLogic = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
'remove-restrictions': removeRestrictions,
|
||||
'repair-pdf': repairPdf,
|
||||
organize,
|
||||
rotate,
|
||||
'add-page-numbers': addPageNumbers,
|
||||
@@ -90,7 +92,7 @@ export const toolLogic = {
|
||||
'png-to-pdf': pngToPdf,
|
||||
'pdf-to-webp': pdfToWebp,
|
||||
'webp-to-pdf': webpToPdf,
|
||||
'delete-pages': deletePages,
|
||||
'delete-pages': { process: deletePages, setup: setupDeletePagesTool },
|
||||
'add-blank-page': addBlankPage,
|
||||
'extract-pages': extractPages,
|
||||
'add-watermark': { process: addWatermark, setup: setupWatermarkUI },
|
||||
|
||||
+31
-54
@@ -1,7 +1,7 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.ts';
|
||||
import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.ts';
|
||||
import { state } from '../state.ts';
|
||||
import { renderPagesProgressively, cleanupLazyRendering, createPlaceholder } from '../utils/render-utils.ts';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.ts';
|
||||
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
@@ -9,9 +9,22 @@ import Sortable from 'sortablejs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
const mergeState = {
|
||||
pdfDocs: {} as Record<string, any>,
|
||||
pdfBytes: {} as Record<string, ArrayBuffer>,
|
||||
interface MergeState {
|
||||
pdfDocs: Record<string, any>;
|
||||
pdfBytes: Record<string, ArrayBuffer>;
|
||||
activeMode: 'file' | 'page';
|
||||
sortableInstances: {
|
||||
fileList?: Sortable;
|
||||
pageThumbnails?: Sortable;
|
||||
};
|
||||
isRendering: boolean;
|
||||
cachedThumbnails: boolean | null;
|
||||
lastFileHash: string | null;
|
||||
}
|
||||
|
||||
const mergeState: MergeState = {
|
||||
pdfDocs: {},
|
||||
pdfBytes: {},
|
||||
activeMode: 'file',
|
||||
sortableInstances: {},
|
||||
isRendering: false,
|
||||
@@ -21,47 +34,14 @@ const mergeState = {
|
||||
|
||||
const mergeWorker = new Worker('/workers/merge.worker.js');
|
||||
|
||||
function parsePageRanges(rangeString: any, totalPages: any) {
|
||||
const indices = new Set();
|
||||
if (!rangeString.trim()) return [];
|
||||
|
||||
const ranges = rangeString.split(',');
|
||||
for (const range of ranges) {
|
||||
const trimmedRange = range.trim();
|
||||
if (trimmedRange.includes('-')) {
|
||||
const [start, end] = trimmedRange.split('-').map(Number);
|
||||
if (
|
||||
isNaN(start) ||
|
||||
isNaN(end) ||
|
||||
start < 1 ||
|
||||
end > totalPages ||
|
||||
start > end
|
||||
)
|
||||
continue;
|
||||
for (let i = start; i <= end; i++) {
|
||||
indices.add(i - 1);
|
||||
}
|
||||
} else {
|
||||
const pageNum = Number(trimmedRange);
|
||||
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
|
||||
indices.add(pageNum - 1);
|
||||
}
|
||||
}
|
||||
// @ts-expect-error TS(2362) FIXME: The left-hand side of an arithmetic operation must... Remove this comment to see the full error message
|
||||
return Array.from(indices).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function initializeFileListSortable() {
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (!fileList) return;
|
||||
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'fileList' does not exist on type '{}'.
|
||||
if (mergeState.sortableInstances.fileList) {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'fileList' does not exist on type '{}'.
|
||||
mergeState.sortableInstances.fileList.destroy();
|
||||
}
|
||||
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'fileList' does not exist on type '{}'.
|
||||
mergeState.sortableInstances.fileList = Sortable.create(fileList, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
@@ -81,13 +61,10 @@ function initializePageThumbnailsSortable() {
|
||||
const container = document.getElementById('page-merge-preview');
|
||||
if (!container) return;
|
||||
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'pageThumbnails' does not exist on type '... Remove this comment to see the full error message
|
||||
if (mergeState.sortableInstances.pageThumbnails) {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'pageThumbnails' does not exist on type '... Remove this comment to see the full error message
|
||||
mergeState.sortableInstances.pageThumbnails.destroy();
|
||||
}
|
||||
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'pageThumbnails' does not exist on type '... Remove this comment to see the full error message
|
||||
mergeState.sortableInstances.pageThumbnails = Sortable.create(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
@@ -192,7 +169,7 @@ async function renderPageMergeThumbnails() {
|
||||
container,
|
||||
createWrapperWithFileName,
|
||||
{
|
||||
batchSize: 6,
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
onProgress: (current, total) => {
|
||||
@@ -224,8 +201,8 @@ async function renderPageMergeThumbnails() {
|
||||
export async function merge() {
|
||||
showLoader('Merging PDFs...');
|
||||
try {
|
||||
const jobs: any[] = [];
|
||||
const filesToMerge: any[] = [];
|
||||
const jobs: MergeJob[] = [];
|
||||
const filesToMerge: MergeFile[] = [];
|
||||
const uniqueFileNames = new Set<string>();
|
||||
|
||||
if (mergeState.activeMode === 'file') {
|
||||
@@ -234,8 +211,7 @@ export async function merge() {
|
||||
|
||||
const sortedFiles = Array.from(fileList.children)
|
||||
.map((li) => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'dataset' does not exist on type 'Element...
|
||||
return state.files.find((f) => f.name === li.dataset.fileName);
|
||||
return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -267,10 +243,9 @@ export async function merge() {
|
||||
|
||||
const rawPages: { fileName: string; pageIndex: number }[] = [];
|
||||
for (const el of pageElements) {
|
||||
// @ts-expect-error TS(2339)
|
||||
const fileName = el.dataset.fileName;
|
||||
// @ts-expect-error TS(2339)
|
||||
const pageIndex = parseInt(el.dataset.pageIndex, 10); // 0-based index from dataset
|
||||
const element = el as HTMLElement;
|
||||
const fileName = element.dataset.fileName;
|
||||
const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset
|
||||
|
||||
if (fileName && !isNaN(pageIndex)) {
|
||||
uniqueFileNames.add(fileName);
|
||||
@@ -304,8 +279,8 @@ export async function merge() {
|
||||
jobs.push({
|
||||
fileName: current.fileName,
|
||||
rangeType: 'range',
|
||||
startPage: current.pageIndex + 1,
|
||||
endPage: endPage + 1
|
||||
startPage: current.pageIndex + 1,
|
||||
endPage: endPage + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -324,13 +299,15 @@ export async function merge() {
|
||||
}
|
||||
}
|
||||
|
||||
mergeWorker.postMessage({
|
||||
const message: MergeMessage = {
|
||||
command: 'merge',
|
||||
files: filesToMerge,
|
||||
jobs: jobs
|
||||
}, filesToMerge.map(f => f.data));
|
||||
};
|
||||
|
||||
mergeWorker.onmessage = (e) => {
|
||||
mergeWorker.postMessage(message, filesToMerge.map(f => f.data));
|
||||
|
||||
mergeWorker.onmessage = (e: MessageEvent<MergeResponse>) => {
|
||||
hideLoader();
|
||||
if (e.data.status === 'success') {
|
||||
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
|
||||
|
||||
+109
-39
@@ -4,20 +4,25 @@ import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/he
|
||||
import { state } from '../state.js';
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { PDFDocument as PDFLibDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
import type { Word } from '../types/index.js';
|
||||
|
||||
let searchablePdfBytes: any = null;
|
||||
let searchablePdfBytes: Uint8Array | null = null;
|
||||
|
||||
function sanitizeTextForWinAnsi(text: string): string {
|
||||
// Remove invisible Unicode control characters (like Left-to-Right Mark U+200E)
|
||||
return text
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '')
|
||||
.replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, '');
|
||||
}
|
||||
import { getFontForLanguage } from '../utils/font-loader.js';
|
||||
|
||||
|
||||
// function sanitizeTextForWinAnsi(text: string): string {
|
||||
// // Remove invisible Unicode control characters (like Left-to-Right Mark U+200E)
|
||||
// return text
|
||||
// .replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '')
|
||||
// .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, '');
|
||||
// }
|
||||
|
||||
function parseHOCR(hocrText: string) {
|
||||
const parser = new DOMParser();
|
||||
@@ -55,7 +60,7 @@ function parseHOCR(hocrText: string) {
|
||||
return words;
|
||||
}
|
||||
|
||||
function binarizeCanvas(ctx: any) {
|
||||
function binarizeCanvas(ctx: CanvasRenderingContext2D) {
|
||||
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
@@ -68,7 +73,7 @@ function binarizeCanvas(ctx: any) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function updateProgress(status: any, progress: any) {
|
||||
function updateProgress(status: string, progress: number) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressStatus = document.getElementById('progress-status');
|
||||
const progressLog = document.getElementById('progress-log');
|
||||
@@ -88,12 +93,13 @@ async function runOCR() {
|
||||
const selectedLangs = Array.from(
|
||||
document.querySelectorAll('.lang-checkbox:checked')
|
||||
).map((cb) => (cb as HTMLInputElement).value);
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const scale = parseFloat(document.getElementById('ocr-resolution').value);
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'checked' does not exist on type 'HTMLEle... Remove this comment to see the full error message
|
||||
const binarize = document.getElementById('ocr-binarize').checked;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const whitelist = document.getElementById('ocr-whitelist').value;
|
||||
const scale = parseFloat(
|
||||
(document.getElementById('ocr-resolution') as HTMLSelectElement).value
|
||||
);
|
||||
const binarize = (document.getElementById('ocr-binarize') as HTMLInputElement)
|
||||
.checked;
|
||||
const whitelist = (document.getElementById('ocr-whitelist') as HTMLInputElement)
|
||||
.value;
|
||||
|
||||
if (selectedLangs.length === 0) {
|
||||
showAlert(
|
||||
@@ -109,12 +115,13 @@ async function runOCR() {
|
||||
|
||||
try {
|
||||
const worker = await Tesseract.createWorker(langString, 1, {
|
||||
logger: (m: any) => updateProgress(m.status, m.progress || 0),
|
||||
logger: (m: { status: string; progress: number }) =>
|
||||
updateProgress(m.status, m.progress || 0),
|
||||
});
|
||||
|
||||
// Enable hOCR output
|
||||
await worker.setParameters({
|
||||
tessjs_create_hocr: '1',
|
||||
tessedit_pageseg_mode: Tesseract.PSM.AUTO,
|
||||
});
|
||||
|
||||
await worker.setParameters({
|
||||
@@ -125,7 +132,48 @@ async function runOCR() {
|
||||
await readFileAsArrayBuffer(state.files[0])
|
||||
).promise;
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
const font = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
newPdfDoc.registerFontkit(fontkit);
|
||||
|
||||
updateProgress('Loading fonts...', 0);
|
||||
|
||||
// Prioritize non-Latin languages for font selection if multiple are selected
|
||||
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
|
||||
const indicLangs = ['hin', 'ben', 'guj', 'kan', 'mal', 'ori', 'pan', 'tam', 'tel', 'sin'];
|
||||
const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
|
||||
|
||||
const primaryLang = selectedLangs.find(l => priorityLangs.includes(l)) || selectedLangs[0] || 'eng';
|
||||
|
||||
const hasCJK = selectedLangs.some(l => cjkLangs.includes(l));
|
||||
const hasIndic = selectedLangs.some(l => indicLangs.includes(l));
|
||||
const hasLatin = selectedLangs.some(l => !priorityLangs.includes(l)) || selectedLangs.includes('eng');
|
||||
const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
|
||||
|
||||
let primaryFont;
|
||||
let latinFont;
|
||||
|
||||
try {
|
||||
let fontBytes;
|
||||
if (isIndicPlusLatin) {
|
||||
const [scriptFontBytes, latinFontBytes] = await Promise.all([
|
||||
getFontForLanguage(primaryLang),
|
||||
getFontForLanguage('eng')
|
||||
]);
|
||||
primaryFont = await newPdfDoc.embedFont(scriptFontBytes, { subset: false });
|
||||
latinFont = await newPdfDoc.embedFont(latinFontBytes, { subset: false });
|
||||
} else {
|
||||
// For CJK or single-script, use one font
|
||||
fontBytes = await getFontForLanguage(primaryLang);
|
||||
primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Font loading failed, falling back to Helvetica', e);
|
||||
primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
latinFont = primaryFont;
|
||||
showAlert('Font Warning', 'Could not load the specific font for this language. Some characters may not appear correctly.');
|
||||
}
|
||||
|
||||
let fullText = '';
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
@@ -135,10 +183,12 @@ async function runOCR() {
|
||||
);
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
|
||||
if (binarize) {
|
||||
@@ -155,8 +205,8 @@ async function runOCR() {
|
||||
const pngImageBytes = await new Promise((resolve) =>
|
||||
canvas.toBlob((blob) => {
|
||||
const reader = new FileReader();
|
||||
// @ts-expect-error TS(2769) FIXME: No overload matches this call.
|
||||
reader.onload = () => resolve(new Uint8Array(reader.result));
|
||||
reader.onload = () =>
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}, 'image/png')
|
||||
);
|
||||
@@ -172,22 +222,37 @@ async function runOCR() {
|
||||
if (data.hocr) {
|
||||
const words = parseHOCR(data.hocr);
|
||||
|
||||
words.forEach((word: any) => {
|
||||
words.forEach((word: Word) => {
|
||||
const { x0, y0, x1, y1 } = word.bbox;
|
||||
// Sanitize the text to remove characters WinAnsi cannot encode
|
||||
const text = sanitizeTextForWinAnsi(word.text);
|
||||
const text = word.text.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '');
|
||||
|
||||
// Skip words that become empty after sanitization
|
||||
if (!text.trim()) return;
|
||||
|
||||
const hasNonLatin = /[^\u0000-\u007F]/.test(text);
|
||||
const font = hasNonLatin ? primaryFont : latinFont;
|
||||
|
||||
if (!font) {
|
||||
console.warn(`Font not available for text: "${text}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bboxWidth = x1 - x0;
|
||||
const bboxHeight = y1 - y0;
|
||||
|
||||
if (bboxWidth <= 0 || bboxHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fontSize = bboxHeight * 0.9;
|
||||
let textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
while (textWidth > bboxWidth && fontSize > 1) {
|
||||
fontSize -= 0.5;
|
||||
textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
try {
|
||||
let textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
while (textWidth > bboxWidth && fontSize > 1) {
|
||||
fontSize -= 0.5;
|
||||
textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not calculate text width for "${text}":`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -200,12 +265,12 @@ async function runOCR() {
|
||||
opacity: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// If drawing fails despite sanitization, log and skip this word
|
||||
console.warn(`Could not draw text "${text}":`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
fullText += data.text + '\n\n';
|
||||
}
|
||||
|
||||
@@ -216,23 +281,27 @@ async function runOCR() {
|
||||
document.getElementById('ocr-results').classList.remove('hidden');
|
||||
|
||||
createIcons({ icons });
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
document.getElementById('ocr-text-output').value = fullText.trim();
|
||||
(
|
||||
document.getElementById('ocr-text-output') as HTMLTextAreaElement
|
||||
).value = fullText.trim();
|
||||
|
||||
document
|
||||
.getElementById('download-searchable-pdf')
|
||||
.addEventListener('click', () => {
|
||||
downloadFile(
|
||||
new Blob([searchablePdfBytes], { type: 'application/pdf' }),
|
||||
'searchable.pdf'
|
||||
);
|
||||
if (searchablePdfBytes) {
|
||||
downloadFile(
|
||||
new Blob([searchablePdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
'searchable.pdf'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// CHANGE: The copy button logic is updated to be safer.
|
||||
document.getElementById('copy-text-btn').addEventListener('click', (e) => {
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme...
|
||||
const textToCopy = document.getElementById('ocr-text-output').value;
|
||||
const textToCopy = (
|
||||
document.getElementById('ocr-text-output') as HTMLTextAreaElement
|
||||
).value;
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
button.textContent = ''; // Clear the button safely
|
||||
@@ -259,8 +328,9 @@ async function runOCR() {
|
||||
document
|
||||
.getElementById('download-txt-btn')
|
||||
.addEventListener('click', () => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const textToSave = document.getElementById('ocr-text-output').value;
|
||||
const textToSave = (
|
||||
document.getElementById('ocr-text-output') as HTMLTextAreaElement
|
||||
).value;
|
||||
const blob = new Blob([textToSave], { type: 'text/plain' });
|
||||
downloadFile(blob, 'ocr-text.txt');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import Sortable from 'sortablejs';
|
||||
import { downloadFile, getPDFDocument } from '../utils/helpers';
|
||||
import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils';
|
||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||
import { repairPdfFile } from './repair-pdf.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -351,8 +352,27 @@ async function loadPdfs(files: File[]) {
|
||||
if (renderCancelled) break;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
|
||||
try {
|
||||
console.log(`Repairing ${file.name}...`);
|
||||
const loadingText = document.getElementById('loading-text');
|
||||
if (loadingText) loadingText.textContent = `Repairing ${file.name}...`;
|
||||
|
||||
const repairedData = await repairPdfFile(file);
|
||||
if (repairedData) {
|
||||
arrayBuffer = repairedData.buffer as ArrayBuffer;
|
||||
console.log(`Successfully repaired ${file.name} before loading.`);
|
||||
} else {
|
||||
console.warn(`Repair returned null for ${file.name}, using original file.`);
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
}
|
||||
} catch (repairError) {
|
||||
console.warn(`Failed to repair ${file.name}, attempting to load original:`, repairError);
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
}
|
||||
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
currentPdfDocs.push(pdfDoc);
|
||||
const pdfIndex = currentPdfDocs.length - 1;
|
||||
|
||||
@@ -735,7 +755,7 @@ async function handleInsertPdf(e: Event) {
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
currentPdfDocs.push(pdfDoc);
|
||||
const pdfIndex = currentPdfDocs.length - 1;
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { repairPdf } from './repair-pdf.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderFileDisplay } from '../ui.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
const updateUI = () => {
|
||||
if (state.files.length > 0) {
|
||||
renderFileDisplay(fileDisplayArea, state.files);
|
||||
if (processBtn) processBtn.classList.remove('hidden');
|
||||
if (fileControls) fileControls.classList.remove('hidden');
|
||||
} else {
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
if (processBtn) processBtn.classList.add('hidden');
|
||||
if (fileControls) fileControls.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files && files.length > 0) {
|
||||
state.files = [...state.files, ...Array.from(files)];
|
||||
updateUI();
|
||||
}
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
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');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfFiles.length > 0) {
|
||||
state.files = [...state.files, ...pdfFiles];
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', () => {
|
||||
state.files = [];
|
||||
updateUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', async () => {
|
||||
await repairPdf();
|
||||
});
|
||||
}
|
||||
|
||||
updateUI();
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import {
|
||||
downloadFile,
|
||||
initializeQpdf,
|
||||
readFileAsArrayBuffer,
|
||||
} from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export async function repairPdfFile(file: File): Promise<Uint8Array | null> {
|
||||
const inputPath = '/input.pdf';
|
||||
const outputPath = '/repaired_form.pdf';
|
||||
let qpdf: any;
|
||||
|
||||
try {
|
||||
qpdf = await initializeQpdf();
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||
|
||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||
|
||||
const args = [inputPath, '--decrypt', outputPath];
|
||||
|
||||
try {
|
||||
qpdf.callMain(args);
|
||||
} catch (e) {
|
||||
console.warn(`QPDF execution warning for ${file.name}:`, e);
|
||||
}
|
||||
|
||||
let repairedData: Uint8Array | null = null;
|
||||
try {
|
||||
repairedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read output for ${file.name}:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('Cleanup error:', cleanupError);
|
||||
}
|
||||
|
||||
return repairedData;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error repairing ${file.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function repairPdf() {
|
||||
if (state.files.length === 0) {
|
||||
showAlert('No Files', 'Please select one or more PDF files.');
|
||||
return;
|
||||
}
|
||||
|
||||
const successfulRepairs: { name: string; data: Uint8Array }[] = [];
|
||||
const failedRepairs: string[] = [];
|
||||
|
||||
try {
|
||||
showLoader('Initializing repair engine...');
|
||||
|
||||
for (let i = 0; i < state.files.length; i++) {
|
||||
const file = state.files[i];
|
||||
showLoader(`Repairing ${file.name} (${i + 1}/${state.files.length})...`);
|
||||
|
||||
const repairedData = await repairPdfFile(file);
|
||||
|
||||
if (repairedData && repairedData.length > 0) {
|
||||
successfulRepairs.push({
|
||||
name: `repaired-${file.name}`,
|
||||
data: repairedData,
|
||||
});
|
||||
} else {
|
||||
failedRepairs.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
|
||||
if (successfulRepairs.length === 0) {
|
||||
showAlert('Repair Failed', 'Unable to repair any of the uploaded PDF files.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedRepairs.length > 0) {
|
||||
const failedList = failedRepairs.join(', ');
|
||||
showAlert(
|
||||
'Partial Success',
|
||||
`Repaired ${successfulRepairs.length} file(s). Failed to repair: ${failedList}`
|
||||
);
|
||||
}
|
||||
|
||||
if (successfulRepairs.length === 1) {
|
||||
const file = successfulRepairs[0];
|
||||
const blob = new Blob([file.data as any], { type: 'application/pdf' });
|
||||
downloadFile(blob, file.name);
|
||||
} else {
|
||||
showLoader('Creating ZIP archive...');
|
||||
const zip = new JSZip();
|
||||
successfulRepairs.forEach((file) => {
|
||||
zip.file(file.name, file.data);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'repaired_pdfs.zip');
|
||||
hideLoader();
|
||||
}
|
||||
|
||||
if (failedRepairs.length === 0) {
|
||||
showAlert('Success', 'All files repaired successfully!');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Critical error during repair:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'An unexpected error occurred during the repair process.');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function reversePages() {
|
||||
for (let j = 0; j < pdfDocs.length; j++) {
|
||||
const file = pdfDocs[j];
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const pageCount = pdfDoc.getPageCount();
|
||||
const reversedIndices = Array.from(
|
||||
|
||||
+42
-11
@@ -3,25 +3,56 @@ import { downloadFile, resetAndReloadTool } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { getRotationState, resetRotationState } from '../handlers/fileHandler.js';
|
||||
|
||||
import { degrees } from 'pdf-lib';
|
||||
import { PDFDocument, degrees } from 'pdf-lib';
|
||||
|
||||
export async function rotate() {
|
||||
showLoader('Applying rotations...');
|
||||
try {
|
||||
const pages = state.pdfDoc.getPages();
|
||||
const originalPdf = state.pdfDoc;
|
||||
const pageCount = originalPdf.getPageCount();
|
||||
const rotationStateArray = getRotationState();
|
||||
|
||||
// Apply rotations from state (not DOM) to ensure all pages including lazy-loaded ones are rotated
|
||||
rotationStateArray.forEach((rotation, pageIndex) => {
|
||||
if (rotation !== 0 && pages[pageIndex]) {
|
||||
const currentRotation = pages[pageIndex].getRotation().angle;
|
||||
pages[pageIndex].setRotation(degrees(currentRotation + rotation));
|
||||
}
|
||||
});
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
|
||||
const rotatedPdfBytes = await state.pdfDoc.save();
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const rotation = rotationStateArray[i] || 0;
|
||||
const originalPage = originalPdf.getPage(i);
|
||||
const currentRotation = originalPage.getRotation().angle;
|
||||
const totalRotation = currentRotation + rotation;
|
||||
|
||||
if (totalRotation % 90 === 0) {
|
||||
const [copiedPage] = await newPdfDoc.copyPages(originalPdf, [i]);
|
||||
copiedPage.setRotation(degrees(totalRotation));
|
||||
newPdfDoc.addPage(copiedPage);
|
||||
} else {
|
||||
const embeddedPage = await newPdfDoc.embedPage(originalPage);
|
||||
const { width, height } = embeddedPage.scale(1);
|
||||
|
||||
const angleRad = (totalRotation * Math.PI) / 180;
|
||||
const absCos = Math.abs(Math.cos(angleRad));
|
||||
const absSin = Math.abs(Math.sin(angleRad));
|
||||
|
||||
const newWidth = width * absCos + height * absSin;
|
||||
const newHeight = width * absSin + height * absCos;
|
||||
|
||||
const newPage = newPdfDoc.addPage([newWidth, newHeight]);
|
||||
|
||||
const x = newWidth / 2 - (width / 2 * Math.cos(angleRad) - height / 2 * Math.sin(angleRad));
|
||||
const y = newHeight / 2 - (width / 2 * Math.sin(angleRad) + height / 2 * Math.cos(angleRad));
|
||||
|
||||
newPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotate: degrees(totalRotation),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rotatedPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([rotatedPdfBytes], { type: 'application/pdf' }),
|
||||
new Blob([rotatedPdfBytes as any], { type: 'application/pdf' }),
|
||||
'rotated.pdf'
|
||||
);
|
||||
|
||||
|
||||
+291
-97
@@ -2,6 +2,9 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, hexToRgb } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import JSZip from 'jszip';
|
||||
import { getFontForLanguage, getLanguageForChar } from '../utils/font-loader.js';
|
||||
import { languageToFontFamily } from '../config/font-mappings.js';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
|
||||
import {
|
||||
PDFDocument as PDFLibDocument,
|
||||
@@ -10,69 +13,46 @@ import {
|
||||
PageSizes,
|
||||
} from 'pdf-lib';
|
||||
|
||||
function sanitizeTextForPdf(text: string): string {
|
||||
return text
|
||||
.split('')
|
||||
.map((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
if (code === 0x20 || code === 0x09 || code === 0x0A) {
|
||||
return char;
|
||||
}
|
||||
|
||||
if ((code >= 0x00 && code <= 0x1F) || (code >= 0x7F && code <= 0x9F)) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
if (code < 0x20 || (code > 0x7E && code < 0xA0)) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
const replacements: { [key: number]: string } = {
|
||||
0x2018: "'",
|
||||
0x2019: "'",
|
||||
0x201C: '"',
|
||||
0x201D: '"',
|
||||
0x2013: '-',
|
||||
0x2014: '--',
|
||||
0x2026: '...',
|
||||
0x00A0: ' ',
|
||||
};
|
||||
|
||||
if (replacements[code]) {
|
||||
return replacements[code];
|
||||
}
|
||||
|
||||
try {
|
||||
if (code <= 0xFF) {
|
||||
return char;
|
||||
}
|
||||
return '?';
|
||||
} catch {
|
||||
return '?';
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createPdfFromText(
|
||||
text: string,
|
||||
fontFamilyKey: string,
|
||||
selectedLanguages: string[],
|
||||
fontSize: number,
|
||||
pageSizeKey: string,
|
||||
colorHex: string
|
||||
colorHex: string,
|
||||
orientation: string,
|
||||
customWidth?: number,
|
||||
customHeight?: number
|
||||
): Promise<Uint8Array> {
|
||||
const sanitizedText = sanitizeTextForPdf(text);
|
||||
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts[fontFamilyKey]);
|
||||
const pageSize = PageSizes[pageSizeKey];
|
||||
pdfDoc.registerFontkit(fontkit);
|
||||
|
||||
console.log(`User selected languages: ${selectedLanguages.join(', ')}`);
|
||||
|
||||
const fontMap = new Map<string, any>();
|
||||
const fallbackFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
if (!selectedLanguages.includes('eng')) {
|
||||
selectedLanguages.push('eng');
|
||||
}
|
||||
|
||||
for (const lang of selectedLanguages) {
|
||||
try {
|
||||
const fontBytes = await getFontForLanguage(lang);
|
||||
const font = await pdfDoc.embedFont(fontBytes, { subset: false });
|
||||
fontMap.set(lang, font);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load font for ${lang}, using fallback`, e);
|
||||
fontMap.set(lang, fallbackFont);
|
||||
}
|
||||
}
|
||||
|
||||
let pageSize = pageSizeKey === 'Custom'
|
||||
? [customWidth || 595, customHeight || 842] as [number, number]
|
||||
: PageSizes[pageSizeKey];
|
||||
|
||||
if (orientation === 'landscape') {
|
||||
pageSize = [pageSize[1], pageSize[0]] as [number, number];
|
||||
}
|
||||
const margin = 72;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
|
||||
@@ -82,43 +62,130 @@ async function createPdfFromText(
|
||||
const lineHeight = fontSize * 1.3;
|
||||
let y = height - margin;
|
||||
|
||||
const paragraphs = sanitizedText.split('\n');
|
||||
const paragraphs = text.split('\n');
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.trim() === '') {
|
||||
y -= lineHeight;
|
||||
if (y < margin) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
let currentLineWords: { text: string; font: any }[] = [];
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine =
|
||||
currentLine.length > 0 ? `${currentLine} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(testLine, fontSize) <= textWidth) {
|
||||
currentLine = testLine;
|
||||
let wordLang = 'eng';
|
||||
|
||||
for (const char of word) {
|
||||
const charLang = getLanguageForChar(char);
|
||||
|
||||
if (charLang === 'chi_sim') {
|
||||
if (selectedLanguages.includes('jpn')) wordLang = 'jpn';
|
||||
else if (selectedLanguages.includes('kor')) wordLang = 'kor';
|
||||
else if (selectedLanguages.includes('chi_tra')) wordLang = 'chi_tra';
|
||||
else if (selectedLanguages.includes('chi_sim')) wordLang = 'chi_sim';
|
||||
} else if (selectedLanguages.includes(charLang)) {
|
||||
wordLang = charLang;
|
||||
}
|
||||
|
||||
if (wordLang !== 'eng') break;
|
||||
}
|
||||
|
||||
const font = fontMap.get(wordLang) || fontMap.get('eng') || fallbackFont;
|
||||
|
||||
let wordWidth = 0;
|
||||
try {
|
||||
wordWidth = font.widthOfTextAtSize(word, fontSize);
|
||||
} catch (e) {
|
||||
console.warn(`Width calculation failed for "${word}"`, e);
|
||||
wordWidth = word.length * fontSize * 0.5;
|
||||
}
|
||||
|
||||
let spaceWidth = 0;
|
||||
if (currentLineWords.length > 0) {
|
||||
try {
|
||||
spaceWidth = font.widthOfTextAtSize(' ', fontSize);
|
||||
} catch {
|
||||
spaceWidth = fontSize * 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLineWidth + spaceWidth + wordWidth <= textWidth) {
|
||||
currentLineWords.push({ text: word, font });
|
||||
currentLineWidth += spaceWidth + wordWidth;
|
||||
} else {
|
||||
// Draw current line
|
||||
if (y < margin + lineHeight) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
}
|
||||
page.drawText(currentLine, {
|
||||
x: margin,
|
||||
y,
|
||||
font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
let currentX = margin;
|
||||
for (let i = 0; i < currentLineWords.length; i++) {
|
||||
const w = currentLineWords[i];
|
||||
try {
|
||||
page.drawText(w.text, {
|
||||
x: currentX,
|
||||
y,
|
||||
font: w.font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
const wWidth = w.font.widthOfTextAtSize(w.text, fontSize);
|
||||
currentX += wWidth;
|
||||
|
||||
if (i < currentLineWords.length - 1) {
|
||||
const sWidth = w.font.widthOfTextAtSize(' ', fontSize);
|
||||
currentX += sWidth;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to draw word: "${w.text}"`, e);
|
||||
}
|
||||
}
|
||||
|
||||
y -= lineHeight;
|
||||
currentLine = word;
|
||||
|
||||
currentLineWords = [{ text: word, font }];
|
||||
currentLineWidth = wordWidth;
|
||||
}
|
||||
}
|
||||
if (currentLine.length > 0) {
|
||||
|
||||
if (currentLineWords.length > 0) {
|
||||
if (y < margin + lineHeight) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
}
|
||||
page.drawText(currentLine, {
|
||||
x: margin,
|
||||
y,
|
||||
font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
let currentX = margin;
|
||||
for (let i = 0; i < currentLineWords.length; i++) {
|
||||
const w = currentLineWords[i];
|
||||
try {
|
||||
page.drawText(w.text, {
|
||||
x: currentX,
|
||||
y,
|
||||
font: w.font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
const wWidth = w.font.widthOfTextAtSize(w.text, fontSize);
|
||||
currentX += wWidth;
|
||||
|
||||
if (i < currentLineWords.length - 1) {
|
||||
const sWidth = w.font.widthOfTextAtSize(' ', fontSize);
|
||||
currentX += sWidth;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to draw word: "${w.text}"`, e);
|
||||
}
|
||||
}
|
||||
|
||||
y -= lineHeight;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +201,99 @@ export async function setupTxtToPdfTool() {
|
||||
|
||||
if (!uploadBtn || !textBtn || !uploadPanel || !textPanel) return;
|
||||
|
||||
const langContainer = document.getElementById('language-list-container');
|
||||
const dropdownBtn = document.getElementById('lang-dropdown-btn');
|
||||
const dropdownContent = document.getElementById('lang-dropdown-content');
|
||||
const dropdownText = document.getElementById('lang-dropdown-text');
|
||||
const searchInput = document.getElementById('lang-search');
|
||||
|
||||
if (langContainer && langContainer.children.length === 0) {
|
||||
const allLanguages = Object.keys(languageToFontFamily).sort().map(code => {
|
||||
let name = code;
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
|
||||
name = displayNames.of(code) || code;
|
||||
} catch (e) {
|
||||
console.warn(`Failed to get language name for ${code}`, e);
|
||||
}
|
||||
return { code, name: `${name} (${code})` };
|
||||
});
|
||||
|
||||
const renderLanguages = (filter = '') => {
|
||||
langContainer.innerHTML = '';
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
|
||||
allLanguages.forEach(lang => {
|
||||
if (lang.name.toLowerCase().includes(lowerFilter) || lang.code.toLowerCase().includes(lowerFilter)) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'flex items-center hover:bg-gray-700 p-1 rounded';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = lang.code;
|
||||
checkbox.id = `lang-${lang.code}`;
|
||||
checkbox.className = 'w-4 h-4 text-indigo-600 bg-gray-600 border-gray-500 rounded focus:ring-indigo-500 ring-offset-gray-800';
|
||||
if (lang.code === 'eng') checkbox.checked = true;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `lang-${lang.code}`;
|
||||
label.className = 'ml-2 text-sm font-medium text-gray-300 w-full cursor-pointer';
|
||||
label.textContent = lang.name;
|
||||
|
||||
checkbox.addEventListener('change', updateButtonText);
|
||||
|
||||
wrapper.appendChild(checkbox);
|
||||
wrapper.appendChild(label);
|
||||
langContainer.appendChild(wrapper);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
renderLanguages();
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const filter = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
const items = langContainer.children;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i] as HTMLElement;
|
||||
const text = item.textContent?.toLowerCase() || '';
|
||||
if (text.includes(filter)) {
|
||||
item.classList.remove('hidden');
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dropdownBtn && dropdownContent) {
|
||||
dropdownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownContent.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dropdownBtn.contains(e.target as Node) && !dropdownContent.contains(e.target as Node)) {
|
||||
dropdownContent.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateButtonText() {
|
||||
const checkboxes = langContainer?.querySelectorAll('input[type="checkbox"]:checked');
|
||||
const count = checkboxes?.length || 0;
|
||||
if (count === 0) {
|
||||
if (dropdownText) dropdownText.textContent = 'Select Languages';
|
||||
} else if (count === 1) {
|
||||
const text = checkboxes[0].nextElementSibling.textContent;
|
||||
if (dropdownText) dropdownText.textContent = text || '1 Language Selected';
|
||||
} else {
|
||||
if (dropdownText) dropdownText.textContent = `${count} Languages Selected`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const switchToUpload = () => {
|
||||
uploadPanel.classList.remove('hidden');
|
||||
textPanel.classList.add('hidden');
|
||||
@@ -155,6 +315,19 @@ export async function setupTxtToPdfTool() {
|
||||
uploadBtn.addEventListener('click', switchToUpload);
|
||||
textBtn.addEventListener('click', switchToText);
|
||||
|
||||
const pageSizeSelect = document.getElementById('page-size') as HTMLSelectElement;
|
||||
const customSizeContainer = document.getElementById('custom-size-container');
|
||||
|
||||
if (pageSizeSelect && customSizeContainer) {
|
||||
pageSizeSelect.addEventListener('change', () => {
|
||||
if (pageSizeSelect.value === 'Custom') {
|
||||
customSizeContainer.classList.remove('hidden');
|
||||
} else {
|
||||
customSizeContainer.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
if (processBtn) {
|
||||
processBtn.onclick = txtToPdf;
|
||||
@@ -167,25 +340,41 @@ export async function txtToPdf() {
|
||||
|
||||
showLoader('Creating PDF...');
|
||||
try {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const fontFamilyKey = document.getElementById('font-family').value;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const fontSize = parseInt(document.getElementById('font-size').value) || 12;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const pageSizeKey = document.getElementById('page-size').value;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const colorHex = document.getElementById('text-color').value;
|
||||
const selectedLanguages: string[] = [];
|
||||
const langContainer = document.getElementById('language-list-container');
|
||||
if (langContainer) {
|
||||
const checkboxes = langContainer.querySelectorAll('input[type="checkbox"]:checked');
|
||||
checkboxes.forEach((cb) => {
|
||||
selectedLanguages.push((cb as HTMLInputElement).value);
|
||||
});
|
||||
}
|
||||
if (selectedLanguages.length === 0) selectedLanguages.push('eng'); // Fallback
|
||||
|
||||
const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement)?.value) || 12;
|
||||
const pageSizeKey = (document.getElementById('page-size') as HTMLSelectElement)?.value;
|
||||
const orientation = (document.getElementById('page-orientation') as HTMLSelectElement)?.value || 'portrait';
|
||||
const colorHex = (document.getElementById('text-color') as HTMLInputElement)?.value;
|
||||
|
||||
let customWidth: number | undefined;
|
||||
let customHeight: number | undefined;
|
||||
if (pageSizeKey === 'Custom') {
|
||||
customWidth = parseInt((document.getElementById('custom-width') as HTMLInputElement)?.value) || 595;
|
||||
customHeight = parseInt((document.getElementById('custom-height') as HTMLInputElement)?.value) || 842;
|
||||
}
|
||||
|
||||
if (isUploadMode && state.files.length > 0) {
|
||||
if (state.files.length === 1) {
|
||||
const file = state.files[0];
|
||||
const text = await file.text();
|
||||
const text = (await file.text()).normalize('NFC');
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
selectedLanguages,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
colorHex,
|
||||
orientation,
|
||||
customWidth,
|
||||
customHeight
|
||||
);
|
||||
const baseName = file.name.replace(/\.txt$/i, '');
|
||||
downloadFile(
|
||||
@@ -197,13 +386,16 @@ export async function txtToPdf() {
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of state.files) {
|
||||
const text = await file.text();
|
||||
const text = (await file.text()).normalize('NFC');
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
selectedLanguages,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
colorHex,
|
||||
orientation,
|
||||
customWidth,
|
||||
customHeight
|
||||
);
|
||||
const baseName = file.name.replace(/\.txt$/i, '');
|
||||
zip.file(`${baseName}.pdf`, pdfBytes);
|
||||
@@ -213,8 +405,7 @@ export async function txtToPdf() {
|
||||
downloadFile(zipBlob, 'text-to-pdf.zip');
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const text = document.getElementById('text-input').value;
|
||||
const text = ((document.getElementById('text-input') as HTMLTextAreaElement)?.value || '').normalize('NFC');
|
||||
if (!text.trim()) {
|
||||
showAlert('Input Required', 'Please enter some text to convert.');
|
||||
hideLoader();
|
||||
@@ -223,10 +414,13 @@ export async function txtToPdf() {
|
||||
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
selectedLanguages,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
colorHex,
|
||||
orientation,
|
||||
customWidth,
|
||||
customHeight
|
||||
);
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
|
||||
+31
-20
@@ -140,15 +140,20 @@ const init = () => {
|
||||
hideBrandingSections();
|
||||
}
|
||||
|
||||
// Hide shortcuts button on touch devices
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
if (isTouchDevice) {
|
||||
const shortcutsBtn = document.getElementById('open-shortcuts-btn');
|
||||
if (shortcutsBtn) {
|
||||
shortcutsBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// Hide shortcuts buttons on mobile devices (Android/iOS)
|
||||
// exclude iPad -> users can connect keyboard and use shortcuts
|
||||
const isMobile = /Android|iPhone|iPod/i.test(navigator.userAgent);
|
||||
const keyboardShortcutBtn = document.getElementById('shortcut');
|
||||
const shortcutSettingsBtn = document.getElementById('open-shortcuts-btn');
|
||||
|
||||
if (isMobile) {
|
||||
keyboardShortcutBtn.style.display = 'none';
|
||||
shortcutSettingsBtn.style.display = 'none';
|
||||
} else {
|
||||
keyboardShortcutBtn.textContent = navigator.userAgent.toUpperCase().includes('MAC')
|
||||
? '⌘ + K'
|
||||
: 'Ctrl + K';
|
||||
}
|
||||
|
||||
dom.toolGrid.textContent = '';
|
||||
|
||||
@@ -205,6 +210,22 @@ const init = () => {
|
||||
|
||||
const searchBar = document.getElementById('search-bar');
|
||||
const categoryGroups = dom.toolGrid.querySelectorAll('.category-group');
|
||||
|
||||
const fuzzyMatch = (searchTerm: string, targetText: string): boolean => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
let searchIndex = 0;
|
||||
let targetIndex = 0;
|
||||
|
||||
while (searchIndex < searchTerm.length && targetIndex < targetText.length) {
|
||||
if (searchTerm[searchIndex] === targetText[targetIndex]) {
|
||||
searchIndex++;
|
||||
}
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
return searchIndex === searchTerm.length;
|
||||
};
|
||||
|
||||
searchBar.addEventListener('input', () => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
@@ -218,8 +239,9 @@ const init = () => {
|
||||
const toolName = card.querySelector('h3').textContent.toLowerCase();
|
||||
const toolSubtitle =
|
||||
card.querySelector('p')?.textContent.toLowerCase() || '';
|
||||
|
||||
const isMatch =
|
||||
toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
|
||||
fuzzyMatch(searchTerm, toolName) || fuzzyMatch(searchTerm, toolSubtitle);
|
||||
|
||||
card.classList.toggle('hidden', !isMatch);
|
||||
if (isMatch) {
|
||||
@@ -243,17 +265,6 @@ const init = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const shortcutK = document.getElementById('shortcut');
|
||||
const isIosOrAndroid = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
|
||||
if (isIosOrAndroid) {
|
||||
shortcutK.style.display = 'none';
|
||||
} else {
|
||||
shortcutK.textContent = navigator.userAgent.toUpperCase().includes('MAC')
|
||||
? '⌘ + K'
|
||||
: 'Ctrl + K';
|
||||
}
|
||||
|
||||
dom.toolGrid.addEventListener('click', (e) => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'closest' does not exist on type 'EventTa... Remove this comment to see the full error message
|
||||
const card = e.target.closest('.tool-card');
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface FormField {
|
||||
id: string
|
||||
type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
name: string
|
||||
defaultValue: string
|
||||
fontSize: number
|
||||
alignment: 'left' | 'center' | 'right'
|
||||
textColor: string
|
||||
required: boolean
|
||||
readOnly: boolean
|
||||
tooltip: string
|
||||
combCells: number
|
||||
maxLength: number
|
||||
options?: string[]
|
||||
checked?: boolean
|
||||
exportValue?: string
|
||||
groupName?: string
|
||||
label?: string
|
||||
pageIndex: number
|
||||
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
|
||||
actionUrl?: string
|
||||
jsScript?: string
|
||||
targetFieldName?: string
|
||||
visibilityAction?: 'show' | 'hide' | 'toggle'
|
||||
dateFormat?: string
|
||||
multiline?: boolean
|
||||
borderColor?: string
|
||||
hideBorder?: boolean
|
||||
}
|
||||
|
||||
export interface PageData {
|
||||
index: number
|
||||
width: number
|
||||
height: number
|
||||
pdfPageData?: string
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ocr.js';
|
||||
export * from './form-creator.js';
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface Word {
|
||||
text: string;
|
||||
bbox: {
|
||||
x0: number;
|
||||
y0: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
};
|
||||
confidence: number;
|
||||
}
|
||||
+222
-47
@@ -44,19 +44,23 @@ export const dom = {
|
||||
};
|
||||
|
||||
export const showLoader = (text = 'Processing...') => {
|
||||
dom.loaderText.textContent = text;
|
||||
dom.loaderModal.classList.remove('hidden');
|
||||
if (dom.loaderText) dom.loaderText.textContent = text;
|
||||
if (dom.loaderModal) dom.loaderModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
export const hideLoader = () => dom.loaderModal.classList.add('hidden');
|
||||
export const hideLoader = () => {
|
||||
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
|
||||
};
|
||||
|
||||
export const showAlert = (title: any, message: any) => {
|
||||
dom.alertTitle.textContent = title;
|
||||
dom.alertMessage.textContent = message;
|
||||
dom.alertModal.classList.remove('hidden');
|
||||
if (dom.alertTitle) dom.alertTitle.textContent = title;
|
||||
if (dom.alertMessage) dom.alertMessage.textContent = message;
|
||||
if (dom.alertModal) dom.alertModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
export const hideAlert = () => dom.alertModal.classList.add('hidden');
|
||||
export const hideAlert = () => {
|
||||
if (dom.alertModal) dom.alertModal.classList.add('hidden');
|
||||
};
|
||||
|
||||
export const switchView = (view: any) => {
|
||||
if (view === 'grid') {
|
||||
@@ -125,7 +129,7 @@ function initializeOrganizeSortable(containerId: any) {
|
||||
* @param {object} pdfDoc The loaded pdf-lib document instance.
|
||||
*/
|
||||
export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
const containerId = toolId === 'organize' ? 'page-organizer' : 'page-rotator';
|
||||
const containerId = toolId === 'organize' ? 'page-organizer' : toolId === 'delete-pages' ? 'delete-pages-preview' : 'page-rotator';
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
@@ -134,6 +138,9 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
// Cleanup any previous lazy loading observers
|
||||
cleanupLazyRendering();
|
||||
|
||||
const currentRenderId = Date.now();
|
||||
container.dataset.renderId = currentRenderId.toString();
|
||||
|
||||
showLoader('Rendering page previews...');
|
||||
|
||||
const pdfData = await pdfDoc.save();
|
||||
@@ -185,7 +192,7 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
|
||||
wrapper.append(pageNumSpan, deleteBtn);
|
||||
} else if (toolId === 'rotate') {
|
||||
wrapper.className = 'page-rotator-item flex flex-col items-center gap-2';
|
||||
wrapper.className = 'page-rotator-item flex flex-col items-center gap-2 relative group';
|
||||
|
||||
// Read rotation from state (handles "Rotate All" on lazy-loaded pages)
|
||||
const rotationStateArray = getRotationState();
|
||||
@@ -202,35 +209,122 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex items-center justify-center gap-3 w-full';
|
||||
|
||||
// Page Number Overlay (Top Left)
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className = 'font-medium text-sm text-white';
|
||||
pageNumSpan.className =
|
||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
wrapper.appendChild(pageNumSpan);
|
||||
|
||||
const rotateBtn = document.createElement('button');
|
||||
rotateBtn.className =
|
||||
'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-2 rounded-full';
|
||||
rotateBtn.title = 'Rotate 90°';
|
||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-5 h-5"></i>';
|
||||
rotateBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const card = (e.currentTarget as HTMLElement).closest(
|
||||
'.page-rotator-item'
|
||||
) as HTMLElement;
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
||||
|
||||
// Custom Stepper Component
|
||||
const stepperContainer = document.createElement('div');
|
||||
stepperContainer.className = 'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
||||
|
||||
const decrementBtn = document.createElement('button');
|
||||
decrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
||||
decrementBtn.innerHTML = '<i data-lucide="minus" class="w-3 h-3"></i>';
|
||||
|
||||
const angleInput = document.createElement('input');
|
||||
angleInput.type = 'number';
|
||||
angleInput.className = 'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
||||
angleInput.value = initialRotation.toString();
|
||||
angleInput.placeholder = "0";
|
||||
|
||||
const incrementBtn = document.createElement('button');
|
||||
incrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
||||
incrementBtn.innerHTML = '<i data-lucide="plus" class="w-3 h-3"></i>';
|
||||
|
||||
// Helper to update rotation
|
||||
const updateRotation = (newRotation: number) => {
|
||||
const card = wrapper; // Closure capture
|
||||
const imgEl = card.querySelector('img');
|
||||
const pageIndex = pageNumber - 1;
|
||||
let currentRotation = parseInt(card.dataset.rotation);
|
||||
currentRotation = (currentRotation + 90) % 360;
|
||||
card.dataset.rotation = currentRotation.toString();
|
||||
imgEl.style.transform = `rotate(${currentRotation}deg)`;
|
||||
|
||||
updateRotationState(pageIndex, currentRotation);
|
||||
// Update UI
|
||||
angleInput.value = newRotation.toString();
|
||||
card.dataset.rotation = newRotation.toString();
|
||||
imgEl.style.transform = `rotate(${newRotation}deg)`;
|
||||
|
||||
// Update State
|
||||
updateRotationState(pageIndex, newRotation);
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
decrementBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current - 1);
|
||||
});
|
||||
|
||||
controlsDiv.append(pageNumSpan, rotateBtn);
|
||||
incrementBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current + 1);
|
||||
});
|
||||
|
||||
angleInput.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
let val = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
updateRotation(val);
|
||||
});
|
||||
angleInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
stepperContainer.append(decrementBtn, angleInput, incrementBtn);
|
||||
|
||||
const rotateBtn = document.createElement('button');
|
||||
rotateBtn.className = 'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
||||
rotateBtn.title = 'Rotate +90°';
|
||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
|
||||
rotateBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current + 90);
|
||||
});
|
||||
|
||||
controlsDiv.append(stepperContainer, rotateBtn);
|
||||
wrapper.appendChild(controlsDiv);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
wrapper.className = 'page-thumbnail relative group cursor-pointer transition-all duration-200';
|
||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const innerContainer = document.createElement('div');
|
||||
innerContainer.className = 'relative w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600 transition-colors duration-200';
|
||||
innerContainer.appendChild(img);
|
||||
wrapper.appendChild(innerContainer);
|
||||
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className =
|
||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
wrapper.appendChild(pageNumSpan);
|
||||
|
||||
wrapper.addEventListener('click', () => {
|
||||
const input = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
if (!input) return;
|
||||
|
||||
const currentVal = input.value;
|
||||
let pages = currentVal.split(',').map(s => s.trim()).filter(s => s);
|
||||
const pageStr = pageNumber.toString();
|
||||
|
||||
if (pages.includes(pageStr)) {
|
||||
pages = pages.filter(p => p !== pageStr);
|
||||
} else {
|
||||
pages.push(pageStr);
|
||||
}
|
||||
|
||||
pages.sort((a, b) => {
|
||||
const numA = parseInt(a.split('-')[0]);
|
||||
const numB = parseInt(b.split('-')[0]);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
input.value = pages.join(', ');
|
||||
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
@@ -243,7 +337,7 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
container,
|
||||
createWrapper,
|
||||
{
|
||||
batchSize: 6,
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
onProgress: (current, total) => {
|
||||
@@ -251,12 +345,17 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
},
|
||||
shouldCancel: () => {
|
||||
return container.dataset.renderId !== currentRenderId.toString();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (toolId === 'organize') {
|
||||
initializeOrganizeSortable(containerId);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
// No sortable needed for delete pages
|
||||
}
|
||||
|
||||
// Reinitialize lucide icons for dynamically added elements
|
||||
@@ -576,15 +675,49 @@ export const toolTemplates = {
|
||||
<div id="rotate-all-controls" class="hidden my-6">
|
||||
<div class="bg-gray-900/50 border border-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-400 mb-3 text-center">BATCH ACTIONS</h3>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="rotate-all-left-btn" class="flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-200 bg-gray-800 border border-gray-600 rounded-lg shadow-sm hover:bg-gray-700 hover:border-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transform transition-all duration-150 active:scale-95">
|
||||
<i data-lucide="rotate-ccw" class="mr-2 h-4 w-4"></i>
|
||||
Rotate All Left
|
||||
</button>
|
||||
<button id="rotate-all-right-btn" class="flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-200 bg-gray-800 border border-gray-600 rounded-lg shadow-sm hover:bg-gray-700 hover:border-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transform transition-all duration-150 active:scale-95">
|
||||
<i data-lucide="rotate-cw" class="mr-2 h-4 w-4"></i>
|
||||
Rotate All Right
|
||||
</button>
|
||||
<div class="flex flex-col md:flex-row justify-center gap-6 items-center">
|
||||
|
||||
<!-- 90 Degree Rotation Group -->
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<span class="text-xs text-gray-400 font-medium uppercase tracking-wider">Rotate By 90 degrees</span>
|
||||
<div class="flex gap-2">
|
||||
<button id="rotate-all-left-btn" class="flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-200 bg-gray-800 border border-gray-600 rounded-lg shadow-sm hover:bg-gray-700 hover:border-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transform transition-all duration-150 active:scale-95" title="Rotate Left 90°">
|
||||
<i data-lucide="rotate-ccw" class="mr-2 h-4 w-4"></i>
|
||||
Left
|
||||
</button>
|
||||
<button id="rotate-all-right-btn" class="flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-200 bg-gray-800 border border-gray-600 rounded-lg shadow-sm hover:bg-gray-700 hover:border-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transform transition-all duration-150 active:scale-95" title="Rotate Right 90°">
|
||||
<i data-lucide="rotate-cw" class="mr-2 h-4 w-4"></i>
|
||||
Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block w-px h-12 bg-gray-700"></div>
|
||||
|
||||
<!-- Custom Rotation Group -->
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<span class="text-xs text-gray-400 font-medium uppercase tracking-wider">Rotate By Custom Degrees</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
|
||||
<!-- Custom Stepper -->
|
||||
<div class="flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden h-10">
|
||||
<button id="rotate-all-decrement-btn" class="px-3 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center">
|
||||
<i data-lucide="minus" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div class="relative w-20 h-full">
|
||||
<input type="number" id="custom-rotate-all-input" placeholder="0" class="no-spinner w-full h-full bg-transparent text-white text-sm text-center focus:outline-none appearance-none m-0 p-0 border-none" min="-360" max="360">
|
||||
</div>
|
||||
<button id="rotate-all-increment-btn" class="px-3 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button id="rotate-all-custom-btn" class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-4 py-2 rounded-lg text-sm transition-colors shadow-sm h-10">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -897,6 +1030,7 @@ export const toolTemplates = {
|
||||
<p class="mb-2 font-medium text-white">Total Pages: <span id="total-pages"></span></p>
|
||||
<label for="pages-to-delete" class="block mb-2 text-sm font-medium text-gray-300">Enter pages to delete (e.g., 2, 4-6, 9):</label>
|
||||
<input type="text" id="pages-to-delete" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 mb-6" placeholder="e.g., 2, 4-6, 9">
|
||||
<div id="delete-pages-preview" class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4 my-6"></div>
|
||||
<button id="process-btn" class="btn-gradient w-full">Delete Pages & Download</button>
|
||||
</div>
|
||||
`,
|
||||
@@ -1175,12 +1309,21 @@ export const toolTemplates = {
|
||||
|
||||
<div class="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="font-family" class="block mb-2 text-sm font-medium text-gray-300">Font Family</label>
|
||||
<select id="font-family" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="TimesRoman">Times New Roman</option>
|
||||
<option value="Courier">Courier</option>
|
||||
</select>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-300">Select Languages</label>
|
||||
<div class="relative">
|
||||
<button id="lang-dropdown-btn" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-left flex justify-between items-center">
|
||||
<span id="lang-dropdown-text" class="truncate">English (Default)</span>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div id="lang-dropdown-content" class="hidden absolute z-10 w-full bg-gray-800 border border-gray-600 rounded-lg mt-1 max-h-60 overflow-y-auto shadow-lg">
|
||||
<div class="p-2 sticky top-0 bg-gray-800 border-b border-gray-700">
|
||||
<input type="text" id="lang-search" class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm" placeholder="Search languages...">
|
||||
</div>
|
||||
<div id="language-list-container" class="p-2 space-y-1">
|
||||
<!-- Checkboxes injected here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-size" class="block mb-2 text-sm font-medium text-gray-300">Font Size</label>
|
||||
@@ -1189,10 +1332,42 @@ export const toolTemplates = {
|
||||
<div>
|
||||
<label for="page-size" class="block mb-2 text-sm font-medium text-gray-300">Page Size</label>
|
||||
<select id="page-size" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||
<option value="A4">A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<optgroup label="ISO A Series">
|
||||
<option value="A4" selected>A4 (210 x 297 mm)</option>
|
||||
<option value="A3">A3 (297 x 420 mm)</option>
|
||||
<option value="A5">A5 (148 x 210 mm)</option>
|
||||
<option value="A6">A6 (105 x 148 mm)</option>
|
||||
</optgroup>
|
||||
<optgroup label="North American">
|
||||
<option value="Letter">Letter (8.5 x 11 in)</option>
|
||||
<option value="Legal">Legal (8.5 x 14 in)</option>
|
||||
<option value="Tabloid">Tabloid (11 x 17 in)</option>
|
||||
<option value="Executive">Executive (7.25 x 10.5 in)</option>
|
||||
</optgroup>
|
||||
<optgroup label="ISO B Series">
|
||||
<option value="B4">B4 (250 x 353 mm)</option>
|
||||
<option value="B5">B5 (176 x 250 mm)</option>
|
||||
</optgroup>
|
||||
<option value="Custom">Custom Size</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="page-orientation" class="block mb-2 text-sm font-medium text-gray-300">Orientation</label>
|
||||
<select id="page-orientation" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||
<option value="portrait">Portrait</option>
|
||||
<option value="landscape">Landscape</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="custom-size-container" class="hidden col-span-2 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="custom-width" class="block mb-2 text-sm font-medium text-gray-300">Width (pt)</label>
|
||||
<input type="number" id="custom-width" value="595" min="1" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||
</div>
|
||||
<div>
|
||||
<label for="custom-height" class="block mb-2 text-sm font-medium text-gray-300">Height (pt)</label>
|
||||
<input type="number" id="custom-height" value="842" min="1" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="text-color" class="block mb-2 text-sm font-medium text-gray-300">Text Color</label>
|
||||
<input type="color" id="text-color" value="#000000" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { languageToFontFamily, fontFamilyToUrl } from '../config/font-mappings.js';
|
||||
|
||||
const fontCache: Map<string, ArrayBuffer> = new Map();
|
||||
|
||||
const DB_NAME = 'bentopdf-fonts';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'fonts';
|
||||
|
||||
async function openFontDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getCachedFontFromDB(fontFamily: string): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const db = await openFontDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(fontFamily);
|
||||
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('IndexedDB read failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFontToDB(fontFamily: string, fontBuffer: ArrayBuffer): Promise<void> {
|
||||
try {
|
||||
const db = await openFontDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.put(fontBuffer, fontFamily);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('IndexedDB write failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFontForLanguage(lang: string): Promise<ArrayBuffer> {
|
||||
const fontFamily = languageToFontFamily[lang] || 'Noto Sans';
|
||||
|
||||
if (fontCache.has(fontFamily)) {
|
||||
return fontCache.get(fontFamily)!;
|
||||
}
|
||||
const cachedFont = await getCachedFontFromDB(fontFamily);
|
||||
if (cachedFont) {
|
||||
fontCache.set(fontFamily, cachedFont);
|
||||
return cachedFont;
|
||||
}
|
||||
|
||||
try {
|
||||
const fontUrl = fontFamilyToUrl[fontFamily] || fontFamilyToUrl['Noto Sans'];
|
||||
|
||||
const fontResponse = await fetch(fontUrl);
|
||||
|
||||
if (!fontResponse.ok) {
|
||||
throw new Error(`Failed to fetch font file: ${fontResponse.statusText}`);
|
||||
}
|
||||
|
||||
const fontBuffer = await fontResponse.arrayBuffer();
|
||||
|
||||
fontCache.set(fontFamily, fontBuffer);
|
||||
await saveFontToDB(fontFamily, fontBuffer);
|
||||
|
||||
return fontBuffer;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch font for ${lang} (${fontFamily}), falling back to default.`, error);
|
||||
|
||||
if (fontFamily !== 'Noto Sans') {
|
||||
return await getFontForLanguage('eng');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectScripts(text: string): string[] {
|
||||
const scripts = new Set<string>();
|
||||
|
||||
// Japanese: Hiragana (\u3040-\u309F) & Katakana (\u30A0-\u30FF)
|
||||
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) {
|
||||
scripts.add('jpn');
|
||||
}
|
||||
|
||||
// Korean: Hangul Syllables (\uAC00-\uD7A3) & Jamo (\u1100-\u11FF)
|
||||
if (/[\uAC00-\uD7A3\u1100-\u11FF]/.test(text)) {
|
||||
scripts.add('kor');
|
||||
}
|
||||
|
||||
// Chinese: CJK Unified Ideographs (\u4E00-\u9FFF) & Ext A (\u3400-\u4DBF)
|
||||
if (/[\u4E00-\u9FFF\u3400-\u4DBF]/.test(text)) {
|
||||
scripts.add('chi_sim');
|
||||
}
|
||||
|
||||
// Check for Arabic
|
||||
if (/[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/.test(text)) {
|
||||
scripts.add('ara');
|
||||
}
|
||||
|
||||
// Check for Devanagari (Hindi, Marathi, etc.)
|
||||
if (/[\u0900-\u097F]/.test(text)) scripts.add('hin');
|
||||
|
||||
// Check for Bengali
|
||||
if (/[\u0980-\u09FF]/.test(text)) scripts.add('ben');
|
||||
|
||||
// Check for Tamil
|
||||
if (/[\u0B80-\u0BFF]/.test(text)) scripts.add('tam');
|
||||
|
||||
// Check for Telugu
|
||||
if (/[\u0C00-\u0C7F]/.test(text)) scripts.add('tel');
|
||||
|
||||
// Check for Kannada
|
||||
if (/[\u0C80-\u0CFF]/.test(text)) scripts.add('kan');
|
||||
|
||||
// Check for Malayalam
|
||||
if (/[\u0D00-\u0D7F]/.test(text)) scripts.add('mal');
|
||||
|
||||
// Check for Gujarati
|
||||
if (/[\u0A80-\u0AFF]/.test(text)) scripts.add('guj');
|
||||
|
||||
// Check for Punjabi (Gurmukhi)
|
||||
if (/[\u0A00-\u0A7F]/.test(text)) scripts.add('pan');
|
||||
|
||||
// Check for Oriya
|
||||
if (/[\u0B00-\u0B7F]/.test(text)) scripts.add('ori');
|
||||
|
||||
// Check for Sinhala
|
||||
if (/[\u0D80-\u0DFF]/.test(text)) scripts.add('sin');
|
||||
|
||||
// Check for Thai
|
||||
if (/[\u0E00-\u0E7F]/.test(text)) scripts.add('tha');
|
||||
|
||||
// Check for Lao
|
||||
if (/[\u0E80-\u0EFF]/.test(text)) scripts.add('lao');
|
||||
|
||||
// Check for Khmer
|
||||
if (/[\u1780-\u17FF]/.test(text)) scripts.add('khm');
|
||||
|
||||
// Check for Myanmar
|
||||
if (/[\u1000-\u109F]/.test(text)) scripts.add('mya');
|
||||
|
||||
// Check for Tibetan
|
||||
if (/[\u0F00-\u0FFF]/.test(text)) scripts.add('bod');
|
||||
|
||||
// Check for Georgian
|
||||
if (/[\u10A0-\u10FF]/.test(text)) scripts.add('kat');
|
||||
|
||||
// Check for Armenian
|
||||
if (/[\u0530-\u058F]/.test(text)) scripts.add('hye');
|
||||
|
||||
// Check for Hebrew
|
||||
if (/[\u0590-\u05FF]/.test(text)) scripts.add('heb');
|
||||
|
||||
// Check for Ethiopic
|
||||
if (/[\u1200-\u137F]/.test(text)) scripts.add('amh');
|
||||
|
||||
// Check for Cherokee
|
||||
if (/[\u13A0-\u13FF]/.test(text)) scripts.add('chr');
|
||||
|
||||
// Check for Syriac
|
||||
if (/[\u0700-\u074F]/.test(text)) scripts.add('syr');
|
||||
|
||||
if (scripts.size === 0 || /[a-zA-Z]/.test(text)) {
|
||||
scripts.add('eng');
|
||||
}
|
||||
|
||||
return Array.from(scripts);
|
||||
}
|
||||
|
||||
export function getLanguageForChar(char: string): string {
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
// Latin (Basic + Supplement + Extended)
|
||||
if (code <= 0x024F) return 'eng';
|
||||
|
||||
// Japanese: Hiragana & Katakana
|
||||
if (
|
||||
(code >= 0x3040 && code <= 0x309F) || // Hiragana
|
||||
(code >= 0x30A0 && code <= 0x30FF) // Katakana
|
||||
) return 'jpn';
|
||||
|
||||
// Korean: Hangul Syllables & Jamo
|
||||
if (
|
||||
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul Syllables
|
||||
(code >= 0x1100 && code <= 0x11FF) // Hangul Jamo
|
||||
) return 'kor';
|
||||
|
||||
// Chinese: CJK Unified Ideographs (Han)
|
||||
if (
|
||||
(code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified
|
||||
(code >= 0x3400 && code <= 0x4DBF) // CJK Ext A
|
||||
) return 'chi_sim';
|
||||
|
||||
// Arabic
|
||||
if ((code >= 0x0600 && code <= 0x06FF) || (code >= 0x0750 && code <= 0x077F) || (code >= 0x08A0 && code <= 0x08FF)) return 'ara';
|
||||
|
||||
// Devanagari
|
||||
if (code >= 0x0900 && code <= 0x097F) return 'hin';
|
||||
|
||||
// Bengali
|
||||
if (code >= 0x0980 && code <= 0x09FF) return 'ben';
|
||||
|
||||
// Tamil
|
||||
if (code >= 0x0B80 && code <= 0x0BFF) return 'tam';
|
||||
|
||||
// Telugu
|
||||
if (code >= 0x0C00 && code <= 0x0C7F) return 'tel';
|
||||
|
||||
// Kannada
|
||||
if (code >= 0x0C80 && code <= 0x0CFF) return 'kan';
|
||||
|
||||
// Malayalam
|
||||
if (code >= 0x0D00 && code <= 0x0D7F) return 'mal';
|
||||
|
||||
// Gujarati
|
||||
if (code >= 0x0A80 && code <= 0x0AFF) return 'guj';
|
||||
|
||||
// Punjabi (Gurmukhi)
|
||||
if (code >= 0x0A00 && code <= 0x0A7F) return 'pan';
|
||||
|
||||
// Oriya
|
||||
if (code >= 0x0B00 && code <= 0x0B7F) return 'ori';
|
||||
|
||||
// Sinhala
|
||||
if (code >= 0x0D80 && code <= 0x0DFF) return 'sin';
|
||||
|
||||
// Thai
|
||||
if (code >= 0x0E00 && code <= 0x0E7F) return 'tha';
|
||||
|
||||
// Lao
|
||||
if (code >= 0x0E80 && code <= 0x0EFF) return 'lao';
|
||||
|
||||
// Khmer
|
||||
if (code >= 0x1780 && code <= 0x17FF) return 'khm';
|
||||
|
||||
// Myanmar
|
||||
if (code >= 0x1000 && code <= 0x109F) return 'mya';
|
||||
|
||||
// Tibetan
|
||||
if (code >= 0x0F00 && code <= 0x0FFF) return 'bod';
|
||||
|
||||
// Georgian
|
||||
if (code >= 0x10A0 && code <= 0x10FF) return 'kat';
|
||||
|
||||
// Armenian
|
||||
if (code >= 0x0530 && code <= 0x058F) return 'hye';
|
||||
|
||||
// Hebrew
|
||||
if (code >= 0x0590 && code <= 0x05FF) return 'heb';
|
||||
|
||||
// Ethiopic
|
||||
if (code >= 0x1200 && code <= 0x137F) return 'amh';
|
||||
|
||||
// Cherokee
|
||||
if (code >= 0x13A0 && code <= 0x13FF) return 'chr';
|
||||
|
||||
// Syriac
|
||||
if (code >= 0x0700 && code <= 0x074F) return 'syr';
|
||||
|
||||
// Default to English (Latin)
|
||||
return 'eng';
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface RenderConfig {
|
||||
batchSize?: number;
|
||||
useLazyLoading?: boolean;
|
||||
lazyLoadMargin?: string;
|
||||
eagerLoadBatches?: number; // Number of batches to load ahead eagerly (default: 1)
|
||||
eagerLoadBatches?: number; // Number of batches to load ahead eagerly (default: 2)
|
||||
onProgress?: (current: number, total: number) => void;
|
||||
onPageRendered?: (pageIndex: number, element: HTMLElement) => void;
|
||||
onBatchComplete?: () => void;
|
||||
@@ -214,7 +214,7 @@ export async function renderPagesProgressively(
|
||||
const {
|
||||
batchSize = 8, // Increased from 5 to 8 for faster initial render
|
||||
useLazyLoading = true,
|
||||
eagerLoadBatches = 1, // Eagerly load 1 batch ahead by default
|
||||
eagerLoadBatches = 2, // Eagerly load 1 batch ahead by default
|
||||
onProgress,
|
||||
onBatchComplete,
|
||||
} = config;
|
||||
@@ -318,7 +318,7 @@ export function observePlaceholder(
|
||||
* Eagerly renders the next batch in the background
|
||||
*/
|
||||
function renderEagerBatch(config: RenderConfig): void {
|
||||
const { eagerLoadBatches = 1, batchSize = 8 } = config;
|
||||
const { eagerLoadBatches = 2, batchSize = 8 } = config;
|
||||
|
||||
if (eagerLoadBatches <= 0 || lazyLoadState.eagerLoadQueue.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Add Stamps - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
+10
-4
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Advanced PDF Bookmark Tool - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
<link href="../../src/css/bookmark.css" rel="stylesheet" />
|
||||
</head>
|
||||
@@ -271,7 +274,7 @@
|
||||
<input type="text" id="bookmark-title" placeholder="Bookmark title..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg mb-2 text-sm" />
|
||||
<button id="add-top-level-btn"
|
||||
class="w-full px-3 py-2 bg-gradient-to-b from-blue-500 to-blue-600 hover:shadow-xl text-white rounded text-sm font-medium flex items-center justify-center gap-2 focus:ring-2 focus:ring-blue-400 transition duration-200">
|
||||
class="w-full px-3 py-2 btn-gradient hover:shadow-xl text-white rounded text-sm font-medium !flex items-center justify-center gap-2 focus:ring-2 focus:ring-blue-400 transition duration-200">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span>Add to Page <span id="current-page-display">1</span></span>
|
||||
</button>
|
||||
@@ -391,11 +394,14 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<button id="download-btn"
|
||||
class="w-full px-4 py-2 bg-gradient-to-b from-blue-600 to-blue-700 hover:shadow-xl text-white rounded font-medium flex items-center justify-center gap-2 focus:ring-2 focus:ring-blue-400 transition duration-200">
|
||||
class="w-full px-4 py-2 btn-gradient hover:shadow-xl text-white rounded font-medium !flex items-center justify-center gap-2 focus:ring-2 focus:ring-blue-400 transition duration-200">
|
||||
Save PDF with Bookmarks
|
||||
</button>
|
||||
<button id="extract-existing-btn"
|
||||
class="w-full px-4 py-2 bg-gradient-to-b from-green-500 to-green-600 hover:shadow-xl text-white rounded font-medium flex items-center justify-center gap-2 focus:ring-2 focus:ring-green-400 transition duration-200">
|
||||
class="w-full px-4 py-2 bg-gray-50 text-gray-800
|
||||
border border-gray-200 rounded-xl font-medium
|
||||
flex items-center justify-center gap-2
|
||||
shadow-sm hover:shadow-md transition duration-200">
|
||||
Extract Existing Bookmarks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create PDF Form - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
@@ -202,6 +205,27 @@
|
||||
disabled>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 ml-2 border-l border-gray-600 pl-2">
|
||||
<span class="text-sm text-gray-400">Go to:</span>
|
||||
<input type="number" id="gotoPageInput" min="1"
|
||||
class="w-16 bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<button id="gotoPageBtn"
|
||||
class="bg-indigo-600 hover:bg-indigo-500 text-white px-2 py-1 rounded text-sm transition-colors">Go</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-2 border-l border-gray-600 pl-2">
|
||||
<input type="checkbox" id="enableGridCheckbox" checked class="mr-1">
|
||||
<label for="enableGridCheckbox" class="text-sm text-gray-400">Grid:</label>
|
||||
<input type="number" id="gridVInput" min="2" max="14" value="2" placeholder="V"
|
||||
class="w-12 bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<span class="text-gray-400">x</span>
|
||||
<input type="number" id="gridHInput" min="2" max="14" value="2" placeholder="H"
|
||||
class="w-12 bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<button id="toggleGridBtn"
|
||||
class="bg-gray-600 hover:bg-gray-500 text-white p-1 rounded transition-colors"
|
||||
title="Toggle Grid">
|
||||
<i data-lucide="grid-3x3" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="resetBtn"
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>JSON to PDF Converter - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PDF Multi Tool - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PDF to JSON Converter - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Repair PDF - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="antialiased bg-gray-900">
|
||||
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
|
||||
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
|
||||
<span class="text-white font-bold text-xl ml-2">
|
||||
<a href="/">BentoPDF</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center space-x-8 text-white">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
<a href="./about.html" class="nav-link">About</a>
|
||||
<a href="./contact.html" class="nav-link">Contact</a>
|
||||
<a href="/" class="nav-link">All Tools</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Hamburger Button -->
|
||||
<div class="md:hidden flex items-center">
|
||||
<button id="mobile-menu-button" type="button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 transition-colors"
|
||||
aria-controls="mobile-menu" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<!-- Hamburger Icon -->
|
||||
<svg id="menu-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<!-- Close Icon -->
|
||||
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Dropdown -->
|
||||
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
|
||||
<a href="/" class="mobile-nav-link">Home</a>
|
||||
<a href="./about.html" class="mobile-nav-link">About</a>
|
||||
<a href="./contact.html" class="mobile-nav-link">Contact</a>
|
||||
<a href="/" class="mobile-nav-link">All Tools</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="uploader" class="min-h-screen flex items-center justify-center p-4 bg-gray-900">
|
||||
<div id="tool-uploader"
|
||||
class="bg-gray-800 rounded-xl shadow-xl p-8 max-w-2xl w-full text-gray-200 border border-gray-700">
|
||||
<button id="back-to-tools"
|
||||
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold">
|
||||
<i data-lucide="arrow-left" class="cursor-pointer"></i>
|
||||
<span class="cursor-pointer"> Back to Tools </span>
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-white mb-2">Repair PDF</h1>
|
||||
<p class="text-gray-400 mb-6">
|
||||
Recover data from corrupted or damaged PDF files.
|
||||
</p>
|
||||
|
||||
<!-- Drop Zone for Main PDF Upload -->
|
||||
<div id="drop-zone"
|
||||
class="relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
|
||||
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">Click to select a file</span> or drag and
|
||||
drop</p>
|
||||
<p class="text-xs text-gray-500">PDFs or Images</p>
|
||||
<p class="text-xs text-gray-500">Your files never leave your device.</p>
|
||||
</div>
|
||||
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" multiple
|
||||
accept=".pdf">
|
||||
</div>
|
||||
|
||||
<!-- Control buttons for multi-file uploads -->
|
||||
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||
<button id="add-more-btn"
|
||||
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<i data-lucide="plus"></i> Add More Files
|
||||
</button>
|
||||
<button id="clear-files-btn"
|
||||
class="btn bg-red-600 hover:bg-red-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<i data-lucide="x"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File Display Area -->
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-6 hidden">Repair PDF</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loader Modal -->
|
||||
<div id="loader-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500 mb-4"></div>
|
||||
<p id="loader-text" class="text-white text-lg font-semibold">Processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Modal -->
|
||||
<div id="alert-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700">
|
||||
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">Alert</h3>
|
||||
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
||||
<button onclick="document.getElementById('alert-modal').classList.add('hidden')"
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="mt-16 border-t-2 border-gray-700 py-8">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
|
||||
<div class="mb-8 md:mb-0">
|
||||
<div class="flex items-center justify-center md:justify-start mb-4">
|
||||
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
|
||||
<span class="text-xl font-bold text-white">BentoPDF</span>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">
|
||||
© 2025 BentoPDF. All rights reserved.
|
||||
</p>
|
||||
<p class="text-gray-500 text-xs mt-2">
|
||||
Version <span id="app-version"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold text-white mb-4">Company</h3>
|
||||
<ul class="space-y-2 text-gray-400">
|
||||
<li>
|
||||
<a href="../../about.html" class="hover:text-indigo-400">About Us</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../../faq.html" class="hover:text-indigo-400">FAQ</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../../contact.html" class="hover:text-indigo-400">Contact Us</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold text-white mb-4">Legal</h3>
|
||||
<ul class="space-y-2 text-gray-400">
|
||||
<li>
|
||||
<a href="../../licensing.html" class="hover:text-indigo-400">Licensing</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../../terms.html" class="hover:text-indigo-400">Terms and Conditions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../../privacy.html" class="hover:text-indigo-400">Privacy Policy</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold text-white mb-4">Follow Us</h3>
|
||||
<div class="flex justify-center md:justify-start space-x-4">
|
||||
<a href="https://github.com/alam00000/bentopdf" target="_blank" rel="noopener noreferrer"
|
||||
class="text-gray-400 hover:text-indigo-400" title="GitHub">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://discord.gg/AP2Y97juZT" target="_blank" rel="noopener noreferrer"
|
||||
class="text-gray-400 hover:text-indigo-400" title="Discord">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://www.instagram.com/thebentopdf/" class="text-gray-400 hover:text-indigo-400"
|
||||
title="Instagram">
|
||||
<i data-lucide="instagram"></i>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/company/bentopdf/" class="text-gray-400 hover:text-indigo-400"
|
||||
title="LinkedIn">
|
||||
<i data-lucide="linkedin"></i>
|
||||
</a>
|
||||
<a href="https://x.com/BentoPDF" class="text-gray-400 hover:text-indigo-400" title="X (Twitter)">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="../js/utils/lucide-init.ts"></script>
|
||||
<script type="module" src="../js/utils/full-width.ts"></script>
|
||||
<script type="module" src="../js/utils/simple-mode-footer.ts"></script>
|
||||
<script type="module" src="../version.ts"></script>
|
||||
<script type="module" src="../js/logic/repair-pdf-page.ts"></script>
|
||||
<script type="module" src="../js/mobileMenu.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Generate Table of Contents - BentoPDF</title>
|
||||
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="../../src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
+4
-1
@@ -5,7 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Terms and Conditions - Bentopdf</title>
|
||||
<link rel="icon" type="image/png" href="images/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link href="/src/css/styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'pdf-multi-tool': resolve(__dirname, 'src/pages/pdf-multi-tool.html'),
|
||||
'add-stamps': resolve(__dirname, 'src/pages/add-stamps.html'),
|
||||
'form-creator': resolve(__dirname, 'src/pages/form-creator.html'),
|
||||
'repair-pdf': resolve(__dirname, 'src/pages/repair-pdf.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user