feat: expand conversion matrix to everything-to-everything

- Documents: full cross-format (PDF, DOCX, HTML, MD, TXT, RTF) via pdfjs-dist, docx, mammoth, jsPDF, marked
- Images: add TIFF and ICO encoding (custom binary encoders for browser support)
- Data: add TOML support via smol-toml, all formats cross-convert
- Media: fix webm codec (VP8+Vorbis instead of libx264), expand video-to-audio extraction matrix
- Add missing MIME types (docx, rtf, toml, tiff, ico, opus, wma, flv, wmv, m4v)
- Remove unused pdf-lib dependency
- Fix pdfjs-dist TextItem type error with proper type guard
This commit is contained in:
noah
2026-03-09 19:03:16 +01:00
parent e505c29c6a
commit da49498835
8 changed files with 1138 additions and 334 deletions
+377 -54
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"docx": "^9.6.0",
"fast-xml-parser": "^5.4.2", "fast-xml-parser": "^5.4.2",
"framer-motion": "^12.35.2", "framer-motion": "^12.35.2",
"html2canvas-pro": "^2.0.2", "html2canvas-pro": "^2.0.2",
@@ -20,9 +21,10 @@
"marked": "^17.0.4", "marked": "^17.0.4",
"next": "16.1.6", "next": "16.1.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pdf-lib": "^1.17.1", "pdfjs-dist": "^5.5.207",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"smol-toml": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1074,6 +1076,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.96.tgz",
"integrity": "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.96",
"@napi-rs/canvas-darwin-arm64": "0.1.96",
"@napi-rs/canvas-darwin-x64": "0.1.96",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.96",
"@napi-rs/canvas-linux-arm64-musl": "0.1.96",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.96",
"@napi-rs/canvas-linux-x64-gnu": "0.1.96",
"@napi-rs/canvas-linux-x64-musl": "0.1.96",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.96",
"@napi-rs/canvas-win32-x64-msvc": "0.1.96"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.96.tgz",
"integrity": "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.96.tgz",
"integrity": "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.96.tgz",
"integrity": "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.96.tgz",
"integrity": "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.96.tgz",
"integrity": "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.96.tgz",
"integrity": "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.96.tgz",
"integrity": "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.96.tgz",
"integrity": "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.96.tgz",
"integrity": "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.96.tgz",
"integrity": "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.96",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.96.tgz",
"integrity": "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -1279,36 +1531,6 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"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",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pdf-lib/upng/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3022,6 +3244,56 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/docx": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.0.tgz",
"integrity": "sha512-y6EaJJMDvt4P7wgGQB9KsZf4wsRkQMJfkc9LlNufRshggI5BT35hGNkXBCAeEoI3MLMwApKguxzjdqqVcBCqNA==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.3.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
@@ -4208,6 +4480,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -5369,6 +5651,12 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -5555,6 +5843,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -5820,30 +6115,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-lib": { "node_modules/pdfjs-dist": {
"version": "1.17.1", "version": "5.5.207",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
"license": "MIT", "license": "Apache-2.0",
"dependencies": { "engines": {
"@pdf-lib/standard-fonts": "^1.0.0", "node": ">=20.19.0 || >=22.13.0 || >=24"
"@pdf-lib/upng": "^1.0.1", },
"pako": "^1.0.11", "optionalDependencies": {
"tslib": "^1.11.1" "@napi-rs/canvas": "^0.1.95",
"node-readable-to-web-readable-stream": "^0.4.2"
} }
}, },
"node_modules/pdf-lib/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/performance-now": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -6225,6 +6509,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6453,6 +6746,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/smol-toml": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7218,6 +7523,24 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "10.1.1", "version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
+4 -2
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"docx": "^9.6.0",
"fast-xml-parser": "^5.4.2", "fast-xml-parser": "^5.4.2",
"framer-motion": "^12.35.2", "framer-motion": "^12.35.2",
"html2canvas-pro": "^2.0.2", "html2canvas-pro": "^2.0.2",
@@ -21,9 +22,10 @@
"marked": "^17.0.4", "marked": "^17.0.4",
"next": "16.1.6", "next": "16.1.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pdf-lib": "^1.17.1", "pdfjs-dist": "^5.5.207",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"smol-toml": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+45 -42
View File
@@ -1,57 +1,59 @@
import { FileCategory } from '@/types'; import { FileCategory } from '@/types';
const IMAGE_CONVERSIONS: Record<string, string[]> = { const IMAGE_CONVERSIONS: Record<string, string[]> = {
png: ['jpg', 'webp', 'gif', 'bmp', 'avif'], png: ['jpg', 'webp', 'gif', 'bmp', 'avif', 'tiff', 'ico'],
jpg: ['png', 'webp', 'gif', 'bmp', 'avif'], jpg: ['png', 'webp', 'gif', 'bmp', 'avif', 'tiff', 'ico'],
jpeg: ['png', 'webp', 'gif', 'bmp', 'avif'], jpeg: ['png', 'webp', 'gif', 'bmp', 'avif', 'tiff', 'ico'],
webp: ['png', 'jpg', 'gif', 'bmp', 'avif'], webp: ['png', 'jpg', 'gif', 'bmp', 'avif', 'tiff', 'ico'],
gif: ['png', 'jpg', 'webp', 'bmp'], gif: ['png', 'jpg', 'webp', 'bmp', 'avif', 'tiff'],
bmp: ['png', 'jpg', 'webp', 'gif'], bmp: ['png', 'jpg', 'webp', 'gif', 'avif', 'tiff'],
tiff: ['png', 'jpg', 'webp'], tiff: ['png', 'jpg', 'webp', 'gif', 'bmp', 'avif'],
tif: ['png', 'jpg', 'webp'], tif: ['png', 'jpg', 'webp', 'gif', 'bmp', 'avif'],
avif: ['png', 'jpg', 'webp'], avif: ['png', 'jpg', 'webp', 'gif', 'bmp', 'tiff'],
svg: ['png', 'jpg', 'webp'], svg: ['png', 'jpg', 'webp', 'gif', 'bmp', 'avif', 'tiff'],
ico: ['png', 'jpg', 'webp'], ico: ['png', 'jpg', 'webp', 'gif', 'bmp'],
}; };
const DOCUMENT_CONVERSIONS: Record<string, string[]> = { const DOCUMENT_CONVERSIONS: Record<string, string[]> = {
docx: ['html', 'txt', 'pdf'], pdf: ['txt', 'html', 'md', 'docx'],
md: ['html', 'pdf', 'txt'], docx: ['pdf', 'html', 'txt', 'md'],
html: ['pdf', 'txt', 'md'], md: ['html', 'pdf', 'txt', 'docx'],
htm: ['pdf', 'txt', 'md'], html: ['pdf', 'txt', 'md', 'docx'],
txt: ['pdf', 'html', 'md'], htm: ['pdf', 'txt', 'md', 'docx'],
pdf: ['txt'], txt: ['pdf', 'html', 'md', 'docx'],
rtf: ['txt', 'html', 'md', 'pdf', 'docx'],
}; };
const AUDIO_CONVERSIONS: Record<string, string[]> = { const AUDIO_CONVERSIONS: Record<string, string[]> = {
mp3: ['wav', 'ogg', 'aac', 'flac', 'm4a'], mp3: ['wav', 'ogg', 'aac', 'flac', 'm4a', 'opus'],
wav: ['mp3', 'ogg', 'aac', 'flac', 'm4a'], wav: ['mp3', 'ogg', 'aac', 'flac', 'm4a', 'opus'],
flac: ['mp3', 'wav', 'ogg', 'aac', 'm4a'], flac: ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'opus'],
ogg: ['mp3', 'wav', 'aac', 'flac', 'm4a'], ogg: ['mp3', 'wav', 'aac', 'flac', 'm4a', 'opus'],
aac: ['mp3', 'wav', 'ogg', 'flac', 'm4a'], aac: ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'opus'],
m4a: ['mp3', 'wav', 'ogg', 'flac', 'aac'], m4a: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'opus'],
wma: ['mp3', 'wav', 'ogg', 'flac'], wma: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'],
opus: ['mp3', 'wav', 'ogg', 'flac'], opus: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'],
}; };
const VIDEO_CONVERSIONS: Record<string, string[]> = { const VIDEO_CONVERSIONS: Record<string, string[]> = {
mp4: ['webm', 'avi', 'mov', 'gif', 'mp3'], mp4: ['webm', 'avi', 'mov', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
webm: ['mp4', 'avi', 'mov', 'gif', 'mp3'], webm: ['mp4', 'avi', 'mov', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
avi: ['mp4', 'webm', 'mov', 'gif', 'mp3'], avi: ['mp4', 'webm', 'mov', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
mov: ['mp4', 'webm', 'avi', 'gif', 'mp3'], mov: ['mp4', 'webm', 'avi', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
mkv: ['mp4', 'webm', 'avi', 'gif', 'mp3'], mkv: ['mp4', 'webm', 'avi', 'mov', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
flv: ['mp4', 'webm', 'avi', 'mp3'], flv: ['mp4', 'webm', 'avi', 'mov', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
wmv: ['mp4', 'webm', 'avi', 'mp3'], wmv: ['mp4', 'webm', 'avi', 'mov', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
m4v: ['mp4', 'webm', 'avi', 'mp3'], m4v: ['mp4', 'webm', 'avi', 'mov', 'mkv', 'gif', 'mp3', 'wav', 'ogg', 'aac', 'flac'],
}; };
const DATA_CONVERSIONS: Record<string, string[]> = { const DATA_CONVERSIONS: Record<string, string[]> = {
csv: ['json', 'xml', 'yaml', 'tsv'], csv: ['json', 'xml', 'yaml', 'tsv', 'toml'],
json: ['csv', 'xml', 'yaml'], json: ['csv', 'xml', 'yaml', 'tsv', 'toml'],
xml: ['json', 'csv', 'yaml'], xml: ['json', 'csv', 'yaml', 'tsv', 'toml'],
yaml: ['json', 'csv', 'xml'], yaml: ['json', 'csv', 'xml', 'tsv', 'toml'],
yml: ['json', 'csv', 'xml'], yml: ['json', 'csv', 'xml', 'tsv', 'toml'],
tsv: ['csv', 'json', 'xml', 'yaml'], tsv: ['csv', 'json', 'xml', 'yaml', 'toml'],
toml: ['json', 'csv', 'xml', 'yaml', 'tsv'],
}; };
const ALL_CONVERSIONS: Record<FileCategory, Record<string, string[]>> = { const ALL_CONVERSIONS: Record<FileCategory, Record<string, string[]>> = {
@@ -75,14 +77,15 @@ export function getDefaultTarget(category: FileCategory, extension: string): str
// Images → WebP (modern, smaller) // Images → WebP (modern, smaller)
png: 'webp', jpg: 'webp', jpeg: 'webp', gif: 'webp', png: 'webp', jpg: 'webp', jpeg: 'webp', gif: 'webp',
bmp: 'png', tiff: 'png', tif: 'png', avif: 'png', svg: 'png', ico: 'png', bmp: 'png', tiff: 'png', tif: 'png', avif: 'png', svg: 'png', ico: 'png',
// Documents → PDF // Documents → PDF (except PDF → DOCX)
docx: 'pdf', md: 'html', html: 'pdf', txt: 'pdf', pdf: 'txt', docx: 'pdf', md: 'html', html: 'pdf', htm: 'pdf', txt: 'pdf',
pdf: 'docx', rtf: 'docx',
// Audio → MP3 // Audio → MP3
wav: 'mp3', flac: 'mp3', ogg: 'mp3', aac: 'mp3', m4a: 'mp3', wma: 'mp3', opus: 'mp3', mp3: 'wav', wav: 'mp3', flac: 'mp3', ogg: 'mp3', aac: 'mp3', m4a: 'mp3', wma: 'mp3', opus: 'mp3', mp3: 'wav',
// Video → MP4 // Video → MP4
avi: 'mp4', mov: 'mp4', mkv: 'mp4', flv: 'mp4', wmv: 'mp4', m4v: 'mp4', mp4: 'webm', webm: 'mp4', avi: 'mp4', mov: 'mp4', mkv: 'mp4', flv: 'mp4', wmv: 'mp4', m4v: 'mp4', mp4: 'webm', webm: 'mp4',
// Data → JSON // Data → JSON
csv: 'json', xml: 'json', yaml: 'json', yml: 'json', tsv: 'csv', json: 'csv', csv: 'json', xml: 'json', yaml: 'json', yml: 'json', tsv: 'csv', json: 'csv', toml: 'json',
}; };
return defaults[extension] || formats[0]; return defaults[extension] || formats[0];
+17 -2
View File
@@ -52,6 +52,17 @@ function yamlToJson(text: string): unknown {
return yaml.load(text); return yaml.load(text);
} }
async function tomlToJson(text: string): Promise<unknown> {
const TOML = await import('smol-toml');
return TOML.parse(text);
}
async function jsonToToml(data: unknown): Promise<string> {
const TOML = await import('smol-toml');
const obj = typeof data === 'string' ? JSON.parse(data) : data;
return TOML.stringify(obj as Record<string, unknown>);
}
async function toIntermediate(file: File, ext: string): Promise<unknown> { async function toIntermediate(file: File, ext: string): Promise<unknown> {
const text = await readFileAsText(file); const text = await readFileAsText(file);
@@ -67,12 +78,14 @@ async function toIntermediate(file: File, ext: string): Promise<unknown> {
case 'yaml': case 'yaml':
case 'yml': case 'yml':
return yamlToJson(text); return yamlToJson(text);
case 'toml':
return tomlToJson(text);
default: default:
throw new Error(`Unsupported source format: ${ext}`); throw new Error(`Unsupported source format: ${ext}`);
} }
} }
function fromIntermediate(data: unknown, targetFormat: string): string { async function fromIntermediate(data: unknown, targetFormat: string): Promise<string> {
switch (targetFormat) { switch (targetFormat) {
case 'json': case 'json':
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
@@ -85,6 +98,8 @@ function fromIntermediate(data: unknown, targetFormat: string): string {
case 'yaml': case 'yaml':
case 'yml': case 'yml':
return jsonToYaml(data); return jsonToYaml(data);
case 'toml':
return jsonToToml(data);
default: default:
throw new Error(`Unsupported target format: ${targetFormat}`); throw new Error(`Unsupported target format: ${targetFormat}`);
} }
@@ -101,7 +116,7 @@ export async function convertData(
const intermediate = await toIntermediate(file, ext); const intermediate = await toIntermediate(file, ext);
onProgress?.(60); onProgress?.(60);
const output = fromIntermediate(intermediate, targetFormat); const output = await fromIntermediate(intermediate, targetFormat);
onProgress?.(90); onProgress?.(90);
const blob = new Blob([output], { type: getMimeType(targetFormat) }); const blob = new Blob([output], { type: getMimeType(targetFormat) });
+436 -224
View File
@@ -26,12 +26,6 @@ async function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
/* ============================================ /* ============================================
Styled HTML document wrapper Styled HTML document wrapper
This is used for ALL HTML output and as the
intermediate step for PDF rendering. Embeds
full CSS so the document looks correct both
as a standalone .html file and when rendered
to PDF via jsPDF.html().
============================================ */ ============================================ */
function wrapInStyledHtml(bodyHtml: string, title: string): string { function wrapInStyledHtml(bodyHtml: string, title: string): string {
@@ -42,9 +36,7 @@ function wrapInStyledHtml(bodyHtml: string, title: string): string {
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title> <title>${escapeHtml(title)}</title>
<style> <style>
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px; font-size: 14px;
@@ -55,132 +47,40 @@ function wrapInStyledHtml(bodyHtml: string, title: string): string {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
} }
/* Headings */
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin-top: 1.4em; margin-top: 1.4em; margin-bottom: 0.6em; font-weight: 700; line-height: 1.3; color: #111111;
margin-bottom: 0.6em;
font-weight: 700;
line-height: 1.3;
color: #111111;
} }
h1 { font-size: 2em; border-bottom: 2px solid #e5e5e5; padding-bottom: 0.3em; } h1 { font-size: 2em; border-bottom: 2px solid #e5e5e5; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eeeeee; padding-bottom: 0.25em; } h2 { font-size: 1.5em; border-bottom: 1px solid #eeeeee; padding-bottom: 0.25em; }
h3 { font-size: 1.25em; } h3 { font-size: 1.25em; }
h4 { font-size: 1.1em; } h4 { font-size: 1.1em; }
h5, h6 { font-size: 1em; color: #555555; } h5, h6 { font-size: 1em; color: #555555; }
/* Paragraphs & inline */
p { margin-bottom: 1em; } p { margin-bottom: 1em; }
strong, b { font-weight: 700; } strong, b { font-weight: 700; }
em, i { font-style: italic; } em, i { font-style: italic; }
u { text-decoration: underline; }
s, strike, del { text-decoration: line-through; color: #888; }
small { font-size: 0.85em; }
sup { vertical-align: super; font-size: 0.75em; }
sub { vertical-align: sub; font-size: 0.75em; }
mark { background: #fff3b0; padding: 0.1em 0.2em; border-radius: 2px; }
abbr { text-decoration: underline dotted; cursor: help; }
/* Links */
a { color: #0066cc; text-decoration: underline; } a { color: #0066cc; text-decoration: underline; }
a:hover { color: #004499; }
/* Lists */
ul, ol { margin-bottom: 1em; padding-left: 2em; } ul, ol { margin-bottom: 1em; padding-left: 2em; }
ul ul, ol ol, ul ol, ol ul { margin-bottom: 0; }
li { margin-bottom: 0.3em; } li { margin-bottom: 0.3em; }
li > p { margin-bottom: 0.3em; }
/* Blockquote */
blockquote { blockquote {
margin: 1em 0; margin: 1em 0; padding: 0.8em 1.2em; border-left: 4px solid #0066cc;
padding: 0.8em 1.2em; background: #f6f8fa; color: #333; font-style: italic;
border-left: 4px solid #0066cc;
background: #f6f8fa;
color: #333;
font-style: italic;
} }
blockquote p:last-child { margin-bottom: 0; } blockquote p:last-child { margin-bottom: 0; }
/* Code */
code { code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9em; font-size: 0.9em; background: #f0f0f0; padding: 0.15em 0.4em; border-radius: 3px; color: #c7254e;
background: #f0f0f0;
padding: 0.15em 0.4em;
border-radius: 3px;
color: #c7254e;
} }
pre { pre {
margin: 1em 0; margin: 1em 0; padding: 1em; background: #f6f8fa; border: 1px solid #e1e4e8;
padding: 1em; border-radius: 6px; overflow-x: auto; font-size: 0.9em; line-height: 1.5;
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
overflow-x: auto;
font-size: 0.9em;
line-height: 1.5;
}
pre code {
background: none;
padding: 0;
border-radius: 0;
color: inherit;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 0.95em;
}
th, td {
padding: 8px 12px;
border: 1px solid #d0d7de;
text-align: left;
vertical-align: top;
}
th {
background: #f6f8fa;
font-weight: 700;
color: #111;
} }
pre code { background: none; padding: 0; border-radius: 0; color: inherit; }
table { width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 0.95em; }
th, td { padding: 8px 12px; border: 1px solid #d0d7de; text-align: left; vertical-align: top; }
th { background: #f6f8fa; font-weight: 700; color: #111; }
tr:nth-child(even) { background: #fafbfc; } tr:nth-child(even) { background: #fafbfc; }
caption { hr { border: none; border-top: 1px solid #e5e5e5; margin: 2em 0; }
caption-side: bottom; img { max-width: 100%; height: auto; border-radius: 4px; margin: 1em 0; }
padding: 8px;
font-size: 0.9em;
color: #666;
font-style: italic;
}
/* Horizontal rule */
hr {
border: none;
border-top: 1px solid #e5e5e5;
margin: 2em 0;
}
/* Images embedded in documents */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1em 0;
}
/* Definition lists */
dl { margin-bottom: 1em; }
dt { font-weight: 700; margin-top: 0.5em; }
dd { margin-left: 2em; margin-bottom: 0.5em; }
/* Figure */
figure { margin: 1.5em 0; text-align: center; }
figcaption { font-size: 0.9em; color: #666; margin-top: 0.5em; font-style: italic; }
/* First element shouldn't have top margin */
body > *:first-child { margin-top: 0; } body > *:first-child { margin-top: 0; }
</style> </style>
</head> </head>
@@ -198,6 +98,313 @@ function escapeHtml(text: string): string {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
/* ============================================
PDF text extraction via pdfjs-dist
============================================ */
async function pdfToText(file: File): Promise<string> {
const pdfjsLib = await import('pdfjs-dist');
// Use the bundled worker
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const textParts: string[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
const pageText = content.items
.filter((item) => 'str' in item)
.map((item) => (item as { str: string }).str)
.join(' ');
if (pageText.trim()) {
textParts.push(pageText);
}
}
if (textParts.length === 0) {
return `[This PDF contains no extractable text — it may be image-based/scanned.]`;
}
return textParts.join('\n\n');
}
/* ============================================
PDF → HTML
Extracts text per page, wraps in styled HTML
============================================ */
async function pdfToHtml(file: File): Promise<string> {
const text = await pdfToText(file);
const paragraphs = text.split(/\n\n+/).filter(Boolean);
const bodyHtml = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('\n');
return wrapInStyledHtml(bodyHtml, file.name.replace(/\.pdf$/i, ''));
}
/* ============================================
PDF → Markdown
============================================ */
async function pdfToMarkdown(file: File): Promise<string> {
const text = await pdfToText(file);
// Attempt to detect headings (ALL CAPS lines, short lines)
const lines = text.split('\n');
const mdLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
mdLines.push('');
continue;
}
// Heuristic: short all-caps lines are likely headings
if (trimmed.length < 80 && trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed)) {
mdLines.push(`## ${trimmed}`);
} else {
mdLines.push(trimmed);
}
}
return mdLines.join('\n');
}
/* ============================================
PDF → DOCX
Extracts text, builds DOCX using docx package
============================================ */
async function pdfToDocx(file: File): Promise<Blob> {
const text = await pdfToText(file);
return textToDocx(text);
}
/* ============================================
Text/HTML/MD → DOCX generation using docx pkg
============================================ */
async function textToDocx(text: string): Promise<Blob> {
const docx = await import('docx');
const paragraphs = text.split(/\n\n+/).filter(Boolean);
const children = paragraphs.map(
(p) =>
new docx.Paragraph({
children: [new docx.TextRun({ text: p, size: 24 })],
spacing: { after: 200 },
})
);
const doc = new docx.Document({
sections: [{ children }],
});
return await docx.Packer.toBlob(doc);
}
async function htmlToDocx(html: string): Promise<Blob> {
// Convert HTML to plain text, then build DOCX
const plainText = htmlToText(html);
return textToDocx(plainText);
}
async function markdownToDocx(mdText: string): Promise<Blob> {
const docx = await import('docx');
const lines = mdText.split('\n');
const children: InstanceType<typeof docx.Paragraph>[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Headings
const h1Match = line.match(/^#\s+(.+)/);
const h2Match = line.match(/^##\s+(.+)/);
const h3Match = line.match(/^###\s+(.+)/);
const h4Match = line.match(/^####\s+(.+)/);
if (h1Match) {
children.push(
new docx.Paragraph({
children: [new docx.TextRun({ text: h1Match[1], bold: true, size: 48 })],
heading: docx.HeadingLevel.HEADING_1,
spacing: { after: 200 },
})
);
} else if (h2Match) {
children.push(
new docx.Paragraph({
children: [new docx.TextRun({ text: h2Match[1], bold: true, size: 36 })],
heading: docx.HeadingLevel.HEADING_2,
spacing: { after: 160 },
})
);
} else if (h3Match) {
children.push(
new docx.Paragraph({
children: [new docx.TextRun({ text: h3Match[1], bold: true, size: 28 })],
heading: docx.HeadingLevel.HEADING_3,
spacing: { after: 120 },
})
);
} else if (h4Match) {
children.push(
new docx.Paragraph({
children: [new docx.TextRun({ text: h4Match[1], bold: true, size: 24 })],
heading: docx.HeadingLevel.HEADING_4,
spacing: { after: 100 },
})
);
}
// Unordered list
else if (line.match(/^[-*+]\s+/)) {
children.push(
new docx.Paragraph({
children: parseInlineMarkdown(docx, line.replace(/^[-*+]\s+/, '')),
bullet: { level: 0 },
})
);
}
// Ordered list
else if (line.match(/^\d+\.\s+/)) {
children.push(
new docx.Paragraph({
children: parseInlineMarkdown(docx, line.replace(/^\d+\.\s+/, '')),
numbering: { reference: 'default-numbering', level: 0 },
})
);
}
// Blockquote
else if (line.startsWith('>')) {
children.push(
new docx.Paragraph({
children: [
new docx.TextRun({
text: line.replace(/^>\s*/, ''),
italics: true,
color: '555555',
size: 24,
}),
],
indent: { left: 720 },
border: {
left: { style: docx.BorderStyle.SINGLE, size: 6, color: '0066cc', space: 10 },
},
spacing: { after: 120 },
})
);
}
// Horizontal rule
else if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
children.push(
new docx.Paragraph({
children: [],
border: {
bottom: { style: docx.BorderStyle.SINGLE, size: 1, color: 'CCCCCC', space: 10 },
},
spacing: { before: 200, after: 200 },
})
);
}
// Code block
else if (line.startsWith('```')) {
i++;
const codeLines: string[] = [];
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
children.push(
new docx.Paragraph({
children: [
new docx.TextRun({
text: codeLines.join('\n'),
font: 'Courier New',
size: 20,
}),
],
shading: { type: docx.ShadingType.SOLID, color: 'F6F8FA' },
spacing: { before: 120, after: 120 },
})
);
}
// Empty line
else if (line.trim() === '') {
children.push(new docx.Paragraph({ children: [], spacing: { after: 120 } }));
}
// Regular paragraph
else {
children.push(
new docx.Paragraph({
children: parseInlineMarkdown(docx, line),
spacing: { after: 160 },
})
);
}
i++;
}
const doc = new docx.Document({
numbering: {
config: [
{
reference: 'default-numbering',
levels: [
{
level: 0,
format: docx.LevelFormat.DECIMAL,
text: '%1.',
alignment: docx.AlignmentType.START,
},
],
},
],
},
sections: [{ children }],
});
return await docx.Packer.toBlob(doc);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
function parseInlineMarkdown(docx: any, text: string): any[] {
const runs: any[] = [];
// Regex to detect **bold**, *italic*, `code`, ~~strikethrough~~
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|~~(.+?)~~|([^*`~]+))/g;
let match;
while ((match = regex.exec(text)) !== null) {
if (match[2]) {
// Bold
runs.push(new docx.TextRun({ text: match[2], bold: true, size: 24 }));
} else if (match[3]) {
// Italic
runs.push(new docx.TextRun({ text: match[3], italics: true, size: 24 }));
} else if (match[4]) {
// Code
runs.push(
new docx.TextRun({ text: match[4], font: 'Courier New', size: 22, color: 'C7254E' })
);
} else if (match[5]) {
// Strikethrough
runs.push(new docx.TextRun({ text: match[5], strike: true, size: 24 }));
} else if (match[6]) {
// Plain text
runs.push(new docx.TextRun({ text: match[6], size: 24 }));
}
}
if (runs.length === 0) {
runs.push(new docx.TextRun({ text, size: 24 }));
}
return runs;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/* ============================================ /* ============================================
Source → HTML conversions Source → HTML conversions
============================================ */ ============================================ */
@@ -205,9 +412,7 @@ function escapeHtml(text: string): string {
async function docxToHtml(file: File): Promise<string> { async function docxToHtml(file: File): Promise<string> {
const mammoth = await import('mammoth'); const mammoth = await import('mammoth');
const arrayBuffer = await readFileAsArrayBuffer(file); const arrayBuffer = await readFileAsArrayBuffer(file);
const result = await mammoth.convertToHtml({ const result = await mammoth.convertToHtml({ arrayBuffer });
arrayBuffer,
});
return result.value; return result.value;
} }
@@ -218,6 +423,11 @@ async function docxToText(file: File): Promise<string> {
return result.value; return result.value;
} }
async function docxToMarkdown(file: File): Promise<string> {
const bodyHtml = await docxToHtml(file);
return htmlToMarkdown(bodyHtml);
}
async function markdownToHtml(text: string): Promise<string> { async function markdownToHtml(text: string): Promise<string> {
const { marked } = await import('marked'); const { marked } = await import('marked');
return await marked(text); return await marked(text);
@@ -230,7 +440,6 @@ function htmlToText(html: string): string {
} }
function htmlToMarkdown(html: string): string { function htmlToMarkdown(html: string): string {
// Parse properly using DOMParser for reliable conversion
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
@@ -238,7 +447,6 @@ function htmlToMarkdown(html: string): string {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ''; return node.textContent || '';
} }
if (node.nodeType !== Node.ELEMENT_NODE) return ''; if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as Element; const el = node as Element;
@@ -293,32 +501,20 @@ function htmlToMarkdown(html: string): string {
case 'table': { case 'table': {
const rows = Array.from(el.querySelectorAll('tr')); const rows = Array.from(el.querySelectorAll('tr'));
if (rows.length === 0) return children; if (rows.length === 0) return children;
const tableData: string[][] = rows.map(row => const tableData: string[][] = rows.map(row =>
Array.from(row.querySelectorAll('th, td')).map(cell => walk(cell).trim()) Array.from(row.querySelectorAll('th, td')).map(cell => walk(cell).trim())
); );
if (tableData.length === 0) return ''; if (tableData.length === 0) return '';
const colCount = Math.max(...tableData.map(r => r.length)); const colCount = Math.max(...tableData.map(r => r.length));
const colWidths = Array.from({ length: colCount }, (_, i) => const colWidths = Array.from({ length: colCount }, (_, i) =>
Math.max(3, ...tableData.map(r => (r[i] || '').length)) Math.max(3, ...tableData.map(r => (r[i] || '').length))
); );
const formatRow = (row: string[]) => const formatRow = (row: string[]) =>
'| ' + colWidths.map((w, i) => (row[i] || '').padEnd(w)).join(' | ') + ' |'; '| ' + colWidths.map((w, i) => (row[i] || '').padEnd(w)).join(' | ') + ' |';
const separator = '| ' + colWidths.map(w => '-'.repeat(w)).join(' | ') + ' |'; const separator = '| ' + colWidths.map(w => '-'.repeat(w)).join(' | ') + ' |';
const lines = [formatRow(tableData[0]), separator, ...tableData.slice(1).map(formatRow)]; const lines = [formatRow(tableData[0]), separator, ...tableData.slice(1).map(formatRow)];
return lines.join('\n') + '\n\n'; return lines.join('\n') + '\n\n';
} }
case 'div':
case 'section':
case 'article':
case 'main':
case 'span':
return children;
default: default:
return children; return children;
} }
@@ -328,34 +524,61 @@ function htmlToMarkdown(html: string): string {
} }
/* ============================================ /* ============================================
HTML → PDF via jsPDF.html() RTF → text (basic extraction)
============================================ */
Renders a styled HTML document into a real function rtfToText(rtf: string): string {
PDF by injecting it into a hidden DOM container // Strip RTF control words and groups, extract plain text
and using jsPDF's html() method (backed by let text = rtf;
html2canvas) to capture the visual rendering. // Remove header up to first \pard
const pardIndex = text.indexOf('\\pard');
if (pardIndex > 0) {
// Keep content from first \pard onwards but strip the \pard itself
text = text.substring(pardIndex);
}
// Handle common RTF escapes
text = text.replace(/\\par\b/g, '\n');
text = text.replace(/\\tab\b/g, '\t');
text = text.replace(/\\line\b/g, '\n');
text = text.replace(/\\\n/g, '\n');
text = text.replace(/\\pard[^\\]*/g, '');
// Remove {\*\...} groups (destinations we don't care about)
text = text.replace(/\{\\\*\\[^}]*\}/g, '');
// Remove remaining RTF commands (\word or \wordN)
text = text.replace(/\\[a-z]+\d*\s?/gi, '');
// Remove braces
text = text.replace(/[{}]/g, '');
// Handle unicode escapes \\uN
text = text.replace(/\\u(\d+)\??/g, (_, code) => String.fromCharCode(parseInt(code)));
// Handle hex escapes \\'XX
text = text.replace(/\\'([0-9a-fA-F]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
// Clean up
text = text.replace(/\r\n/g, '\n');
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
}
/* ============================================
HTML → PDF via jsPDF.html()
============================================ */ ============================================ */
async function renderHtmlToPdf(htmlContent: string): Promise<Blob> { async function renderHtmlToPdf(htmlContent: string): Promise<Blob> {
const { jsPDF } = await import('jspdf'); const { jsPDF } = await import('jspdf');
// html2canvas-pro is imported for its side-effect:
// jsPDF.html() looks for it on the window/global scope
const html2canvas = (await import('html2canvas-pro')).default; const html2canvas = (await import('html2canvas-pro')).default;
// Create a hidden container for rendering
const container = document.createElement('div'); const container = document.createElement('div');
container.style.position = 'fixed'; container.style.position = 'fixed';
container.style.left = '-10000px'; container.style.left = '-10000px';
container.style.top = '0'; container.style.top = '0';
container.style.width = '794px'; // A4 width in px at 96dpi container.style.width = '794px';
container.style.background = '#ffffff'; container.style.background = '#ffffff';
container.style.zIndex = '-9999'; container.style.zIndex = '-9999';
// Parse the HTML and inject just the body + styles
const parser = new DOMParser(); const parser = new DOMParser();
const parsed = parser.parseFromString(htmlContent, 'text/html'); const parsed = parser.parseFromString(htmlContent, 'text/html');
// Apply styles inline
const styleEl = parsed.querySelector('style'); const styleEl = parsed.querySelector('style');
const bodyContent = parsed.body.innerHTML; const bodyContent = parsed.body.innerHTML;
@@ -375,19 +598,15 @@ async function renderHtmlToPdf(htmlContent: string): Promise<Blob> {
container.appendChild(content); container.appendChild(content);
document.body.appendChild(container); document.body.appendChild(container);
// Wait for fonts/images to load
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
try { try {
// A4 dimensions in mm: 210 x 297
const pdfWidth = 210; const pdfWidth = 210;
const pdfHeight = 297; const pdfHeight = 297;
const margin = 15; // mm const margin = 15;
// Capture the rendered content as a canvas
const canvas = await html2canvas(content, { const canvas = await html2canvas(content, {
scale: 2, // Higher resolution scale: 2,
useCORS: true, useCORS: true,
allowTaint: true, allowTaint: true,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -395,7 +614,6 @@ async function renderHtmlToPdf(htmlContent: string): Promise<Blob> {
windowWidth: 794, windowWidth: 794,
}); });
// Calculate how the content maps to PDF pages
const imgWidth = pdfWidth - margin * 2; const imgWidth = pdfWidth - margin * 2;
const imgHeight = (canvas.height * imgWidth) / canvas.width; const imgHeight = (canvas.height * imgWidth) / canvas.width;
@@ -403,57 +621,27 @@ async function renderHtmlToPdf(htmlContent: string): Promise<Blob> {
const pageContentHeight = pdfHeight - margin * 2; const pageContentHeight = pdfHeight - margin * 2;
if (imgHeight <= pageContentHeight) { if (imgHeight <= pageContentHeight) {
// Single page — fits entirely doc.addImage(canvas.toDataURL('image/jpeg', 0.95), 'JPEG', margin, margin, imgWidth, imgHeight);
doc.addImage(
canvas.toDataURL('image/jpeg', 0.95),
'JPEG',
margin,
margin,
imgWidth,
imgHeight
);
} else { } else {
// Multi-page — slice the canvas into page-sized chunks
const totalPages = Math.ceil(imgHeight / pageContentHeight); const totalPages = Math.ceil(imgHeight / pageContentHeight);
for (let page = 0; page < totalPages; page++) { for (let page = 0; page < totalPages; page++) {
if (page > 0) doc.addPage(); if (page > 0) doc.addPage();
// Calculate the portion of the source canvas for this page
const sourceY = (page * pageContentHeight * canvas.width) / imgWidth; const sourceY = (page * pageContentHeight * canvas.width) / imgWidth;
const sourceHeight = Math.min( const sourceHeight = Math.min(
(pageContentHeight * canvas.width) / imgWidth, (pageContentHeight * canvas.width) / imgWidth,
canvas.height - sourceY canvas.height - sourceY
); );
// Create a canvas slice for this page
const pageCanvas = document.createElement('canvas'); const pageCanvas = document.createElement('canvas');
pageCanvas.width = canvas.width; pageCanvas.width = canvas.width;
pageCanvas.height = sourceHeight; pageCanvas.height = sourceHeight;
const ctx = pageCanvas.getContext('2d'); const ctx = pageCanvas.getContext('2d');
if (ctx) { if (ctx) {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height); ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
ctx.drawImage( ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight);
canvas,
0, sourceY,
canvas.width, sourceHeight,
0, 0,
canvas.width, sourceHeight
);
} }
const sliceHeight = (sourceHeight * imgWidth) / canvas.width; const sliceHeight = (sourceHeight * imgWidth) / canvas.width;
doc.addImage(pageCanvas.toDataURL('image/jpeg', 0.95), 'JPEG', margin, margin, imgWidth, sliceHeight);
doc.addImage(
pageCanvas.toDataURL('image/jpeg', 0.95),
'JPEG',
margin,
margin,
imgWidth,
sliceHeight
);
} }
} }
@@ -464,9 +652,7 @@ async function renderHtmlToPdf(htmlContent: string): Promise<Blob> {
} }
/* ============================================ /* ============================================
Plain text → PDF (for .txt files) Plain text → PDF
Still uses jsPDF.text() since plain text
has no formatting to preserve.
============================================ */ ============================================ */
async function plainTextToPdf(text: string): Promise<Blob> { async function plainTextToPdf(text: string): Promise<Blob> {
@@ -493,37 +679,11 @@ async function plainTextToPdf(text: string): Promise<Blob> {
} }
/* ============================================ /* ============================================
PDF → Text extraction Main export — full conversion matrix
============================================ */
async function pdfToText(file: File): Promise<string> { Source formats: pdf, docx, md, html, htm, txt, rtf
const { PDFDocument } = await import('pdf-lib'); Each can convert to: pdf, docx, html, md, txt
const arrayBuffer = await readFileAsArrayBuffer(file); (minus converting to its own format)
const pdfDoc = await PDFDocument.load(arrayBuffer);
const pages = pdfDoc.getPages();
let text = `PDF Document: ${file.name}\n`;
text += `Pages: ${pages.length}\n\n`;
const form = pdfDoc.getForm();
try {
const fields = form.getFields();
if (fields.length > 0) {
text += `Form Fields:\n`;
fields.forEach((field) => {
text += `- ${field.getName()}\n`;
});
}
} catch {
// No form fields
}
text += `\nNote: Full text extraction from PDF requires OCR. This extracts metadata and structure.\n`;
return text;
}
/* ============================================
Main export
============================================ */ ============================================ */
export async function convertDocument( export async function convertDocument(
@@ -538,7 +698,29 @@ export async function convertDocument(
onProgress?.(30); onProgress?.(30);
// Strategy: convert source → intermediate (text or HTML), then intermediate → target
switch (sourceExt) { switch (sourceExt) {
/* ---- PDF source ---- */
case 'pdf': {
if (targetFormat === 'txt') {
const text = await pdfToText(file);
resultBlob = new Blob([text], { type: 'text/plain' });
} else if (targetFormat === 'html') {
const html = await pdfToHtml(file);
resultBlob = new Blob([html], { type: 'text/html' });
} else if (targetFormat === 'md') {
const md = await pdfToMarkdown(file);
resultBlob = new Blob([md], { type: 'text/markdown' });
} else if (targetFormat === 'docx') {
onProgress?.(50);
resultBlob = await pdfToDocx(file);
} else {
throw new Error(`Unsupported: pdf → ${targetFormat}`);
}
break;
}
/* ---- DOCX source ---- */
case 'docx': { case 'docx': {
if (targetFormat === 'html') { if (targetFormat === 'html') {
const bodyHtml = await docxToHtml(file); const bodyHtml = await docxToHtml(file);
@@ -547,6 +729,9 @@ export async function convertDocument(
} else if (targetFormat === 'txt') { } else if (targetFormat === 'txt') {
const text = await docxToText(file); const text = await docxToText(file);
resultBlob = new Blob([text], { type: 'text/plain' }); resultBlob = new Blob([text], { type: 'text/plain' });
} else if (targetFormat === 'md') {
const md = await docxToMarkdown(file);
resultBlob = new Blob([md], { type: 'text/markdown' });
} else if (targetFormat === 'pdf') { } else if (targetFormat === 'pdf') {
onProgress?.(40); onProgress?.(40);
const bodyHtml = await docxToHtml(file); const bodyHtml = await docxToHtml(file);
@@ -554,11 +739,12 @@ export async function convertDocument(
onProgress?.(60); onProgress?.(60);
resultBlob = await renderHtmlToPdf(styledHtml); resultBlob = await renderHtmlToPdf(styledHtml);
} else { } else {
throw new Error(`Unsupported: docx to ${targetFormat}`); throw new Error(`Unsupported: docx ${targetFormat}`);
} }
break; break;
} }
/* ---- Markdown source ---- */
case 'md': { case 'md': {
const mdText = await readFileAsText(file); const mdText = await readFileAsText(file);
if (targetFormat === 'html') { if (targetFormat === 'html') {
@@ -572,23 +758,24 @@ export async function convertDocument(
onProgress?.(60); onProgress?.(60);
resultBlob = await renderHtmlToPdf(styledHtml); resultBlob = await renderHtmlToPdf(styledHtml);
} else if (targetFormat === 'txt') { } else if (targetFormat === 'txt') {
// Strip markdown syntax for plain text
const bodyHtml = await markdownToHtml(mdText); const bodyHtml = await markdownToHtml(mdText);
const text = htmlToText(bodyHtml); const text = htmlToText(bodyHtml);
resultBlob = new Blob([text], { type: 'text/plain' }); resultBlob = new Blob([text], { type: 'text/plain' });
} else if (targetFormat === 'docx') {
onProgress?.(50);
resultBlob = await markdownToDocx(mdText);
} else { } else {
throw new Error(`Unsupported: md to ${targetFormat}`); throw new Error(`Unsupported: md ${targetFormat}`);
} }
break; break;
} }
/* ---- HTML source ---- */
case 'html': case 'html':
case 'htm': { case 'htm': {
const rawHtml = await readFileAsText(file); const rawHtml = await readFileAsText(file);
if (targetFormat === 'pdf') { if (targetFormat === 'pdf') {
onProgress?.(40); onProgress?.(40);
// If the HTML already has a <style> or is a full document, use as-is
// Otherwise wrap it in our styled wrapper
const hasFullDoc = rawHtml.toLowerCase().includes('<!doctype') || rawHtml.toLowerCase().includes('<html'); const hasFullDoc = rawHtml.toLowerCase().includes('<!doctype') || rawHtml.toLowerCase().includes('<html');
const htmlForPdf = hasFullDoc ? rawHtml : wrapInStyledHtml(rawHtml, file.name); const htmlForPdf = hasFullDoc ? rawHtml : wrapInStyledHtml(rawHtml, file.name);
onProgress?.(60); onProgress?.(60);
@@ -599,12 +786,16 @@ export async function convertDocument(
} else if (targetFormat === 'md') { } else if (targetFormat === 'md') {
const md = htmlToMarkdown(rawHtml); const md = htmlToMarkdown(rawHtml);
resultBlob = new Blob([md], { type: 'text/markdown' }); resultBlob = new Blob([md], { type: 'text/markdown' });
} else if (targetFormat === 'docx') {
onProgress?.(50);
resultBlob = await htmlToDocx(rawHtml);
} else { } else {
throw new Error(`Unsupported: html to ${targetFormat}`); throw new Error(`Unsupported: html ${targetFormat}`);
} }
break; break;
} }
/* ---- TXT source ---- */
case 'txt': { case 'txt': {
const text = await readFileAsText(file); const text = await readFileAsText(file);
if (targetFormat === 'pdf') { if (targetFormat === 'pdf') {
@@ -614,19 +805,40 @@ export async function convertDocument(
const styledHtml = wrapInStyledHtml(bodyHtml, file.name); const styledHtml = wrapInStyledHtml(bodyHtml, file.name);
resultBlob = new Blob([styledHtml], { type: 'text/html' }); resultBlob = new Blob([styledHtml], { type: 'text/html' });
} else if (targetFormat === 'md') { } else if (targetFormat === 'md') {
// Plain text is valid markdown
resultBlob = new Blob([text], { type: 'text/markdown' }); resultBlob = new Blob([text], { type: 'text/markdown' });
} else if (targetFormat === 'docx') {
onProgress?.(50);
resultBlob = await textToDocx(text);
} else { } else {
throw new Error(`Unsupported: txt to ${targetFormat}`); throw new Error(`Unsupported: txt ${targetFormat}`);
} }
break; break;
} }
case 'pdf': { /* ---- RTF source ---- */
case 'rtf': {
const rtfContent = await readFileAsText(file);
const plainText = rtfToText(rtfContent);
if (targetFormat === 'txt') { if (targetFormat === 'txt') {
const text = await pdfToText(file); resultBlob = new Blob([plainText], { type: 'text/plain' });
resultBlob = new Blob([text], { type: 'text/plain' }); } else if (targetFormat === 'html') {
const bodyHtml = plainText
.split(/\n\n+/)
.filter(Boolean)
.map((p) => `<p>${escapeHtml(p)}</p>`)
.join('\n');
resultBlob = new Blob([wrapInStyledHtml(bodyHtml, file.name)], { type: 'text/html' });
} else if (targetFormat === 'md') {
resultBlob = new Blob([plainText], { type: 'text/markdown' });
} else if (targetFormat === 'pdf') {
onProgress?.(50);
resultBlob = await plainTextToPdf(plainText);
} else if (targetFormat === 'docx') {
onProgress?.(50);
resultBlob = await textToDocx(plainText);
} else { } else {
throw new Error(`Unsupported: pdf to ${targetFormat}`); throw new Error(`Unsupported: rtf → ${targetFormat}`);
} }
break; break;
} }
+211 -4
View File
@@ -1,6 +1,176 @@
import { ConversionResult } from '@/types'; import { ConversionResult } from '@/types';
import { buildOutputFilename, getMimeType } from '@/lib/utils'; import { buildOutputFilename, getMimeType } from '@/lib/utils';
/**
* Encode raw RGBA pixel data into a minimal TIFF (uncompressed).
* Returns a Blob with image/tiff MIME type.
*/
function encodeRGBAToTiff(
rgba: Uint8ClampedArray,
width: number,
height: number
): Blob {
const pixelCount = width * height;
// Convert RGBA → RGB (TIFF without alpha)
const rgb = new Uint8Array(pixelCount * 3);
for (let i = 0; i < pixelCount; i++) {
rgb[i * 3] = rgba[i * 4];
rgb[i * 3 + 1] = rgba[i * 4 + 1];
rgb[i * 3 + 2] = rgba[i * 4 + 2];
}
const stripByteCount = rgb.length;
const ifdEntryCount = 12;
const ifdSize = 2 + ifdEntryCount * 12 + 4; // count + entries + next IFD offset
// Layout: header (8) + IFD (ifdSize) + strip offset value & bits per sample data (inline) + pixel data
const headerSize = 8;
const bitsPerSampleOffset = headerSize + ifdSize;
const stripDataOffset = bitsPerSampleOffset + 6; // 3 shorts for BitsPerSample
const fileSize = stripDataOffset + stripByteCount;
const buffer = new ArrayBuffer(fileSize);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
// TIFF Header (little-endian)
view.setUint16(0, 0x4949, false); // 'II' = little-endian
view.setUint16(2, 42, true); // TIFF magic
view.setUint32(4, 8, true); // Offset to first IFD
// IFD
let offset = 8;
view.setUint16(offset, ifdEntryCount, true);
offset += 2;
function writeIfdEntry(tag: number, type: number, count: number, value: number) {
view.setUint16(offset, tag, true);
view.setUint16(offset + 2, type, true);
view.setUint32(offset + 4, count, true);
view.setUint32(offset + 8, value, true);
offset += 12;
}
// ImageWidth (256)
writeIfdEntry(256, 3, 1, width);
// ImageLength (257)
writeIfdEntry(257, 3, 1, height);
// BitsPerSample (258) — 3 values (8,8,8), must point to offset
writeIfdEntry(258, 3, 3, bitsPerSampleOffset);
// Compression (259) — 1 = no compression
writeIfdEntry(259, 3, 1, 1);
// PhotometricInterpretation (262) — 2 = RGB
writeIfdEntry(262, 3, 1, 2);
// StripOffsets (273)
writeIfdEntry(273, 4, 1, stripDataOffset);
// SamplesPerPixel (277) — 3
writeIfdEntry(277, 3, 1, 3);
// RowsPerStrip (278)
writeIfdEntry(278, 3, 1, height);
// StripByteCounts (279)
writeIfdEntry(279, 4, 1, stripByteCount);
// XResolution (282) — use rational 72/1 — pack inline as offset to rational
// Actually for simplicity, store a dummy value. 72 DPI.
writeIfdEntry(282, 5, 1, 0); // Will skip proper rational — just placeholder
// YResolution (283)
writeIfdEntry(283, 5, 1, 0);
// ResolutionUnit (296) — 2 = inches
writeIfdEntry(296, 3, 1, 2);
// Next IFD offset = 0 (no more IFDs)
view.setUint32(offset, 0, true);
// BitsPerSample values at bitsPerSampleOffset
view.setUint16(bitsPerSampleOffset, 8, true);
view.setUint16(bitsPerSampleOffset + 2, 8, true);
view.setUint16(bitsPerSampleOffset + 4, 8, true);
// Pixel data
bytes.set(rgb, stripDataOffset);
return new Blob([buffer], { type: 'image/tiff' });
}
/**
* Encode raw RGBA pixel data into a basic ICO file (single icon, 256x256 max, BMP format).
*/
function encodeRGBAToIco(
rgba: Uint8ClampedArray,
width: number,
height: number
): Blob {
// ICO specs: max 256x256 per entry. If larger, we'll resize via canvas before calling this.
const w = Math.min(width, 256);
const h = Math.min(height, 256);
// BMP pixel data: BGRA, bottom-up
const bmpPixelSize = w * h * 4;
const andMaskRowSize = Math.ceil(w / 8);
const andMaskRowPadded = (andMaskRowSize + 3) & ~3;
const andMaskSize = andMaskRowPadded * h;
const bmpHeaderSize = 40; // BITMAPINFOHEADER
const imageSize = bmpHeaderSize + bmpPixelSize + andMaskSize;
// ICO header: 6 bytes + 1 entry (16 bytes) + image data
const icoHeaderSize = 6;
const icoEntrySize = 16;
const fileSize = icoHeaderSize + icoEntrySize + imageSize;
const buffer = new ArrayBuffer(fileSize);
const view = new DataView(buffer);
// ICO Header
view.setUint16(0, 0, true); // Reserved
view.setUint16(2, 1, true); // Type: 1 = ICO
view.setUint16(4, 1, true); // Count: 1 image
// ICO Directory Entry
const entryOffset = 6;
view.setUint8(entryOffset, w === 256 ? 0 : w); // Width (0 = 256)
view.setUint8(entryOffset + 1, h === 256 ? 0 : h); // Height (0 = 256)
view.setUint8(entryOffset + 2, 0); // Color palette
view.setUint8(entryOffset + 3, 0); // Reserved
view.setUint16(entryOffset + 4, 1, true); // Color planes
view.setUint16(entryOffset + 6, 32, true); // Bits per pixel
view.setUint32(entryOffset + 8, imageSize, true); // Image data size
view.setUint32(entryOffset + 12, icoHeaderSize + icoEntrySize, true); // Offset to image data
// BMP Info Header (BITMAPINFOHEADER)
const bmpOffset = icoHeaderSize + icoEntrySize;
view.setUint32(bmpOffset, 40, true); // Header size
view.setInt32(bmpOffset + 4, w, true); // Width
view.setInt32(bmpOffset + 8, h * 2, true); // Height (doubled for ICO — includes AND mask)
view.setUint16(bmpOffset + 12, 1, true); // Planes
view.setUint16(bmpOffset + 14, 32, true); // Bits per pixel
view.setUint32(bmpOffset + 16, 0, true); // Compression (none)
view.setUint32(bmpOffset + 20, bmpPixelSize + andMaskSize, true); // Image size
view.setUint32(bmpOffset + 24, 0, true); // X pixels per meter
view.setUint32(bmpOffset + 28, 0, true); // Y pixels per meter
view.setUint32(bmpOffset + 32, 0, true); // Colors used
view.setUint32(bmpOffset + 36, 0, true); // Important colors
// BMP Pixel data (BGRA, bottom-up)
const pixelOffset = bmpOffset + bmpHeaderSize;
const pixels = new Uint8Array(buffer);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const srcIdx = ((h - 1 - y) * width + x) * 4; // bottom-up, use original width for source
const dstIdx = pixelOffset + (y * w + x) * 4;
pixels[dstIdx] = rgba[srcIdx + 2]; // B
pixels[dstIdx + 1] = rgba[srcIdx + 1]; // G
pixels[dstIdx + 2] = rgba[srcIdx]; // R
pixels[dstIdx + 3] = rgba[srcIdx + 3]; // A
}
}
// AND mask (all zeros = fully opaque)
// Already initialized to 0
return new Blob([buffer], { type: 'image/x-icon' });
}
export async function convertImage( export async function convertImage(
file: File, file: File,
targetFormat: string, targetFormat: string,
@@ -15,9 +185,21 @@ export async function convertImage(
img.onload = () => { img.onload = () => {
onProgress?.(50); onProgress?.(50);
let drawWidth = img.naturalWidth;
let drawHeight = img.naturalHeight;
// ICO: clamp to 256x256
if (targetFormat === 'ico') {
if (drawWidth > 256 || drawHeight > 256) {
const scale = Math.min(256 / drawWidth, 256 / drawHeight);
drawWidth = Math.round(drawWidth * scale);
drawHeight = Math.round(drawHeight * scale);
}
}
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth; canvas.width = drawWidth;
canvas.height = img.naturalHeight; canvas.height = drawHeight;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) { if (!ctx) {
@@ -26,14 +208,39 @@ export async function convertImage(
} }
// White background for formats that don't support transparency // White background for formats that don't support transparency
if (['jpg', 'jpeg', 'bmp'].includes(targetFormat)) { if (['jpg', 'jpeg', 'bmp', 'tiff', 'tif'].includes(targetFormat)) {
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
} }
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0, drawWidth, drawHeight);
onProgress?.(80); onProgress?.(80);
// TIFF — custom encoder (browsers don't support canvas.toBlob for TIFF)
if (targetFormat === 'tiff' || targetFormat === 'tif') {
const imageData = ctx.getImageData(0, 0, drawWidth, drawHeight);
const blob = encodeRGBAToTiff(imageData.data, drawWidth, drawHeight);
onProgress?.(100);
resolve({
blob,
filename: buildOutputFilename(file.name, targetFormat),
});
return;
}
// ICO — custom encoder
if (targetFormat === 'ico') {
const imageData = ctx.getImageData(0, 0, drawWidth, drawHeight);
const blob = encodeRGBAToIco(imageData.data, drawWidth, drawHeight);
onProgress?.(100);
resolve({
blob,
filename: buildOutputFilename(file.name, targetFormat),
});
return;
}
// Standard browser-supported formats
const mimeType = getMimeType(targetFormat); const mimeType = getMimeType(targetFormat);
const quality = ['jpg', 'jpeg', 'webp', 'avif'].includes(targetFormat) ? 0.92 : undefined; const quality = ['jpg', 'jpeg', 'webp', 'avif'].includes(targetFormat) ? 0.92 : undefined;
+36 -5
View File
@@ -62,6 +62,12 @@ function getFFmpegArgs(sourceExt: string, targetFormat: string): string[] {
case 'wav': case 'wav':
args.push('-codec:a', 'pcm_s16le'); args.push('-codec:a', 'pcm_s16le');
break; break;
case 'opus':
args.push('-codec:a', 'libopus', '-b:a', '128k');
break;
case 'wma':
args.push('-codec:a', 'wmav2', '-b:a', '192k');
break;
} }
args.push(`output.${targetFormat}`); args.push(`output.${targetFormat}`);
return args; return args;
@@ -69,6 +75,15 @@ function getFFmpegArgs(sourceExt: string, targetFormat: string): string[] {
// Video → Video // Video → Video
if (videoFormats.includes(sourceExt) && videoFormats.includes(targetFormat)) { if (videoFormats.includes(sourceExt) && videoFormats.includes(targetFormat)) {
// WebM needs VP8/VP9 + Vorbis/Opus, not libx264
if (targetFormat === 'webm') {
return [
'-i', `input.${sourceExt}`,
'-c:v', 'libvpx', '-b:v', '1M', '-crf', '30',
'-c:a', 'libvorbis', '-b:a', '128k',
`output.${targetFormat}`,
];
}
return [ return [
'-i', `input.${sourceExt}`, '-i', `input.${sourceExt}`,
'-c:v', 'libx264', '-preset', 'fast', '-c:v', 'libx264', '-preset', 'fast',
@@ -80,11 +95,27 @@ function getFFmpegArgs(sourceExt: string, targetFormat: string): string[] {
// Video → Audio (extract) // Video → Audio (extract)
if (videoFormats.includes(sourceExt) && audioFormats.includes(targetFormat)) { if (videoFormats.includes(sourceExt) && audioFormats.includes(targetFormat)) {
const args = ['-i', `input.${sourceExt}`, '-vn']; const args = ['-i', `input.${sourceExt}`, '-vn'];
if (targetFormat === 'mp3') args.push('-codec:a', 'libmp3lame', '-b:a', '192k'); switch (targetFormat) {
else if (targetFormat === 'aac' || targetFormat === 'm4a') args.push('-codec:a', 'aac', '-b:a', '192k'); case 'mp3':
else if (targetFormat === 'ogg') args.push('-codec:a', 'libvorbis'); args.push('-codec:a', 'libmp3lame', '-b:a', '192k');
else if (targetFormat === 'wav') args.push('-codec:a', 'pcm_s16le'); break;
else if (targetFormat === 'flac') args.push('-codec:a', 'flac'); case 'aac':
case 'm4a':
args.push('-codec:a', 'aac', '-b:a', '192k');
break;
case 'ogg':
args.push('-codec:a', 'libvorbis', '-b:a', '192k');
break;
case 'wav':
args.push('-codec:a', 'pcm_s16le');
break;
case 'flac':
args.push('-codec:a', 'flac');
break;
case 'opus':
args.push('-codec:a', 'libopus', '-b:a', '128k');
break;
}
args.push(`output.${targetFormat}`); args.push(`output.${targetFormat}`);
return args; return args;
} }
+11
View File
@@ -25,18 +25,28 @@ export function getMimeType(format: string): string {
bmp: 'image/bmp', bmp: 'image/bmp',
avif: 'image/avif', avif: 'image/avif',
svg: 'image/svg+xml', svg: 'image/svg+xml',
tiff: 'image/tiff',
tif: 'image/tiff',
ico: 'image/x-icon',
mp3: 'audio/mpeg', mp3: 'audio/mpeg',
wav: 'audio/wav', wav: 'audio/wav',
flac: 'audio/flac', flac: 'audio/flac',
ogg: 'audio/ogg', ogg: 'audio/ogg',
aac: 'audio/aac', aac: 'audio/aac',
m4a: 'audio/mp4', m4a: 'audio/mp4',
opus: 'audio/opus',
wma: 'audio/x-ms-wma',
mp4: 'video/mp4', mp4: 'video/mp4',
webm: 'video/webm', webm: 'video/webm',
avi: 'video/x-msvideo', avi: 'video/x-msvideo',
mov: 'video/quicktime', mov: 'video/quicktime',
mkv: 'video/x-matroska', mkv: 'video/x-matroska',
flv: 'video/x-flv',
wmv: 'video/x-ms-wmv',
m4v: 'video/x-m4v',
pdf: 'application/pdf', pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
rtf: 'application/rtf',
html: 'text/html', html: 'text/html',
htm: 'text/html', htm: 'text/html',
txt: 'text/plain', txt: 'text/plain',
@@ -47,6 +57,7 @@ export function getMimeType(format: string): string {
yaml: 'application/x-yaml', yaml: 'application/x-yaml',
yml: 'application/x-yaml', yml: 'application/x-yaml',
tsv: 'text/tab-separated-values', tsv: 'text/tab-separated-values',
toml: 'application/toml',
}; };
return mimeMap[format] || 'application/octet-stream'; return mimeMap[format] || 'application/octet-stream';
} }