aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md27
-rw-r--r--README.md12
-rw-r--r--VERSION2
-rw-r--r--docs/USAGE.md2
-rw-r--r--src/index.html359
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.
diff --git a/README.md b/README.md
index 566b335..a55d2ed 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/VERSION b/VERSION
index 3eefcb9..7dea76e 100644
--- a/VERSION
+++ b/VERSION
@@ -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 &amp; 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');
}