Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>PDF Crop Tool</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Syne:wght@600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --color-bg: #fafafa; | |
| --color-surface: #ffffff; | |
| --color-text: #0f172a; | |
| --color-text-muted: #64748b; | |
| --color-primary: #1e293b; | |
| --color-accent: #334155; | |
| --color-border: #e2e8f0; | |
| --color-success: #059669; | |
| --color-error: #dc2626; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'DM Sans', sans-serif; | |
| background: var(--color-bg); | |
| color: var(--color-text); | |
| line-height: 1.7; | |
| overflow-x: hidden; | |
| position: relative; | |
| min-height: 100vh; | |
| } | |
| .geometric-bg { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 0; | |
| overflow: hidden; | |
| pointer-events: none; | |
| } | |
| .shape { | |
| position: absolute; | |
| opacity: 0.08; | |
| } | |
| .shape-1 { | |
| width: 500px; | |
| height: 500px; | |
| border: 3px solid var(--color-primary); | |
| border-radius: 50%; | |
| top: -150px; | |
| right: -150px; | |
| animation: float 20s ease-in-out infinite; | |
| } | |
| .shape-2 { | |
| width: 400px; | |
| height: 400px; | |
| border: 3px solid var(--color-accent); | |
| transform: rotate(45deg); | |
| bottom: -100px; | |
| left: -100px; | |
| animation: float 25s ease-in-out infinite reverse; | |
| } | |
| .shape-3 { | |
| width: 300px; | |
| height: 300px; | |
| border: 2px solid var(--color-primary); | |
| border-radius: 30%; | |
| top: 40%; | |
| left: 5%; | |
| animation: float 30s ease-in-out infinite; | |
| } | |
| .shape-4 { | |
| width: 250px; | |
| height: 250px; | |
| background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); | |
| opacity: 0.05; | |
| border-radius: 50%; | |
| top: 15%; | |
| right: 15%; | |
| animation: pulse 15s ease-in-out infinite; | |
| } | |
| .shape-5 { | |
| width: 350px; | |
| height: 350px; | |
| border: 2px solid var(--color-accent); | |
| border-radius: 40%; | |
| top: 60%; | |
| right: 30%; | |
| animation: float 35s ease-in-out infinite; | |
| } | |
| .shape-6 { | |
| width: 200px; | |
| height: 200px; | |
| border: 3px solid var(--color-primary); | |
| transform: rotate(30deg); | |
| top: 25%; | |
| left: 40%; | |
| animation: float 28s ease-in-out infinite reverse; | |
| } | |
| .shape-7 { | |
| width: 180px; | |
| height: 180px; | |
| background: linear-gradient(45deg, var(--color-accent), var(--color-primary)); | |
| opacity: 0.04; | |
| border-radius: 50%; | |
| bottom: 20%; | |
| right: 40%; | |
| animation: pulse 20s ease-in-out infinite; | |
| } | |
| @keyframes float { | |
| 0%, 100% { transform: translate(0, 0) rotate(0deg); } | |
| 25% { transform: translate(30px, -30px) rotate(8deg); } | |
| 50% { transform: translate(-30px, 30px) rotate(-8deg); } | |
| 75% { transform: translate(30px, 30px) rotate(8deg); } | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { transform: scale(1); opacity: 0.04; } | |
| 50% { transform: scale(1.15); opacity: 0.08; } | |
| } | |
| @keyframes fadeInUp { | |
| from { opacity: 0; transform: translateY(30px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .container { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 60px 24px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| header { | |
| text-align: center; | |
| margin-bottom: 80px; | |
| animation: fadeInUp 0.8s ease-out; | |
| } | |
| h1 { | |
| font-family: 'Syne', sans-serif; | |
| font-size: 52px; | |
| font-weight: 700; | |
| margin-bottom: 16px; | |
| letter-spacing: -0.02em; | |
| line-height: 1.1; | |
| } | |
| .subtitle { | |
| color: var(--color-text-muted); | |
| font-size: 18px; | |
| font-weight: 400; | |
| max-width: 500px; | |
| margin: 0 auto; | |
| } | |
| .upload-section { | |
| background: var(--color-surface); | |
| padding: 80px 48px; | |
| border-radius: 24px; | |
| border: 1px solid var(--color-border); | |
| margin-bottom: 40px; | |
| text-align: center; | |
| position: relative; | |
| overflow: hidden; | |
| animation: fadeInUp 0.8s ease-out 0.2s both; | |
| transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .upload-section::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 1px; | |
| background: linear-gradient(90deg, transparent, var(--color-border), transparent); | |
| } | |
| .upload-section:hover { | |
| transform: translateY(-4px); | |
| box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08); | |
| } | |
| .upload-section h2 { | |
| font-family: 'Syne', sans-serif; | |
| font-size: 24px; | |
| font-weight: 600; | |
| margin-bottom: 32px; | |
| color: var(--color-text); | |
| } | |
| .file-input-wrapper { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| input[type="file"] { display: none; } | |
| .upload-btn { | |
| background: var(--color-primary); | |
| color: white; | |
| padding: 16px 40px; | |
| border: none; | |
| border-radius: 12px; | |
| font-size: 15px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .upload-btn::before { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| width: 0; | |
| height: 0; | |
| border-radius: 50%; | |
| background: rgba(255,255,255,0.1); | |
| transform: translate(-50%, -50%); | |
| transition: width 0.6s, height 0.6s; | |
| } | |
| .upload-btn:hover::before { width: 300px; height: 300px; } | |
| .upload-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 12px 40px rgba(30, 41, 59, 0.3); | |
| } | |
| .upload-hint { | |
| margin-top: 24px; | |
| color: var(--color-text-muted); | |
| font-size: 14px; | |
| } | |
| .canvas-section { | |
| background: var(--color-surface); | |
| padding: 48px; | |
| border-radius: 24px; | |
| border: 1px solid var(--color-border); | |
| margin-bottom: 40px; | |
| display: none; | |
| animation: fadeInUp 0.8s ease-out; | |
| } | |
| .instructions { | |
| background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); | |
| padding: 24px; | |
| border-radius: 16px; | |
| margin-bottom: 24px; | |
| font-size: 15px; | |
| color: var(--color-text-muted); | |
| border-left: 3px solid var(--color-primary); | |
| line-height: 1.6; | |
| } | |
| .instructions strong { | |
| color: var(--color-text); | |
| font-weight: 600; | |
| } | |
| /* βββ Zoom controls βββ */ | |
| .zoom-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| justify-content: center; | |
| margin-bottom: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .zoom-label { | |
| font-size: 13px; | |
| color: var(--color-text-muted); | |
| font-weight: 500; | |
| } | |
| .zoom-btn { | |
| width: 36px; | |
| height: 36px; | |
| border: 2px solid var(--color-border); | |
| background: var(--color-surface); | |
| border-radius: 10px; | |
| cursor: pointer; | |
| font-size: 18px; | |
| line-height: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s; | |
| color: var(--color-text); | |
| } | |
| .zoom-btn:hover { | |
| border-color: var(--color-accent); | |
| background: var(--color-bg); | |
| transform: translateY(-1px); | |
| } | |
| .zoom-value { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--color-text); | |
| min-width: 52px; | |
| text-align: center; | |
| background: var(--color-bg); | |
| border: 2px solid var(--color-border); | |
| border-radius: 10px; | |
| padding: 6px 10px; | |
| } | |
| .zoom-reset-btn { | |
| font-size: 13px; | |
| padding: 6px 14px; | |
| border: 2px solid var(--color-border); | |
| background: var(--color-surface); | |
| border-radius: 10px; | |
| cursor: pointer; | |
| color: var(--color-text-muted); | |
| font-weight: 500; | |
| transition: all 0.2s; | |
| font-family: 'DM Sans', sans-serif; | |
| } | |
| .zoom-reset-btn:hover { | |
| border-color: var(--color-accent); | |
| color: var(--color-text); | |
| } | |
| /* βββ Scrollable canvas viewport βββ */ | |
| .canvas-viewport { | |
| width: 100%; | |
| max-height: 600px; | |
| overflow: auto; | |
| border-radius: 16px; | |
| border: 1px solid var(--color-border); | |
| background: #e8ecf0; | |
| margin: 0 auto 8px; | |
| /* Custom scrollbar */ | |
| scrollbar-width: thin; | |
| scrollbar-color: #94a3b8 transparent; | |
| } | |
| .canvas-viewport::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| .canvas-viewport::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .canvas-viewport::-webkit-scrollbar-thumb { | |
| background: #94a3b8; | |
| border-radius: 4px; | |
| } | |
| .canvas-viewport::-webkit-scrollbar-corner { | |
| background: transparent; | |
| } | |
| /* Inner padding so canvas doesn't kiss the edges */ | |
| .canvas-inner { | |
| display: inline-block; | |
| padding: 24px; | |
| min-width: 100%; | |
| text-align: center; | |
| } | |
| .canvas-wrapper { | |
| position: relative; | |
| display: inline-block; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| box-shadow: 0 8px 32px rgba(15, 23, 42, 0.15); | |
| } | |
| canvas { | |
| display: block; | |
| cursor: crosshair; | |
| } | |
| .selection-box { | |
| position: absolute; | |
| border: 2px solid var(--color-primary); | |
| background: rgba(30, 41, 59, 0.08); | |
| pointer-events: none; | |
| backdrop-filter: blur(2px); | |
| } | |
| /* Scroll hint badge */ | |
| .scroll-hint { | |
| text-align: center; | |
| font-size: 12px; | |
| color: var(--color-text-muted); | |
| margin-bottom: 20px; | |
| display: none; | |
| gap: 6px; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .scroll-hint svg { | |
| opacity: 0.5; | |
| } | |
| .button-group { | |
| display: flex; | |
| gap: 16px; | |
| justify-content: center; | |
| margin-top: 32px; | |
| flex-wrap: wrap; | |
| } | |
| .btn { | |
| padding: 14px 32px; | |
| border: none; | |
| border-radius: 12px; | |
| font-size: 15px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| font-family: 'DM Sans', sans-serif; | |
| } | |
| .btn-primary { | |
| background: var(--color-success); | |
| color: white; | |
| box-shadow: 0 4px 16px rgba(5, 150, 105, 0.2); | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background: #047857; | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 24px rgba(5, 150, 105, 0.3); | |
| } | |
| .btn-primary:disabled { | |
| background: #cbd5e1; | |
| cursor: not-allowed; | |
| box-shadow: none; | |
| } | |
| .btn-secondary { | |
| background: transparent; | |
| color: var(--color-text); | |
| border: 2px solid var(--color-border); | |
| } | |
| .btn-secondary:hover { | |
| background: var(--color-bg); | |
| border-color: var(--color-accent); | |
| transform: translateY(-2px); | |
| } | |
| .btn-reset { | |
| background: var(--color-error); | |
| color: white; | |
| box-shadow: 0 4px 16px rgba(220, 38, 38, 0.2); | |
| } | |
| .btn-reset:hover { | |
| background: #b91c1c; | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 24px rgba(220, 38, 38, 0.3); | |
| } | |
| .status { | |
| text-align: center; | |
| padding: 16px 24px; | |
| border-radius: 12px; | |
| margin-top: 32px; | |
| font-size: 15px; | |
| display: none; | |
| animation: fadeInUp 0.4s ease-out; | |
| } | |
| .status.success { | |
| background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); | |
| color: var(--color-success); | |
| border: 1px solid #6ee7b7; | |
| display: block; | |
| } | |
| .status.processing { | |
| background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); | |
| color: #92400e; | |
| border: 1px solid #fcd34d; | |
| display: block; | |
| } | |
| .status.error { | |
| background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); | |
| color: var(--color-error); | |
| border: 1px solid #fca5a5; | |
| display: block; | |
| } | |
| .loader { | |
| border: 3px solid rgba(30, 41, 59, 0.1); | |
| border-top: 3px solid var(--color-primary); | |
| border-radius: 50%; | |
| width: 20px; | |
| height: 20px; | |
| animation: spin 0.8s linear infinite; | |
| display: inline-block; | |
| margin-right: 12px; | |
| vertical-align: middle; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| /* βββ Modal βββ */ | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(15, 23, 42, 0.6); | |
| backdrop-filter: blur(8px); | |
| z-index: 1000; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| animation: fadeIn 0.3s ease-out; | |
| } | |
| .modal-overlay.active { display: flex; } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .modal-content { | |
| background: var(--color-surface); | |
| padding: 48px; | |
| border-radius: 24px; | |
| text-align: center; | |
| max-width: 450px; | |
| width: 90%; | |
| box-shadow: 0 24px 80px rgba(15, 23, 42, 0.2); | |
| animation: scaleIn 0.4s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| @keyframes scaleIn { | |
| from { opacity: 0; transform: scale(0.9); } | |
| to { opacity: 1; transform: scale(1); } | |
| } | |
| .modal-icon { | |
| width: 80px; | |
| height: 80px; | |
| margin: 0 auto 24px; | |
| } | |
| .processing-spinner { | |
| width: 80px; height: 80px; | |
| border: 3px solid var(--color-border); | |
| border-top: 3px solid var(--color-primary); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| .success-icon { | |
| width: 80px; height: 80px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| animation: successPop 0.6s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .success-icon::after { | |
| content: 'β'; | |
| font-size: 48px; | |
| color: var(--color-success); | |
| font-weight: 700; | |
| } | |
| @keyframes successPop { | |
| 0% { transform: scale(0); opacity: 0; } | |
| 50% { transform: scale(1.1); } | |
| 100% { transform: scale(1); opacity: 1; } | |
| } | |
| .modal-title { | |
| font-family: 'Syne', sans-serif; | |
| font-size: 28px; | |
| font-weight: 700; | |
| margin-bottom: 12px; | |
| color: var(--color-text); | |
| } | |
| .modal-text { | |
| color: var(--color-text-muted); | |
| font-size: 15px; | |
| margin-bottom: 32px; | |
| line-height: 1.6; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 4px; | |
| background: var(--color-border); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| margin-top: 24px; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: var(--color-primary); | |
| border-radius: 2px; | |
| animation: progressFlow 2s ease-in-out infinite; | |
| } | |
| @keyframes progressFlow { | |
| 0% { width: 0%; opacity: 1; } | |
| 50% { width: 100%; opacity: 1; } | |
| 100% { width: 100%; opacity: 0; } | |
| } | |
| .modal-btn { | |
| background: var(--color-primary); | |
| color: white; | |
| padding: 16px 48px; | |
| border: none; | |
| border-radius: 12px; | |
| font-size: 15px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| font-family: 'DM Sans', sans-serif; | |
| width: 100%; | |
| margin-bottom: 12px; | |
| } | |
| .modal-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 12px 40px rgba(30, 41, 59, 0.3); | |
| } | |
| .modal-btn-secondary { | |
| background: transparent; | |
| color: var(--color-text-muted); | |
| border: 2px solid var(--color-border); | |
| margin-top: 8px; | |
| } | |
| .modal-btn-secondary:hover { | |
| background: var(--color-bg); | |
| border-color: var(--color-accent); | |
| } | |
| @media (max-width: 768px) { | |
| h1 { font-size: 36px; } | |
| .container { padding: 40px 20px; } | |
| .upload-section { padding: 60px 32px; } | |
| .canvas-section { padding: 32px 16px; } | |
| .button-group { flex-direction: column; } | |
| .canvas-viewport { max-height: 450px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="geometric-bg"> | |
| <div class="shape shape-1"></div> | |
| <div class="shape shape-2"></div> | |
| <div class="shape shape-3"></div> | |
| <div class="shape shape-4"></div> | |
| <div class="shape shape-5"></div> | |
| <div class="shape shape-6"></div> | |
| <div class="shape shape-7"></div> | |
| </div> | |
| <div class="container"> | |
| <header> | |
| <h1>PDF Crop Tool</h1> | |
| <p class="subtitle">Crop the same area across all PDF pages</p> | |
| </header> | |
| <div class="upload-section" id="uploadSection"> | |
| <h2>Upload Document</h2> | |
| <div class="file-input-wrapper"> | |
| <input type="file" id="pdfInput" accept=".pdf"> | |
| <button class="upload-btn" onclick="document.getElementById('pdfInput').click()"> | |
| Select File | |
| </button> | |
| </div> | |
| <p class="upload-hint">Supported formats: PDF</p> | |
| </div> | |
| <div class="canvas-section" id="canvasSection"> | |
| <div class="instructions"> | |
| <strong>Instructions:</strong> Click and drag on the first page to select the area you want to crop. | |
| Use the zoom controls or scroll/pan to navigate large documents. | |
| The selection will be applied to all pages. | |
| </div> | |
| <!-- Zoom controls --> | |
| <div class="zoom-bar"> | |
| <span class="zoom-label">Zoom</span> | |
| <button class="zoom-btn" id="zoomOutBtn" title="Zoom out">β</button> | |
| <span class="zoom-value" id="zoomValue">100%</span> | |
| <button class="zoom-btn" id="zoomInBtn" title="Zoom in">+</button> | |
| <button class="zoom-reset-btn" id="zoomResetBtn">Fit to view</button> | |
| </div> | |
| <!-- Scroll hint (shown only when content overflows) --> | |
| <div class="scroll-hint" id="scrollHint"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/> | |
| </svg> | |
| Scroll inside the preview area to navigate the document | |
| </div> | |
| <!-- Scrollable viewport --> | |
| <div class="canvas-viewport" id="canvasViewport"> | |
| <div class="canvas-inner"> | |
| <div class="canvas-wrapper" id="canvasWrapper"> | |
| <canvas id="pdfCanvas"></canvas> | |
| <div class="selection-box" id="selectionBox"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="button-group"> | |
| <button class="btn btn-secondary" id="resetBtn">Clear Selection</button> | |
| <button class="btn btn-primary" id="cropBtn" disabled>Process PDF</button> | |
| <button class="btn btn-reset" id="startOverBtn">Start Over</button> | |
| </div> | |
| <div class="status" id="status"></div> | |
| </div> | |
| </div> | |
| <div class="modal-overlay" id="modalOverlay"> | |
| <div class="modal-content"> | |
| <div class="modal-icon" id="modalIcon"> | |
| <div class="processing-spinner"></div> | |
| </div> | |
| <h2 class="modal-title" id="modalTitle">Processing PDF</h2> | |
| <p class="modal-text" id="modalText">Please wait while we process your document...</p> | |
| <div class="progress-bar" id="progressBar"> | |
| <div class="progress-fill"></div> | |
| </div> | |
| <div id="modalButtons" style="display: none;"> | |
| <button class="modal-btn" id="downloadBtn">Download PDF</button> | |
| <button class="modal-btn modal-btn-secondary" id="newDocBtn">Process New Document</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; | |
| let pdfDoc = null; | |
| let pdfBytes = null; | |
| let pdfArrayBuffer = null; | |
| let processedPdfBlob = null; | |
| let canvas = document.getElementById('pdfCanvas'); | |
| let ctx = canvas.getContext('2d'); | |
| let isSelecting = false; | |
| let startX, startY, endX, endY; | |
| let selection = null; | |
| // Zoom state | |
| let currentScale = 1.5; // pdf.js render scale | |
| let baseScale = 1.5; // "fit-to-view" scale computed on load | |
| const ZOOM_STEP = 0.25; | |
| const MIN_SCALE = 0.5; | |
| const MAX_SCALE = 5; | |
| // βββ Viewport dimensions used for fit-to-view βββββββββββββββββββββββ | |
| const VIEWPORT_MAX_WIDTH = 1000; // matches canvas-section max inner width | |
| const VIEWPORT_MAX_HEIGHT = 600; // matches max-height of canvas-viewport | |
| // βββ Event listeners βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('pdfInput').addEventListener('change', handleFileSelect); | |
| canvas.addEventListener('mousedown', handleMouseDown); | |
| canvas.addEventListener('mousemove', handleMouseMove); | |
| canvas.addEventListener('mouseup', handleMouseUp); | |
| document.getElementById('resetBtn').addEventListener('click', resetSelection); | |
| document.getElementById('cropBtn').addEventListener('click', processPDF); | |
| document.getElementById('downloadBtn').addEventListener('click', downloadPDF); | |
| document.getElementById('startOverBtn').addEventListener('click', startOver); | |
| document.getElementById('newDocBtn').addEventListener('click', startOver); | |
| document.getElementById('zoomInBtn').addEventListener('click', () => zoom(currentScale + ZOOM_STEP)); | |
| document.getElementById('zoomOutBtn').addEventListener('click', () => zoom(currentScale - ZOOM_STEP)); | |
| document.getElementById('zoomResetBtn').addEventListener('click', () => zoom(baseScale)); | |
| // βββ File load ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| try { | |
| const originalBuffer = await file.arrayBuffer(); | |
| pdfBytes = new Uint8Array(originalBuffer.slice(0)); | |
| pdfArrayBuffer = originalBuffer.slice(0); | |
| const loadingTask = pdfjsLib.getDocument({ data: pdfBytes }); | |
| pdfDoc = await loadingTask.promise; | |
| // Compute a sensible initial scale so the PDF fits in the viewport | |
| const page = await pdfDoc.getPage(1); | |
| const rawVP = page.getViewport({ scale: 1 }); | |
| const scaleW = VIEWPORT_MAX_WIDTH / rawVP.width; | |
| const scaleH = VIEWPORT_MAX_HEIGHT / rawVP.height; | |
| baseScale = Math.min(scaleW, scaleH, 1.5); // never zoom in beyond 1.5Γ on load | |
| currentScale = baseScale; | |
| await renderFirstPage(); | |
| document.getElementById('uploadSection').style.display = 'none'; | |
| document.getElementById('canvasSection').style.display = 'block'; | |
| checkScrollHint(); | |
| showStatus('PDF loaded successfully. Select the area to crop.', 'success'); | |
| } catch (error) { | |
| showStatus('Error loading PDF: ' + error.message, 'error'); | |
| } | |
| } | |
| async function renderFirstPage() { | |
| const page = await pdfDoc.getPage(1); | |
| const viewport = page.getViewport({ scale: currentScale }); | |
| canvas.width = viewport.width; | |
| canvas.height = viewport.height; | |
| await page.render({ canvasContext: ctx, viewport }).promise; | |
| updateZoomUI(); | |
| // Reset selection overlay | |
| resetSelection(); | |
| } | |
| // βββ Zoom βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function zoom(newScale) { | |
| newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); | |
| if (Math.abs(newScale - currentScale) < 0.001) return; | |
| // Preserve scroll position ratio | |
| const vp = document.getElementById('canvasViewport'); | |
| const ratioX = (vp.scrollLeft + vp.clientWidth / 2) / vp.scrollWidth; | |
| const ratioY = (vp.scrollTop + vp.clientHeight / 2) / vp.scrollHeight; | |
| currentScale = newScale; | |
| await renderFirstPage(); | |
| // Restore scroll position | |
| requestAnimationFrame(() => { | |
| vp.scrollLeft = ratioX * vp.scrollWidth - vp.clientWidth / 2; | |
| vp.scrollTop = ratioY * vp.scrollHeight - vp.clientHeight / 2; | |
| }); | |
| checkScrollHint(); | |
| } | |
| function updateZoomUI() { | |
| document.getElementById('zoomValue').textContent = | |
| Math.round((currentScale / baseScale) * 100) + '%'; | |
| } | |
| function checkScrollHint() { | |
| const vp = document.getElementById('canvasViewport'); | |
| const hint = document.getElementById('scrollHint'); | |
| const overflows = canvas.width > vp.clientWidth || | |
| canvas.height > vp.clientHeight; | |
| hint.style.display = overflows ? 'flex' : 'none'; | |
| } | |
| // βββ Mouse selection βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function getCanvasPos(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { | |
| x: e.clientX - rect.left, | |
| y: e.clientY - rect.top | |
| }; | |
| } | |
| function handleMouseDown(e) { | |
| const pos = getCanvasPos(e); | |
| startX = pos.x; | |
| startY = pos.y; | |
| endX = pos.x; | |
| endY = pos.y; | |
| isSelecting = true; | |
| } | |
| function handleMouseMove(e) { | |
| if (!isSelecting) return; | |
| const pos = getCanvasPos(e); | |
| endX = pos.x; | |
| endY = pos.y; | |
| updateSelectionBox(); | |
| } | |
| function handleMouseUp(e) { | |
| if (!isSelecting) return; | |
| isSelecting = false; | |
| const pos = getCanvasPos(e); | |
| endX = pos.x; | |
| endY = pos.y; | |
| const w = Math.abs(endX - startX); | |
| const h = Math.abs(endY - startY); | |
| if (w > 5 && h > 5) { | |
| selection = { | |
| x: Math.min(startX, endX), | |
| y: Math.min(startY, endY), | |
| width: w, | |
| height: h | |
| }; | |
| updateSelectionBox(); | |
| document.getElementById('cropBtn').disabled = false; | |
| } | |
| } | |
| function updateSelectionBox() { | |
| const box = document.getElementById('selectionBox'); | |
| const x = Math.min(startX, endX); | |
| const y = Math.min(startY, endY); | |
| box.style.left = x + 'px'; | |
| box.style.top = y + 'px'; | |
| box.style.width = Math.abs(endX - startX) + 'px'; | |
| box.style.height = Math.abs(endY - startY) + 'px'; | |
| box.style.display = 'block'; | |
| } | |
| function resetSelection() { | |
| selection = null; | |
| document.getElementById('selectionBox').style.display = 'none'; | |
| document.getElementById('cropBtn').disabled = true; | |
| } | |
| // βββ PDF processing βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function processPDF() { | |
| if (!selection || !pdfArrayBuffer) return; | |
| try { | |
| showModal(); | |
| document.getElementById('cropBtn').disabled = true; | |
| const page = await pdfDoc.getPage(1); | |
| const viewport = page.getViewport({ scale: currentScale }); | |
| // Map canvas pixels β PDF units (using the page's native size) | |
| const scaleX = page.view[2] / viewport.width; | |
| const scaleY = page.view[3] / viewport.height; | |
| const cropBox = { | |
| x: selection.x * scaleX, | |
| y: (viewport.height - selection.y - selection.height) * scaleY, | |
| width: selection.width * scaleX, | |
| height: selection.height * scaleY | |
| }; | |
| const pdfLibDoc = await PDFLib.PDFDocument.load(pdfArrayBuffer); | |
| const newPdf = await PDFLib.PDFDocument.create(); | |
| const numPages = pdfLibDoc.getPageCount(); | |
| for (let i = 0; i < numPages; i++) { | |
| const [croppedPage] = await newPdf.copyPages(pdfLibDoc, [i]); | |
| croppedPage.setCropBox (cropBox.x, cropBox.y, cropBox.width, cropBox.height); | |
| croppedPage.setMediaBox(cropBox.x, cropBox.y, cropBox.width, cropBox.height); | |
| newPdf.addPage(croppedPage); | |
| } | |
| const pdfBytesOutput = await newPdf.save(); | |
| processedPdfBlob = new Blob([pdfBytesOutput], { type: 'application/pdf' }); | |
| showSuccess(); | |
| document.getElementById('cropBtn').disabled = false; | |
| } catch (error) { | |
| closeModal(); | |
| showStatus('Error processing PDF: ' + error.message, 'error'); | |
| document.getElementById('cropBtn').disabled = false; | |
| } | |
| } | |
| // βββ Modal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showModal() { | |
| document.getElementById('modalOverlay').classList.add('active'); | |
| document.getElementById('modalIcon').innerHTML = '<div class="processing-spinner"></div>'; | |
| document.getElementById('modalTitle').textContent = 'Processing PDF'; | |
| document.getElementById('modalText').textContent = 'Please wait while we process your document...'; | |
| document.getElementById('progressBar').style.display = 'block'; | |
| document.getElementById('modalButtons').style.display = 'none'; | |
| } | |
| function showSuccess() { | |
| document.getElementById('modalIcon').innerHTML = '<div class="success-icon"></div>'; | |
| document.getElementById('modalTitle').textContent = 'PDF Processed'; | |
| document.getElementById('modalText').textContent = 'Your document has been processed successfully and is ready to download.'; | |
| document.getElementById('progressBar').style.display = 'none'; | |
| document.getElementById('modalButtons').style.display = 'block'; | |
| } | |
| function downloadPDF() { | |
| if (!processedPdfBlob) return; | |
| const url = URL.createObjectURL(processedPdfBlob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'cropped_document.pdf'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function closeModal() { | |
| document.getElementById('modalOverlay').classList.remove('active'); | |
| } | |
| // βββ Start over βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function startOver() { | |
| pdfDoc = pdfBytes = pdfArrayBuffer = processedPdfBlob = selection = null; | |
| currentScale = baseScale = 1.5; | |
| document.getElementById('pdfInput').value = ''; | |
| document.getElementById('selectionBox').style.display = 'none'; | |
| document.getElementById('cropBtn').disabled = true; | |
| document.getElementById('status').style.display = 'none'; | |
| document.getElementById('scrollHint').style.display = 'none'; | |
| document.getElementById('zoomValue').textContent = '100%'; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| document.getElementById('canvasSection').style.display = 'none'; | |
| document.getElementById('uploadSection').style.display = 'block'; | |
| closeModal(); | |
| } | |
| // βββ Status bar βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showStatus(message, type) { | |
| const status = document.getElementById('status'); | |
| status.className = 'status ' + type; | |
| status.innerHTML = type === 'processing' | |
| ? '<span class="loader"></span>' + message | |
| : message; | |
| } | |
| </script> | |
| </body> | |
| </html> |