fix: landing page Unicode rendering, orbit overlap, and add PDF OCR fallback
- Fix Unicode escapes (\u26A1, \u2192, \u{1F6E1}) rendered as literal text in JSX
by wrapping them in JSX expressions
- Increase orbit radii (520/320px -> 720/480px) so items don't overlap hero text
- Add orbit animation keyframes (orbit-slow, orbit-med) to globals.css
- Add Tesseract.js OCR fallback for scanned/image-based PDFs that have no
extractable text layer — renders pages to canvas then runs browser-based OCR
- Thread onProgress through all PDF conversion functions for OCR progress
This commit is contained in:
Generated
+110
-2
@@ -29,6 +29,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"woff2-encoder": "^2.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
@@ -2862,6 +2863,12 @@
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -4635,6 +4642,12 @@
|
||||
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -5089,6 +5102,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-weakmap": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@@ -5946,6 +5965,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -6083,6 +6122,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/opencollective-postinstall": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencollective-postinstall": "index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/opentype.js": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
|
||||
@@ -6487,8 +6535,7 @@
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
@@ -7208,6 +7255,30 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^7.0.0",
|
||||
"wasm-feature-detect": "^1.8.0",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
@@ -7284,6 +7355,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
@@ -7580,6 +7657,28 @@
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -7790,6 +7889,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"woff2-encoder": "^2.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,33 @@ body {
|
||||
animation: pulse-soft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ---- Orbit animations for landing page constellation ---- */
|
||||
@keyframes orbit-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes orbit-med {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
|
||||
.animate-orbit-slow {
|
||||
animation: orbit-slow 60s linear infinite;
|
||||
}
|
||||
|
||||
.animate-counter-orbit-slow {
|
||||
animation: orbit-slow 60s linear infinite reverse;
|
||||
}
|
||||
|
||||
.animate-orbit-med {
|
||||
animation: orbit-med 40s linear infinite reverse;
|
||||
}
|
||||
|
||||
.animate-counter-orbit-med {
|
||||
animation: orbit-med 40s linear infinite;
|
||||
}
|
||||
|
||||
/* ---- Dot pattern background ---- */
|
||||
.bg-dots {
|
||||
background-image: radial-gradient(circle, rgba(180, 140, 100, 0.12) 1px, transparent 1px);
|
||||
|
||||
+327
-60
@@ -1,65 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
|
||||
const floatingBadges = [
|
||||
{ label: 'PNG', color: 'bg-pink-200', dot: 'bg-pink-400', top: '18%', left: '8%', delay: 0 },
|
||||
{ label: 'MP4', color: 'bg-orange-100', dot: 'bg-orange-400', top: '22%', right: '10%', delay: 0.5 },
|
||||
{ label: 'CSV', color: 'bg-emerald-100', dot: 'bg-emerald-400', bottom: '32%', left: '6%', delay: 1.0 },
|
||||
{ label: 'PDF', color: 'bg-blue-100', dot: 'bg-blue-400', bottom: '28%', right: '8%', delay: 0.3 },
|
||||
{ label: 'WAV', color: 'bg-purple-100', dot: 'bg-purple-400', top: '42%', left: '3%', delay: 0.7 },
|
||||
{ label: 'WEBP', color: 'bg-pink-100', dot: 'bg-pink-400', top: '35%', right: '4%', delay: 1.2 },
|
||||
/* ─── Orbiting Constellation Data ─── */
|
||||
|
||||
const orbitItems = [
|
||||
{ icon: '\u{1F5BC}', label: 'PNG', angle: 0 },
|
||||
{ icon: '\u{1F3B5}', label: 'MP3', angle: 45 },
|
||||
{ icon: '\u{1F4C4}', label: 'PDF', angle: 90 },
|
||||
{ icon: '\u{1F3AC}', label: 'MP4', angle: 135 },
|
||||
{ icon: '\u{1F4CA}', label: 'CSV', angle: 180 },
|
||||
{ icon: '\u{1F310}', label: 'SVG', angle: 225 },
|
||||
{ icon: '\u{1F4D6}', label: 'EPUB', angle: 270 },
|
||||
{ icon: '\u{1F3A8}', label: 'PSD', angle: 315 },
|
||||
];
|
||||
|
||||
const innerOrbitItems = [
|
||||
{ icon: '\u{2728}', label: 'WebP', angle: 30 },
|
||||
{ icon: '\u{1F4DD}', label: 'DOCX', angle: 120 },
|
||||
{ icon: '\u{1F4BE}', label: 'JSON', angle: 210 },
|
||||
{ icon: '\u{1F399}', label: 'WAV', angle: 300 },
|
||||
];
|
||||
|
||||
/* ─── Format Ticker Data ─── */
|
||||
|
||||
const conversionPairs = [
|
||||
{ from: 'PNG', to: 'WebP', icon: '\u{1F5BC}', color: '#f472b6' },
|
||||
{ from: 'DOCX', to: 'PDF', icon: '\u{1F4C4}', color: '#60a5fa' },
|
||||
{ from: 'MP4', to: 'GIF', icon: '\u{1F3AC}', color: '#fb923c' },
|
||||
{ from: 'CSV', to: 'JSON', icon: '\u{1F4CA}', color: '#34d399' },
|
||||
{ from: 'WAV', to: 'MP3', icon: '\u{1F3B5}', color: '#a78bfa' },
|
||||
{ from: 'HEIC', to: 'JPG', icon: '\u{1F4F7}', color: '#f472b6' },
|
||||
{ from: 'XLSX', to: 'CSV', icon: '\u{1F4CA}', color: '#34d399' },
|
||||
{ from: 'TTF', to: 'WOFF2', icon: '\u{1F524}', color: '#2dd4bf' },
|
||||
{ from: 'EPUB', to: 'PDF', icon: '\u{1F4D6}', color: '#60a5fa' },
|
||||
{ from: 'YAML', to: 'JSON', icon: '\u{2699}', color: '#34d399' },
|
||||
{ from: 'PSD', to: 'PNG', icon: '\u{1F3A8}', color: '#f472b6' },
|
||||
{ from: 'MKV', to: 'MP4', icon: '\u{1F39E}', color: '#fb923c' },
|
||||
];
|
||||
|
||||
/* ─── Conversion Flow Data ─── */
|
||||
|
||||
const flowSteps = [
|
||||
{ inputIcon: '\u{1F5BC}', inputLabel: '.PNG', outputIcon: '\u{2728}', outputLabel: '.WebP' },
|
||||
{ inputIcon: '\u{1F4C4}', inputLabel: '.DOCX', outputIcon: '\u{1F4D1}', outputLabel: '.PDF' },
|
||||
{ inputIcon: '\u{1F3AC}', inputLabel: '.MKV', outputIcon: '\u{1F4F1}', outputLabel: '.MP4' },
|
||||
{ inputIcon: '\u{1F4CA}', inputLabel: '.CSV', outputIcon: '\u{1F4CB}', outputLabel: '.JSON' },
|
||||
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
|
||||
];
|
||||
|
||||
/* ─── Features ─── */
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: '\u{1F5BC}',
|
||||
title: 'Images',
|
||||
desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG \u2014 convert between any format using Canvas API.',
|
||||
bg: 'bg-pink-50',
|
||||
desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG, PSD, HEIC \u2014 convert between any format.',
|
||||
iconBg: 'bg-pink-100',
|
||||
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG'],
|
||||
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC'],
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
icon: '\u{1F4C4}',
|
||||
title: 'Documents',
|
||||
desc: 'DOCX, PDF, Markdown, HTML, TXT \u2014 preserves formatting with styled rendering.',
|
||||
bg: 'bg-blue-50',
|
||||
desc: 'DOCX, PDF, Markdown, HTML, TXT, PPTX, EPUB \u2014 preserves formatting.',
|
||||
iconBg: 'bg-blue-100',
|
||||
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT'],
|
||||
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT', 'PPTX', 'EPUB'],
|
||||
wide: false,
|
||||
},
|
||||
{
|
||||
icon: '\u{1F3B5}',
|
||||
title: 'Audio',
|
||||
desc: 'MP3, WAV, OGG, AAC, FLAC, M4A \u2014 powered by FFmpeg WebAssembly.',
|
||||
bg: 'bg-purple-50',
|
||||
iconBg: 'bg-purple-100',
|
||||
formats: ['MP3', 'WAV', 'OGG', 'FLAC'],
|
||||
formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC'],
|
||||
wide: false,
|
||||
},
|
||||
{
|
||||
icon: '\u{1F3AC}',
|
||||
title: 'Video',
|
||||
desc: 'MP4, WebM, AVI, MOV, MKV \u2014 full video transcoding in your browser.',
|
||||
bg: 'bg-orange-50',
|
||||
iconBg: 'bg-orange-100',
|
||||
formats: ['MP4', 'WebM', 'AVI', 'MOV'],
|
||||
formats: ['MP4', 'WebM', 'AVI', 'MOV', 'MKV'],
|
||||
wide: false,
|
||||
},
|
||||
{
|
||||
icon: '\u{1F4CA}',
|
||||
title: 'Data',
|
||||
desc: 'CSV, JSON, XML, YAML, TSV \u2014 smart parsing with structure preservation.',
|
||||
bg: 'bg-emerald-50',
|
||||
title: 'Data & Fonts',
|
||||
desc: 'CSV, JSON, XML, YAML, XLSX, TTF, OTF, WOFF2 \u2014 smart structure preservation.',
|
||||
iconBg: 'bg-emerald-100',
|
||||
formats: ['CSV', 'JSON', 'XML', 'YAML', 'TSV'],
|
||||
formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TTF', 'WOFF2'],
|
||||
wide: true,
|
||||
},
|
||||
];
|
||||
|
||||
/* ─── Animation Variants ─── */
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.08 } },
|
||||
@@ -74,6 +112,236 @@ const fadeUp = {
|
||||
},
|
||||
};
|
||||
|
||||
/* ─── Orbiting Constellation Component ─── */
|
||||
|
||||
function OrbitingConstellation() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none opacity-50">
|
||||
{/* Center glow */}
|
||||
<div className="absolute w-20 h-20 rounded-full bg-pink/8 blur-xl" />
|
||||
<div className="absolute w-10 h-10 rounded-full bg-purple/10 blur-lg" />
|
||||
|
||||
{/* Outer orbit ring (visual) */}
|
||||
<div className="absolute w-[720px] h-[720px] rounded-full border border-border-soft/40 hidden md:block" />
|
||||
{/* Inner orbit ring */}
|
||||
<div className="absolute w-[480px] h-[480px] rounded-full border border-border-soft/25 hidden md:block" />
|
||||
|
||||
{/* Outer orbit items */}
|
||||
<div className="absolute w-[720px] h-[720px] hidden md:block animate-orbit-slow">
|
||||
{orbitItems.map((item) => {
|
||||
const rad = (item.angle * Math.PI) / 180;
|
||||
const x = Math.cos(rad) * 360;
|
||||
const y = Math.sin(rad) * 360;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="absolute animate-counter-orbit-slow"
|
||||
style={{
|
||||
left: `calc(50% + ${x}px - 24px)`,
|
||||
top: `calc(50% + ${y}px - 24px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<div className="w-12 h-12 rounded-2xl bg-white border border-border-soft shadow-[0_2px_8px_rgba(160,120,80,0.06)] flex items-center justify-center text-lg">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="font-mono text-[9px] font-bold text-text-light tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Inner orbit items */}
|
||||
<div className="absolute w-[480px] h-[480px] hidden md:block animate-orbit-med">
|
||||
{innerOrbitItems.map((item) => {
|
||||
const rad = (item.angle * Math.PI) / 180;
|
||||
const x = Math.cos(rad) * 240;
|
||||
const y = Math.sin(rad) * 240;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="absolute animate-counter-orbit-med"
|
||||
style={{
|
||||
left: `calc(50% + ${x}px - 18px)`,
|
||||
top: `calc(50% + ${y}px - 18px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<div className="w-9 h-9 rounded-xl bg-white/80 border border-border-soft/60 shadow-[0_1px_4px_rgba(160,120,80,0.04)] flex items-center justify-center text-sm">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="font-mono text-[8px] font-bold text-text-light/70 tracking-wider">{item.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Format Ticker Component ─── */
|
||||
|
||||
function FormatTicker() {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % conversionPairs.length);
|
||||
}, 2200);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const pair = conversionPairs[index];
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-3 px-5 py-2.5 bg-white border border-border-soft rounded-2xl shadow-[0_2px_8px_rgba(160,120,80,0.06)] min-w-[280px] justify-center">
|
||||
<span className="text-lg">{pair.icon}</span>
|
||||
<div className="flex items-center gap-2 font-mono text-sm font-bold overflow-hidden h-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={`from-${index}`}
|
||||
className="text-text-dark"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
.{pair.from}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.span
|
||||
className="text-text-light"
|
||||
animate={{ x: [0, 4, 0] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{'\u2192'}
|
||||
</motion.span>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={`to-${index}`}
|
||||
style={{ color: pair.color }}
|
||||
className="font-extrabold"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] as const, delay: 0.08 }}
|
||||
>
|
||||
.{pair.to}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Conversion Flow Animation ─── */
|
||||
|
||||
function ConversionFlow() {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setStep((prev) => (prev + 1) % flowSteps.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const current = flowSteps[step];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative w-full max-w-[600px] mx-auto py-8"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-4 sm:gap-8">
|
||||
{/* Input file */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={`in-${step}`}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
initial={{ x: -40, opacity: 0, scale: 0.8 }}
|
||||
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ x: -20, opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-white border-2 border-border-soft shadow-[0_4px_16px_rgba(160,120,80,0.08)] flex items-center justify-center text-2xl sm:text-3xl">
|
||||
{current.inputIcon}
|
||||
</div>
|
||||
<span className="font-mono text-[11px] font-bold text-text-mid">{current.inputLabel}</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Arrow + bolt animation */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Traveling dots */}
|
||||
<div className="relative w-12 sm:w-20 h-[2px] bg-border-soft overflow-hidden rounded">
|
||||
<motion.div
|
||||
className="absolute top-[-2px] w-2 h-2 rounded-full bg-pink"
|
||||
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-[-2px] w-1.5 h-1.5 rounded-full bg-purple"
|
||||
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut', delay: 0.4 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center bolt */}
|
||||
<motion.div
|
||||
className="w-12 h-12 sm:w-14 sm:h-14 rounded-full bg-white border-2 border-pink/30 shadow-[0_4px_20px_rgba(244,114,182,0.15)] flex items-center justify-center text-xl sm:text-2xl flex-shrink-0"
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
borderColor: ['rgba(244,114,182,0.3)', 'rgba(167,139,250,0.4)', 'rgba(244,114,182,0.3)'],
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{'\u26A1'}
|
||||
</motion.div>
|
||||
|
||||
{/* More traveling dots */}
|
||||
<div className="relative w-12 sm:w-20 h-[2px] bg-border-soft overflow-hidden rounded">
|
||||
<motion.div
|
||||
className="absolute top-[-2px] w-2 h-2 rounded-full bg-purple"
|
||||
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut', delay: 0.6 }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-[-2px] w-1.5 h-1.5 rounded-full bg-mint"
|
||||
animate={{ x: [0, 48, 80], opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeInOut', delay: 1.0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output file */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={`out-${step}`}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
initial={{ x: 40, opacity: 0, scale: 0.8 }}
|
||||
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ x: 20, opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const, delay: 0.15 }}
|
||||
>
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-white border-2 border-mint/30 shadow-[0_4px_16px_rgba(52,211,153,0.12)] flex items-center justify-center text-2xl sm:text-3xl">
|
||||
{current.outputIcon}
|
||||
</div>
|
||||
<span className="font-mono text-[11px] font-bold text-mint">{current.outputLabel}</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Page ─── */
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen relative bg-bg-cream">
|
||||
@@ -84,6 +352,7 @@ export default function LandingPage() {
|
||||
{/* ──── NAV ──── */}
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 bg-bg-cream/80 backdrop-blur-xl border-b border-border-soft">
|
||||
<Link href="/" className="flex items-center gap-2.5 no-underline">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="Transmute" className="w-8 h-8 rounded-[10px]" />
|
||||
<span className="font-serif font-extrabold text-xl tracking-tight text-text-dark">Transmute</span>
|
||||
</Link>
|
||||
@@ -97,27 +366,8 @@ export default function LandingPage() {
|
||||
|
||||
{/* ──── HERO ──── */}
|
||||
<section className="relative flex flex-col items-center justify-center min-h-screen px-6 pt-32 pb-20 text-center overflow-hidden">
|
||||
{/* Floating format badges */}
|
||||
{floatingBadges.map((badge) => (
|
||||
<motion.div
|
||||
key={badge.label}
|
||||
className="absolute hidden md:flex items-center gap-1.5 px-3.5 py-2 bg-white border border-border-soft rounded-2xl font-mono text-xs font-semibold text-text-mid shadow-[0_4px_12px_rgba(160,120,80,0.08)] pointer-events-none select-none"
|
||||
style={{
|
||||
top: badge.top,
|
||||
bottom: badge.bottom,
|
||||
left: badge.left,
|
||||
right: badge.right,
|
||||
} as React.CSSProperties}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.8 + badge.delay, duration: 0.5, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-sm ${badge.dot}`} />
|
||||
.{badge.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{/* Orbiting constellation behind hero text */}
|
||||
<OrbitingConstellation />
|
||||
|
||||
<motion.div
|
||||
variants={stagger}
|
||||
@@ -152,8 +402,13 @@ export default function LandingPage() {
|
||||
right in your browser. No uploads. No accounts. No limits.
|
||||
</motion.p>
|
||||
|
||||
{/* Format Ticker */}
|
||||
<motion.div className="mt-7" variants={fadeUp}>
|
||||
<FormatTicker />
|
||||
</motion.div>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div className="flex items-center gap-4 mt-10 flex-wrap justify-center" variants={fadeUp}>
|
||||
<motion.div className="flex items-center gap-4 mt-8 flex-wrap justify-center" variants={fadeUp}>
|
||||
<Link
|
||||
href="/convert"
|
||||
className="inline-flex items-center gap-2.5 px-8 py-3.5 text-base font-bold text-white bg-pink rounded-2xl no-underline shadow-[0_4px_24px_rgba(244,114,182,0.3)] hover:shadow-[0_8px_36px_rgba(244,114,182,0.4)] hover:-translate-y-0.5 transition-all"
|
||||
@@ -176,6 +431,23 @@ export default function LandingPage() {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ──── CONVERSION FLOW ──── */}
|
||||
<section className="relative z-10 flex flex-col items-center px-6 -mt-10 mb-4">
|
||||
<motion.div
|
||||
className="text-center flex flex-col items-center gap-2 mb-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 px-3.5 py-1.5 bg-purple/10 rounded-full font-mono text-[11px] font-semibold uppercase tracking-wider text-purple">
|
||||
Live Preview
|
||||
</span>
|
||||
<p className="text-sm text-text-mid">Watch files transform in real time</p>
|
||||
</motion.div>
|
||||
<ConversionFlow />
|
||||
</section>
|
||||
|
||||
{/* ──── FEATURES ──── */}
|
||||
<section
|
||||
id="features"
|
||||
@@ -195,7 +467,7 @@ export default function LandingPage() {
|
||||
Every format you need
|
||||
</h2>
|
||||
<p className="text-[17px] text-text-mid leading-relaxed max-w-[520px]">
|
||||
40+ file formats across 5 categories, all converted instantly with zero quality loss.
|
||||
70+ file formats across 5 categories, all converted instantly with zero quality loss.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -257,15 +529,16 @@ export default function LandingPage() {
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
>
|
||||
{[
|
||||
{ num: '1', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' },
|
||||
{ num: '2', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' },
|
||||
{ num: '3', title: 'Download', desc: 'Hit convert and download instantly. Files never leave your browser.' },
|
||||
{ num: '1', icon: '\u{1F4E5}', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' },
|
||||
{ num: '2', icon: '\u{2699}', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' },
|
||||
{ num: '3', icon: '\u{2B07}', title: 'Download', desc: 'Hit convert and download instantly. Files never leave your browser.' },
|
||||
].map((step, i) => (
|
||||
<motion.div
|
||||
key={step.num}
|
||||
className="flex-1 relative bg-white border border-border-soft rounded-3xl p-8 text-center shadow-[0_1px_3px_rgba(160,120,80,0.06)] hover:shadow-[0_12px_32px_rgba(160,120,80,0.1)] hover:-translate-y-1 transition-all duration-300"
|
||||
variants={fadeUp}
|
||||
>
|
||||
<div className="text-3xl mb-3">{step.icon}</div>
|
||||
<div className="w-10 h-10 rounded-full inline-flex items-center justify-center font-serif font-extrabold text-lg text-white bg-pink mb-4">
|
||||
{step.num}
|
||||
</div>
|
||||
@@ -292,11 +565,7 @@ export default function LandingPage() {
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full bg-emerald-50 flex items-center justify-center">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#2d1f14" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-4xl mb-4">{'\u{1F6E1}'}</div>
|
||||
<h2 className="font-serif font-extrabold text-[28px] text-text-dark mb-3">Your files stay yours</h2>
|
||||
<p className="text-base text-text-mid leading-[1.7] max-w-[500px] mx-auto">
|
||||
Every conversion happens entirely in your browser using WebAssembly and Canvas APIs.
|
||||
@@ -304,16 +573,14 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="flex justify-center gap-6 mt-7 flex-wrap">
|
||||
{[
|
||||
{ label: 'No uploads', color: 'bg-emerald-50', stroke: '#34d399' },
|
||||
{ label: 'No servers', color: 'bg-blue-50', stroke: '#60a5fa' },
|
||||
{ label: 'No tracking', color: 'bg-purple-50', stroke: '#a78bfa' },
|
||||
{ label: 'No limits', color: 'bg-orange-50', stroke: '#fb923c' },
|
||||
{ icon: '\u{1F6AB}', label: 'No uploads', color: 'bg-emerald-50' },
|
||||
{ icon: '\u{1F4BB}', label: 'No servers', color: 'bg-blue-50' },
|
||||
{ icon: '\u{1F440}', label: 'No tracking', color: 'bg-purple-50' },
|
||||
{ icon: '\u{267E}', label: 'No limits', color: 'bg-orange-50' },
|
||||
].map((b) => (
|
||||
<div key={b.label} className="flex items-center gap-2 text-sm font-semibold text-text-mid">
|
||||
<div className={`w-8 h-8 rounded-[10px] flex items-center justify-center ${b.color}`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={b.stroke} strokeWidth="2.5">
|
||||
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-base">{b.icon}</span>
|
||||
</div>
|
||||
{b.label}
|
||||
</div>
|
||||
|
||||
@@ -100,9 +100,11 @@ function escapeHtml(text: string): string {
|
||||
|
||||
/* ============================================
|
||||
PDF text extraction via pdfjs-dist
|
||||
With OCR fallback via Tesseract.js for
|
||||
scanned/image-based PDFs
|
||||
============================================ */
|
||||
|
||||
async function pdfToText(file: File): Promise<string> {
|
||||
async function pdfToText(file: File, onProgress?: (progress: number) => void): Promise<string> {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
|
||||
// Try loading the worker from CDN; if it fails, run without worker (main thread)
|
||||
@@ -133,11 +135,61 @@ async function pdfToText(file: File): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
if (textParts.length === 0) {
|
||||
return `[This PDF contains no extractable text — it may be image-based/scanned.]`;
|
||||
// If we got text via normal extraction, return it
|
||||
if (textParts.length > 0) {
|
||||
return textParts.join('\n\n');
|
||||
}
|
||||
|
||||
return textParts.join('\n\n');
|
||||
// ── OCR fallback for scanned/image-based PDFs ──
|
||||
// Render each page to canvas, then run Tesseract.js OCR
|
||||
onProgress?.(35);
|
||||
|
||||
try {
|
||||
const Tesseract = await import('tesseract.js');
|
||||
const ocrTextParts: string[] = [];
|
||||
|
||||
// Create a single worker for all pages
|
||||
const worker = await Tesseract.createWorker('eng');
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 }); // 2x for better OCR quality
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) continue;
|
||||
|
||||
await page.render({
|
||||
canvas,
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
} as Parameters<typeof page.render>[0]).promise;
|
||||
|
||||
// Run OCR on the rendered page
|
||||
const { data } = await worker.recognize(canvas);
|
||||
if (data.text.trim()) {
|
||||
ocrTextParts.push(data.text.trim());
|
||||
}
|
||||
|
||||
// Update progress (spread OCR progress from 35% to 85%)
|
||||
const pageProgress = 35 + ((i / pdf.numPages) * 50);
|
||||
onProgress?.(Math.round(pageProgress));
|
||||
}
|
||||
|
||||
await worker.terminate();
|
||||
|
||||
if (ocrTextParts.length > 0) {
|
||||
return ocrTextParts.join('\n\n');
|
||||
}
|
||||
} catch (ocrError) {
|
||||
console.warn('OCR fallback failed:', ocrError);
|
||||
// Fall through to the error message below
|
||||
}
|
||||
|
||||
return `[This PDF contains no extractable text and OCR could not recover text. It may contain only vector graphics or be empty.]`;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -145,8 +197,8 @@ async function pdfToText(file: File): Promise<string> {
|
||||
Extracts text per page, wraps in styled HTML
|
||||
============================================ */
|
||||
|
||||
async function pdfToHtml(file: File): Promise<string> {
|
||||
const text = await pdfToText(file);
|
||||
async function pdfToHtml(file: File, onProgress?: (progress: number) => void): Promise<string> {
|
||||
const text = await pdfToText(file, onProgress);
|
||||
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, ''));
|
||||
@@ -156,8 +208,8 @@ async function pdfToHtml(file: File): Promise<string> {
|
||||
PDF → Markdown
|
||||
============================================ */
|
||||
|
||||
async function pdfToMarkdown(file: File): Promise<string> {
|
||||
const text = await pdfToText(file);
|
||||
async function pdfToMarkdown(file: File, onProgress?: (progress: number) => void): Promise<string> {
|
||||
const text = await pdfToText(file, onProgress);
|
||||
// Attempt to detect headings (ALL CAPS lines, short lines)
|
||||
const lines = text.split('\n');
|
||||
const mdLines: string[] = [];
|
||||
@@ -184,8 +236,8 @@ async function pdfToMarkdown(file: File): Promise<string> {
|
||||
Extracts text, builds DOCX using docx package
|
||||
============================================ */
|
||||
|
||||
async function pdfToDocx(file: File): Promise<Blob> {
|
||||
const text = await pdfToText(file);
|
||||
async function pdfToDocx(file: File, onProgress?: (progress: number) => void): Promise<Blob> {
|
||||
const text = await pdfToText(file, onProgress);
|
||||
return textToDocx(text);
|
||||
}
|
||||
|
||||
@@ -711,17 +763,17 @@ export async function convertDocument(
|
||||
/* ---- PDF source ---- */
|
||||
case 'pdf': {
|
||||
if (targetFormat === 'txt') {
|
||||
const text = await pdfToText(file);
|
||||
const text = await pdfToText(file, onProgress);
|
||||
resultBlob = new Blob([text], { type: 'text/plain' });
|
||||
} else if (targetFormat === 'html') {
|
||||
const html = await pdfToHtml(file);
|
||||
const html = await pdfToHtml(file, onProgress);
|
||||
resultBlob = new Blob([html], { type: 'text/html' });
|
||||
} else if (targetFormat === 'md') {
|
||||
const md = await pdfToMarkdown(file);
|
||||
const md = await pdfToMarkdown(file, onProgress);
|
||||
resultBlob = new Blob([md], { type: 'text/markdown' });
|
||||
} else if (targetFormat === 'docx') {
|
||||
onProgress?.(50);
|
||||
resultBlob = await pdfToDocx(file);
|
||||
resultBlob = await pdfToDocx(file, onProgress);
|
||||
} else {
|
||||
throw new Error(`Unsupported: pdf → ${targetFormat}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user