diff options
| -rw-r--r-- | CHANGELOG.md | 27 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | VERSION | 2 | ||||
| -rw-r--r-- | docs/USAGE.md | 2 | ||||
| -rw-r--r-- | src/index.html | 359 |
5 files changed, 293 insertions, 109 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 0684b9b..72d7731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## 1.0.1 — 2026-11-09 + +A review that drove the page in Chromium found that the 1.0.0 file **did not run at all**. Every fix +below was reproduced before it was made. + +- **P0: the console showed `SyntaxError: Identifier 'config' has already been declared`** — a + duplicated `const config = loadConfig()` inside `fetchModels`, so no handler was ever bound: no + extraction, no translation, no config. Both inline scripts are now syntax-checked at packaging time. +- **The API key was written to `localStorage`**, on an origin shared with the rest of the site, and the + **whole extracted document was stored** on every action. The key now lives in `sessionStorage` + (per tab, gone when the tab closes), old copies are scrubbed on load, and the document is never + persisted. +- Extraction joins pages with `hasEOL` instead of spaces, so paragraphs survive. +- Chunks split on word boundaries (they used to cut mid-word); the size is clamped to 500–5000. +- A 200 response that is not JSON, or a reply without a `response` field, now reports the real error + instead of writing the literal text `undefined` into the editor and logging success. +- Translate re-enables its button and hides the banner in a `finally`; `stopRequested` resets, so a + second run after Stop actually runs; a failed workflow step stops the workflow (the `break` used to + break only the `switch`). +- A PDF with no text layer says so, disables Download/Translate and keeps the previous text. +- `.PDF` (uppercase) is recognised as a PDF; the page count is shown after parsing. +- Mobile: the `@media (max-width: 600px)` block was unterminated and held no rules — fixed and filled + in; the Fullscreen button no longer overlaps the first line of the editor. +- Accessibility: the drop zone is a real `role="button"` with `tabindex="0"` and Enter/Space opens the + file picker; the file input stays in the accessibility tree; every visible label is associated and + the icon-only buttons carry `aria-label`s (39 controls, 0 unnamed). + ## [1.0.0] - 2026-11-09 First packaged release: single-file browser app plus local PDF.js vendor assets. @@ -18,8 +18,9 @@ server, no account, no sign-up and no tracking code in the app. `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. + Turkish, Dutch, Polish, Swedish. The text is split at word boundaries and sent one + chunk at a time (1,500 characters by default, 2,500 when a `:cloud` model is chosen), + 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. @@ -42,9 +43,10 @@ pdf2txt/ ## 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: +The page loads PDF.js as a Web Worker. Some browsers refuse to start a worker from a +`file://` URL (Chrome blocks it; a headless Chromium on this host happened to allow it), +so double-clicking `src/index.html` may load but extract nothing. If that happens, serve +the folder over plain HTTP — no dependencies, Python 3 is enough: ```bash cd products/pdf2txt @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/docs/USAGE.md b/docs/USAGE.md index df194b6..35c77be 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -90,7 +90,7 @@ 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 +- Long documents are sent in pieces (1,500 characters, or 2,500 with a cloud model), 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`. diff --git a/src/index.html b/src/index.html index ffb3dc8..1848aca 100644 --- a/src/index.html +++ b/src/index.html @@ -60,7 +60,15 @@ .drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: rgba(0,255,0,0.05); } - .drop-zone input { display: none; } + /* The file input stays in the accessibility tree (not display:none) so it can + still be labelled and triggered from the keyboard; it is only visually hidden. */ + .visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; + } + .drop-zone:focus-visible, .log-header:focus-visible { + outline: 2px solid var(--accent); outline-offset: 2px; + } .file-info { display: flex; gap: 20px; flex-wrap: wrap; @@ -131,6 +139,10 @@ .fullscreen-btn:hover { color: var(--accent); border-color: var(--accent); } .preview-area textarea { width: 100%; height: 100%; min-height: 280px; + /* Top padding keeps the first line clear of the absolutely positioned + fullscreen button on desktop; on narrow screens the button is moved + into normal flow instead. */ + padding: 34px 4px 4px; background: transparent; color: var(--text); border: none; resize: none; font-family: 'SF Mono', monospace; font-size: 0.9em; line-height: 1.6; outline: none; @@ -171,21 +183,46 @@ input[type="number"]:focus { border-color: var(--accent); outline: none; } @media (max-width: 600px) { + body { padding: 12px; } + .container { max-width: 100%; } + h1 { font-size: 1.4em; margin-bottom: 16px; } + .card { padding: 12px; margin-bottom: 12px; } + .tabs { gap: 6px; padding-bottom: 8px; margin-bottom: 14px; } + .tab { padding: 8px 12px; font-size: 0.85em; } + .drop-zone { padding: 24px 12px; } + .form-row { gap: 8px; } + .form-row > label { flex: 1 0 100%; } + select, input[type="text"], input[type="password"], input[type="number"] { + min-width: 0; width: 100%; flex: 1 1 100%; + } + .btn { padding: 10px 14px; font-size: 0.9em; } + .btn-group { gap: 8px; } + .btn-group .btn { flex: 1 1 auto; } + .actions-row .btn { flex: 1 1 auto; } + .preview-area { padding: 10px; min-height: 260px; } + /* The fullscreen toggle must not float over the editor's first line: + on narrow screens it sits in normal flow above the text. */ + .fullscreen-btn { position: static; display: block; width: 100%; margin: 0 0 8px; } + .preview-area textarea { min-height: 240px; padding-top: 4px; } + .log-content { max-height: 120px; } + } </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> + <button class="tab active" onclick="switchTab('main')" aria-label="Main tab">Main</button> + <button class="tab" onclick="switchTab('config')" aria-label="Config tab">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 class="drop-zone" id="dropZone" role="button" tabindex="0" + aria-label="Choose a file to extract text from, or drag and drop a PDF, TXT, MD or JSON file here"> + <input type="file" id="fileInput" accept=".pdf,.txt,.md,.json" class="visually-hidden" tabindex="-1" + aria-label="Choose a file to extract text from" /> + <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> @@ -193,21 +230,21 @@ <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> + <button class="btn btn-primary" onclick="extractText()" id="extractBtn" aria-label="Load file">Load File</button> + <button class="btn btn-secondary" onclick="clearAll()" aria-label="New: clear the loaded file and editor">🗑️ 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> + <textarea id="pasteArea" aria-label="Paste text to load into the editor" 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()" aria-label="Load pasted text" 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> + <label for="cleanModel">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> @@ -224,25 +261,25 @@ <option value="gemma4:31b-cloud">gemma4:31b-cloud</option> </optgroup> </select> - <button class="btn btn-secondary" onclick="cleanText()" id="cleanBtn">Clean Text</button> + <button class="btn btn-secondary" onclick="cleanText()" id="cleanBtn" aria-label="Clean text">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> + <button class="fullscreen-btn" onclick="toggleFullscreen()" aria-label="Toggle fullscreen editor">⛶ Fullscreen</button> + <textarea id="editor" spellcheck="false" aria-label="Extracted text editor"></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> + <button class="btn btn-secondary" onclick="selectAll()" aria-label="Select all text">Select All</button> + <button class="btn btn-secondary" onclick="deselectAll()" aria-label="Deselect all text">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> + <label for="translateModel">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> @@ -258,7 +295,7 @@ <option value="mistral:latest">mistral:latest</option> </optgroup> </select> - <label>Target:</label> + <label for="targetLang">Target:</label> <select id="targetLang"> <option value="English">English</option> <option value="Spanish">Spanish</option> @@ -279,7 +316,7 @@ </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> + <label for="validateModel">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> @@ -293,8 +330,8 @@ <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> + <button class="btn btn-primary" onclick="translateText()" id="translateBtn" aria-label="Translate text" disabled>🔄 Translate</button> + <button class="btn btn-secondary" onclick="validateText()" id="validateBtn" aria-label="Validate translation">✓ Validate</button> </div> <div id="translating" style="display:none;margin-top:8px;" class="success-msg"> Translating... <span id="progressStatusText"></span> @@ -303,14 +340,14 @@ <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"> + <select id="workflowSelect" aria-label="Workflow preset"> <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> + <button class="btn btn-primary" onclick="runWorkflow()" id="workflowBtn" aria-label="Run workflow">▶️ Run Workflow</button> </div> </div> </div> @@ -323,20 +360,20 @@ </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> + <button class="btn btn-sm" onclick="togglePause()" id="pauseBtn" aria-label="Pause translation" style="display:none;">⏸️</button> + <button class="btn btn-sm btn-danger" onclick="requestStop()" id="stopBtn" aria-label="Stop translation" 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> + <button class="btn btn-primary" onclick="downloadTxt()" id="downloadBtn" aria-label="Download extracted text as TXT" disabled>Download TXT</button> + <button class="btn btn-secondary" onclick="copyToClipboard()" id="copyBtn" aria-label="Copy all text to clipboard" disabled>Copy All</button> </div> </div> <div class="log-area" id="logArea"> - <div class="log-header" onclick="toggleLog()"> + <div class="log-header" onclick="toggleLog()" role="button" tabindex="0" aria-label="Toggle activity log" aria-expanded="true"> <span class="title">📋 Activity Log</span> <span class="toggle" id="logToggle">▼</span> </div> @@ -354,7 +391,7 @@ </p> <div class="form-row"> - <label>API Source:</label> + <label for="apiSource">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> @@ -363,52 +400,52 @@ </div> <div class="form-row" id="customUrlGroup" style="display:none;"> - <label>Custom URL:</label> + <label for="customUrlSelect">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> + <label for="ollamaUrl">Ollama URL:</label> + <input type="text" id="ollamaUrl" placeholder="https://host/ollama" style="flex:1;"> + <span style="color:var(--text2);font-size:0.85em;">Ollama-compatible base URL</span> </div> <div class="form-row"> - <label>API Key:</label> + <label for="ollamaApiKey">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 class="group-label" style="color:var(--text2);font-size:0.9em;">Models:</span> + <button class="btn btn-secondary btn-sm" onclick="fetchModels()" id="refreshModelsBtn" aria-label="Refresh available models">🔄 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> + <label for="configCleanModel">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> + <label for="configTranslateModel">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> + <label for="configValidateModel">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> + <label for="configTargetLang">Target Language:</label> <select id="configTargetLang"> <option value="English">English</option> <option value="Spanish">Spanish</option> @@ -430,13 +467,13 @@ </div> <div class="form-row"> - <label>Chunk Size:</label> + <label for="configChunkSize">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> + <label for="configWorkflow">Default Workflow:</label> <select id="configWorkflow"> <option value="clean_translate_validate">Clean → Translate → Validate</option> <option value="translate_validate">Translate → Validate</option> @@ -447,8 +484,8 @@ </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> + <button class="btn btn-primary" onclick="saveConfig()" aria-label="Save configuration">Save Config</button> + <button class="btn btn-secondary" onclick="resetConfig()" aria-label="Reset configuration to defaults">Reset to Defaults</button> </div> <div id="configMsg" style="margin-top:8px;"></div> </div> @@ -457,6 +494,7 @@ <script> const CONFIG_KEY = 'pdf2txt_config'; + const API_KEY_KEY = 'pdf2txt_api_key'; const DEFAULT_CONFIG = { apiSource: 'proxied', customUrls: [], @@ -478,6 +516,35 @@ let isPaused = false; let stopRequested = false; let totalTokens = 0; + let pdfDoc = null; + let workflowRunning = false; + + async function readJson(response) { + const body = await response.text(); + try { + return JSON.parse(body); + } catch (e) { + throw new Error('Server did not return JSON (HTTP ' + response.status + ')'); + } + } + + // Split on word boundaries when possible so translations do not start mid-word, + // and always make progress (no infinite loop) even if the size is odd. + function chunkText(text, size) { + const chunks = []; + let i = 0; + while (i < text.length) { + let end = Math.min(i + size, text.length); + if (end < text.length) { + const space = text.lastIndexOf(' ', end); + if (space > i + Math.floor(size / 2)) end = space; + } + const part = text.slice(i, end).trim(); + if (part) chunks.push(part); + i = end; + } + return chunks; + } function switchTab(tab) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); @@ -495,7 +562,15 @@ try { const saved = localStorage.getItem(CONFIG_KEY); if (saved) { - return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }; + const parsed = JSON.parse(saved); + // Older versions stored the API key in localStorage; move it to sessionStorage + // so the secret is not left at rest on a shared origin. + if (parsed.ollamaApiKey) { + try { sessionStorage.setItem(API_KEY_KEY, parsed.ollamaApiKey); } catch (e) {} + delete parsed.ollamaApiKey; + localStorage.setItem(CONFIG_KEY, JSON.stringify(parsed)); + } + return { ...DEFAULT_CONFIG, ...parsed, ollamaApiKey: sessionStorage.getItem(API_KEY_KEY) || '' }; } } catch (e) {} return { ...DEFAULT_CONFIG }; @@ -549,7 +624,7 @@ const source = config.apiSource || 'proxied'; if (source === 'local') return 'http://localhost:11434'; - if (source === 'custom') return config.ollamaUrl || '/ollama'; + if (source === 'custom') return (config.ollamaUrl || '/ollama').replace(/\/+$/, ''); return '/ollama'; } @@ -578,7 +653,6 @@ 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, @@ -586,7 +660,10 @@ chunkSize: parseInt(document.getElementById('configChunkSize').value) || 1500, workflow: document.getElementById('configWorkflow').value }; + const apiKey = document.getElementById('ollamaApiKey').value.trim(); try { + if (apiKey) sessionStorage.setItem(API_KEY_KEY, apiKey); + else sessionStorage.removeItem(API_KEY_KEY); localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); applyConfig(config); showConfigMsg('Settings saved!', 'success'); @@ -667,7 +744,6 @@ } }; - const config = loadConfig(); populate(cleanSel, config.cleanModel); populate(transSel, config.translateModel); populate(validSel, config.validateModel); @@ -702,12 +778,12 @@ fetchModels(); } + // Document text is deliberately not persisted; it stays in this tab. Any copy left + // behind by an older version is removed. function saveState() { try { - localStorage.setItem('pdf2txt_text', cleanedText || editor.value); - } catch (e) { - console.warn('saveState failed:', e); - } + localStorage.removeItem('pdf2txt_text'); + } catch (e) {} } initConfig(); @@ -738,10 +814,29 @@ logCollapsed = !logCollapsed; logContent.classList.toggle('collapsed', logCollapsed); document.getElementById('logToggle').textContent = logCollapsed ? '▶' : '▼'; + const header = document.querySelector('.log-header'); + if (header) header.setAttribute('aria-expanded', String(!logCollapsed)); } dropZone.addEventListener('click', () => fileInput.click()); + // Keyboard parity for the drop zone: Enter/Space open the file picker. + dropZone.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + fileInput.click(); + } + }); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); + // The log header is a div that toggles on click; make it operable from the keyboard too. + const logHeaderEl = document.querySelector('.log-header'); + if (logHeaderEl) { + logHeaderEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + toggleLog(); + } + }); + } dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); dropZone.addEventListener('drop', (e) => { e.preventDefault(); @@ -758,14 +853,18 @@ document.getElementById('fileSize').textContent = formatSize(file.size); document.getElementById('fileInfo').style.display = 'flex'; - if (file.type === 'application/pdf') { + if (file.type === 'application/pdf' || ext === 'pdf') { log('Loading PDF: ' + file.name, 'info'); pdfData = file; - document.getElementById('pageCount').textContent = 'PDF'; + pdfDoc = null; + document.getElementById('pageCount').textContent = '…'; document.getElementById('extractBtn').disabled = false; + loadPdfDocument(file); log('PDF ready for extraction', 'success'); } else if (textExts.includes(ext) || file.type === 'text/plain') { log('Loading text file: ' + file.name, 'info'); + pdfData = null; + pdfDoc = null; const reader = new FileReader(); reader.onload = (e) => { extractedText = e.target.result; @@ -778,7 +877,26 @@ reader.onerror = () => log('Failed to read file', 'error'); reader.readAsText(file); } else { - log('Unsupported file type: ' + file.type, 'error'); + pdfData = null; + pdfDoc = null; + document.getElementById('extractBtn').disabled = true; + log('Unsupported file type: ' + (file.type || ext), 'error'); + } + } + + async function loadPdfDocument(file) { + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const doc = await pdfjsLib.getDocument({ data: bytes }).promise; + if (pdfData !== file) return; // another file was chosen while this one parsed + pdfDoc = doc; + document.getElementById('pageCount').textContent = doc.numPages; + } catch (err) { + if (pdfData !== file) return; + pdfDoc = null; + document.getElementById('pageCount').textContent = 'unreadable'; + document.getElementById('extractBtn').disabled = true; + log('Could not open PDF: ' + err.message, 'error'); } } @@ -806,16 +924,8 @@ } 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'); + if (!pdfData) { + log('No PDF loaded — text files are already in the editor', 'info'); return; } @@ -824,21 +934,36 @@ 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 pdf = pdfDoc; + if (!pdf) { + const bytes = new Uint8Array(await pdfData.arrayBuffer()); + pdf = await pdfjsLib.getDocument({ data: bytes }).promise; + pdfDoc = pdf; + } let fullText = ''; const total = pdf.numPages; + document.getElementById('pageCount').textContent = total; 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'; + // Keep the line breaks pdf.js reports instead of flattening a whole page + // into one paragraph. + let pageText = ''; + for (const item of textContent.items) { + pageText += item.str + (item.hasEOL ? '\n' : ' '); + } + fullText += pageText.trim() + '\n\n'; + page.cleanup(); showProgress((i / total) * 100, `Page ${i}/${total}`); } + if (!fullText.trim()) { + log('No text layer found — this looks like a scanned PDF; OCR is not supported.', 'warn'); + hideProgress(); + return; + } + extractedText = fullText; cleanedText = fullText; editor.value = extractedText; @@ -850,12 +975,13 @@ document.getElementById('translateBtn').disabled = false; hideProgress(); - log('Extraction complete!', 'success'); + log('Extraction complete! ' + total + ' page(s), ' + fullText.length + ' characters.', 'success'); } catch (err) { log('Extraction failed: ' + err.message, 'error'); hideProgress(); + } finally { + document.getElementById('extractBtn').disabled = false; } - document.getElementById('extractBtn').disabled = false; } async function cleanText() { @@ -892,8 +1018,11 @@ }) }); - if (!response.ok) throw new Error('API error'); - const data = await response.json(); + if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.statusText); + const data = await readJson(response); + if (typeof data.response !== 'string' || !data.response.trim()) { + throw new Error('Model returned no text' + (data.error ? ': ' + data.error : ' — is "' + model + '" installed?')); + } logTokens(data.prompt_eval_count || 0, data.eval_count || 0); @@ -928,25 +1057,25 @@ log('Translating to ' + targetLang + ' with ' + translateModel + '...', 'info'); document.getElementById('translateBtn').disabled = true; document.getElementById('translating').style.display = 'block'; + if (!workflowRunning) { + stopRequested = false; + isPaused = false; + } + isProcessing = true; 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)); - } + const CHUNK_SIZE = Math.min(5000, Math.max(500, parseInt(config.chunkSize, 10) || 1500)); + const chunks = chunkText(textToTranslate, CHUNK_SIZE); let translated = ''; try { for (let i = 0; i < chunks.length; i++) { - if (stopRequested) { - log('Translation stopped', 'warn'); - break; - } + if (stopRequested) break; while (isPaused) { await new Promise(r => setTimeout(r, 100)); if (stopRequested) break; } + if (stopRequested) break; const pct = Math.round(((i + 1) / chunks.length) * 100); showProgress(pct, `Translating chunk ${i + 1}/${chunks.length}`); @@ -954,7 +1083,6 @@ 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}`; @@ -972,17 +1100,25 @@ }) }); - if (!response.ok) throw new Error('Translation API error'); - const data = await response.json(); + if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.statusText); + const data = await readJson(response); + if (typeof data.response !== 'string') { + throw new Error('Model returned no text' + (data.error ? ': ' + data.error : ' — is "' + translateModel + '" installed?')); + } logTokens(data.prompt_eval_count || 0, data.eval_count || 0); translated += data.response + '\n\n'; } - if (!stopRequested) { - editor.value = translated; - cleanedText = translated; - saveState(); + if (stopRequested) { + hideProgress(); + log('Translation stopped — the editor was left unchanged.', 'warn'); + return false; } + if (!translated.trim()) throw new Error('Model returned no text'); + + editor.value = translated; + cleanedText = translated; + saveState(); hideProgress(); log('Translation complete! ' + totalTokens + ' tokens used.', 'success'); @@ -992,6 +1128,10 @@ log('Translation error: ' + err.message, 'error'); hideProgress(); return false; + } finally { + document.getElementById('translating').style.display = 'none'; + document.getElementById('translateBtn').disabled = false; + isProcessing = false; } } @@ -1030,11 +1170,12 @@ }) }); - if (!response.ok) throw new Error('Validation API error'); - const data = await response.json(); + if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.statusText); + const data = await readJson(response); + if (typeof data.response !== 'string') throw new Error('Model returned no text' + (data.error ? ': ' + data.error : '')); logTokens(data.prompt_eval_count || 0, data.eval_count || 0); - const result = data.response; + const result = data.response.trim(); if (result.startsWith('NEEDS FIX:')) { const corrected = result.replace('NEEDS FIX:', '').trim(); editor.value = corrected; @@ -1062,7 +1203,10 @@ const targetLang = document.getElementById('targetLang').value; log('Starting workflow: ' + workflow, 'info'); + stopRequested = false; + isPaused = false; isProcessing = true; + workflowRunning = true; const steps = { 'clean_translate_validate': ['clean', 'translate', 'validate'], @@ -1072,7 +1216,7 @@ 'clean_only': ['clean'] }; - for (const step of steps[workflow]) { + workflow: for (const step of steps[workflow]) { if (stopRequested) break; while (isPaused && !stopRequested) { await new Promise(r => setTimeout(r, 100)); @@ -1080,20 +1224,23 @@ switch (step) { case 'clean': - const cleaned = await cleanText(); - if (!cleaned) { log('Workflow stopped: clean failed', 'error'); break; } + if (!(await cleanText())) { log('Workflow stopped: clean failed', 'error'); break workflow; } break; case 'translate': - const translated = await translateText(); - if (!translated) { log('Workflow stopped: translate failed', 'error'); break; } + if (!(await translateText())) { log('Workflow stopped: translate failed', 'error'); break workflow; } break; case 'validate': - await validateText(); + if (!(await validateText())) { log('Workflow stopped: validate failed', 'error'); break workflow; } break; } } + workflowRunning = false; isProcessing = false; + if (stopRequested) { + log('Workflow stopped.', 'warn'); + return; + } log('Workflow complete!', 'success'); saveState(); } @@ -1102,7 +1249,9 @@ const area = document.getElementById('previewArea'); const btn = area.querySelector('.fullscreen-btn'); area.classList.toggle('fullscreen'); - btn.textContent = area.classList.contains('fullscreen') ? '⛶ Exit' : '⛶ Fullscreen'; + const on = area.classList.contains('fullscreen'); + btn.textContent = on ? '⛶ Exit' : '⛶ Fullscreen'; + btn.setAttribute('aria-label', on ? 'Exit fullscreen editor' : 'Toggle fullscreen editor'); } function selectAll() { @@ -1140,9 +1289,13 @@ function clearAll() { pdfData = null; + pdfDoc = null; extractedText = ''; cleanedText = ''; + stopRequested = false; + isPaused = false; editor.value = ''; + document.getElementById('translating').style.display = 'none'; document.getElementById('fileInfo').style.display = 'none'; document.getElementById('fileInput').value = ''; document.getElementById('editCard').style.display = 'none'; @@ -1173,7 +1326,9 @@ function togglePause() { isPaused = !isPaused; - document.getElementById('pauseBtn').textContent = isPaused ? '▶️' : '⏸️'; + const btn = document.getElementById('pauseBtn'); + btn.textContent = isPaused ? '▶️' : '⏸️'; + btn.setAttribute('aria-label', isPaused ? 'Resume translation' : 'Pause translation'); log(isPaused ? 'Paused' : 'Resumed', 'info'); } |