diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-09 15:02:12 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-09 15:02:12 +0100 |
| commit | 58bbe104a1fd31a4f349d6a68bb8f6f67cfe2dd2 (patch) | |
| tree | 11b3c16ac4d50e2c457109cffc98da28a99b0089 | |
feat(pdf2txt): canonical product tree with pdf.js vendored
Source: spaces/chatstudio/pdf2txt.html (2026-10-19, unchanged, md5 fbe1df56).
Packaged copy differs by exactly two lines (vendor/ paths). MIT, PDF.js Apache-2.0.
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | LICENSE | 29 | ||||
| -rw-r--r-- | README.md | 88 | ||||
| -rw-r--r-- | VERSION | 1 | ||||
| -rw-r--r-- | docs/USAGE.md | 122 | ||||
| -rw-r--r-- | src/index.html | 1192 |
6 files changed, 1435 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0684b9b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [1.0.0] - 2026-11-09 + +First packaged release: single-file browser app plus local PDF.js vendor assets. @@ -0,0 +1,29 @@ +MIT License + +Copyright (c) 2026 Akan Goksu Valdes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------------------------------------------------------------- +THIRD-PARTY NOTICE + +The files in vendor/ (pdf.min.js, pdf.worker.min.js) are PDF.js and are +licensed under the Apache License, Version 2.0, not the MIT licence above. +See https://www.apache.org/licenses/LICENSE-2.0 for the full text. +The MIT licence above covers only the application file src/index.html. diff --git a/README.md b/README.md new file mode 100644 index 0000000..566b335 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# PDF2TXT + Translator 1.0.0 + +A single-page, browser-only tool that pulls the text out of a PDF. The document is +opened and parsed **on your own machine** — nothing is uploaded anywhere. There is no +server, no account, no sign-up and no tracking code in the app. + +## What the tool actually does + +- **Load one file at a time** — drag & drop it onto the drop zone or click to choose it. + The picker accepts `.pdf`, `.txt`, `.md` and `.json`. +- **Extract text from a PDF** — every page is read with the bundled PDF.js and joined + into one plain-text document, with a progress bar (`Page i/total`). +- **Or paste text directly** into the paste box and press *Load Pasted Text*. +- **Edit the result** in a full-size editor (Select All / Deselect All / Fullscreen). +- **Download TXT** (`extracted.txt`) or **Copy All** to the clipboard. +- **Optional language-model steps** — Clean, Translate, Validate, or a combined + workflow: `Clean → Translate → Validate`, `Translate → Validate`, + `Clean → Validate`, `Translate Only`, `Clean Only`. +- **Translate into 16 target languages** offered in the page: English, Spanish, French, + German, Italian, Portuguese, Chinese, Japanese, Korean, Russian, Arabic, Hindi, + Turkish, Dutch, Polish, Swedish. Text is split into 1,500-character chunks and sent + one chunk at a time, with pause and stop buttons and a token counter. +- **Settings tab** — API source (Proxied `/ollama`, Local `localhost:11434`, or a Custom + URL), optional API key, *Refresh Models* (reads `/api/tags`), and chunk size. + +It does **not** do OCR, batch/multi-file processing, or translation presets. A scanned +PDF with no text layer will come out empty. + +## What you receive + +``` +pdf2txt/ +├── src/index.html the whole application (HTML + CSS + JS, no build step) +├── vendor/pdf.min.js PDF.js core library +├── vendor/pdf.worker.min.js PDF.js worker +├── docs/USAGE.md step-by-step guide for non-technical users +├── README.md this file +├── CHANGELOG.md +├── LICENSE +└── VERSION +``` + +## How to run it + +The page loads PDF.js as a Web Worker, and browsers refuse to start a worker from a +`file://` URL. So double-clicking `src/index.html` will load but extraction will fail. +Serve the folder over plain HTTP instead — no dependencies, Python 3 is enough: + +```bash +cd products/pdf2txt +python3 -m http.server 8000 +``` + +Then open <http://localhost:8000/src/index.html>. Any static web server works the same +way; upload the whole `pdf2txt` folder to any host and open `src/index.html`. + +## Optional translation + +Translation is **off by default in the sense that you must point it at a model**. The +page talks to an Ollama-compatible API using `POST /api/generate`. The model used for +offline translation is named in the page's Local model list as: + +``` +translategemma:latest +``` + +To use it entirely offline: + +1. Install [Ollama](https://ollama.com) and run `ollama pull translategemma:latest`. +2. In the app open the **Config** tab, set **API Source** to `Local (localhost:11434)`. +3. In the **Translate** section pick `translategemma:latest` as the Translation Model. +4. Choose a target language and press **Translate**. + +Two honest caveats about the shipped defaults: + +- The default **API Source** is `Proxied (/ollama)`, which expects a server-side proxy + at `/ollama`. On a plain static host use `Local` or a `Custom URL`. +- The default **Translation Model** is `glm-5.3-flash:cloud` — a cloud model. For a fully + local, offline run you must switch it to `translategemma:latest` (or another model you + have pulled, such as `llama3.2:latest` or `qwen2.5:7b-instruct`). + +Without a reachable model endpoint, PDF extraction, editing, copy and TXT download all +still work; only the Clean/Translate/Validate buttons fail. + +## Licence + +MIT © 2026 Akan Goksu Valdes — see [LICENSE](LICENSE). The files in `vendor/` are +PDF.js and remain under the Apache License 2.0. @@ -0,0 +1 @@ +1.0.0 diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..df194b6 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,122 @@ +# PDF2TXT + Translator — How to use it + +This guide assumes you have never run a web page from a folder before. It takes about +two minutes. + +## What this program is + +PDF2TXT reads the text inside a PDF and gives it to you as plain text you can copy or +save. Everything happens inside your own browser: the PDF is not uploaded to us or to +anyone else. There is no installation, no account and no internet connection needed for +the text extraction itself. + +--- + +## Step 1 — Start the app + +A browser will not load the PDF engine from a file you double-click, so start a tiny +local web server first. You only do this once per session. + +**On Linux / macOS** + +1. Open a Terminal. +2. Type these two lines, pressing Enter after each: + +```bash +cd path/to/pdf2txt +python3 -m http.server 8000 +``` + +3. Leave that window open. It must stay running while you use the app. + +**On Windows** + +1. Install Python 3 from python.org if you do not have it (tick "Add Python to PATH"). +2. Open the folder `pdf2txt` in File Explorer. +3. Click the address bar, type `cmd` and press Enter. +4. Type `python -m http.server 8000` and press Enter. + +**Then**, in any browser, open: + +``` +http://localhost:8000/src/index.html +``` + +You will see a dark page titled **PDF2TXT + Translator** with a **Main** and a +**Config** tab. If the page is blank or text extraction fails, you opened +`src/index.html` by double-clicking instead of through `http://localhost:8000` — go back +to Step 1. + +## Step 2 — Load your PDF + +- Drag the PDF from your file manager and drop it on the big dashed box, **or** click + the box and pick the file. +- The app shows the file name, the number of pages and the file size. +- Click **Load File**. A progress bar counts the pages while the text is extracted. +- One file at a time. To start over, click **🗑️ New**. + +You can also skip the PDF entirely: paste text into **Or paste text directly** and click +**Load Pasted Text**. + +## Step 3 — Check and edit the text + +The extracted text appears in the editor under **2. Edit & Select Parts**. You can type +in it, fix line breaks, or delete pages you do not want. **Select All**, **Deselect All** +and **⛶ Fullscreen** help with long documents. + +## Step 4 — Take the text out + +- **Download TXT** saves a file called `extracted.txt`. +- **Copy All** copies everything to the clipboard so you can paste it into Word, Google + Docs, an email, and so on. + +Both buttons use whatever is currently in the editor — your edits included. + +## Step 5 (optional) — Translate + +Translation uses a language model that runs through **Ollama**, and it is entirely +optional. Extraction, editing, copy and download work without it. + +1. Install Ollama from ollama.com. +2. In a terminal run: `ollama pull translategemma:latest` +3. Back in the app, open the **Config** tab and set **API Source** to + `Local (localhost:11434)`. +4. Open the **Translate** section. Set **Translation Model** to + `translategemma:latest`. +5. Choose your **Target** language from the 16 offered and press **🔄 Translate**. + +Things worth knowing before you translate: + +- The package ships with **API Source** set to `Proxied (/ollama)` and **Translation + Model** set to `glm-5.3-flash:cloud`. Both are cloud settings. For an offline run you + must change them as in steps 3 and 4. +- Long documents are sent in 1,500-character pieces, one after another, so a big book + takes a while. Use the pause/stop buttons if needed. +- The **Clean** and **Validate** steps also use a model (`llama3.2:latest` by default). If + you only want a translation, choose the workflow `Translate Only`. +- Machine translation is a draft, not a certified translation. Check names, numbers and + legal wording yourself. + +## Limits you should know + +- **No OCR.** If the PDF is a scan (a photo of a page), there is no text inside it and + the result will be empty. This tool reads text layers only. +- **No batch mode.** One document per run. +- **No upload.** Because nothing leaves your computer, there is no cloud backup and no + sync between devices. +- **Memory-bound.** Very large PDFs are limited by your browser's memory, not by an + artificial page cap. +- **Layout is flattened.** Columns and tables come out as a stream of text and may need + tidying in the editor. + +## Your purchase and the download link + +The download link you received from the store is **permanent for that purchase**: you can +open it again later to re-download the same package, with no expiry and no subscription. +Keep the link (or the email containing it) somewhere safe, and keep your own copy of the +`pdf2txt` folder as a backup — it is a plain folder of files, so copying it is enough. + +## Licence + +MIT © 2026 Akan Goksu Valdes. The PDF engine in `vendor/` is PDF.js, licensed under +Apache-2.0. See `LICENSE` in the package folder. diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..ffb3dc8 --- /dev/null +++ b/src/index.html @@ -0,0 +1,1192 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>PDF2TXT + Translator</title> + <script src="../vendor/pdf.min.js"></script> + <script> + pdfjsLib.GlobalWorkerOptions.workerSrc = '../vendor/pdf.worker.min.js'; + </script> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + :root { + --bg: #0a0a0a; --bg2: #111111; --bg3: #1a1a1a; + --border: #2a2a2a; --text: #e5e7eb; --text2: #94a3b8; + --accent: #00ff00; --accent-dim: #00cc00; + } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); color: var(--text); min-height: 100vh; + padding: 20px; line-height: 1.5; + } + .container { max-width: 900px; margin: 0 auto; } + + h1 { color: var(--accent); margin-bottom: 24px; font-size: 1.8em; } + h2 { color: var(--text2); font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; } + + .card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: 12px; padding: 20px; margin-bottom: 16px; + } + + .log-area { + background: #000; border: 1px solid #333; + border-radius: 8px; margin-bottom: 16px; + font-family: 'SF Mono', monospace; font-size: 0.8em; + } + .log-header { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; background: var(--bg2); border-radius: 8px 8px 0 0; + cursor: pointer; user-select: none; + } + .log-header:hover { background: var(--bg3); } + .log-header .title { color: var(--text2); font-size: 0.85em; text-transform: uppercase; letter-spacing: 1px; } + .log-header .toggle { color: var(--text2); font-size: 0.9em; } + .log-content { + padding: 12px; max-height: 150px; overflow: auto; color: #888; + } + .log-content.collapsed { display: none; } + .log-area .info { color: #48aaff; } + .log-area .success { color: var(--accent); } + .log-area .error { color: #ff4444; } + .log-area .warn { color: #ffaa00; } + + .drop-zone { + border: 2px dashed var(--border); border-radius: 12px; + padding: 40px 20px; text-align: center; cursor: pointer; + transition: all 0.2s; + } + .drop-zone:hover, .drop-zone.dragover { + border-color: var(--accent); background: rgba(0,255,0,0.05); + } + .drop-zone input { display: none; } + + .file-info { + display: flex; gap: 20px; flex-wrap: wrap; + margin-top: 12px; padding: 12px; background: var(--bg3); + border-radius: 8px; align-items: center; + } + .file-info .label { color: var(--text2); font-size: 0.85em; } + .file-info .value { color: var(--accent); font-weight: 600; } + + .btn { + padding: 12px 20px; border-radius: 8px; border: none; + cursor: pointer; font-size: 1em; font-weight: 600; + transition: all 0.2s; + } + .btn-primary { background: var(--accent); color: #000; } + .btn-primary:hover { background: var(--accent-dim); } + .btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); } + .btn-secondary:hover { border-color: var(--accent); color: var(--accent); } + .btn-group { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .form-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; align-items: center; } + .form-row label { color: var(--text2); font-size: 0.9em; } + + select, input[type="text"] { + padding: 10px 14px; background: var(--bg3); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; font-size: 0.95em; + flex: 1; min-width: 150px; + } + select:focus, input:focus { border-color: var(--accent); outline: none; } + + .progress-container { + margin: 16px 0; display: none; + } + .progress-bar { + height: 24px; background: var(--bg3); border-radius: 12px; + overflow: hidden; position: relative; + } + .progress-fill { + height: 100%; background: var(--accent); transition: width 0.3s; + width: 0%; + } + .progress-text { + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + font-size: 0.85em; font-weight: 600; color: #000; + } + .progress-status { margin-top: 8px; color: var(--text2); font-size: 0.9em; } + .progress-controls { display: flex; gap: 8px; align-items: center; margin-top: 8px; } + .btn-sm { padding: 4px 10px; font-size: 0.85em; } + + .preview-area { + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; padding: 16px; min-height: 300px; + max-height: 500px; overflow: auto; + position: relative; + } + .preview-area.fullscreen { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + max-height: 100vh; height: 100vh; z-index: 1000; + border-radius: 0; margin: 0; + } + .fullscreen-btn { + position: absolute; top: 8px; right: 8px; + background: var(--bg2); border: 1px solid var(--border); + color: var(--text2); padding: 4px 8px; border-radius: 4px; + cursor: pointer; font-size: 0.85em; + } + .fullscreen-btn:hover { color: var(--accent); border-color: var(--accent); } + .preview-area textarea { + width: 100%; height: 100%; min-height: 280px; + background: transparent; color: var(--text); + border: none; resize: none; font-family: 'SF Mono', monospace; + font-size: 0.9em; line-height: 1.6; outline: none; + } + + .actions-row { + display: flex; gap: 8px; flex-wrap: wrap; + margin-top: 12px; padding-top: 12px; + border-top: 1px solid var(--border); + } + .actions-row .btn { padding: 8px 14px; font-size: 0.9em; } + + .success-msg { color: var(--accent); padding: 8px 12px; background: rgba(0,255,0,0.1); border-radius: 8px; margin-top: 8px; } + .error-msg { color: #ff4444; padding: 8px 12px; background: rgba(255,68,68,0.1); border-radius: 8px; margin-top: 8px; } + + .tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 12px; } + .tab { padding: 10px 20px; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; color: var(--text2); cursor: pointer; font-size: 0.95em; transition: all 0.2s; } + .tab:hover { border-color: var(--accent); color: var(--text); } + .tab.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } + .tab-content { display: none; } + .tab-content.active { display: block; } + + .model-info { + margin-left: 6px; + font-size: 0.9em; + cursor: help; + color: var(--text2); + border-bottom: 1px dotted var(--text2); + } + .model-info:hover { + color: var(--accent); + } + input[type="number"] { + padding: 10px 14px; background: var(--bg3); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; font-size: 0.95em; + width: 100px; + } + input[type="number"]:focus { border-color: var(--accent); outline: none; } + + @media (max-width: 600px) { + </style> +</head> +<body> + <div class="container"> + + <div class="tabs"> + <button class="tab active" onclick="switchTab('main')">Main</button> + <button class="tab" onclick="switchTab('config')">Config</button> + </div> + + <div id="mainContent" class="tab-content active"> + <h2>1. Select File</h2> + <div class="drop-zone" id="dropZone"> + <input type="file" id="fileInput" accept=".pdf,.txt,.md,.json" /> + <div>📂 Click to choose or drag & drop<br><small style="color:#666;">PDF, TXT, MD, JSON</small></div> + </div> + <div id="fileInfo" class="file-info" style="display:none;"> + <div><span class="label">File:</span> <span class="value" id="fileName">-</span></div> + <div><span class="label">Pages:</span> <span class="value" id="pageCount">-</span></div> + <div><span class="label">Size:</span> <span class="value" id="fileSize">-</span></div> + </div> + <div class="btn-group"> + <button class="btn btn-primary" onclick="extractText()" id="extractBtn">Load File</button> + <button class="btn btn-secondary" onclick="clearAll()">🗑️ New</button> + </div> + + <div class="paste-section" id="pasteSection"> + <h3 style="color:var(--text2);font-size:0.85em;margin:16px 0 8px;">Or paste text directly:</h3> + <textarea id="pasteArea" placeholder="Paste text here..." style="width:100%;min-height:100px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:12px;font-family:monospace;font-size:0.9em;resize:vertical;"></textarea> + <button class="btn btn-secondary" onclick="loadPastedText()" style="margin-top:8px;">Load Pasted Text</button> + </div> + </div> + + <div class="card" id="editCard" style="display:none;"> + <h2>2. Edit & Select Parts</h2> + <div class="form-row"> + <label>Cleaning Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">ⓘ</span></label> + <select id="cleanModel"> + <optgroup label="Local"> + <option value="llama3.2:latest">llama3.2:latest</option> + <option value="qwen2.5:7b-instruct">qwen2.5:7b-instruct</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="mistral:latest">mistral:latest</option> + <option value="translategemma:latest">translategemma:latest</option> + </optgroup> + <optgroup label="Cloud (Online)"> + <option value="glm-5.3-flash:cloud">glm-5.3-flash:cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + </optgroup> + </select> + <button class="btn btn-secondary" onclick="cleanText()" id="cleanBtn">Clean Text</button> + </div> + <p style="color:var(--text2);font-size:0.85em;margin-bottom:12px;"> + Edit the text below. Then go to Translate section to translate. + </p> + <div class="preview-area" id="previewArea"> + <button class="fullscreen-btn" onclick="toggleFullscreen()">⛶ Fullscreen</button> + <textarea id="editor" spellcheck="false"></textarea> + </div> + <div class="actions-row"> + <button class="btn btn-secondary" onclick="selectAll()">Select All</button> + <button class="btn btn-secondary" onclick="deselectAll()">Deselect All</button> + </div> + </div> + + <div class="card" id="translateCard" style="display:none;"> + <h2>3. Translate</h2> + <div class="form-row"> + <label>Translation Model:<span class="model-info" title="Fast multimodal cloud model" data-model="glm-5.3-flash:cloud">ⓘ</span></label> + <select id="translateModel"> + <optgroup label="Cloud (Fast)"> + <option value="glm-5.3-flash:cloud" selected>glm-5.3-flash:cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + </optgroup> + <optgroup label="Local"> + <option value="translategemma:latest">translategemma:latest</option> + <option value="llama3.2:latest">llama3.2:latest</option> + <option value="qwen2.5:7b-instruct">qwen2.5:7b-instruct</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="mistral:latest">mistral:latest</option> + </optgroup> + </select> + <label>Target:</label> + <select id="targetLang"> + <option value="English">English</option> + <option value="Spanish">Spanish</option> + <option value="French">French</option> + <option value="German">German</option> + <option value="Italian">Italian</option> + <option value="Portuguese">Portuguese</option> + <option value="Chinese">Chinese</option> + <option value="Japanese">Japanese</option> + <option value="Korean">Korean</option> + <option value="Russian">Russian</option> + <option value="Arabic">Arabic</option> + <option value="Hindi">Hindi</option> + <option value="Turkish">Turkish</option> + <option value="Dutch">Dutch</option> + <option value="Polish">Polish</option> + <option value="Swedish">Swedish</option> + </select> + </div> + <div class="form-row"> + <label>Validation Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">ⓘ</span></label> + <select id="validateModel"> + <optgroup label="Cloud"> + <option value="glm-5.3-flash:cloud">glm-5.3-flash:cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + </optgroup> + <optgroup label="Local"> + <option value="llama3.2:latest" selected>llama3.2:latest</option> + <option value="qwen2.5:7b-instruct">qwen2.5:7b-instruct</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> + <option value="mistral:latest">mistral:latest</option> + </optgroup> + </select> + <button class="btn btn-primary" onclick="translateText()" id="translateBtn" disabled>🔄 Translate</button> + <button class="btn btn-secondary" onclick="validateText()" id="validateBtn">✓ Validate</button> + </div> + <div id="translating" style="display:none;margin-top:8px;" class="success-msg"> + Translating... <span id="progressStatusText"></span> + </div> + + <div class="workflow-section"> + <h3 style="color:var(--text2);font-size:0.85em;margin:16px 0 8px;">⚡ Workflow</h3> + <div class="form-row"> + <select id="workflowSelect"> + <option value="clean_translate_validate">Clean → Translate → Validate</option> + <option value="translate_validate">Translate → Validate</option> + <option value="clean_validate">Clean → Validate</option> + <option value="translate_only">Translate Only</option> + <option value="clean_only">Clean Only</option> + </select> + <button class="btn btn-primary" onclick="runWorkflow()" id="workflowBtn">▶️ Run Workflow</button> + </div> + </div> + </div> + + <div class="card"> + <div class="progress-container" id="progressContainer"> + <div class="progress-bar"> + <div class="progress-fill" id="progressFill"></div> + <div class="progress-text" id="progressText">0%</div> + </div> + <div class="progress-status" id="progressStatus"></div> + <div class="progress-controls"> + <button class="btn btn-sm" onclick="togglePause()" id="pauseBtn" style="display:none;">⏸️</button> + <button class="btn btn-sm btn-danger" onclick="requestStop()" id="stopBtn" style="display:none;">⏹️</button> + <span id="tokenCount" style="color:var(--accent);font-size:0.85em;margin-left:auto;">📊 Tokens: 0</span> + </div> + </div> + + <div class="btn-group"> + <button class="btn btn-primary" onclick="downloadTxt()" id="downloadBtn" disabled>Download TXT</button> + <button class="btn btn-secondary" onclick="copyToClipboard()" id="copyBtn" disabled>Copy All</button> + </div> + </div> + + <div class="log-area" id="logArea"> + <div class="log-header" onclick="toggleLog()"> + <span class="title">📋 Activity Log</span> + <span class="toggle" id="logToggle">▼</span> + </div> + <div class="log-content" id="logContent"> + <div class="info">Ready. Select a PDF to begin.</div> + </div> + </div> + </div> + + <div id="configContent" class="tab-content"> + <div class="card"> + <h2>⚙️ Settings</h2> + <p style="color:var(--text2);font-size:0.85em;margin-bottom:16px;"> + Configure default values. Chunk size auto-adjusts based on model type (cloud vs local). + </p> + + <div class="form-row"> + <label>API Source:</label> + <select id="apiSource" onchange="handleApiSourceChange()" style="background: var(--bg3); color: var(--text); border: 1px solid var(--border); padding: 10px; border-radius: 8px;"> + <option value="proxied">Proxied (/ollama)</option> + <option value="local">Local (localhost:11434)</option> + <option value="custom">Custom URL</option> + </select> + </div> + + <div class="form-row" id="customUrlGroup" style="display:none;"> + <label>Custom URL:</label> + <select id="customUrlSelect" onchange="handleCustomUrlSelect()" style="flex:1;"> + <option value="">-- Select or enter new --</option> + </select> + </div> + + <div class="form-row" id="customUrlInputRow" style="display:none;"> + <label></label> + <input type="text" id="ollamaUrl" placeholder="https://api.openai.com/v1" style="flex:1;"> + <span style="color:var(--text2);font-size:0.85em;">OpenAI-compatible endpoints</span> + </div> + + <div class="form-row"> + <label>API Key:</label> + <input type="password" id="ollamaApiKey" placeholder="Enter API key if required" style="flex:1;"> + </div> + + <div class="form-row"> + <label>Models:</label> + <button class="btn btn-secondary btn-sm" onclick="fetchModels()" id="refreshModelsBtn">🔄 Refresh Models</button> + <span id="modelsStatus" style="color:var(--text2);font-size:0.85em;margin-left:8px;"></span> + </div> + + <div class="form-row"> + <label>Cleaning Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">ⓘ</span></label> + <select id="configCleanModel" onchange="adjustChunkSize()"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-row"> + <label>Translation Model:<span class="model-info" title="Fast multimodal cloud model" data-model="glm-5.3-flash:cloud">ⓘ</span></label> + <select id="configTranslateModel" onchange="adjustChunkSize()"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-row"> + <label>Validation Model:<span class="model-info" title="7B local model, 8K context" data-model="llama3.2:latest">ⓘ</span></label> + <select id="configValidateModel"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-row"> + <label>Target Language:</label> + <select id="configTargetLang"> + <option value="English">English</option> + <option value="Spanish">Spanish</option> + <option value="French">French</option> + <option value="German">German</option> + <option value="Italian">Italian</option> + <option value="Portuguese">Portuguese</option> + <option value="Chinese">Chinese</option> + <option value="Japanese">Japanese</option> + <option value="Korean">Korean</option> + <option value="Russian">Russian</option> + <option value="Arabic">Arabic</option> + <option value="Hindi">Hindi</option> + <option value="Turkish">Turkish</option> + <option value="Dutch">Dutch</option> + <option value="Polish">Polish</option> + <option value="Swedish">Swedish</option> + </select> + </div> + + <div class="form-row"> + <label>Chunk Size:</label> + <input type="number" id="configChunkSize" min="500" max="5000" step="100" value="1500" /> + <span style="color:var(--text2);font-size:0.85em;">(500-5000, auto-adjusts with cloud models)</span> + </div> + + <div class="form-row"> + <label>Default Workflow:</label> + <select id="configWorkflow"> + <option value="clean_translate_validate">Clean → Translate → Validate</option> + <option value="translate_validate">Translate → Validate</option> + <option value="clean_validate">Clean → Validate</option> + <option value="translate_only">Translate Only</option> + <option value="clean_only">Clean Only</option> + </select> + </div> + + <div class="btn-group"> + <button class="btn btn-primary" onclick="saveConfig()">Save Config</button> + <button class="btn btn-secondary" onclick="resetConfig()">Reset to Defaults</button> + </div> + <div id="configMsg" style="margin-top:8px;"></div> + </div> + </div> + </div> + + <script> + const CONFIG_KEY = 'pdf2txt_config'; + const DEFAULT_CONFIG = { + apiSource: 'proxied', + customUrls: [], + ollamaUrl: '/ollama', + ollamaApiKey: '', + cleanModel: 'llama3.2:latest', + translateModel: 'glm-5.3-flash:cloud', + validateModel: 'llama3.2:latest', + targetLang: 'Spanish', + chunkSize: 1500, + workflow: 'clean_translate_validate' + }; + + let pdfData = null; + let extractedText = ''; + let cleanedText = ''; + let logCollapsed = false; + let isProcessing = false; + let isPaused = false; + let stopRequested = false; + let totalTokens = 0; + + function switchTab(tab) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + if (tab === 'main') { + document.querySelector('.tab:nth-child(1)').classList.add('active'); + document.getElementById('mainContent').classList.add('active'); + } else { + document.querySelector('.tab:nth-child(2)').classList.add('active'); + document.getElementById('configContent').classList.add('active'); + } + } + + function loadConfig() { + try { + const saved = localStorage.getItem(CONFIG_KEY); + if (saved) { + return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }; + } + } catch (e) {} + return { ...DEFAULT_CONFIG }; + } + + function handleApiSourceChange() { + const source = document.getElementById('apiSource').value; + const customGroup = document.getElementById('customUrlGroup'); + const customInputRow = document.getElementById('customUrlInputRow'); + const urlInput = document.getElementById('ollamaUrl'); + + if (source === 'custom') { + customGroup.style.display = 'flex'; + customInputRow.style.display = 'flex'; + populateCustomUrlDropdown(); + } else { + customGroup.style.display = 'none'; + customInputRow.style.display = 'none'; + urlInput.value = source === 'local' ? 'http://localhost:11434' : '/ollama'; + } + } + + function populateCustomUrlDropdown() { + const config = loadConfig(); + const select = document.getElementById('customUrlSelect'); + select.innerHTML = '<option value="">-- Select or enter new --</option>'; + + (config.customUrls || []).forEach(url => { + const opt = document.createElement('option'); + opt.value = url; + opt.textContent = url; + select.appendChild(opt); + }); + + if (config.apiSource === 'custom' && config.ollamaUrl) { + select.value = config.ollamaUrl; + document.getElementById('ollamaUrl').value = config.ollamaUrl; + } + } + + function handleCustomUrlSelect() { + const select = document.getElementById('customUrlSelect'); + const urlInput = document.getElementById('ollamaUrl'); + if (select.value) { + urlInput.value = select.value; + } + } + + function getApiBaseUrl() { + const config = loadConfig(); + const source = config.apiSource || 'proxied'; + + if (source === 'local') return 'http://localhost:11434'; + if (source === 'custom') return config.ollamaUrl || '/ollama'; + return '/ollama'; + } + + function saveCustomUrl(url) { + if (!url) return; + const config = loadConfig(); + config.customUrls = config.customUrls || []; + + config.customUrls = config.customUrls.filter(u => u !== url); + config.customUrls.unshift(url); + config.customUrls = config.customUrls.slice(0, 3); + + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + } + + function saveConfig() { + const source = document.getElementById('apiSource').value; + let url = document.getElementById('ollamaUrl').value.trim(); + + if (source === 'custom' && url) { + saveCustomUrl(url); + } + if (source === 'local') url = 'http://localhost:11434'; + if (source === 'proxied') url = '/ollama'; + + const config = { + apiSource: source, + ollamaUrl: url, + ollamaApiKey: document.getElementById('ollamaApiKey').value.trim(), + cleanModel: document.getElementById('configCleanModel').value, + translateModel: document.getElementById('configTranslateModel').value, + validateModel: document.getElementById('configValidateModel').value, + targetLang: document.getElementById('configTargetLang').value, + chunkSize: parseInt(document.getElementById('configChunkSize').value) || 1500, + workflow: document.getElementById('configWorkflow').value + }; + try { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + applyConfig(config); + showConfigMsg('Settings saved!', 'success'); + } catch (e) { + showConfigMsg('Failed to save: ' + e.message, 'error'); + } + } + + function applyConfig(config) { + document.getElementById('cleanModel').value = config.cleanModel; + document.getElementById('translateModel').value = config.translateModel; + document.getElementById('validateModel').value = config.validateModel; + document.getElementById('targetLang').value = config.targetLang; + document.getElementById('workflowSelect').value = config.workflow; + } + + function resetConfig() { + document.getElementById('apiSource').value = DEFAULT_CONFIG.apiSource; + document.getElementById('ollamaUrl').value = DEFAULT_CONFIG.ollamaUrl; + document.getElementById('ollamaApiKey').value = ''; + handleApiSourceChange(); + document.getElementById('configCleanModel').value = DEFAULT_CONFIG.cleanModel; + document.getElementById('configTranslateModel').value = DEFAULT_CONFIG.translateModel; + document.getElementById('configValidateModel').value = DEFAULT_CONFIG.validateModel; + document.getElementById('configTargetLang').value = DEFAULT_CONFIG.targetLang; + document.getElementById('configChunkSize').value = DEFAULT_CONFIG.chunkSize; + document.getElementById('configWorkflow').value = DEFAULT_CONFIG.workflow; + showConfigMsg('Reset to defaults', 'info'); + } + + function showConfigMsg(msg, type) { + const el = document.getElementById('configMsg'); + el.textContent = msg; + el.style.color = type === 'success' ? 'var(--accent)' : type === 'error' ? '#ff4444' : 'var(--text2)'; + setTimeout(() => { el.textContent = ''; }, 3000); + } + + async function fetchModels() { + const statusEl = document.getElementById('modelsStatus'); + const cleanSel = document.getElementById('configCleanModel'); + const transSel = document.getElementById('configTranslateModel'); + const validSel = document.getElementById('configValidateModel'); + + statusEl.textContent = 'Fetching...'; + document.getElementById('refreshModelsBtn').disabled = true; + + try { + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + const response = await fetch(`${baseUrl}/api/tags`, { headers }); + if (!response.ok) throw new Error('HTTP ' + response.status); + + const data = await response.json(); + const models = data.models || []; + const modelNames = models.map(m => m.name).sort(); + + if (modelNames.length === 0) { + statusEl.textContent = 'No models found'; + return; + } + + statusEl.textContent = modelNames.length + ' models loaded'; + + const populate = (select, currentVal) => { + select.innerHTML = ''; + modelNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + select.appendChild(opt); + }); + if (currentVal && modelNames.includes(currentVal)) { + select.value = currentVal; + } + }; + + const config = loadConfig(); + populate(cleanSel, config.cleanModel); + populate(transSel, config.translateModel); + populate(validSel, config.validateModel); + + } catch (e) { + statusEl.textContent = 'Error: ' + e.message; + } finally { + document.getElementById('refreshModelsBtn').disabled = false; + } + } + + function adjustChunkSize() { + const model = document.getElementById('configTranslateModel').value; + const chunkInput = document.getElementById('configChunkSize'); + if (model.includes('cloud')) { + chunkInput.value = 2500; + } else { + chunkInput.value = 1500; + } + } + + function initConfig() { + const config = loadConfig(); + document.getElementById('configTargetLang').value = config.targetLang; + document.getElementById('configChunkSize').value = config.chunkSize; + document.getElementById('configWorkflow').value = config.workflow; + document.getElementById('apiSource').value = config.apiSource || 'proxied'; + document.getElementById('ollamaUrl').value = config.ollamaUrl || '/ollama'; + document.getElementById('ollamaApiKey').value = config.ollamaApiKey || ''; + handleApiSourceChange(); + applyConfig(config); + fetchModels(); + } + + function saveState() { + try { + localStorage.setItem('pdf2txt_text', cleanedText || editor.value); + } catch (e) { + console.warn('saveState failed:', e); + } + } + + initConfig(); + + const logArea = document.getElementById('logArea'); + const logContent = document.getElementById('logContent'); + const dropZone = document.getElementById('dropZone'); + const fileInput = document.getElementById('fileInput'); + const editor = document.getElementById('editor'); + + function log(msg, type = 'info') { + const div = document.createElement('div'); + div.className = type; + div.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg; + logContent.appendChild(div); + logContent.scrollTop = logContent.scrollHeight; + console.log('[' + type.toUpperCase() + ']', msg); + } + + function logTokens(promptTokens, completionTokens) { + totalTokens += (promptTokens || 0) + (completionTokens || 0); + const el = document.getElementById('tokenCount'); + if (el) el.textContent = `📊 Tokens: ${totalTokens.toLocaleString()}`; + log(` └─ prompt: ${promptTokens || 0} | completion: ${completionTokens || 0} | total: ${totalTokens.toLocaleString()}`, 'info'); + } + + function toggleLog() { + logCollapsed = !logCollapsed; + logContent.classList.toggle('collapsed', logCollapsed); + document.getElementById('logToggle').textContent = logCollapsed ? '▶' : '▼'; + } + + dropZone.addEventListener('click', () => fileInput.click()); + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); + }); + fileInput.addEventListener('change', (e) => { if (e.target.files.length) handleFile(e.target.files[0]); }); + + function handleFile(file) { + const ext = file.name.split('.').pop().toLowerCase(); + const textExts = ['txt', 'md', 'json']; + + document.getElementById('fileName').textContent = file.name; + document.getElementById('fileSize').textContent = formatSize(file.size); + document.getElementById('fileInfo').style.display = 'flex'; + + if (file.type === 'application/pdf') { + log('Loading PDF: ' + file.name, 'info'); + pdfData = file; + document.getElementById('pageCount').textContent = 'PDF'; + document.getElementById('extractBtn').disabled = false; + log('PDF ready for extraction', 'success'); + } else if (textExts.includes(ext) || file.type === 'text/plain') { + log('Loading text file: ' + file.name, 'info'); + const reader = new FileReader(); + reader.onload = (e) => { + extractedText = e.target.result; + cleanedText = extractedText; + editor.value = extractedText; + document.getElementById('pageCount').textContent = (ext === 'json' ? 'JSON' : 'Text'); + showInEditor(extractedText); + log('Loaded ' + extractedText.length + ' characters', 'success'); + }; + reader.onerror = () => log('Failed to read file', 'error'); + reader.readAsText(file); + } else { + log('Unsupported file type: ' + file.type, 'error'); + } + } + + function loadPastedText() { + const text = document.getElementById('pasteArea').value.trim(); + if (!text) { + log('No text to load', 'warn'); + return; + } + extractedText = text; + cleanedText = text; + currentFileName = 'pasted_text.txt'; + showInEditor(text); + log('Loaded ' + text.length + ' characters from paste', 'success'); + } + + function showInEditor(text) { + editor.value = text; + document.getElementById('editCard').style.display = 'block'; + document.getElementById('translateCard').style.display = 'block'; + document.getElementById('downloadBtn').disabled = false; + document.getElementById('copyBtn').disabled = false; + document.getElementById('translateBtn').disabled = false; + saveState(); + } + + async function extractText() { + const fileName = document.getElementById('fileName').textContent; + const isPdf = fileName.endsWith('.pdf'); + + if (isPdf && !pdfData) { + log('No PDF loaded', 'warn'); + return; + } + + if (!isPdf) { + log('Text already loaded', 'info'); + return; + } + + log('Starting PDF extraction...', 'info'); + showProgress(0, 'Extracting PDF...'); + document.getElementById('extractBtn').disabled = true; + + try { + const arrayBuffer = await pdfData.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + + const pdf = await pdfjsLib.getDocument({ data: bytes }).promise; + let fullText = ''; + const total = pdf.numPages; + + for (let i = 1; i <= total; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + const pageText = textContent.items.map(item => item.str).join(' '); + fullText += pageText + '\n\n'; + showProgress((i / total) * 100, `Page ${i}/${total}`); + } + + extractedText = fullText; + cleanedText = fullText; + editor.value = extractedText; + + document.getElementById('editCard').style.display = 'block'; + document.getElementById('translateCard').style.display = 'block'; + document.getElementById('downloadBtn').disabled = false; + document.getElementById('copyBtn').disabled = false; + document.getElementById('translateBtn').disabled = false; + + hideProgress(); + log('Extraction complete!', 'success'); + } catch (err) { + log('Extraction failed: ' + err.message, 'error'); + hideProgress(); + } + document.getElementById('extractBtn').disabled = false; + } + + async function cleanText() { + const textToClean = editor.value.trim(); + if (!textToClean) { + log('No text to clean', 'warn'); + return false; + } + + const model = document.getElementById('cleanModel').value; + log('Cleaning text with ' + model + '...', 'info'); + document.getElementById('cleanBtn').disabled = true; + isProcessing = true; + showProgress(50, 'Cleaning...'); + + const prompt = `Clean the following text. Remove artifacts, fix encoding issues, and make it readable. Keep the original meaning.\n\nText:\n${textToClean}`; + + try { + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false, + options: { temperature: 0.3 } + }) + }); + + if (!response.ok) throw new Error('API error'); + const data = await response.json(); + + logTokens(data.prompt_eval_count || 0, data.eval_count || 0); + + cleanedText = data.response; + editor.value = cleanedText; + saveState(); + + hideProgress(); + log('Text cleaned!', 'success'); + return true; + } catch (err) { + log('Cleaning failed: ' + err.message, 'error'); + hideProgress(); + return false; + } finally { + document.getElementById('cleanBtn').disabled = false; + isProcessing = false; + } + } + + async function translateText() { + const textToTranslate = editor.value.trim(); + if (!textToTranslate) { + log('No text to translate', 'warn'); + return; + } + + const translateModel = document.getElementById('translateModel').value; + const targetLang = document.getElementById('targetLang').value; + const validateModel = document.getElementById('validateModel').value; + + log('Translating to ' + targetLang + ' with ' + translateModel + '...', 'info'); + document.getElementById('translateBtn').disabled = true; + document.getElementById('translating').style.display = 'block'; + + const config = loadConfig(); + const CHUNK_SIZE = config.chunkSize; + const chunks = []; + for (let i = 0; i < textToTranslate.length; i += CHUNK_SIZE) { + chunks.push(textToTranslate.substring(i, i + CHUNK_SIZE)); + } + + let translated = ''; + try { + for (let i = 0; i < chunks.length; i++) { + if (stopRequested) { + log('Translation stopped', 'warn'); + break; + } + while (isPaused) { + await new Promise(r => setTimeout(r, 100)); + if (stopRequested) break; + } + + const pct = Math.round(((i + 1) / chunks.length) * 100); + showProgress(pct, `Translating chunk ${i + 1}/${chunks.length}`); + document.getElementById('progressStatusText').textContent = `${i + 1}/${chunks.length}`; + + const prompt = `Translate to ${targetLang}. Keep meaning.\n\nText:\n${chunks[i]}`; + + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: translateModel, + prompt: prompt, + stream: false, + options: { temperature: 0.3 } + }) + }); + + if (!response.ok) throw new Error('Translation API error'); + const data = await response.json(); + logTokens(data.prompt_eval_count || 0, data.eval_count || 0); + translated += data.response + '\n\n'; + } + + if (!stopRequested) { + editor.value = translated; + cleanedText = translated; + saveState(); + } + + hideProgress(); + log('Translation complete! ' + totalTokens + ' tokens used.', 'success'); + return true; + + } catch (err) { + log('Translation error: ' + err.message, 'error'); + hideProgress(); + return false; + } + } + + async function validateText() { + const textToValidate = editor.value.trim(); + if (!textToValidate) { + log('No text to validate', 'warn'); + return false; + } + + const model = document.getElementById('validateModel').value; + const targetLang = document.getElementById('targetLang').value; + log('Validating with ' + model + '...', 'info'); + document.getElementById('validateBtn').disabled = true; + isProcessing = true; + showProgress(50, 'Validating...'); + + const prompt = `Check if this text in ${targetLang} is accurate, natural, and well-formatted. If good, return "PASS". If issues found, correct them and return the improved version prefixed with "NEEDS FIX:"\n\nText:\n${textToValidate}`; + + try { + const config = loadConfig(); + const headers = { 'Content-Type': 'application/json' }; + if (config.ollamaApiKey) { + headers['Authorization'] = `Bearer ${config.ollamaApiKey}`; + } + const baseUrl = getApiBaseUrl(); + + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false, + options: { temperature: 0.3 } + }) + }); + + if (!response.ok) throw new Error('Validation API error'); + const data = await response.json(); + logTokens(data.prompt_eval_count || 0, data.eval_count || 0); + + const result = data.response; + if (result.startsWith('NEEDS FIX:')) { + const corrected = result.replace('NEEDS FIX:', '').trim(); + editor.value = corrected; + cleanedText = corrected; + saveState(); + log('Validation: Issues found and corrected', 'warn'); + } else { + log('Validation: PASS ✓', 'success'); + } + + hideProgress(); + return true; + } catch (err) { + log('Validation error: ' + err.message, 'error'); + hideProgress(); + return false; + } finally { + document.getElementById('validateBtn').disabled = false; + isProcessing = false; + } + } + + async function runWorkflow() { + const workflow = document.getElementById('workflowSelect').value; + const targetLang = document.getElementById('targetLang').value; + + log('Starting workflow: ' + workflow, 'info'); + isProcessing = true; + + const steps = { + 'clean_translate_validate': ['clean', 'translate', 'validate'], + 'translate_validate': ['translate', 'validate'], + 'clean_validate': ['clean', 'validate'], + 'translate_only': ['translate'], + 'clean_only': ['clean'] + }; + + for (const step of steps[workflow]) { + if (stopRequested) break; + while (isPaused && !stopRequested) { + await new Promise(r => setTimeout(r, 100)); + } + + switch (step) { + case 'clean': + const cleaned = await cleanText(); + if (!cleaned) { log('Workflow stopped: clean failed', 'error'); break; } + break; + case 'translate': + const translated = await translateText(); + if (!translated) { log('Workflow stopped: translate failed', 'error'); break; } + break; + case 'validate': + await validateText(); + break; + } + } + + isProcessing = false; + log('Workflow complete!', 'success'); + saveState(); + } + + function toggleFullscreen() { + const area = document.getElementById('previewArea'); + const btn = area.querySelector('.fullscreen-btn'); + area.classList.toggle('fullscreen'); + btn.textContent = area.classList.contains('fullscreen') ? '⛶ Exit' : '⛶ Fullscreen'; + } + + function selectAll() { + editor.select(); + } + + function deselectAll() { + editor.setSelectionRange(0, 0); + } + + function downloadTxt() { + const text = editor.value; + if (!text) return; + + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'extracted.txt'; + a.click(); + URL.revokeObjectURL(url); + log('Downloaded as extracted.txt', 'success'); + } + + function copyToClipboard() { + const text = editor.value; + if (!text) return; + + navigator.clipboard.writeText(text).then(() => { + log('Copied to clipboard!', 'success'); + }).catch(() => { + log('Failed to copy', 'error'); + }); + } + + function clearAll() { + pdfData = null; + extractedText = ''; + cleanedText = ''; + editor.value = ''; + document.getElementById('fileInfo').style.display = 'none'; + document.getElementById('fileInput').value = ''; + document.getElementById('editCard').style.display = 'none'; + document.getElementById('translateCard').style.display = 'none'; + document.getElementById('downloadBtn').disabled = true; + document.getElementById('copyBtn').disabled = true; + log('Cleared', 'info'); + } + + function showProgress(percent, status) { + document.getElementById('progressContainer').style.display = 'block'; + document.getElementById('progressFill').style.width = percent + '%'; + document.getElementById('progressText').textContent = Math.round(percent) + '%'; + document.getElementById('progressStatus').textContent = status; + + if (isProcessing) { + document.getElementById('pauseBtn').style.display = 'inline-block'; + document.getElementById('stopBtn').style.display = 'inline-block'; + } + } + + function hideProgress() { + document.getElementById('progressContainer').style.display = 'none'; + document.getElementById('pauseBtn').style.display = 'none'; + document.getElementById('stopBtn').style.display = 'none'; + isProcessing = false; + } + + function togglePause() { + isPaused = !isPaused; + document.getElementById('pauseBtn').textContent = isPaused ? '▶️' : '⏸️'; + log(isPaused ? 'Paused' : 'Resumed', 'info'); + } + + function requestStop() { + stopRequested = true; + log('Stopping...', 'warn'); + } + + function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + </script> +</body> +</html>
\ No newline at end of file |