summaryrefslogtreecommitdiff
path: root/src/index.html
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-09 15:02:12 +0100
committerAgando <roduccomm@gmail.com>2026-11-09 15:02:12 +0100
commit58bbe104a1fd31a4f349d6a68bb8f6f67cfe2dd2 (patch)
tree11b3c16ac4d50e2c457109cffc98da28a99b0089 /src/index.html
feat(pdf2txt): canonical product tree with pdf.js vendored
Source: spaces/chatstudio/pdf2txt.html (2026-10-19, unchanged, md5 fbe1df56). Packaged copy differs by exactly two lines (vendor/ paths). MIT, PDF.js Apache-2.0.
Diffstat (limited to 'src/index.html')
-rw-r--r--src/index.html1192
1 files changed, 1192 insertions, 0 deletions
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