About
9 |Free Text From Image is a web app that helps you extract text from images easily and securely...
10 | {/* ...more content... */} 11 |Terms of Service
9 |These terms govern your use of Free Text From Image...
10 | {/* ...more content... */} 11 |Privacy Policy
9 |Your privacy is important to us. This page explains how we handle your data...
10 | {/* ...more content... */} 11 |Contact
10 |For support or feedback, email us at {CONTACT_EMAIL}.
11 | {/* Or add a simple form here, no backend required */} 12 |About
11 |Free Text From Image is a free web app for extracting text from images. Our mission is to make OCR accessible to everyone, with privacy and accuracy as top priorities.
13 |Our Team
14 |Built by engineers and designers passionate about open access to information.
15 |Contact
16 |Email rajasekaran.parthiban7@gmail.com for questions or feedback.
17 |{title}
24 |{body}
25 |Terms of Service
11 |Acceptance of Terms
13 |By using Free Text From Image, you agree to these terms.
14 |Use of Service
15 |This service is provided as-is, without warranty. Do not use for illegal purposes.
16 |Intellectual Property
17 |All content and code are the property of their respective owners.
18 |Contact
19 |Questions? Email rajasekaran.parthiban7@gmail.com.
20 |Contact
11 |For support or feedback, email rajasekaran.parthiban7@gmail.com or use the form below.
13 | 25 |Privacy Policy
11 |Your privacy is important to us. This policy explains what data we collect and how we use it.
13 |Information We Collect
14 |We do not store uploaded images. Text extraction is performed in-memory and not retained.
15 |Cookies
16 |We use cookies only for essential site functionality and analytics, never for tracking or advertising without consent.
17 |Consent
18 |Ad-related cookies and scripts are loaded only after you provide consent.
19 |Contact
20 |If you have questions, email rajasekaran.parthiban7@gmail.com.
21 |Analyzing image
JPG to Excel Guide
11 |Overview
13 |Convert JPG images to Excel spreadsheets using our free tool.
14 |How it works
15 |Upload your JPG, extract text, and download as Excel.
16 |Step-by-step
17 |-
18 |
- Go to the Home page. 19 |
- Upload your JPG image. 20 |
- Extract text and download as Excel. 21 |
Accuracy tips
23 |-
24 |
- Use clear, high-resolution JPGs. 25 |
- Text should be unobstructed. 26 |
Privacy note
28 |We do not store images. All processing is in-memory.
29 |FAQ
30 |-
31 |
- Is it free? 32 |
- Yes, the service is free. 33 |
- Do you keep my images? 34 |
- No, images are never stored. 35 |
JPG to Word Guide
11 |Overview
13 |Convert JPG images to editable Word documents using our free tool.
14 |How it works
15 |Upload your JPG, extract text, and download as a Word file.
16 |Step-by-step
17 |-
18 |
- Go to the Home page. 19 |
- Upload your JPG image. 20 |
- Extract text and download as Word. 21 |
Accuracy tips
23 |-
24 |
- Use clear, high-resolution JPGs. 25 |
- Text should be unobstructed. 26 |
Privacy note
28 |We do not store images. All processing is in-memory.
29 |FAQ
30 |-
31 |
- Is it free? 32 |
- Yes, the service is free. 33 |
- Do you keep my images? 34 |
- No, images are never stored. 35 |
Copy Text from Image Guide
11 |Overview
13 |Copying text from images is simple with our free OCR tool. No registration required.
14 |How it works
15 |Upload your image, and our OCR engine extracts the text for you to copy.
16 |Step-by-step
17 |-
18 |
- Go to the Home page. 19 |
- Upload your image. 20 |
- Copy the extracted text. 21 |
Accuracy tips
23 |-
24 |
- Use high-quality images. 25 |
- Text should be clear and readable. 26 |
Privacy note
28 |We do not store images. All processing is in-memory.
29 |FAQ
30 |-
31 |
- Is it secure? 32 |
- Yes, your images are never stored. 33 |
- Can I copy handwriting? 34 |
- Yes, but results may vary. 35 |
Extract Text from Image Guide
11 |Overview
13 |Our app makes it simple to extract text from any image, securely and quickly.
14 |How it works
15 |Upload your image, and our OCR engine extracts the text instantly.
16 |Step-by-step
17 |-
18 |
- Go to the Home page. 19 |
- Upload your image. 20 |
- View and copy the extracted text. 21 |
Accuracy tips
23 |-
24 |
- Use clear, well-lit images. 25 |
- Supported formats: JPG, PNG, etc. 26 |
Privacy note
28 |We do not store images. All processing is in-memory.
29 |FAQ
30 |-
31 |
- Is my data safe? 32 |
- Yes, we never store your images. 33 |
- Can I use this for handwriting? 34 |
- Yes, but accuracy may vary. 35 |
How It Works
23 | *...
24 | *Image to Text Guide
11 |Overview
13 |Extracting text from images is easy and secure with our free web app. No registration required.
14 |How it works
15 |Upload your image, and our OCR engine processes it in-memory. No images are stored.
16 |Step-by-step
17 |-
18 |
- Go to the Home page. 19 |
- Click the upload area and select your image. 20 |
- Wait for the text extraction to complete. 21 |
- Copy or download the extracted text. 22 |
Accuracy tips
24 |-
25 |
- Use high-resolution images. 26 |
- Ensure text is clear and unobstructed. 27 |
- Supported formats: JPG, PNG, etc. 28 |
Privacy note
30 |We do not store your images. All processing is done in-memory.
31 |FAQ
32 |-
33 |
- Is it free? 34 |
- Yes, the service is completely free. 35 |
- Do you keep my images? 36 |
- No, images are never stored. 37 |
${text.replace(//g, '>')}
25 |
26 |
27 | `;
28 |
29 | const blob = new Blob([content], { type: 'application/msword' });
30 | const link = document.createElement('a');
31 | link.href = URL.createObjectURL(blob);
32 | link.download = filename;
33 | document.body.appendChild(link);
34 | link.click();
35 | document.body.removeChild(link);
36 | };
37 |
38 | /**
39 | * Download text as PDF
40 | * Now with dynamic import - jsPDF only loaded when user clicks "Download PDF"
41 | */
42 | export const downloadPdf = async (text: string, filename: string) => {
43 | // Lazy-load jsPDF library (only when user exports to PDF)
44 | const { default: jsPDF } = await import('jspdf');
45 |
46 | const doc = new jsPDF();
47 |
48 | const lines = doc.splitTextToSize(text, 180); // 180 is the width of the text block
49 | doc.text(lines, 10, 10);
50 | doc.save(filename);
51 | };
52 |
--------------------------------------------------------------------------------
/lib/env-guards.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Environment variable guards and utilities
3 | * Ensures critical variables are set at runtime with helpful warnings
4 | */
5 |
6 | let siteUrlWarningShown = false;
7 |
8 | /**
9 | * Get the site URL with runtime guard in production
10 | * Warns once if VITE_SITE_URL is missing in production builds
11 | */
12 | export function getSiteUrl(): string {
13 | const url = import.meta.env.VITE_SITE_URL;
14 |
15 | if (!url && !import.meta.env.DEV && !siteUrlWarningShown) {
16 | siteUrlWarningShown = true;
17 | console.warn(
18 | '⚠️ [ENV] VITE_SITE_URL is not set in production. ' +
19 | 'This may affect canonical links, sitemaps, and SEO metadata. ' +
20 | 'Set VITE_SITE_URL in your production environment variables. ' +
21 | 'Falling back to window.location.origin.'
22 | );
23 | }
24 |
25 | return url || (typeof window !== 'undefined' ? window.location.origin : 'https://freetextfromimage.com');
26 | }
27 |
28 | /**
29 | * Get the AdSense publisher ID
30 | * Returns null if not set (ads will be disabled)
31 | */
32 | export function getAdSensePubId(): string | null {
33 | return import.meta.env.VITE_ADSENSE_PUB_ID || null;
34 | }
35 |
36 | /**
37 | * Check if ads are enabled in preview builds
38 | */
39 | export function isAdsEnabledInPreview(): boolean {
40 | return import.meta.env.VITE_ENABLE_ADS_IN_PREVIEW === 'true' || import.meta.env.VITE_ENABLE_ADS_IN_PREVIEW === '1';
41 | }
42 |
43 | /**
44 | * Check if specific ad slots are enabled
45 | */
46 | export function areAdSlotsEnabled(): boolean {
47 | return import.meta.env.VITE_ADS_SLOTS_ENABLED === 'true' || import.meta.env.VITE_ADS_SLOTS_ENABLED === '1';
48 | }
49 |
--------------------------------------------------------------------------------
/app/jpg-to-excel/page.tsx:
--------------------------------------------------------------------------------
1 | import ImageToExcelPage from '../image-to-excel/page';
2 |
3 | /**
4 | * JPG to Excel - Alias Route
5 | *
6 | * This is an SEO-optimized alias that redirects to the main
7 | * image-to-excel page. Users searching for "jpg to excel" will
8 | * land here, but the content and functionality are identical.
9 | *
10 | * The canonical link points to /image-to-excel to avoid duplicate
11 | * content penalties and consolidate SEO authority.
12 | */
13 |
14 | // Re-export the same page component
15 | export default ImageToExcelPage;
16 |
17 | // Override metadata with JPG-specific keywords and canonical link
18 | export const metadata = {
19 | title: 'JPG to Excel Converter – Extract Tables from JPG Images (Free)',
20 | description: 'Convert JPG images of tables to Excel or CSV instantly. Extract data from photo spreadsheets, receipts, and reports. Free OCR with header and cell detection.',
21 |
22 | // Canonical link to consolidate SEO authority
23 | alternates: {
24 | canonical: '/image-to-excel',
25 | },
26 |
27 | openGraph: {
28 | title: 'JPG to Excel Converter – Extract Tables from JPG Images (Free)',
29 | description: 'Convert JPG images of tables to Excel or CSV instantly. Extract data from photo spreadsheets, receipts, and reports. Free OCR with header and cell detection.',
30 | type: 'website',
31 | },
32 |
33 | twitter: {
34 | card: 'summary_large_image',
35 | title: 'JPG to Excel Converter – Extract Tables from JPG Images (Free)',
36 | description: 'Convert JPG images of tables to Excel or CSV instantly. Extract data from photo spreadsheets, receipts, and reports. Free OCR with header and cell detection.',
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/pages/ImageToExcel.tsx:
--------------------------------------------------------------------------------
1 | import { IntentPage } from '../components/v3/IntentPage';
2 |
3 | /**
4 | * Image to Excel page
5 | * SEO-optimized route for /image-to-excel
6 | */
7 | export default function ImageToExcel() {
8 | return (
9 | ⚠️ Extraction Failed
29 |{message}
30 |Both fast and AI methods were attempted
31 |Image to Text Guide
14 |Overview
16 |Learn how to convert images to text using our free web app...
17 |How it works
20 |Upload your image, and our OCR engine extracts the text...
21 |Step-by-step
24 |-
25 |
- Go to the Home page. 26 |
- Upload your image. 27 |
- Click "Extract Text". 28 |
- Copy or download the result. 29 |
Accuracy tips
33 |-
34 |
- Use high-resolution images. 35 |
- Avoid glare and shadows. 36 |
Privacy note
40 |Your images are processed securely and never stored.
41 |FAQ
44 |-
45 | {faqs.map(faq => (
46 |
- {faq.question} {faq.answer} 47 | ))} 48 |
Hello world
Title Here
36 |This is a paragraph with bold text.
37 |-
38 |
- Item one 39 |
- Item two 40 |
and making footer headings semantic () while preserving visual size.
9 |
10 | Files changed (high level)
11 |
12 | - src/ads/AutoAds.tsx (script loader) — existing
13 | - src/ads/InArticle.tsx (consent-gated slot) — existing
14 | - components/v3/IntentPage.tsx — replaced InlineAdSlot with InArticle and added env gating
15 | - pages/CopyTextFromImageGuide.tsx — added InArticle slot + env gating
16 | - pages/JpgToExcelGuide.tsx — added InArticle slot + env gating
17 | - App.tsx — accessibility: skip link, header h1, footer headings h3 (visual size preserved)
18 |
19 | Why
20 |
21 | - Preserve Production pixel-parity: ad slots are feature-flag gated and default to not showing in Production unless flags are set.
22 | - Ensure CLS-free reserved space for ad slots.
23 | - Improve accessibility (heading order) to satisfy automated a11y checks.
24 |
25 | Verification steps (local)
26 |
27 | 1. Install deps and start dev server
28 |
29 | ```bash
30 | npm install
31 | npm run dev
32 | # open http://localhost:3000 or the alternate port printed by Vite
33 | ```
34 |
35 | 2. Run tests (unit + accessibility)
36 |
37 | ```bash
38 | npm run test
39 | ```
40 |
41 | Expected results
42 |
43 | - All tests should pass (vitest). Accessibility tests for `layout.accessibility.a11y.spec.tsx` should be green after the heading fixes.
44 | - No runtime errors in dev server.
45 | - With no flags set (production-like), no `` elements should be mounted. With `VITE_ADS_SLOTS_ENABLED=true` (or preview flag), the InArticle reserved slots will be present and will mount `` only when consent is `'granted'` and the AdSense script is present.
46 |
47 | How to create a PR locally
48 |
49 | ```bash
50 | # From your feature branch (changes are made locally on feat/adsense-readiness)
51 | git checkout -b feat/adsense-readiness
52 | git add .
53 | git commit -m "feat(ads): Add consent-gated InArticle slots to guides; fix heading order a11y"
54 | git push origin feat/adsense-readiness
55 | # then open a PR on GitHub comparing feat/adsense-readiness -> main
56 | ```
57 |
58 | Notes & safety
59 |
60 | - No ad script will load in Production unless `VITE_ADSENSE_PUB_ID` is set and consent is granted.
61 | - The consent defaults are intentionally set to denied so that ads never fire until a certified CMP updates consent.
62 | - If you want me to open the PR for you, I can prepare the branch and PR body here; however I don't have network push permission from this environment. I can provide the exact git commands (above) and the PR body.
63 |
64 | Follow-ups
65 |
66 | - Add a tiny integration test that verifies the InArticle slot renders its reserved container when `VITE_ADS_SLOTS_ENABLED=true` in preview mode.
67 | - Add documentation for CMP integration and consent wiring in `docs/`.
68 |
--------------------------------------------------------------------------------
/router.tsx:
--------------------------------------------------------------------------------
1 | import { lazy } from 'react';
2 | import { createBrowserRouter, RouterProvider } from 'react-router-dom';
3 |
4 | // V3: Futuristic Hero UI (feature-gated)
5 | import { HeroOCR } from './components/v3/HeroOCR';
6 |
7 | // Lazy load feature pages for code splitting
8 | // See pages/_structure.md for organization
9 | const ImageToText = lazy(() => import('./pages/ImageToText'));
10 | const ImageToTextConverter = lazy(() => import('./pages/ImageToTextConverter'));
11 | const JpgToWord = lazy(() => import('./pages/JpgToWord'));
12 | const ImageToExcel = lazy(() => import('./pages/ImageToExcel'));
13 | const ExtractTextFromImage = lazy(() => import('./pages/ExtractTextFromImage'));
14 | const NotFound = lazy(() => import('./pages/NotFound'));
15 | const CopyTextFromImageGuide = lazy(() => import('./pages/CopyTextFromImageGuide'));
16 | const JpgToExcelGuide = lazy(() => import('./pages/JpgToExcelGuide'));
17 | const About = lazy(() => import('./pages/About'));
18 | const Contact = lazy(() => import('./pages/Contact'));
19 |
20 | // Route path constants - single source of truth for all route paths
21 | export const ROUTES = {
22 | HOME: '/',
23 | IMAGE_TO_TEXT: '/image-to-text',
24 | IMAGE_TO_TEXT_CONVERTER: '/image-to-text-converter',
25 | JPG_TO_WORD: '/jpg-to-word',
26 | IMAGE_TO_EXCEL: '/image-to-excel',
27 | EXTRACT_TEXT_FROM_IMAGE: '/extract-text-from-image',
28 | COPY_TEXT_FROM_IMAGE: '/copy-text-from-image',
29 | JPG_TO_EXCEL: '/jpg-to-excel',
30 | ABOUT: '/about',
31 | CONTACT: '/contact',
32 | } as const;
33 |
34 | // Always use HeroOCR as the home component
35 | const HomeComponent = HeroOCR;
36 |
37 | /**
38 | * App router with SEO-friendly paths
39 | * - / (home) - Uses V3 HeroOCR if VITE_UX_V2=1, otherwise legacy App
40 | * - /image-to-text
41 | * - /image-to-text-converter
42 | * - /jpg-to-word
43 | * - /image-to-excel
44 | * - /extract-text-from-image
45 | * - /about
46 | * - /contact
47 | *
48 | * All routes use same OCR component with different metadata
49 | */
50 | export const router = createBrowserRouter([
51 | {
52 | path: ROUTES.HOME,
53 | element: ,
54 | },
55 | {
56 | path: ROUTES.IMAGE_TO_TEXT,
57 | element: ,
58 | },
59 | {
60 | path: ROUTES.IMAGE_TO_TEXT_CONVERTER,
61 | element: ,
62 | },
63 | {
64 | path: ROUTES.JPG_TO_WORD,
65 | element: ,
66 | },
67 | {
68 | path: ROUTES.IMAGE_TO_EXCEL,
69 | element: ,
70 | },
71 | {
72 | path: ROUTES.EXTRACT_TEXT_FROM_IMAGE,
73 | element: ,
74 | },
75 | {
76 | path: ROUTES.COPY_TEXT_FROM_IMAGE,
77 | element: ,
78 | },
79 | {
80 | path: ROUTES.JPG_TO_EXCEL,
81 | element: ,
82 | },
83 | {
84 | path: ROUTES.ABOUT,
85 | element: ,
86 | },
87 | {
88 | path: ROUTES.CONTACT,
89 | element: ,
90 | },
91 | {
92 | path: '*',
93 | element: ,
94 | },
95 | ]);
96 |
97 | /**
98 | * Router wrapper component
99 | */
100 | export function AppRouter() {
101 | return ;
102 | }
103 |
--------------------------------------------------------------------------------
/hooks/useWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | interface WebVitalsMetric {
4 | name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB' | 'INP';
5 | value: number;
6 | rating: 'good' | 'needs-improvement' | 'poor';
7 | delta: number;
8 | id: string;
9 | }
10 |
11 | /**
12 | * Core Web Vitals monitoring hook
13 | *
14 | * Metrics tracked:
15 | * - LCP (Largest Contentful Paint): Target < 1.8s
16 | * - FID (First Input Delay): Target < 100ms
17 | * - CLS (Cumulative Layout Shift): Target < 0.1
18 | * - INP (Interaction to Next Paint): Target < 200ms
19 | * - FCP (First Contentful Paint): Target < 1.2s
20 | * - TTFB (Time to First Byte): Target < 600ms
21 | *
22 | * Usage:
23 | * useWebVitals((metric) => {
24 | * console.log(metric.name, metric.value);
25 | * // Send to analytics service
26 | * });
27 | */
28 | export function useWebVitals(onMetric?: (metric: WebVitalsMetric) => void) {
29 | useEffect(() => {
30 | // Only run in browser
31 | if (typeof window === 'undefined') return;
32 |
33 | // Dynamically import web-vitals to avoid SSR issues
34 | import('web-vitals').then(({ onCLS, onFCP, onLCP, onTTFB, onINP }) => {
35 | const handleMetric = (metric: any) => {
36 | const webVitalsMetric: WebVitalsMetric = {
37 | name: metric.name,
38 | value: metric.value,
39 | rating: metric.rating,
40 | delta: metric.delta,
41 | id: metric.id,
42 | };
43 |
44 | // Log to console in development
45 | if (import.meta.env.DEV) {
46 | const emoji = metric.rating === 'good' ? '✅' : metric.rating === 'needs-improvement' ? '⚠️' : '❌';
47 | console.log(`${emoji} [Web Vitals] ${metric.name}:`, {
48 | value: Math.round(metric.value),
49 | rating: metric.rating,
50 | });
51 | }
52 |
53 | // Call custom handler
54 | onMetric?.(webVitalsMetric);
55 |
56 | // Send to analytics in production
57 | if (import.meta.env.PROD) {
58 | // Example: Send to Google Analytics
59 | if (typeof window !== 'undefined' && (window as any).gtag) {
60 | (window as any).gtag('event', metric.name, {
61 | value: Math.round(metric.value),
62 | metric_rating: metric.rating,
63 | metric_id: metric.id,
64 | metric_delta: Math.round(metric.delta),
65 | });
66 | }
67 | }
68 | };
69 |
70 | // Register all Core Web Vitals
71 | onCLS(handleMetric);
72 | onFCP(handleMetric);
73 | onLCP(handleMetric);
74 | onTTFB(handleMetric);
75 | onINP(handleMetric);
76 | }).catch((err) => {
77 | console.error('Failed to load web-vitals:', err);
78 | });
79 | }, [onMetric]);
80 | }
81 |
82 | /**
83 | * Get performance thresholds for each metric
84 | */
85 | export function getPerformanceThresholds() {
86 | return {
87 | LCP: { good: 1800, needsImprovement: 2500 }, // ms
88 | FID: { good: 100, needsImprovement: 300 }, // ms
89 | CLS: { good: 0.1, needsImprovement: 0.25 }, // score
90 | INP: { good: 200, needsImprovement: 500 }, // ms
91 | FCP: { good: 1200, needsImprovement: 1800 }, // ms
92 | TTFB: { good: 600, needsImprovement: 800 }, // ms
93 | };
94 | }
95 |
--------------------------------------------------------------------------------
/components/ResultDisplay.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from 'react';
2 | import { CopyIcon } from './icons/CopyIcon';
3 | import { DownloadIcon } from './icons/DownloadIcon';
4 | import { downloadDoc, downloadPdf } from '../utils/fileUtils';
5 |
6 | interface ResultDisplayProps {
7 | text: string;
8 | originalFilename: string;
9 | }
10 |
11 | export const ResultDisplay: React.FC = ({ text, originalFilename }) => {
12 | const [isCopied, setIsCopied] = useState(false);
13 | const [isDownloading, setIsDownloading] = useState(null);
14 |
15 | const handleCopy = useCallback(() => {
16 | navigator.clipboard.writeText(text).then(() => {
17 | setIsCopied(true);
18 | setTimeout(() => setIsCopied(false), 2000);
19 | });
20 | }, [text]);
21 |
22 | const handleDownload = async (format: 'doc' | 'pdf') => {
23 | setIsDownloading(format);
24 | const baseFilename = originalFilename.split('.').slice(0, -1).join('.') || originalFilename;
25 | try {
26 | if (format === 'doc') {
27 | downloadDoc(text, `${baseFilename}.doc`);
28 | } else {
29 | // downloadPdf is now async (dynamic import)
30 | await downloadPdf(text, `${baseFilename}.pdf`);
31 | }
32 | } catch(e) {
33 | console.error("Download failed", e);
34 | } finally {
35 | setTimeout(() => setIsDownloading(null), 1000);
36 | }
37 | };
38 |
39 | return (
40 |
41 |
42 | Extracted Text
43 |
44 |
52 |
60 |
68 |
69 |
70 |
71 | {text}
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/__tests__/lib/monetization.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isUtilityRoute,
3 | hasPublisherContent,
4 | shouldShowAds,
5 | getAdPlacementStrategy,
6 | } from '../../lib/monetization';
7 |
8 | describe('monetization utilities', () => {
9 | describe('isUtilityRoute', () => {
10 | it('should return true for utility routes', () => {
11 | expect(isUtilityRoute('/404')).toBe(true);
12 | expect(isUtilityRoute('/500')).toBe(true);
13 | expect(isUtilityRoute('/login')).toBe(true);
14 | expect(isUtilityRoute('/auth/callback')).toBe(true);
15 | });
16 |
17 | it('should return false for content routes', () => {
18 | expect(isUtilityRoute('/')).toBe(false);
19 | expect(isUtilityRoute('/extract')).toBe(false);
20 | expect(isUtilityRoute('/receipt-ocr')).toBe(false);
21 | });
22 |
23 | it('should handle invalid paths safely', () => {
24 | expect(isUtilityRoute('')).toBe(true);
25 | expect(isUtilityRoute(null as any)).toBe(true);
26 | });
27 | });
28 |
29 | describe('hasPublisherContent', () => {
30 | it('should return true when word count >= 250', () => {
31 | expect(hasPublisherContent({ wordCount: 250, hasResult: false, hasExplainers: false })).toBe(true);
32 | expect(hasPublisherContent({ wordCount: 500, hasResult: false, hasExplainers: false })).toBe(true);
33 | });
34 |
35 | it('should return true when hasResult is true', () => {
36 | expect(hasPublisherContent({ wordCount: 50, hasResult: true, hasExplainers: false })).toBe(true);
37 | });
38 |
39 | it('should return true when hasExplainers is true', () => {
40 | expect(hasPublisherContent({ wordCount: 100, hasResult: false, hasExplainers: true })).toBe(true);
41 | });
42 |
43 | it('should return false when no content criteria met', () => {
44 | expect(hasPublisherContent({ wordCount: 100, hasResult: false, hasExplainers: false })).toBe(false);
45 | });
46 | });
47 |
48 | describe('shouldShowAds', () => {
49 | it('should return false on utility routes regardless of content', () => {
50 | expect(shouldShowAds('/404', { wordCount: 500, hasResult: true, hasExplainers: true })).toBe(false);
51 | });
52 |
53 | it('should return true on content routes with sufficient content', () => {
54 | expect(shouldShowAds('/', { wordCount: 300, hasResult: false, hasExplainers: false })).toBe(true);
55 | });
56 |
57 | it('should return false on content routes without sufficient content', () => {
58 | expect(shouldShowAds('/', { wordCount: 50, hasResult: false, hasExplainers: false })).toBe(false);
59 | });
60 | });
61 |
62 | describe('getAdPlacementStrategy', () => {
63 | it('should return 1 ad slot for short content', () => {
64 | const strategy = getAdPlacementStrategy({ wordCount: 300, hasResult: false, hasExplainers: false });
65 | expect(strategy.maxAdSlots).toBe(1);
66 | });
67 |
68 | it('should return 2 ad slots for medium content', () => {
69 | const strategy = getAdPlacementStrategy({ wordCount: 500, hasResult: true, hasExplainers: false });
70 | expect(strategy.maxAdSlots).toBe(2);
71 | });
72 |
73 | it('should return 3 ad slots for long content', () => {
74 | const strategy = getAdPlacementStrategy({ wordCount: 800, hasResult: false, hasExplainers: true });
75 | expect(strategy.maxAdSlots).toBe(3);
76 | });
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/__tests__/ResultToolbar.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import { axe, toHaveNoViolations } from 'jest-axe';
5 | import { ResultToolbar } from '../components/ResultToolbar';
6 |
7 | expect.extend(toHaveNoViolations);
8 |
9 | // Mock clipboard API
10 | Object.assign(navigator, {
11 | clipboard: {
12 | writeText: jest.fn(() => Promise.resolve()),
13 | },
14 | });
15 |
16 | describe('ResultToolbar', () => {
17 | const mockText = 'Sample extracted text';
18 | const mockOnCopy = jest.fn();
19 | const mockOnDownload = jest.fn();
20 |
21 | beforeEach(() => {
22 | jest.clearAllMocks();
23 | });
24 |
25 | it('renders without crashing', () => {
26 | render( );
27 | expect(screen.getByRole('toolbar')).toBeInTheDocument();
28 | });
29 |
30 | it('displays character count', () => {
31 | render( );
32 | expect(screen.getByText(/21 characters/)).toBeInTheDocument();
33 | });
34 |
35 | it('copies text to clipboard when copy button is clicked', async () => {
36 | render( );
37 |
38 | const copyButton = screen.getByRole('button', { name: /copy/i });
39 | await userEvent.click(copyButton);
40 |
41 | await waitFor(() => {
42 | expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockText);
43 | expect(mockOnCopy).toHaveBeenCalled();
44 | });
45 | });
46 |
47 | it('shows toast notification after copying', async () => {
48 | render( );
49 |
50 | const copyButton = screen.getByRole('button', { name: /copy/i });
51 | await userEvent.click(copyButton);
52 |
53 | await waitFor(() => {
54 | expect(screen.getByText('Copied!')).toBeInTheDocument();
55 | });
56 | });
57 |
58 | it('downloads text when download button is clicked', async () => {
59 | // Mock URL.createObjectURL and revokeObjectURL
60 | global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
61 | global.URL.revokeObjectURL = jest.fn();
62 |
63 | render( );
64 |
65 | const downloadButton = screen.getByRole('button', { name: /download/i });
66 | await userEvent.click(downloadButton);
67 |
68 | await waitFor(() => {
69 | expect(mockOnDownload).toHaveBeenCalled();
70 | expect(global.URL.createObjectURL).toHaveBeenCalled();
71 | });
72 | });
73 |
74 | it('shows keyboard shortcuts in buttons', () => {
75 | render( );
76 |
77 | expect(screen.getByText('C')).toBeInTheDocument();
78 | expect(screen.getByText('D')).toBeInTheDocument();
79 | });
80 |
81 | it('should have no accessibility violations', async () => {
82 | const { container } = render( );
83 | const results = await axe(container);
84 | expect(results).toHaveNoViolations();
85 | });
86 |
87 | it('shows tooltips on hover', async () => {
88 | render( );
89 |
90 | const copyButton = screen.getByRole('button', { name: /copy/i });
91 | fireEvent.mouseEnter(copyButton);
92 |
93 | await waitFor(() => {
94 | expect(screen.getByText(/Copy to clipboard/)).toBeInTheDocument();
95 | });
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/utils/fileValidation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Image file validation utilities
3 | *
4 | * Extracted from GlassDropzone.tsx to follow SRP
5 | * Provides pure validation logic separated from UI/event handling
6 | */
7 |
8 | const MAX_FILE_SIZE_MB = 20;
9 | const SUPPORTED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
10 | const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'];
11 |
12 | export interface FileValidationResult {
13 | valid: boolean;
14 | error?: string;
15 | }
16 |
17 | /**
18 | * Validate image file type
19 | * @param file - File to validate
20 | * @returns Validation result with error message if invalid
21 | *
22 | * @example
23 | * const result = validateImageType(file);
24 | * if (!result.valid) console.error(result.error);
25 | */
26 | export function validateImageType(file: File): FileValidationResult {
27 | if (!file.type.startsWith('image/')) {
28 | return {
29 | valid: false,
30 | error: `Invalid file type: ${file.type || 'unknown'}. Please select an image file (PNG, JPG, WEBP).`,
31 | };
32 | }
33 |
34 | if (!SUPPORTED_TYPES.includes(file.type)) {
35 | return {
36 | valid: false,
37 | error: `File type "${file.type}" is not supported. Supported formats: JPG, PNG, WEBP.`,
38 | };
39 | }
40 |
41 | return { valid: true };
42 | }
43 |
44 | /**
45 | * Validate image file size
46 | * @param file - File to validate
47 | * @returns Validation result with error message if invalid
48 | *
49 | * @example
50 | * const result = validateFileSize(file);
51 | * if (!result.valid) console.error(result.error);
52 | */
53 | export function validateFileSize(file: File): FileValidationResult {
54 | const fileSizeMB = file.size / (1024 * 1024);
55 |
56 | if (fileSizeMB > MAX_FILE_SIZE_MB) {
57 | return {
58 | valid: false,
59 | error: `File too large (${fileSizeMB.toFixed(2)} MB). Maximum size is ${MAX_FILE_SIZE_MB} MB.`,
60 | };
61 | }
62 |
63 | return { valid: true };
64 | }
65 |
66 | /**
67 | * Validate both type and size
68 | * @param file - File to validate
69 | * @returns Validation result, returns first error found if any
70 | *
71 | * @example
72 | * const result = validateImageFile(file);
73 | * if (!result.valid) {
74 | * setError(result.error);
75 | * return;
76 | * }
77 | */
78 | export function validateImageFile(file: File): FileValidationResult {
79 | // Check type first
80 | const typeValidation = validateImageType(file);
81 | if (!typeValidation.valid) return typeValidation;
82 |
83 | // Check size second
84 | const sizeValidation = validateFileSize(file);
85 | if (!sizeValidation.valid) return sizeValidation;
86 |
87 | return { valid: true };
88 | }
89 |
90 | /**
91 | * Get supported file extensions for accept attribute
92 | * @returns Comma-separated list of supported types
93 | *
94 | * @example
95 | *
96 | */
97 | export function getSupportedFileTypes(): string {
98 | return SUPPORTED_TYPES.join(',');
99 | }
100 |
101 | /**
102 | * Get user-friendly list of supported formats
103 | * @returns Formatted string for display
104 | *
105 | * @example
106 | * Supported formats: {getFormatsLabel()}
107 | */
108 | export function getFormatsLabel(): string {
109 | return SUPPORTED_EXTENSIONS.map(ext => ext.toUpperCase().substring(1)).join(', ');
110 | }
111 |
--------------------------------------------------------------------------------
/components/AdSlotLazy.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import { trackAdSlotVisible } from '../lib/analytics';
3 |
4 | interface AdSlotLazyProps {
5 | /** Ad placement position (for tracking/analytics) */
6 | slot: 'top' | 'mid' | 'bottom' | 'sidebar';
7 | /** Test mode flag (disables real ads) */
8 | dataAdTest?: boolean;
9 | /** Optional custom className */
10 | className?: string;
11 | }
12 |
13 | /**
14 | * AdSlotLazy - Lazy-loading ad container with zero CLS
15 | *
16 | * AdSense-compliant features:
17 | * - Reserved min-height (300px) prevents layout shift
18 | * - IntersectionObserver lazy-loads ads only when visible
19 | * - ARIA accessibility label
20 | * - Test mode support
21 | *
22 | * Performance:
23 | * - Zero CLS: fixed height reserved before ad loads
24 | * - Lazy: ads only load when scrolled into viewport
25 | * - LCP: minimal impact on initial page load
26 | *
27 | * @example
28 | *
29 | *
30 | */
31 | export function AdSlotLazy({ slot, dataAdTest = false, className = '' }: AdSlotLazyProps) {
32 | const adRef = useRef(null);
33 | const [isVisible, setIsVisible] = useState(false);
34 |
35 | useEffect(() => {
36 | const currentRef = adRef.current;
37 | if (!currentRef) return;
38 |
39 | // IntersectionObserver: lazy-load ads only when visible
40 | const observer = new IntersectionObserver(
41 | (entries) => {
42 | entries.forEach((entry) => {
43 | if (entry.isIntersecting) {
44 | setIsVisible(true);
45 |
46 | // Track ad slot visibility
47 | trackAdSlotVisible({
48 | slotId: slot,
49 | });
50 |
51 | observer.disconnect(); // Load once, then stop observing
52 | }
53 | });
54 | },
55 | {
56 | rootMargin: '200px', // Load 200px before entering viewport
57 | threshold: 0.01,
58 | }
59 | );
60 |
61 | observer.observe(currentRef);
62 |
63 | return () => {
64 | if (observer && currentRef) {
65 | observer.disconnect();
66 | }
67 | };
68 | }, []);
69 |
70 | return (
71 |
86 | {isVisible ? (
87 |
88 | {dataAdTest ? (
89 |
90 | Ad Slot: {slot.toUpperCase()}
91 |
92 | ) : (
93 | // Real ad code would go here (AdSense script, etc.)
94 |
95 | )}
96 |
97 | ) : (
98 | // Placeholder while lazy-loading
99 |
100 | )}
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/components/AdSlot.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export type AdSlotSize = 'leaderboard' | 'rectangle' | 'skyscraper' | 'mobile-banner';
4 |
5 | interface AdSlotProps {
6 | slot: AdSlotSize;
7 | className?: string;
8 | }
9 |
10 | /**
11 | * Reserved ad slot container with zero CLS
12 | *
13 | * Sizes:
14 | * - leaderboard: 728x90 (desktop) / 320x50 (mobile)
15 | * - rectangle: 300x250
16 | * - skyscraper: 160x600
17 | * - mobile-banner: 320x100
18 | *
19 | * Features:
20 | * - Reserved space prevents layout shift
21 | * - Glass-morphism placeholder
22 | * - Responsive sizing
23 | * - ARIA labels for screen readers
24 | */
25 | export function AdSlot({ slot, className = '' }: AdSlotProps) {
26 | const dimensions = {
27 | leaderboard: {
28 | desktop: { width: 728, height: 90 },
29 | mobile: { width: 320, height: 50 },
30 | },
31 | rectangle: {
32 | desktop: { width: 300, height: 250 },
33 | mobile: { width: 300, height: 250 },
34 | },
35 | skyscraper: {
36 | desktop: { width: 160, height: 600 },
37 | mobile: { width: 160, height: 600 },
38 | },
39 | 'mobile-banner': {
40 | desktop: { width: 320, height: 100 },
41 | mobile: { width: 320, height: 100 },
42 | },
43 | };
44 |
45 | const size = dimensions[slot];
46 | const isLeaderboard = slot === 'leaderboard';
47 |
48 | return (
49 |
60 |
68 | {/* Placeholder content */}
69 |
70 |
71 | Advertisement
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
79 | /**
80 | * Ad slot positioned between content sections
81 | */
82 | export function InlineAdSlot({ slot = 'rectangle', className = '' }: { slot?: AdSlotSize; className?: string }) {
83 | return (
84 |
85 |
86 |
87 | );
88 | }
89 |
90 | /**
91 | * Sticky sidebar ad slot
92 | */
93 | export function SidebarAdSlot({ className = '' }: { className?: string }) {
94 | return (
95 |
98 | );
99 | }
100 |
101 | /**
102 | * Top banner ad slot (leaderboard)
103 | */
104 | export function TopBannerAdSlot({ className = '' }: { className?: string }) {
105 | return (
106 |
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/src/seo.spec.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { describe, it, expect, beforeEach, vi } from 'vitest';
3 | import { useSEO } from './seo';
4 | import { render } from '@testing-library/react';
5 |
6 | function TestSEO(props: Parameters[0]) {
7 | useSEO(props);
8 | return null;
9 | }
10 |
11 | const ORIGIN = 'https://freetextfromimage.com';
12 |
13 | describe('useSEO', () => {
14 | beforeEach(() => {
15 | document.head.innerHTML = '';
16 | document.title = '';
17 | vi.stubEnv('VITE_SITE_URL', ORIGIN);
18 | });
19 |
20 | it('defaults OG image and canonical to absolute', () => {
21 | render( );
22 | const og = document.querySelector('meta[property="og:image"]');
23 | const tw = document.querySelector('meta[name="twitter:image"]');
24 | expect(og).toBeTruthy();
25 | expect(tw).toBeTruthy();
26 | expect(og.getAttribute('content')).toBe(`${ORIGIN}/og/default.png`);
27 | expect(tw.getAttribute('content')).toBe(`${ORIGIN}/og/default.png`);
28 | const canonical = document.querySelector('link[rel="canonical"]');
29 | expect(canonical).toBeTruthy();
30 | expect(canonical.getAttribute('href')).toBe(ORIGIN);
31 | });
32 |
33 | it('overrides OG image and canonical with absolute URLs', () => {
34 | render( );
35 | const og = document.querySelector('meta[property="og:image"]');
36 | expect(og.getAttribute('content')).toBe(`${ORIGIN}/og/image-to-text.png`);
37 | const canonical = document.querySelector('link[rel="canonical"]');
38 | expect(canonical.getAttribute('href')).toBe(`${ORIGIN}/image-to-text`);
39 | });
40 |
41 | it('sets og:image:alt from prop or default', () => {
42 | render( );
43 | const ogAlt = document.querySelector('meta[property="og:image:alt"]');
44 | expect(ogAlt).toBeTruthy();
45 | expect(ogAlt.getAttribute('content')).toBe('Custom Alt');
46 | // Default fallback
47 | document.head.innerHTML = '';
48 | render( );
49 | const ogAltDefault = document.querySelector('meta[property="og:image:alt"]');
50 | expect(ogAltDefault.getAttribute('content')).toBe('Extract text from image — Free OCR');
51 | });
52 |
53 | it('sets title and description', () => {
54 | render( );
55 | expect(document.title).toBe('SEO Title');
56 | const meta = document.querySelector('meta[name="description"]');
57 | expect(meta).toBeTruthy();
58 | expect(meta.getAttribute('content')).toBe('SEO Desc');
59 | });
60 |
61 | it('emits rich OG/Twitter tags', () => {
62 | render( );
63 | expect(document.querySelector('meta[property="og:type"]')).toBeTruthy();
64 | expect(document.querySelector('meta[property="og:site_name"]')).toBeTruthy();
65 | expect(document.querySelector('meta[property="og:title"]')).toBeTruthy();
66 | expect(document.querySelector('meta[property="og:description"]')).toBeTruthy();
67 | expect(document.querySelector('meta[property="og:url"]')).toBeTruthy();
68 | expect(document.querySelector('meta[property="og:image:width"]')).toBeTruthy();
69 | expect(document.querySelector('meta[property="og:image:height"]')).toBeTruthy();
70 | expect(document.querySelector('meta[property="og:image:type"]')).toBeTruthy();
71 | expect(document.querySelector('meta[property="og:locale"]')).toBeTruthy();
72 | expect(document.querySelector('meta[name="twitter:card"]')).toBeTruthy();
73 | expect(document.querySelector('meta[name="twitter:title"]')).toBeTruthy();
74 | expect(document.querySelector('meta[name="twitter:description"]')).toBeTruthy();
75 | expect(document.querySelector('meta[name="twitter:image"]')).toBeTruthy();
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/__tests__/ProgressBar.test.tsx:
--------------------------------------------------------------------------------
1 | import React, { createRef } from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import { axe, toHaveNoViolations } from 'jest-axe';
4 | import { ProgressBar, ProgressBarHandle } from '../components/ProgressBar';
5 |
6 | expect.extend(toHaveNoViolations);
7 |
8 | describe('ProgressBar', () => {
9 | it('renders without crashing', () => {
10 | render( );
11 | const progressBar = screen.getByRole('progressbar');
12 | expect(progressBar).toBeInTheDocument();
13 | });
14 |
15 | it('renders simple mode when no props provided (backwards compatible)', () => {
16 | render( );
17 | const progressBar = screen.getByRole('progressbar');
18 | expect(progressBar).toHaveAttribute('aria-label', 'Loading');
19 | });
20 |
21 | it('renders staged mode with upload stage', () => {
22 | render( );
23 | const progressBar = screen.getByRole('progressbar');
24 | expect(progressBar).toHaveAttribute('aria-valuenow', '25');
25 | expect(screen.getByText('Upload')).toBeInTheDocument();
26 | });
27 |
28 | it('renders staged mode with OCR stage', () => {
29 | render( );
30 | expect(screen.getByText('OCR')).toBeInTheDocument();
31 | expect(screen.getByText('50%')).toBeInTheDocument();
32 | });
33 |
34 | it('renders complete stage with 100%', () => {
35 | render( );
36 | expect(screen.getByText('Done')).toBeInTheDocument();
37 | expect(screen.getByText('100%')).toBeInTheDocument();
38 | });
39 |
40 | it('renders error stage', () => {
41 | render( );
42 | expect(screen.getByText('Error')).toBeInTheDocument();
43 | expect(screen.getByText('Something went wrong')).toBeInTheDocument();
44 | });
45 |
46 | it('should have no accessibility violations in simple mode', async () => {
47 | const { container } = render( );
48 | const results = await axe(container);
49 | expect(results).toHaveNoViolations();
50 | });
51 |
52 | it('should have no accessibility violations in staged mode', async () => {
53 | const { container } = render( );
54 | const results = await axe(container);
55 | expect(results).toHaveNoViolations();
56 | });
57 |
58 | it('updates aria-valuenow when percent changes', () => {
59 | const { rerender } = render( );
60 | expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '10');
61 |
62 | rerender( );
63 | expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
64 | });
65 |
66 | it('exposes announce() method via ref', () => {
67 | const ref = createRef();
68 | render( );
69 |
70 | expect(ref.current).toBeDefined();
71 | expect(typeof ref.current?.announce).toBe('function');
72 |
73 | // Should not throw
74 | ref.current?.announce('Processing image');
75 | });
76 |
77 | it('updates aria-live region when announce() is called', () => {
78 | const ref = createRef();
79 | const { container } = render( );
80 |
81 | const liveRegion = container.querySelector('[role="status"][aria-live="polite"]');
82 | expect(liveRegion).toBeInTheDocument();
83 |
84 | // Call announce
85 | ref.current?.announce('Halfway done');
86 |
87 | // RAF batches updates - wait for next frame
88 | return new Promise((resolve) => {
89 | requestAnimationFrame(() => {
90 | expect(liveRegion).toHaveTextContent('Halfway done');
91 | resolve();
92 | });
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/hooks/useFocusTrap.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | /**
4 | * Focus trap hook for modals and dialogs
5 | *
6 | * Traps keyboard focus within a container element, preventing users from
7 | * tabbing to elements outside the modal. Essential for accessibility.
8 | *
9 | * Also sets the background as inert to prevent interaction.
10 | *
11 | * @param isActive - Whether the focus trap is active
12 | * @param restoreFocusOnClean - Element to restore focus to on cleanup (optional)
13 | * @param inertSelector - CSS selector for element to mark as inert (default: '#app, #root, main')
14 | *
15 | * @returns Ref to attach to the focus trap container
16 | *
17 | * @example
18 | * const modalRef = useFocusTrap(isOpen);
19 | * return
20 | */
21 | export function useFocusTrap(
22 | isActive: boolean,
23 | restoreFocusOnCleanup: boolean = true,
24 | inertSelector: string = 'main, #app, #root'
25 | ) {
26 | const containerRef = useRef(null);
27 | const previousFocusRef = useRef(null);
28 |
29 | useEffect(() => {
30 | if (!isActive) return;
31 |
32 | const container = containerRef.current;
33 | if (!container) return;
34 |
35 | // Save the currently focused element to restore later
36 | previousFocusRef.current = document.activeElement as HTMLElement;
37 |
38 | // Set background as inert (prevents interaction)
39 | const inertElements = document.querySelectorAll(inertSelector);
40 | inertElements.forEach((el) => {
41 | el.setAttribute('inert', '');
42 | });
43 |
44 | // Focus first focusable element in container
45 | const focusableElements = getFocusableElements(container);
46 | if (focusableElements.length > 0) {
47 | focusableElements[0].focus();
48 | }
49 |
50 | // Handle Tab key to trap focus
51 | const handleKeyDown = (e: KeyboardEvent) => {
52 | if (e.key !== 'Tab') return;
53 |
54 | const focusableElements = getFocusableElements(container);
55 | if (focusableElements.length === 0) return;
56 |
57 | const firstElement = focusableElements[0];
58 | const lastElement = focusableElements[focusableElements.length - 1];
59 |
60 | if (e.shiftKey) {
61 | // Shift + Tab: Move to last element if on first
62 | if (document.activeElement === firstElement) {
63 | lastElement.focus();
64 | e.preventDefault();
65 | }
66 | } else {
67 | // Tab: Move to first element if on last
68 | if (document.activeElement === lastElement) {
69 | firstElement.focus();
70 | e.preventDefault();
71 | }
72 | }
73 | };
74 |
75 | container.addEventListener('keydown', handleKeyDown);
76 |
77 | return () => {
78 | container.removeEventListener('keydown', handleKeyDown);
79 |
80 | // Remove inert from background
81 | inertElements.forEach((el) => {
82 | el.removeAttribute('inert');
83 | });
84 |
85 | // Restore focus to previous element
86 | if (restoreFocusOnCleanup && previousFocusRef.current) {
87 | previousFocusRef.current.focus();
88 | }
89 | };
90 | }, [isActive, inertSelector, restoreFocusOnCleanup]);
91 |
92 | return containerRef;
93 | }
94 |
95 | /**
96 | * Get all focusable elements within a container
97 | */
98 | function getFocusableElements(container: HTMLElement): HTMLElement[] {
99 | const selector = [
100 | 'a[href]',
101 | 'button:not([disabled])',
102 | 'textarea:not([disabled])',
103 | 'input:not([disabled])',
104 | 'select:not([disabled])',
105 | '[tabindex]:not([tabindex="-1"])',
106 | ].join(', ');
107 |
108 | return Array.from(container.querySelectorAll(selector)).filter(
109 | (el) => {
110 | // Exclude elements with display: none or visibility: hidden
111 | const style = window.getComputedStyle(el);
112 | return style.display !== 'none' && style.visibility !== 'hidden';
113 | }
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/hooks/useLocalHistory.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 |
3 | export interface HistoryItem {
4 | id: string;
5 | filename: string;
6 | text: string;
7 | timestamp: number;
8 | preview?: string; // Base64 thumbnail
9 | method?: string;
10 | confidence?: number;
11 | }
12 |
13 | const STORAGE_KEY = 'ocr_history';
14 | const MAX_HISTORY_ITEMS = 20;
15 |
16 | /**
17 | * Generate a UUID v4-compatible string with fallback for older browsers
18 | * Uses crypto.randomUUID() if available, otherwise uses crypto.getRandomValues()
19 | * Falls back to Math.random() only if crypto is completely unavailable
20 | */
21 | function generateUUID(): string {
22 | // Use native implementation if available (modern browsers)
23 | if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
24 | return crypto.randomUUID();
25 | }
26 |
27 | // Fallback using crypto.getRandomValues (Safari 6.1+, widely supported)
28 | if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
29 | // Generate UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
30 | const bytes = new Uint8Array(16);
31 | crypto.getRandomValues(bytes);
32 |
33 | // Set version (4) and variant bits
34 | bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
35 | bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
36 |
37 | // Convert to hex string with dashes
38 | const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
39 | return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
40 | }
41 |
42 | // Final fallback for very old browsers (non-cryptographic)
43 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
44 | const r = Math.random() * 16 | 0;
45 | const v = c === 'x' ? r : (r & 0x3 | 0x8);
46 | return v.toString(16);
47 | });
48 | }
49 |
50 | /**
51 | * Custom hook for managing local OCR history
52 | * Stores results in localStorage with size limits
53 | */
54 | export function useLocalHistory() {
55 | const [history, setHistory] = useState([]);
56 | const [isLoading, setIsLoading] = useState(true);
57 |
58 | // Load history from localStorage on mount
59 | useEffect(() => {
60 | try {
61 | const stored = localStorage.getItem(STORAGE_KEY);
62 | if (stored) {
63 | const parsed = JSON.parse(stored) as HistoryItem[];
64 | setHistory(parsed);
65 | }
66 | } catch (error) {
67 | console.error('Failed to load history:', error);
68 | } finally {
69 | setIsLoading(false);
70 | }
71 | }, []);
72 |
73 | // Save history to localStorage whenever it changes
74 | useEffect(() => {
75 | if (!isLoading) {
76 | try {
77 | localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
78 | } catch (error) {
79 | console.error('Failed to save history:', error);
80 | }
81 | }
82 | }, [history, isLoading]);
83 |
84 | const addToHistory = useCallback((item: Omit) => {
85 | const newItem: HistoryItem = {
86 | ...item,
87 | id: generateUUID(),
88 | timestamp: Date.now(),
89 | };
90 |
91 | setHistory(prev => {
92 | const updated = [newItem, ...prev];
93 | // Keep only the most recent items
94 | return updated.slice(0, MAX_HISTORY_ITEMS);
95 | });
96 |
97 | return newItem.id;
98 | }, []);
99 |
100 | const removeFromHistory = useCallback((id: string) => {
101 | setHistory(prev => prev.filter(item => item.id !== id));
102 | }, []);
103 |
104 | const clearHistory = useCallback(() => {
105 | setHistory([]);
106 | try {
107 | localStorage.removeItem(STORAGE_KEY);
108 | } catch (error) {
109 | console.error('Failed to clear history:', error);
110 | }
111 | }, []);
112 |
113 | const getHistoryItem = useCallback((id: string): HistoryItem | undefined => {
114 | return history.find(item => item.id === id);
115 | }, [history]);
116 |
117 | return {
118 | history,
119 | isLoading,
120 | addToHistory,
121 | removeFromHistory,
122 | clearHistory,
123 | getHistoryItem,
124 | };
125 | }
126 |
--------------------------------------------------------------------------------
/components/ui/ScrollNav.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type ScrollNavSection = { id: string; label: string };
5 |
6 | const HEADER_OFFSET = 96; // sticky header height + spacing
7 | const RESUME_AFTER_MS = 600; // pause spy after click to avoid flicker
8 |
9 | export function ScrollNav({ sections }: { sections: ScrollNavSection[] }) {
10 | const [activeId, setActiveId] = React.useState(sections[0]?.id);
11 | const suspendRef = React.useRef(false);
12 | const timer = React.useRef();
13 |
14 | // Make anchor targets stop below sticky header
15 | React.useEffect(() => {
16 | sections.forEach(s => {
17 | const el = document.getElementById(s.id);
18 | if (el) (el.style as any).scrollMarginTop = `${HEADER_OFFSET + 8}px`;
19 | });
20 | }, [sections]);
21 |
22 | // One active section at a time using IntersectionObserver
23 | React.useEffect(() => {
24 | const targets = sections
25 | .map(s => document.getElementById(s.id))
26 | .filter(Boolean) as HTMLElement[];
27 | if (!targets.length) return;
28 |
29 | const io = new IntersectionObserver(
30 | entries => {
31 | if (suspendRef.current) return;
32 | const inView = entries
33 | .filter(e => e.isIntersecting)
34 | .sort((a, b) => b.intersectionRatio - a.intersectionRatio);
35 | const top = inView[0]?.target as HTMLElement | undefined;
36 | if (top && top.id !== activeId) setActiveId(top.id);
37 | },
38 | { root: null, rootMargin: `-${HEADER_OFFSET}px 0px -55% 0px`, threshold: [0.15, 0.55, 0.8] }
39 | );
40 |
41 | targets.forEach(t => io.observe(t));
42 | return () => io.disconnect();
43 | }, [sections, activeId]);
44 |
45 | const onClick = (id: string) => (_e: React.MouseEvent) => {
46 | setActiveId(id); // immediate feedback
47 | suspendRef.current = true; // pause spy while smooth scroll happens
48 | window.clearTimeout(timer.current);
49 | timer.current = window.setTimeout(() => (suspendRef.current = false), RESUME_AFTER_MS);
50 | };
51 |
52 | return (
53 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/__tests__/useShortcuts.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react';
2 | import { useShortcuts, getCommonShortcuts } from '../hooks/useShortcuts';
3 |
4 | describe('useShortcuts', () => {
5 | let mockCallback: jest.Mock;
6 |
7 | beforeEach(() => {
8 | mockCallback = jest.fn();
9 | });
10 |
11 | it('triggers callback when matching key is pressed', () => {
12 | const shortcuts = [
13 | {
14 | key: 'c',
15 | action: mockCallback,
16 | description: 'Copy',
17 | },
18 | ];
19 |
20 | renderHook(() => useShortcuts(shortcuts, true));
21 |
22 | const event = new KeyboardEvent('keydown', { key: 'c' });
23 | act(() => {
24 | window.dispatchEvent(event);
25 | });
26 |
27 | expect(mockCallback).toHaveBeenCalled();
28 | });
29 |
30 | it('does not trigger when shortcuts are disabled', () => {
31 | const shortcuts = [
32 | {
33 | key: 'c',
34 | action: mockCallback,
35 | description: 'Copy',
36 | },
37 | ];
38 |
39 | renderHook(() => useShortcuts(shortcuts, false));
40 |
41 | const event = new KeyboardEvent('keydown', { key: 'c' });
42 | act(() => {
43 | window.dispatchEvent(event);
44 | });
45 |
46 | expect(mockCallback).not.toHaveBeenCalled();
47 | });
48 |
49 | it('does not trigger when typing in input field', () => {
50 | const shortcuts = [
51 | {
52 | key: 'c',
53 | action: mockCallback,
54 | description: 'Copy',
55 | },
56 | ];
57 |
58 | renderHook(() => useShortcuts(shortcuts, true));
59 |
60 | // Create input and dispatch event from it
61 | const input = document.createElement('input');
62 | document.body.appendChild(input);
63 |
64 | const event = new KeyboardEvent('keydown', {
65 | key: 'c',
66 | bubbles: true,
67 | });
68 | Object.defineProperty(event, 'target', { value: input });
69 |
70 | act(() => {
71 | window.dispatchEvent(event);
72 | });
73 |
74 | expect(mockCallback).not.toHaveBeenCalled();
75 | document.body.removeChild(input);
76 | });
77 |
78 | it('respects modifier keys', () => {
79 | const shortcuts = [
80 | {
81 | key: 's',
82 | ctrlKey: true,
83 | action: mockCallback,
84 | description: 'Save',
85 | },
86 | ];
87 |
88 | renderHook(() => useShortcuts(shortcuts, true));
89 |
90 | // Without ctrl key
91 | const event1 = new KeyboardEvent('keydown', { key: 's' });
92 | act(() => {
93 | window.dispatchEvent(event1);
94 | });
95 | expect(mockCallback).not.toHaveBeenCalled();
96 |
97 | // With ctrl key
98 | const event2 = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
99 | act(() => {
100 | window.dispatchEvent(event2);
101 | });
102 | expect(mockCallback).toHaveBeenCalled();
103 | });
104 |
105 | it('skips disabled shortcuts', () => {
106 | const shortcuts = [
107 | {
108 | key: 'c',
109 | action: mockCallback,
110 | description: 'Copy',
111 | disabled: true,
112 | },
113 | ];
114 |
115 | renderHook(() => useShortcuts(shortcuts, true));
116 |
117 | const event = new KeyboardEvent('keydown', { key: 'c' });
118 | act(() => {
119 | window.dispatchEvent(event);
120 | });
121 |
122 | expect(mockCallback).not.toHaveBeenCalled();
123 | });
124 | });
125 |
126 | describe('getCommonShortcuts', () => {
127 | it('returns array of shortcuts', () => {
128 | const handlers = {
129 | onCopy: jest.fn(),
130 | onDownload: jest.fn(),
131 | };
132 |
133 | const shortcuts = getCommonShortcuts(handlers);
134 |
135 | expect(Array.isArray(shortcuts)).toBe(true);
136 | expect(shortcuts.length).toBeGreaterThan(0);
137 | });
138 |
139 | it('disables shortcuts without handlers', () => {
140 | const handlers = {
141 | onCopy: jest.fn(),
142 | // onDownload not provided
143 | };
144 |
145 | const shortcuts = getCommonShortcuts(handlers);
146 | const copyShortcut = shortcuts.find((s) => s.key === 'c');
147 | const downloadShortcut = shortcuts.find((s) => s.key === 'd');
148 |
149 | expect(copyShortcut?.disabled).toBe(false);
150 | expect(downloadShortcut?.disabled).toBe(true);
151 | });
152 | });
153 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 | import { execSync } from 'child_process';
5 | import { visualizer } from 'rollup-plugin-visualizer';
6 |
7 | // Get git commit SHA for version display
8 | function getCommitSHA() {
9 | try {
10 | return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
11 | } catch {
12 | return 'unknown';
13 | }
14 | }
15 |
16 | export default defineConfig(({ mode: _mode }) => {
17 | const commitSHA = getCommitSHA();
18 | const isAnalyze = process.env.ANALYZE === 'true';
19 |
20 | return {
21 | server: {
22 | port: 3000,
23 | host: '0.0.0.0',
24 | },
25 | // Configure workers for code splitting
26 | worker: {
27 | format: 'es', // Use ES modules for workers in code-splitting builds
28 | },
29 | test: {
30 | environment: 'jsdom',
31 | setupFiles: 'src/test/setupTests.ts',
32 | globals: true,
33 | include: [
34 | // Core unit & integration tests
35 | 'src/**/*.spec.ts',
36 | 'src/**/*.spec.tsx',
37 | // Accessibility tests
38 | 'src/**/*.a11y.spec.tsx',
39 | // Spec-driven tests
40 | 'src/**/*.spec.json',
41 | // __tests__ directory tests
42 | '__tests__/**/*.spec.ts',
43 | '__tests__/**/*.spec.tsx',
44 | '__tests__/**/*.a11y.spec.tsx',
45 | ],
46 | },
47 | plugins: [
48 | react(),
49 | // Bundle analyzer - generates stats.html when ANALYZE=true
50 | isAnalyze && visualizer({
51 | open: true,
52 | filename: 'stats.html',
53 | gzipSize: true,
54 | brotliSize: true,
55 | }),
56 | ].filter(Boolean),
57 | resolve: {
58 | alias: {
59 | '@': path.resolve(__dirname, '.'),
60 | }
61 | },
62 | define: {
63 | // Inject commit SHA as environment variable
64 | 'import.meta.env.VITE_COMMIT': JSON.stringify(commitSHA),
65 | 'import.meta.env.VITE_BUILD_TIME': JSON.stringify(new Date().toISOString()),
66 | },
67 | build: {
68 | // Disable Vite's built-in compression reporting (we use check-budgets.mjs instead)
69 | reportCompressedSize: false,
70 | // Conservative chunk size warning limit (600–800k range)
71 | chunkSizeWarningLimit: 900, // Increased to accommodate split OCR vendors
72 | rollupOptions: {
73 | output: {
74 | // Manual chunk splitting for better caching and lazy loading
75 | manualChunks: (id) => {
76 | // React vendor chunk - core framework (loaded on every page)
77 | if (id.includes('node_modules/react') ||
78 | id.includes('node_modules/react-dom') ||
79 | id.includes('node_modules/react-router-dom')) {
80 | return 'react-vendor';
81 | }
82 |
83 | // Split heavy OCR dependencies into separate lazy-loaded chunks
84 | // Each chunk loads only when user performs OCR on tool pages
85 | if (id.includes('node_modules/onnxruntime-web') ||
86 | id.includes('node_modules/onnxruntime-common')) {
87 | return 'onnx-vendor';
88 | }
89 |
90 | if (id.includes('node_modules/tesseract.js')) {
91 | return 'tesseract-vendor';
92 | }
93 |
94 | if (id.includes('node_modules/@xenova/transformers')) {
95 | return 'transformers-vendor';
96 | }
97 |
98 | // Editor & UI vendor chunk - framer-motion for animations
99 | if (id.includes('node_modules/framer-motion')) {
100 | return 'motion-vendor';
101 | }
102 |
103 | // Other vendor dependencies in shared chunk
104 | if (id.includes('node_modules')) {
105 | return 'vendor';
106 | }
107 |
108 | // Default: let Rollup decide for app code
109 | },
110 | },
111 | },
112 | },
113 | };
114 | });
115 |
--------------------------------------------------------------------------------
/components/AuroraBackground.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { motion, useReducedMotion } from 'framer-motion';
3 |
4 | interface AuroraBackgroundProps {
5 | children: React.ReactNode;
6 | className?: string;
7 | }
8 |
9 | /**
10 | * Futuristic aurora background with:
11 | * - Two animated radial gradient blobs + conic layer
12 | * - Subtle grid mask overlay
13 | * - SVG grain texture for premium feel
14 | * - Respects prefers-reduced-motion
15 | * - Zero CLS (all positioned absolutely)
16 | */
17 | export function AuroraBackground({ children, className = '' }: AuroraBackgroundProps) {
18 | const shouldReduceMotion = useReducedMotion();
19 | const [mounted, setMounted] = useState(false);
20 |
21 | useEffect(() => {
22 | setMounted(true);
23 | }, []);
24 |
25 | return (
26 |
27 | {/* Aurora gradient blobs */}
28 |
32 | {/* Blob 1: Cyan/Blue */}
33 |
52 |
53 | {/* Blob 2: Purple/Magenta */}
54 |
73 |
74 | {/* Conic gradient layer for complexity */}
75 |
100 |
101 |
102 | {/* Grid overlay */}
103 |
113 |
114 | {/* SVG grain texture */}
115 |
122 |
123 | {/* Content */}
124 | {children}
125 |
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/__tests__/GlassDropzone.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, fireEvent } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { GlassDropzone } from '../components/v3/GlassDropzone';
5 |
6 | // Mock framer-motion to avoid animation issues in tests
7 | jest.mock('framer-motion', () => ({
8 | motion: {
9 | div: ({ children, ...props }: any) => {children},
10 | },
11 | useReducedMotion: () => false,
12 | }));
13 |
14 | describe('GlassDropzone', () => {
15 | it('should call onError when non-image file is provided', () => {
16 | const mockOnFileSelect = jest.fn();
17 | const mockOnError = jest.fn();
18 |
19 | render(
20 |
24 | );
25 |
26 | // Create a non-image file (text file)
27 | const nonImageFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
28 |
29 | // Get the hidden file input
30 | const fileInput = screen.getByLabelText('File upload input') as HTMLInputElement;
31 |
32 | // Simulate file selection
33 | Object.defineProperty(fileInput, 'files', {
34 | value: [nonImageFile],
35 | writable: false,
36 | });
37 |
38 | fireEvent.change(fileInput);
39 |
40 | // Verify onError was called and onFileSelect was not
41 | expect(mockOnError).toHaveBeenCalledWith(
42 | 'Invalid file type: text/plain. Please select an image file (PNG, JPG, WEBP).'
43 | );
44 | expect(mockOnFileSelect).not.toHaveBeenCalled();
45 | });
46 |
47 | it('should call onFileSelect when valid image file is provided', () => {
48 | const mockOnFileSelect = jest.fn();
49 | const mockOnError = jest.fn();
50 |
51 | render(
52 |
56 | );
57 |
58 | // Create an image file
59 | const imageFile = new File(['image content'], 'test.png', { type: 'image/png' });
60 |
61 | // Get the hidden file input
62 | const fileInput = screen.getByLabelText('File upload input') as HTMLInputElement;
63 |
64 | // Simulate file selection
65 | Object.defineProperty(fileInput, 'files', {
66 | value: [imageFile],
67 | writable: false,
68 | });
69 |
70 | fireEvent.change(fileInput);
71 |
72 | // Verify onFileSelect was called and onError was not
73 | expect(mockOnFileSelect).toHaveBeenCalledWith(imageFile);
74 | expect(mockOnError).not.toHaveBeenCalled();
75 | });
76 |
77 | it('should work without onError callback (optional)', () => {
78 | const mockOnFileSelect = jest.fn();
79 |
80 | render(
81 |
84 | );
85 |
86 | // Create a non-image file
87 | const nonImageFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
88 |
89 | // Get the hidden file input
90 | const fileInput = screen.getByLabelText('File upload input') as HTMLInputElement;
91 |
92 | // Simulate file selection
93 | Object.defineProperty(fileInput, 'files', {
94 | value: [nonImageFile],
95 | writable: false,
96 | });
97 |
98 | // Should not throw when onError is not provided
99 | expect(() => fireEvent.change(fileInput)).not.toThrow();
100 | expect(mockOnFileSelect).not.toHaveBeenCalled();
101 | });
102 |
103 | it('should handle file with unknown type', () => {
104 | const mockOnFileSelect = jest.fn();
105 | const mockOnError = jest.fn();
106 |
107 | render(
108 |
112 | );
113 |
114 | // Create a file with no type
115 | const unknownFile = new File(['content'], 'test.xyz', { type: '' });
116 |
117 | // Get the hidden file input
118 | const fileInput = screen.getByLabelText('File upload input') as HTMLInputElement;
119 |
120 | // Simulate file selection
121 | Object.defineProperty(fileInput, 'files', {
122 | value: [unknownFile],
123 | writable: false,
124 | });
125 |
126 | fireEvent.change(fileInput);
127 |
128 | // Verify onError was called with 'unknown' type
129 | expect(mockOnError).toHaveBeenCalledWith(
130 | 'Invalid file type: unknown. Please select an image file (PNG, JPG, WEBP).'
131 | );
132 | expect(mockOnFileSelect).not.toHaveBeenCalled();
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
Extracted Text
43 |{text}
72 | Supported formats: {getFormatsLabel()}
107 | */ 108 | export function getFormatsLabel(): string { 109 | return SUPPORTED_EXTENSIONS.map(ext => ext.toUpperCase().substring(1)).join(', '); 110 | } 111 | -------------------------------------------------------------------------------- /components/AdSlotLazy.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { trackAdSlotVisible } from '../lib/analytics'; 3 | 4 | interface AdSlotLazyProps { 5 | /** Ad placement position (for tracking/analytics) */ 6 | slot: 'top' | 'mid' | 'bottom' | 'sidebar'; 7 | /** Test mode flag (disables real ads) */ 8 | dataAdTest?: boolean; 9 | /** Optional custom className */ 10 | className?: string; 11 | } 12 | 13 | /** 14 | * AdSlotLazy - Lazy-loading ad container with zero CLS 15 | * 16 | * AdSense-compliant features: 17 | * - Reserved min-height (300px) prevents layout shift 18 | * - IntersectionObserver lazy-loads ads only when visible 19 | * - ARIA accessibility label 20 | * - Test mode support 21 | * 22 | * Performance: 23 | * - Zero CLS: fixed height reserved before ad loads 24 | * - Lazy: ads only load when scrolled into viewport 25 | * - LCP: minimal impact on initial page load 26 | * 27 | * @example 28 | *71 | Advertisement 72 |
73 |