summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--LICENSE29
-rw-r--r--README.md88
-rw-r--r--VERSION1
-rw-r--r--docs/USAGE.md122
-rw-r--r--src/index.html1192
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.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c2e674d
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..3eefcb9
--- /dev/null
+++ b/VERSION
@@ -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