Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python3 | |
| """ | |
| normaere — Middle High German text normalizer. | |
| Original design: vertical workflow. | |
| 1. Input textarea at top (wide, line-numbered) | |
| 2. Control bar with options + Normalize button | |
| 3. Side-by-side numbered comparison table as output | |
| """ | |
| import argparse | |
| import base64 | |
| import html as html_mod | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| from threading import Lock | |
| from typing import Optional | |
| # ZeroGPU: `spaces` MUST be imported before torch (which is loaded lazily via | |
| # src.inference) so that ZeroGPU's CUDA patches are active before any CUDA call. | |
| # Do not set SPACES=false or other env hacks — they disable ZeroGPU integration. | |
| import spaces | |
| import gradio as gr | |
| # --------------------------------------------------------------------------- | |
| # Lazy-loaded normalizer | |
| # --------------------------------------------------------------------------- | |
| _normalizer = None | |
| _lock = Lock() | |
| def _get_normalizer(model_path: str, config_path: Optional[str] = None): | |
| global _normalizer | |
| if _normalizer is not None: | |
| return _normalizer | |
| with _lock: | |
| if _normalizer is not None: | |
| return _normalizer | |
| from src.inference import MHGNormalizer | |
| _normalizer = MHGNormalizer(model_path, config_path, defer_gpu=True) | |
| return _normalizer | |
| # --------------------------------------------------------------------------- | |
| # Normalization | |
| # --------------------------------------------------------------------------- | |
| def run_normalize( | |
| input_text: str, | |
| preserve_punctuation: bool, | |
| preserve_capitalization: bool, | |
| attach_en_proclitic: bool, | |
| lenition_t_after_n: bool, | |
| lenition_t_after_l: bool, | |
| niet_to_niht: bool, | |
| common_apocopes: bool, | |
| model_path: str, | |
| config_path: str, | |
| ): | |
| """Normalize text. Returns (raw_input, raw_output) or (input, error_msg).""" | |
| if not input_text or not input_text.strip(): | |
| return "", "" | |
| # Enforce the word limit BEFORE touching the GPU so oversized inputs never | |
| # consume ZeroGPU quota. Truncate to the first _MAX_WORDS words while | |
| # preserving line breaks (the UI also auto-caps; this is the safety net). | |
| wc = len(input_text.split()) | |
| if wc > _MAX_WORDS: | |
| kept_lines = [] | |
| running = 0 | |
| for line in input_text.split("\n"): | |
| line_words = line.split() | |
| if running + len(line_words) > _MAX_WORDS: | |
| # Partial last line: take only the words that fit | |
| remaining = _MAX_WORDS - running | |
| if remaining > 0: | |
| kept_lines.append(" ".join(line_words[:remaining])) | |
| break | |
| kept_lines.append(line) | |
| running += len(line_words) | |
| input_text = "\n".join(kept_lines) | |
| config = Path(config_path) if config_path else None | |
| norm = _get_normalizer(model_path, str(config) if config else None) | |
| norm._ensure_on_device() | |
| try: | |
| result = norm.normalize_multiline_text( | |
| input_text, | |
| preserve_punctuation=preserve_punctuation, | |
| preserve_capitalization=preserve_capitalization, | |
| attach_en_proclitic=attach_en_proclitic, | |
| lenition_t_after_n=lenition_t_after_n, | |
| lenition_t_after_l=lenition_t_after_l, | |
| niet_to_niht=niet_to_niht, | |
| common_apocopes=common_apocopes, | |
| ) | |
| return input_text, result | |
| except Exception as exc: | |
| return input_text, f"⚠ Error: {exc}" | |
| # --------------------------------------------------------------------------- | |
| # Input size limit | |
| # --------------------------------------------------------------------------- | |
| # ZeroGPU grants a limited daily GPU quota. To prevent a single large request | |
| # from consuming it (or timing out the @spaces.GPU call), we cap the input at | |
| # _MAX_WORDS. This is enforced server-side in run_normalize and surfaced in | |
| # the UI via a live word counter. | |
| _MAX_WORDS = 4000 | |
| def load_file(file_obj): | |
| if file_obj is None: | |
| return "" | |
| try: | |
| return file_obj.decode("utf-8") | |
| except UnicodeDecodeError: | |
| return file_obj.decode("latin-1") | |
| # --------------------------------------------------------------------------- | |
| # Comparison table builder | |
| # --------------------------------------------------------------------------- | |
| def build_table(input_text: str, output_text: str) -> str: | |
| """Build a numbered 3-column HTML table.""" | |
| if not input_text and not output_text: | |
| return "" | |
| in_lines = input_text.split("\n") | |
| out_lines = output_text.split("\n") | |
| n = max(len(in_lines), len(out_lines)) | |
| rows = [] | |
| for i in range(n): | |
| il = in_lines[i] if i < len(in_lines) else "" | |
| ol = out_lines[i] if i < len(out_lines) else "" | |
| if not il.strip() or not ol.strip(): | |
| continue | |
| rows.append( | |
| f"<tr>" | |
| f"<td class='ln'>{i + 1}</td>" | |
| f"<td class='src' contenteditable='true'>{html_mod.escape(il)}</td>" | |
| f"<td class='dst' contenteditable='true'>{html_mod.escape(ol)}</td>" | |
| f"</tr>" | |
| ) | |
| if not rows: | |
| return '<div class="empty-state">Nothing to show. Enter some text and press Normalize.</div>' | |
| return ( | |
| "<div class='table-wrap'><table>" | |
| "<thead><tr><th>#</th><th>Input</th><th>Normalized</th></tr></thead>" | |
| f"<tbody>{''.join(rows)}</tbody></table></div>" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # GPU / Device detection | |
| # --------------------------------------------------------------------------- | |
| def _detect_device() -> str: | |
| """Detect and return a short GPU/device badge label.""" | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| try: | |
| name = torch.cuda.get_device_name(0) | |
| mem = round(torch.cuda.get_device_properties(0).total_memory / (1024 ** 3), 1) | |
| if torch.version.hip: | |
| return f"AMD ROCm · {name} · {mem} GB" | |
| return f"CUDA · {name} · {mem} GB" | |
| except Exception: | |
| # ZeroGPU: CUDA reports available but device queries can fail | |
| # outside an active @spaces.GPU context. Report ZeroGPU status. | |
| return "ZeroGPU" | |
| if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): | |
| try: | |
| import subprocess as _sp | |
| chip = _sp.check_output(["sysctl", "-n", "machdep.cpu.brand_string"], text=True).strip() | |
| if chip: | |
| return f"MPS · {chip}" | |
| except Exception: | |
| pass | |
| return "MPS · Apple Silicon" | |
| except Exception: | |
| pass | |
| return "CPU" | |
| # --------------------------------------------------------------------------- | |
| # CSS — dark scholarly aesthetic | |
| # --------------------------------------------------------------------------- | |
| _CSS = """ | |
| :root { | |
| --bg: #f5f3f0; | |
| --surface: #ffffff; | |
| --surface2: #f0edf7; | |
| --border: #e2dde8; | |
| --text: #140531; | |
| --text2: #3d2a5c; | |
| --text3: #9ca8c0; | |
| --accent: #008cd0; | |
| --accent2: #00b0ba; | |
| --gold: #e0b71c; | |
| --mauve: #b885ad; | |
| --red: #d63031; | |
| --mono: "IBM Plex Mono", "Cascadia Code", "JetBrains Mono", monospace; | |
| --sans: -apple-system, "Segoe UI", Inter, sans-serif; | |
| } | |
| body { | |
| background: var(--bg) !important; | |
| overflow-x: hidden !important; | |
| } | |
| .gradio-container, .main { | |
| max-width: 100% !important; | |
| padding: 0 !important; | |
| overflow-x: hidden !important; | |
| box-sizing: border-box !important; | |
| background: var(--bg) !important; | |
| } | |
| .main > .wrap { | |
| max-width: 100% !important; | |
| overflow-x: hidden !important; | |
| box-sizing: border-box !important; | |
| background: var(--bg) !important; | |
| padding-left: 1rem !important; | |
| padding-right: 1rem !important; | |
| } | |
| .gradio-container *, | |
| .gradio-container .gr-row, | |
| .gradio-container .gr-column, | |
| .gradio-container .gr-box { | |
| box-sizing: border-box !important; | |
| max-width: 100% !important; | |
| } | |
| /* Force light background on all Gradio container layers */ | |
| .gradio-container, | |
| .gradio-container .gr-row, | |
| .gradio-container .gr-column, | |
| .gradio-container .gr-box, | |
| .gradio-container .tabs, | |
| .gradio-container .tabitem, | |
| .main, | |
| .main > .wrap { | |
| background: var(--bg) !important; | |
| } | |
| footer { display: none !important; } | |
| /* ---- Header ---- */ | |
| .app-header { | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| padding: 2rem 2rem 1.5rem !important; | |
| background: var(--bg); | |
| border-bottom: none; | |
| text-align: center !important; | |
| width: 100% !important; | |
| } | |
| /* Collapse space between heading and subheading in brand */ | |
| .brand { | |
| gap: 0 !important; | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: center !important; | |
| text-align: center !important; | |
| width: 100% !important; | |
| } | |
| .brand > div, | |
| .brand > .gr-row > div { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| gap: 0 !important; | |
| text-align: center !important; | |
| align-items: center !important; | |
| } | |
| /* Remove margin/padding from Gradio markdown wrappers inside brand */ | |
| .brand .prose, | |
| .brand .markdown-prose, | |
| .brand .markdown, | |
| .brand > div > .prose, | |
| .brand > div > .markdown-prose, | |
| .brand > div > .markdown { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| gap: 0 !important; | |
| text-align: center !important; | |
| } | |
| .brand h1, | |
| .brand .prose h1, | |
| .brand .markdown-prose h1, | |
| .brand .markdown h1, | |
| .brand [data-testid="md-heading"] { | |
| font-size: 2.8rem !important; | |
| font-weight: 800 !important; | |
| color: #140531 !important; | |
| margin: 0 !important; | |
| text-align: center !important; | |
| letter-spacing: -0.03em !important; | |
| display: block !important; | |
| } | |
| /* Ensure no child element overrides the color */ | |
| .brand h1 *, | |
| .brand .prose h1 *, | |
| .brand .markdown-prose h1 *, | |
| .brand .markdown h1 * { | |
| color: #140531 !important; | |
| } | |
| .brand p, | |
| .brand p * { | |
| font-size: 1.15rem; | |
| color: #6b7a94 !important; | |
| margin: 0 !important; | |
| } | |
| /* ---- Input area ---- */ | |
| .input-wrap { | |
| margin: 0.5rem 0 0; | |
| background: var(--surface); | |
| border: 1px solid var(--border) !important; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| max-width: 100% !important; | |
| gap: 0 !important; | |
| position: relative !important; | |
| } | |
| .input-wrap > .gr-column { | |
| gap: 0 !important; | |
| } | |
| /* ---- Loading overlay ---- */ | |
| #loadingOverlay { | |
| display: none; | |
| position: fixed; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| z-index: 99999; | |
| background: rgba(245, 243, 240, 0.92); | |
| } | |
| #loadingOverlay.active { | |
| display: block !important; | |
| } | |
| #loadingOverlay .spinner { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 52px; | |
| height: 52px; | |
| border: 5px solid #e2dde8; | |
| border-top-color: #008cd0; | |
| border-radius: 50%; | |
| animation: spin 0.7s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: translate(-50%, -50%) rotate(360deg); } | |
| } | |
| /* ---- Gradio loading indicator color ---- */ | |
| .icon-loading path, | |
| .icon-spin-loading path, | |
| svg.icon-loading path, | |
| svg.icon-spin-loading path, | |
| [data-testid="icon-loading"] path { | |
| fill: #e0b71c !important; | |
| } | |
| .input-actions { | |
| display: flex; | |
| gap: 0.75rem; | |
| font-size: 0.82rem; | |
| } | |
| .input-actions button, .input-actions a { | |
| background: none !important; | |
| border: none !important; | |
| color: var(--text3) !important; | |
| cursor: pointer; | |
| padding: 0.2rem 0.4rem !important; | |
| font-size: 0.82rem !important; | |
| transition: color 0.15s; | |
| } | |
| .input-actions button:hover, .input-actions a:hover { | |
| color: var(--accent) !important; | |
| } | |
| #input-text { | |
| border: none !important; | |
| outline: none !important; | |
| box-shadow: none !important; | |
| margin-top: 0 !important; | |
| } | |
| #input-text textarea { | |
| border: none !important; | |
| border-radius: 0 !important; | |
| background: var(--surface) !important; | |
| color: var(--text) !important; | |
| font-family: var(--mono) !important; | |
| font-size: 0.88rem !important; | |
| line-height: 1.65 !important; | |
| padding: 0rem 1rem 1rem !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| } | |
| #input-text textarea:focus, | |
| #input-text textarea:focus-visible { | |
| border: none !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| } | |
| #input-text textarea:focus { | |
| box-shadow: none !important; | |
| } | |
| #input-text textarea::placeholder { | |
| color: var(--text3) !important; | |
| } | |
| /* Darken Gradio label above input textarea */ | |
| #input-text label { | |
| color: var(--text2) !important; | |
| font-weight: 600 !important; | |
| } | |
| /* Word/char/line counter above the input textarea */ | |
| .input-meta { | |
| font-size: 0.92rem !important; | |
| color: #00b0ba; | |
| font-family: var(--sans) !important; | |
| font-weight: 500; | |
| padding: 0 0.25rem 0.1rem !important; | |
| margin: 0 !important; | |
| text-align: right; | |
| transition: color 0.15s ease; | |
| } | |
| /* Zero the gap between the word counter and the input textbox. | |
| Gradio wraps each component in its own div with default margins; we | |
| must zero all of them, not just the column gap. */ | |
| .input-col { | |
| gap: 0 !important; | |
| } | |
| .input-col > * { | |
| margin: 0 !important; | |
| padding-top: 0 !important; | |
| padding-bottom: 0 !important; | |
| } | |
| .input-meta { | |
| line-height: 1.1 !important; | |
| } | |
| /* Output textbox */ | |
| #output-text { | |
| border: none !important; | |
| outline: none !important; | |
| box-shadow: none !important; | |
| } | |
| #output-text textarea { | |
| border: none !important; | |
| border-radius: 0 !important; | |
| background: var(--surface) !important; | |
| color: var(--text) !important; | |
| font-family: var(--mono) !important; | |
| font-size: 0.88rem !important; | |
| line-height: 1.65 !important; | |
| padding: 1rem !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| } | |
| #output-text textarea:focus, | |
| #output-text textarea:focus-visible { | |
| border: none !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| } | |
| #output-text textarea::placeholder { | |
| color: var(--text3) !important; | |
| } | |
| /* Darken Gradio label above output textarea */ | |
| #output-text label { | |
| color: var(--text2) !important; | |
| font-weight: 600 !important; | |
| } | |
| /* ---- Layout: sidebar + main ---- */ | |
| .layout-row { | |
| gap: 0.75rem !important; | |
| margin: 0.75rem 0 0 !important; | |
| align-items: flex-start !important; | |
| } | |
| /* ---- Footer ---- */ | |
| .app-footer { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.8rem; | |
| padding: 2rem 1rem 1rem; | |
| margin-top: 1rem; | |
| } | |
| .app-footer span { | |
| font-size: 0.85rem; | |
| color: #999; | |
| } | |
| .app-footer a { | |
| display: inline-block; | |
| text-decoration: none; | |
| } | |
| .footer-logo { | |
| height: 128px !important; | |
| width: auto !important; | |
| max-height: 128px !important; | |
| max-width: 400px !important; | |
| display: block !important; | |
| } | |
| /* ---- Sidebar ---- */ | |
| .sidebar-wrap { | |
| background: var(--surface) !important; | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 0.8rem 0.7rem !important; | |
| gap: 0 !important; | |
| } | |
| /* Collapse spacing between toggle components in sidebar */ | |
| .sidebar-wrap > div > div > div { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| gap: 0 !important; | |
| } | |
| .sidebar-title { | |
| font-size: 0.72rem !important; | |
| font-weight: 700 !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 0.08em !important; | |
| color: #140531 !important; | |
| margin: 0 0 0.3rem 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* ---- Device indicator (dot + label under subtitle) ---- */ | |
| .device-indicator { | |
| display: inline-flex !important; | |
| align-items: center !important; | |
| gap: 0.35rem !important; | |
| font-size: 0.72rem !important; | |
| font-weight: 500 !important; | |
| font-family: var(--sans) !important; | |
| color: var(--text3) !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| .device-dot { | |
| display: inline-block !important; | |
| width: 8px !important; | |
| height: 8px !important; | |
| border-radius: 50% !important; | |
| flex-shrink: 0 !important; | |
| } | |
| /* ---- Toggle Rows (real switches) ---- */ | |
| .toggle-row { | |
| display: flex !important; | |
| align-items: center !important; | |
| gap: 0.5rem !important; | |
| padding: 0.3rem 0.4rem !important; | |
| background: transparent !important; | |
| border: none !important; | |
| border-radius: 6px !important; | |
| cursor: pointer; | |
| user-select: none; | |
| margin-bottom: 0.3rem !important; | |
| } | |
| .toggle-spacer { | |
| height: 0.5rem !important; | |
| width: 100% !important; | |
| display: block !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| .toggle-row:hover { | |
| background: var(--surface2) !important; | |
| } | |
| .toggle-label { | |
| font-size: 0.82rem !important; | |
| color: #140531 !important; | |
| line-height: 1.2 !important; | |
| } | |
| .toggle-label i { | |
| color: #140531 !important; | |
| } | |
| /* Tooltip question mark */ | |
| .tooltip-q { | |
| display: inline-flex !important; | |
| align-items: center; | |
| justify-content: center; | |
| width: 14px !important; | |
| height: 14px !important; | |
| border-radius: 50%; | |
| background: #94a3b8; | |
| color: white; | |
| font-size: 10px; | |
| font-weight: 700; | |
| line-height: 1; | |
| cursor: help; | |
| position: relative; | |
| flex-shrink: 0; | |
| } | |
| .tooltip-q > .tooltip-content { | |
| display: none; | |
| position: absolute; | |
| bottom: calc(100% + 6px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #1e293b; | |
| color: #f8fafc; | |
| padding-top: 0.6rem !important; | |
| padding-bottom: 0.6rem !important; | |
| padding-left: 1.5rem !important; | |
| padding-right: 1.5rem !important; | |
| border-radius: 6px; | |
| font-size: 0.75rem; | |
| font-weight: 400; | |
| white-space: normal; | |
| min-width: 180px; | |
| width: auto; | |
| z-index: 200; | |
| pointer-events: none; | |
| line-height: 1.5; | |
| } | |
| .tooltip-q:hover > .tooltip-content { | |
| display: block; | |
| } | |
| /* Toggle switch track */ | |
| .toggle-switch { | |
| width: 36px !important; | |
| height: 20px !important; | |
| border-radius: 20px !important; | |
| background: #ccc !important; | |
| transition: background 0.2s ease !important; | |
| flex-shrink: 0 !important; | |
| position: relative !important; | |
| display: block !important; | |
| cursor: pointer; | |
| } | |
| .toggle-switch.active { | |
| background: var(--accent) !important; | |
| } | |
| /* Toggle thumb */ | |
| .toggle-thumb { | |
| width: 16px !important; | |
| height: 16px !important; | |
| background: #fff !important; | |
| border-radius: 50% !important; | |
| position: absolute !important; | |
| top: 2px !important; | |
| left: 2px !important; | |
| transition: transform 0.2s ease !important; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2) !important; | |
| } | |
| .toggle-switch.active .toggle-thumb { | |
| transform: translateX(16px) !important; | |
| } | |
| /* Ensure HTML container in sidebar is transparent */ | |
| .sidebar-wrap .prose, | |
| .sidebar-wrap .markdown, | |
| .sidebar-wrap div[role="document"] { | |
| background: transparent !important; | |
| } | |
| .sidebar-wrap .prose p, | |
| .sidebar-wrap .prose * { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* ---- Control bar (between header and content) ---- */ | |
| .control-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| margin: 0.5rem 0 0; | |
| padding: 0.4rem; | |
| flex-wrap: wrap; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| } | |
| .ctrl-btn { | |
| padding: 0.35rem 0.7rem !important; | |
| font-size: 0.8rem !important; | |
| font-weight: 600 !important; | |
| border-radius: 8px !important; | |
| cursor: pointer; | |
| white-space: nowrap !important; | |
| line-height: 1.4 !important; | |
| } | |
| .ctrl-btn.normalize { | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; | |
| color: white !important; | |
| border: none !important; | |
| } | |
| .ctrl-btn.normalize:hover { | |
| opacity: 0.9 !important; | |
| } | |
| .ctrl-btn.secondary { | |
| background: var(--surface2) !important; | |
| color: var(--text2) !important; | |
| border: 1px solid var(--border) !important; | |
| } | |
| .ctrl-btn.secondary:hover { | |
| background: var(--border) !important; | |
| } | |
| .ctrl-btn.dl-btn { | |
| background: #e0b71c !important; | |
| color: #140531 !important; | |
| border: none !important; | |
| font-weight: 700 !important; | |
| } | |
| .ctrl-btn.dl-btn:hover { | |
| opacity: 0.85 !important; | |
| } | |
| /* ---- Output ---- */ | |
| .output-wrap { | |
| margin: 0.75rem 0 1rem; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| max-width: 100% !important; | |
| } | |
| .output-toolbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0.5rem 1rem; | |
| background: var(--surface2); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .output-toolbar label { | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| color: var(--accent2); | |
| } | |
| .output-actions { | |
| display: flex; | |
| gap: 0.75rem; | |
| font-size: 0.82rem; | |
| } | |
| .output-actions button { | |
| background: none !important; | |
| border: none !important; | |
| color: var(--text3) !important; | |
| cursor: pointer; | |
| padding: 0.2rem 0.4rem !important; | |
| transition: color 0.15s; | |
| } | |
| .output-actions button:hover { | |
| color: var(--accent2) !important; | |
| } | |
| /* Comparison table */ | |
| .table-wrap { | |
| overflow-x: auto; | |
| padding: 0; | |
| } | |
| .table-wrap table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| .table-wrap thead th { | |
| position: sticky; | |
| top: 0; | |
| background: var(--surface2); | |
| padding: 0.6rem 1rem; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| color: #140531 !important; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .table-wrap tbody tr { | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .table-wrap tbody tr:last-child { | |
| border-bottom: none; | |
| } | |
| .table-wrap tbody tr:hover { | |
| background: var(--surface2); | |
| } | |
| .table-wrap tbody td { | |
| padding: 0.4rem 1rem; | |
| font-family: var(--mono); | |
| font-size: 0.84rem; | |
| line-height: 1.6; | |
| vertical-align: top; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .table-wrap .ln { | |
| text-align: center; | |
| color: #140531 !important; | |
| width: 45px; | |
| font-family: var(--sans); | |
| font-size: 0.75rem; | |
| user-select: none; | |
| white-space: nowrap !important; | |
| } | |
| .table-wrap .src { | |
| color: #140531 !important; | |
| width: 50%; | |
| user-select: text; | |
| -webkit-user-select: text; | |
| -moz-user-select: text; | |
| -ms-user-select: text; | |
| outline: none; | |
| } | |
| .table-wrap .src:focus { | |
| background: rgba(20, 5, 49, 0.04); | |
| box-shadow: inset 0 0 0 2px rgba(20, 5, 49, 0.15); | |
| } | |
| .table-wrap .dst { | |
| color: #140531 !important; | |
| font-weight: 500; | |
| width: 50%; | |
| user-select: text; | |
| -webkit-user-select: text; | |
| -moz-user-select: text; | |
| -ms-user-select: text; | |
| outline: none; | |
| } | |
| .table-wrap .dst:focus { | |
| background: rgba(20, 5, 49, 0.04); | |
| box-shadow: inset 0 0 0 2px rgba(20, 5, 49, 0.15); | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 3rem 2rem; | |
| color: var(--text3); | |
| font-size: 0.9rem; | |
| } | |
| /* Error output */ | |
| .output-wrap .error { | |
| color: var(--red); | |
| padding: 1rem; | |
| } | |
| /* ---- Diff highlights ---- */ | |
| .diff-highlight { | |
| background: #efe9a0 !important; | |
| color: #140531 !important; | |
| border-radius: 2px; | |
| padding: 0 1px; | |
| } | |
| /* Hide the revert button column (triggered programmatically) */ | |
| .revert-col { | |
| display: none !important; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .input-wrap, .control-bar, .output-wrap { | |
| margin-left: 0.75rem; | |
| margin-right: 0.75rem; | |
| } | |
| .app-header { padding: 1rem; } | |
| } | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # JS helpers | |
| # --------------------------------------------------------------------------- | |
| _HEAD_JS = r""" | |
| <script> | |
| // Word/char/line counter with ZeroGPU word-limit warning + auto-cap | |
| (function() { | |
| var MAX_WORDS = 4000; | |
| var capping = false; // guard against re-entrant input events | |
| function update() { | |
| var ta = document.querySelector('#input-text textarea'); | |
| if (!ta) return; | |
| var v = ta.value; | |
| lastVal = v; | |
| var words = v ? v.trim().split(/\s+/).filter(function(w){return w.length;}).length : 0; | |
| // Auto-cap: truncate input at MAX_WORDS, preserving line breaks. | |
| // Dispatch an input event so Gradio syncs the truncated value to the server. | |
| if (words > MAX_WORDS) { | |
| var keptLines = []; | |
| var running = 0; | |
| var allLines = v.split('\n'); | |
| for (var li = 0; li < allLines.length; li++) { | |
| var lw = allLines[li].split(/\s+/).filter(function(w){return w.length;}); | |
| if (running + lw.length > MAX_WORDS) { | |
| var remaining = MAX_WORDS - running; | |
| if (remaining > 0) keptLines.push(lw.slice(0, remaining).join(' ')); | |
| break; | |
| } | |
| keptLines.push(allLines[li]); | |
| running += lw.length; | |
| } | |
| capping = true; | |
| ta.value = keptLines.join('\n'); | |
| ta.dispatchEvent(new Event('input', {bubbles: true})); | |
| capping = false; | |
| v = ta.value; | |
| words = MAX_WORDS; | |
| } | |
| var chars = v.length; | |
| var lines = v ? v.split('\n').length : 0; | |
| var el = document.getElementById('metaCount'); | |
| if (!el) return; | |
| el.textContent = words.toLocaleString() + ' words · ' + lines + ' lines · ' + chars.toLocaleString() + ' chars (limit: ' + MAX_WORDS.toLocaleString() + ' words)'; | |
| // Color: green ≤3000, orange 3001–3999, red at 4000 | |
| if (words >= MAX_WORDS) { | |
| el.style.color = '#b885ad'; | |
| } else if (words > 3000) { | |
| el.style.color = '#e0b71c'; | |
| } else { | |
| el.style.color = '#00b0ba'; | |
| } | |
| el.style.fontWeight = (words > 3000) ? '600' : ''; | |
| } | |
| var tries = 0; | |
| var lastVal = null; | |
| function poll() { | |
| var ta = document.querySelector('#input-text textarea'); | |
| if (ta) { | |
| ta.addEventListener('input', function() { if (!capping) update(); }); | |
| update(); | |
| // Safety net: catch programmatic value changes (e.g. Clear button, | |
| // file upload) that don't fire native 'input' events. | |
| setInterval(function() { | |
| var t = document.querySelector('#input-text textarea'); | |
| if (!t) return; | |
| if (t.value !== lastVal) { | |
| lastVal = t.value; | |
| update(); | |
| } | |
| }, 300); | |
| return; | |
| } | |
| if (++tries < 30) setTimeout(poll, 200); | |
| } | |
| poll(); | |
| })(); | |
| // Copy output | |
| window._copyOut = function() { | |
| var rows = document.querySelectorAll('.table-wrap tbody tr'); | |
| if (!rows.length) return; | |
| var lines = []; | |
| rows.forEach(function(r) { | |
| var cells = r.querySelectorAll('td'); | |
| if (cells.length >= 3) lines.push(cells[2].textContent); | |
| }); | |
| var nl = String.fromCharCode(10); | |
| navigator.clipboard.writeText(lines.join(nl)).then(function() { | |
| var b = document.getElementById('copyBtn'); | |
| if (b) { b.textContent = '✓ copied'; setTimeout(function() { b.textContent = '📋 Copy Output'; }, 1200); } | |
| }); | |
| }; | |
| // Download output | |
| window._dlOut = function() { | |
| var rows = document.querySelectorAll('.table-wrap tbody tr'); | |
| if (!rows.length) return; | |
| var lines = []; | |
| rows.forEach(function(r) { | |
| var cells = r.querySelectorAll('td'); | |
| if (cells.length >= 3) lines.push(cells[2].textContent); | |
| }); | |
| var nl = String.fromCharCode(10); | |
| var blob = new Blob([lines.join(nl)], {type:'text/plain'}); | |
| var a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = 'normaere_output.txt'; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| }; | |
| // Watch output for empty content and revert to input | |
| (function() { | |
| var _reverting = false; | |
| setInterval(function() { | |
| if (_reverting) return; | |
| var outputWrap = document.querySelector('.output-wrap'); | |
| if (!outputWrap || outputWrap.offsetParent === null) return; | |
| var table = outputWrap.querySelector('.table-wrap'); | |
| if (!table) return; | |
| var rows = table.querySelectorAll('tbody tr'); | |
| if (rows.length > 0) { | |
| var allEmpty = true; | |
| for (var i = 0; i < rows.length; i++) { | |
| var src = rows[i].querySelector('td.src'); | |
| var dst = rows[i].querySelector('td.dst'); | |
| if (src && src.textContent.trim()) { allEmpty = false; break; } | |
| if (dst && dst.textContent.trim()) { allEmpty = false; break; } | |
| } | |
| if (!allEmpty) return; | |
| } | |
| // Output table is empty (no rows or all cells emptied) — trigger revert | |
| _reverting = true; | |
| var revertBtn = document.getElementById('revertBtn'); | |
| if (revertBtn) revertBtn.click(); | |
| setTimeout(function() { _reverting = false; }, 2000); | |
| }, 500); | |
| })(); | |
| // Upload button — opens a native file picker | |
| (function() { | |
| var tries = 0; | |
| function poll() { | |
| var btn = document.getElementById('uploadBtn'); | |
| var textarea = document.querySelector('#input-text textarea'); | |
| if (btn && textarea) { | |
| btn.addEventListener('click', function() { | |
| var inp = document.createElement('input'); | |
| inp.type = 'file'; | |
| inp.accept = '.txt'; | |
| inp.onchange = function() { | |
| var f = inp.files[0]; | |
| if (!f) return; | |
| var reader = new FileReader(); | |
| reader.onload = function() { | |
| textarea.value = reader.result; | |
| textarea.dispatchEvent(new Event('input', {bubbles:true})); | |
| // Show input card without clearing content | |
| var showInputBtn = document.getElementById('showInputBtn'); | |
| if (showInputBtn) showInputBtn.click(); | |
| }; | |
| reader.readAsText(f, 'UTF-8'); | |
| }; | |
| inp.click(); | |
| }); | |
| return; | |
| } | |
| if (++tries < 30) setTimeout(poll, 200); | |
| } | |
| poll(); | |
| })(); | |
| // Loading overlay — inject, show on Normalize click, hide when output appears | |
| (function() { | |
| var tries = 0; | |
| function init() { | |
| var inputWrap = document.querySelector('.input-wrap'); | |
| var normalizeBtn = document.querySelector('.normalize'); | |
| if (!inputWrap || !normalizeBtn) { | |
| if (++tries < 30) setTimeout(init, 200); | |
| return; | |
| } | |
| // Inject overlay into body | |
| var overlay = document.createElement('div'); | |
| overlay.id = 'loadingOverlay'; | |
| overlay.innerHTML = '<div class="spinner"></div>'; | |
| document.body.appendChild(overlay); | |
| // Show on normalize click | |
| normalizeBtn.addEventListener('click', function() { | |
| overlay.classList.add('active'); | |
| }); | |
| // Hide when output card appears | |
| setInterval(function() { | |
| var outputCard = document.querySelector('.output-wrap'); | |
| if (overlay.classList.contains('active')) { | |
| if (outputCard && outputCard.offsetParent !== null) { | |
| overlay.classList.remove('active'); | |
| } | |
| } | |
| }, 200); | |
| } | |
| init(); | |
| })(); | |
| // Toggle row click handlers | |
| (function() { | |
| var tries = 0; | |
| function poll() { | |
| var rows = document.querySelectorAll('.toggle-row'); | |
| if (rows.length) { | |
| rows.forEach(function(row) { | |
| var sw = row.querySelector('.toggle-switch'); | |
| if (!sw) return; | |
| row.addEventListener('click', function() { | |
| sw.classList.toggle('active'); | |
| if (window._applyPostProcessing) window._applyPostProcessing(); | |
| }); | |
| }); | |
| return; | |
| } | |
| if (++tries < 30) setTimeout(poll, 200); | |
| } | |
| poll(); | |
| })(); | |
| // Client-side post-processing | |
| var _PUNCT = '.,;:!?()[]«»‹›\u201c\u201d\u201e\u0022\u201a\u2018\u2019\u2014\u2013<>'; | |
| function _isPunct(ch) { return _PUNCT.indexOf(ch) !== -1; } | |
| // Strip all punctuation characters | |
| function _applyRemovePunct(line) { | |
| return line.split('.').join('') | |
| .split(',').join('') | |
| .split(';').join('') | |
| .split(':').join('') | |
| .split('!').join('') | |
| .split('?').join('') | |
| .split('"').join('') | |
| .split("'").join('') | |
| .split('(').join('') | |
| .split(')').join('') | |
| .split('[').join('') | |
| .split(']').join('') | |
| .split('{').join('') | |
| .split('}').join('') | |
| .split('-').join('') | |
| .split('_').join('') | |
| .split('\u2013').join('') | |
| .split('\u2014').join('') | |
| .split('\u00ab').join('') | |
| .split('\u00bb').join(''); | |
| } | |
| // Convert to lowercase | |
| function _applyLowercase(line) { | |
| return line.toLowerCase(); | |
| } | |
| // MHG: Attach en-proclitic to following word ("en guot" \u2192 "enguot") | |
| function _applyEnProclitic(line) { | |
| var tokens = line.split(/\s+/); | |
| var result = []; | |
| for (var i = 0; i < tokens.length; i++) { | |
| var tok = tokens[i]; | |
| if (tok.length === 2 && tok.toLowerCase() === 'en' && i + 1 < tokens.length) { | |
| result.push(tok + tokens[i + 1]); | |
| i++; // skip next token | |
| } else { | |
| result.push(tok); | |
| } | |
| } | |
| return result.join(' '); | |
| } | |
| // MHG: Lenition nt \u2192 nd before a vowel | |
| function _applyLenition(line) { | |
| var vowels = 'aeiouyAEIOUY\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00e2\u00ea\u00ee\u00f4\u00fb\u00c2\u00ca\u00ce\u00d4\u00db\u0101\u0115\u012b\u014d\u016b\u0100\u0114\u012a\u014c\u016a\u00e5\u00e6\u0153\u00f8\u00c5\u00c6\u0152\u00d8'; | |
| var out = ''; | |
| for (var i = 0; i < line.length - 1; i++) { | |
| var c = line[i], c2 = line[i + 1]; | |
| if ((c === 'n' && c2 === 't') || (c === 'N' && c2 === 'T')) { | |
| var next = (i + 2 < line.length) ? line[i + 2] : ''; | |
| if (vowels.indexOf(next) !== -1) { | |
| out += c + (c2 === 't' ? 'd' : 'D'); | |
| i++; | |
| continue; | |
| } | |
| } | |
| out += c; | |
| } | |
| out += line.slice(i); | |
| return out; | |
| } | |
| // MHG: Lenition lt \u2192 ld before a vowel | |
| function _applyLenitionL(line) { | |
| var vowels = 'aeiouyAEIOUY\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00e2\u00ea\u00ee\u00f4\u00fb\u00c2\u00ca\u00ce\u00d4\u00db\u0101\u0115\u012b\u014d\u016b\u0100\u0114\u012a\u014c\u016a\u00e5\u00e6\u0153\u00f8\u00c5\u00c6\u0152\u00d8'; | |
| var out = ''; | |
| for (var i = 0; i < line.length - 1; i++) { | |
| var c = line[i], c2 = line[i + 1]; | |
| if ((c === 'l' && c2 === 't') || (c === 'L' && c2 === 'T')) { | |
| var next = (i + 2 < line.length) ? line[i + 2] : ''; | |
| if (vowels.indexOf(next) !== -1) { | |
| out += c + (c2 === 't' ? 'd' : 'D'); | |
| i++; | |
| continue; | |
| } | |
| } | |
| out += c; | |
| } | |
| out += line.slice(i); | |
| return out; | |
| } | |
| // MHG: niet / niut \u2192 niht (preserve case + trailing punctuation) | |
| function _applyNiet(line) { | |
| var out = ''; | |
| var i = 0; | |
| while (i < line.length) { | |
| var substr = line.slice(i).toLowerCase(); | |
| var word = '', j; | |
| if (substr.indexOf('niet') === 0) { word = 'niet'; j = 4; } | |
| else if (substr.indexOf('niut') === 0) { word = 'niut'; j = 4; } | |
| else { out += line[i]; i++; continue; } | |
| var start = i, end = i + j; | |
| // allow trailing punctuation | |
| while (end < line.length && _isPunct(line[end])) end++; | |
| var afterOk = (end >= line.length || line[end] === ' ' || line[end] === '\n' || line[end] === '\t'); | |
| var beforeOk = (start === 0 || line[start - 1] === ' ' || line[start - 1] === '\n' || line[start - 1] === '\t'); | |
| if (beforeOk && afterOk) { | |
| var trailing = line.slice(i + j, end); | |
| var orig = line.slice(i, i + j); | |
| var base = 'niht'; | |
| if (orig[0] === orig[0].toUpperCase()) { | |
| base = base[0].toUpperCase() + base.slice(1); | |
| } | |
| var allUpper = (orig === orig.toUpperCase()); | |
| if (allUpper) base = base.toUpperCase(); | |
| out += base + trailing; | |
| i = end; | |
| } else { | |
| out += line[i]; i++; | |
| } | |
| } | |
| return out; | |
| } | |
| // MHG: Common apocopes vile\u2192vil, vore\u2192vor, wile\u2192wil, wole\u2192wol (preserve case + trailing punctuation) | |
| function _applyApocopes(line) { | |
| var REPL = { vile: 'vil', vore: 'vor', wile: 'wil', wole: 'wol' }; | |
| var out = ''; | |
| var i = 0; | |
| while (i < line.length) { | |
| var substr = line.slice(i).toLowerCase(); | |
| var word = '', j; | |
| if (substr.indexOf('vile') === 0) { word = 'vile'; j = 4; } | |
| else if (substr.indexOf('vore') === 0) { word = 'vore'; j = 4; } | |
| else if (substr.indexOf('wile') === 0) { word = 'wile'; j = 4; } | |
| else if (substr.indexOf('wole') === 0) { word = 'wole'; j = 4; } | |
| else { out += line[i]; i++; continue; } | |
| var start = i, end = i + j; | |
| while (end < line.length && _isPunct(line[end])) end++; | |
| var afterOk = (end >= line.length || line[end] === ' ' || line[end] === '\n' || line[end] === '\t'); | |
| var beforeOk = (start === 0 || line[start - 1] === ' ' || line[start - 1] === '\n' || line[start - 1] === '\t'); | |
| if (beforeOk && afterOk) { | |
| var trailing = line.slice(i + j, end); | |
| var orig = line.slice(i, i + j); | |
| var base = REPL[word]; | |
| if (orig[0] === orig[0].toUpperCase()) { | |
| base = base[0].toUpperCase() + base.slice(1); | |
| } | |
| out += base + trailing; | |
| i = end; | |
| } else { | |
| out += line[i]; i++; | |
| } | |
| } | |
| return out; | |
| } | |
| // Character-level diff highlighting using simple LCS | |
| function _highlightDiffs(original, target) { | |
| var origLen = original.length, tgtLen = target.length; | |
| var cap = 5000; | |
| var oLen = Math.min(origLen, cap), tLen = Math.min(tgtLen, cap); | |
| // Build LCS table | |
| var dp = []; | |
| for (var r = 0; r <= oLen; r++) { | |
| dp[r] = [0]; | |
| } | |
| for (var c = 1; c <= tLen; c++) { | |
| dp[0][c] = 0; | |
| } | |
| for (var r = 1; r <= oLen; r++) { | |
| for (var c = 1; c <= tLen; c++) { | |
| if (original[r - 1] === target[c - 1]) | |
| dp[r][c] = dp[r - 1][c - 1] + 1; | |
| else | |
| dp[r][c] = Math.max(dp[r - 1][c], dp[r][c - 1]); | |
| } | |
| } | |
| // Back-track to find matching positions in target | |
| var matchSet = {}; | |
| var r = oLen, c = tLen; | |
| while (r > 0 && c > 0) { | |
| if (original[r - 1] === target[c - 1]) { | |
| matchSet[c - 1] = true; | |
| r--; c--; | |
| } else if (dp[r - 1][c] >= dp[r][c - 1]) { | |
| r--; | |
| } else { | |
| c--; | |
| } | |
| } | |
| // Build highlighted HTML | |
| var result = ''; | |
| for (var k = 0; k < tgtLen; k++) { | |
| if (matchSet.hasOwnProperty(k)) { | |
| result += target[k]; | |
| } else { | |
| result += '<mark class="diff-highlight">' + target[k] + '</mark>'; | |
| } | |
| } | |
| return result; | |
| } | |
| // Track user edits per row index so toggles can still revert. | |
| if (typeof window._mhgUserEdits === 'undefined') window._mhgUserEdits = {}; | |
| function _recordUserEdit(idx) { | |
| var dstCells = document.querySelectorAll('.table-wrap tbody tr td.dst'); | |
| if (dstCells[idx]) window._mhgUserEdits[idx] = dstCells[idx].textContent || ''; | |
| } | |
| function _clearUserEdits() { | |
| window._mhgUserEdits = {}; | |
| } | |
| // Delegate input events to capture user edits in dst cells. | |
| (function() { | |
| document.addEventListener('input', function(e) { | |
| var cell = e.target.closest('td.dst'); | |
| if (!cell) return; | |
| var tr = cell.closest('tr'); | |
| if (!tr) return; | |
| var allRows = Array.from(tr.parentElement.querySelectorAll('tr')); | |
| var idx = allRows.indexOf(tr); | |
| if (idx >= 0) _recordUserEdit(idx); | |
| }); | |
| })(); | |
| window._applyPostProcessing = function() { | |
| // Re-read raw baseline | |
| var el = document.getElementById('rawOutData'); | |
| if (!el) return; | |
| var b64 = el.getAttribute('data-lines'); | |
| if (!b64) return; | |
| var decoded = atob(b64); | |
| var lines = JSON.parse(decoded); | |
| if (!lines.length) return; | |
| // Overlay user edits: if user edited a row, start from that instead of baseline | |
| var edits = window._mhgUserEdits; | |
| if (edits) { | |
| for (var idx in edits) { | |
| if (edits.hasOwnProperty(idx)) { | |
| var i = parseInt(idx); | |
| if (i >= 0 && i < lines.length) lines[i] = edits[idx]; | |
| } | |
| } | |
| } | |
| lines = lines.slice(0); // copy | |
| var getToggle = function(name) { | |
| var row = document.querySelector('.toggle-row[data-toggle="' + name + '"]'); | |
| if (!row) return false; | |
| return row.querySelector('.toggle-switch').classList.contains('active'); | |
| }; | |
| var removePunct = getToggle('punct'); | |
| var convertLower = getToggle('cap'); | |
| var doEn = getToggle('en'); | |
| var doLenition = getToggle('lenition'); | |
| var doLenitionL = getToggle('lenitionl'); | |
| var doNiet = getToggle('niet'); | |
| var doApocopes = getToggle('apocopes'); | |
| var doHighlight = getToggle('highlight'); | |
| for (var i = 0; i < lines.length; i++) { | |
| if (removePunct) lines[i] = _applyRemovePunct(lines[i]); | |
| if (convertLower) lines[i] = _applyLowercase(lines[i]); | |
| if (doApocopes) lines[i] = _applyApocopes(lines[i]); | |
| if (doEn) lines[i] = _applyEnProclitic(lines[i]); | |
| if (doLenition) lines[i] = _applyLenition(lines[i]); | |
| if (doLenitionL) lines[i] = _applyLenitionL(lines[i]); | |
| if (doNiet) lines[i] = _applyNiet(lines[i]); | |
| } | |
| // Update table dst column | |
| var dstCells = document.querySelectorAll('.table-wrap tbody tr td.dst'); | |
| var srcCells = document.querySelectorAll('.table-wrap tbody tr td.src'); | |
| for (var j = 0; j < dstCells.length && j < lines.length; j++) { | |
| if (doHighlight) { | |
| var srcText = srcCells[j] ? srcCells[j].textContent : ''; | |
| dstCells[j].innerHTML = _highlightDiffs(srcText, lines[j]); | |
| } else { | |
| dstCells[j].textContent = lines[j]; | |
| } | |
| } | |
| }; | |
| // Watch for output updates and re-apply post-processing | |
| (function() { | |
| var prev = ''; | |
| setInterval(function() { | |
| var el = document.getElementById('rawOutData'); | |
| if (!el) return; | |
| var cur = el.getAttribute('data-lines'); | |
| if (cur && cur !== prev) { | |
| prev = cur; | |
| _clearUserEdits(); | |
| window._applyPostProcessing(); | |
| } | |
| }, 300); | |
| })(); | |
| // Force sidebar gap between toggle rows | |
| (function() { | |
| function setSidebarGap() { | |
| var el = document.querySelector('.sidebar-wrap'); | |
| if (el) { | |
| el.style.gap = '0.2rem'; | |
| } | |
| } | |
| setSidebarGap(); | |
| setInterval(setSidebarGap, 500); | |
| })(); | |
| // Sync edited input cells back to textarea before normalize | |
| (function() { | |
| var tries = 0; | |
| function poll() { | |
| var normalizeBtn = document.querySelector('.normalize'); | |
| var ta = document.querySelector('#input-text textarea'); | |
| if (normalizeBtn && ta) { | |
| normalizeBtn.addEventListener('click', function() { | |
| var rows = document.querySelectorAll('.table-wrap tbody tr'); | |
| if (rows.length) { | |
| var lines = []; | |
| rows.forEach(function(r) { | |
| var src = r.querySelector('td.src'); | |
| if (src) lines.push(src.textContent); | |
| }); | |
| if (lines.length) { | |
| ta.value = lines.join('\n'); | |
| ta.dispatchEvent(new Event('input', {bubbles:true})); | |
| } | |
| } | |
| }, true); // capture phase = fires before Gradio handler | |
| return; | |
| } | |
| if (++tries < 30) setTimeout(poll, 200); | |
| } | |
| poll(); | |
| })(); | |
| // Auto-insert new row on Enter/Shift+Enter in editable table cells | |
| (function() { | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key !== 'Enter' && e.key !== 'Shift+Enter') return; | |
| if (!e.target || !e.target.classList.contains('src') && !e.target.classList.contains('dst')) return; | |
| if (!e.target.getAttribute('contenteditable')) return; | |
| e.preventDefault(); | |
| var cell = e.target; | |
| var row = cell.closest('tr'); | |
| if (!row) return; | |
| var tbody = row.parentNode; | |
| if (!tbody) return; | |
| var rows = Array.prototype.slice.call(tbody.children); | |
| var idx = rows.indexOf(row); | |
| var cells = row.querySelectorAll('td'); | |
| var ln = parseInt(cells[0].textContent) + 1 || (idx + 2); | |
| // Get selection range to split text at cursor | |
| var sel = window.getSelection(); | |
| var range = sel.getRangeAt(0); | |
| var textBefore = ''; | |
| var textAfter = ''; | |
| if (range.startContainer === cell || cell.contains(range.startContainer)) { | |
| var splitRange = document.createRange(); | |
| splitRange.selectNodeContents(cell); | |
| splitRange.setEnd(range.startContainer, range.startOffset); | |
| textBefore = splitRange.toString(); | |
| textAfter = cell.textContent.substring(textBefore.length); | |
| } else { | |
| textBefore = cell.textContent; | |
| textAfter = ''; | |
| } | |
| // Create new row with empty cells | |
| var newRow = document.createElement('tr'); | |
| newRow.innerHTML = '<td class="ln">' + ln + '</td><td class="src" contenteditable="true"></td><td class="dst" contenteditable="true"></td>'; | |
| tbody.insertBefore(newRow, row.nextSibling); | |
| // Update current cell to text before cursor | |
| cell.textContent = textBefore; | |
| // Set new cell to text after cursor | |
| var sameClass = cell.classList.contains('src') ? 'src' : 'dst'; | |
| var newCell = newRow.querySelector('td.' + sameClass); | |
| if (newCell) newCell.textContent = textAfter; | |
| // Focus the new cell | |
| if (newCell) newCell.focus(); | |
| }); | |
| })(); | |
| </script> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Build UI | |
| # --------------------------------------------------------------------------- | |
| def build_app(model_path: str, config_path: str): | |
| with gr.Blocks(title="normaere") as demo: | |
| # ---- Header ---- | |
| with gr.Row(elem_classes=["app-header"]): | |
| with gr.Column(elem_classes=["brand"]): | |
| gr.Markdown("# **normære (beta)**") | |
| gr.Markdown("<i>ich wil die krümbẹ an allen orten slihten</i> — Jüngerer Titurel 20,3") | |
| device_label = _detect_device() | |
| has_gpu = not device_label == "CPU" | |
| dot_color = "#00b0ba" if has_gpu else "#999" | |
| gr.HTML(f'<span class="device-indicator"><span class="device-dot" style="background:{dot_color}"></span>{device_label}</span>') | |
| # ---- Control bar ---- | |
| with gr.Row(elem_classes=["control-bar"]): | |
| btn_normalize = gr.Button("⚙ Normalize", elem_classes=["ctrl-btn", "normalize"], variant="primary", size="sm") | |
| btn_clear_all = gr.Button("✕ Clear Input", elem_classes=["ctrl-btn", "secondary"], size="sm") | |
| btn_upload = gr.Button("📄 Upload .txt", elem_classes=["ctrl-btn", "secondary"], size="sm", elem_id="uploadBtn") | |
| btn_copy = gr.Button("📋 Copy Output", elem_classes=["ctrl-btn", "secondary"], size="sm", elem_id="copyBtn") | |
| btn_dl = gr.Button("⬇ Download .txt", elem_classes=["ctrl-btn", "dl-btn"], size="sm") | |
| # ---- Layout: Sidebar + Main ---- | |
| with gr.Row(elem_classes=["layout-row"]): | |
| # Sidebar (post-processing options) | |
| with gr.Column(scale=1, elem_classes=["sidebar-wrap"]): | |
| gr.HTML(''' | |
| <span class="sidebar-title">Post-processing</span> | |
| <div class="toggle-row" data-toggle="punct"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Remove Punctuation</span></div> | |
| <div style="height:4px"></div> | |
| <div class="toggle-row" data-toggle="cap"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Convert to Lowercase</span></div> | |
| <div style="height:4px"></div> | |
| <div class="toggle-row" data-toggle="en"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Attach <i>en</i>-Proclitic</span></div> | |
| <div style="height:4px"></div> | |
| <div class="toggle-row" data-toggle="lenition"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label"><i>n</i>-Lenition</span><span class="tooltip-q">?<span class="tooltip-content"> <i>süntære</i> → <i>sündære</i></span></span></div> | |
| <div style="height:4px"></div> | |
| <div class="toggle-row" data-toggle="lenitionl"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label"><i>l</i>-Lenition</span><span class="tooltip-q">?<span class="tooltip-content"> <i>solte</i> → <i>solde</i></span></span></div> | |
| <div style="height:4px"></div> | |
| <div class="toggle-row" data-toggle="niet"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label"><i>niet</i>, <i>niut</i> → <i>niht</i></span></div> | |
| <div style="height:4px"></div> | |
| <div class="toggle-row" data-toggle="apocopes"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Common Apocopes</span><span class="tooltip-q">?<span class="tooltip-content"> <i>vile</i> → <i>vil</i><br> <i>vore</i> → <i>vor</i><br> <i>wile</i> → <i>wil</i><br> <i>wole</i> → <i>wol</i></span></span></div> | |
| <div style="height:1rem"></div> | |
| <span class="sidebar-title">Visualization</span> | |
| <div class="toggle-row" data-toggle="highlight"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Highlight Changes</span></div> | |
| ''') | |
| # Main content (input card → replaced by output card after normalize) | |
| with gr.Column(scale=4): | |
| with gr.Column(elem_classes=["input-wrap"]) as input_card: | |
| with gr.Row(): | |
| with gr.Column(elem_classes=["input-col"]): | |
| gr.HTML('<div id="metaCount" class="input-meta">0 words · 0 lines · 0 chars (limit: 4,000 words)</div>') | |
| input_text = gr.Textbox( | |
| lines=12, | |
| max_lines=30, | |
| show_label=False, | |
| container=False, | |
| elem_id="input-text", | |
| placeholder="Paste or type Middle High German text here…", | |
| ) | |
| with gr.Column(elem_classes=["output-wrap"], visible=False) as output_card: | |
| comparison_output = gr.HTML(value="") | |
| # ---- State ---- | |
| mp_state = gr.State(model_path) | |
| cp_state = gr.State(config_path or "") | |
| # ---- Normalize (no post-processing — handled client-side) ---- | |
| def _do(text, *args): | |
| mp, cp = args[-2], args[-1] | |
| # Only normalize — post-processing happens client-side. | |
| # run_normalize is decorated with @spaces.GPU, so inference runs | |
| # on an allocated ZeroGPU (CUDA) device. A server-side word limit | |
| # (run_normalize) prevents oversized inputs from exhausting quota. | |
| raw_in, raw_out = run_normalize(text, True, True, False, False, False, False, False, mp, cp) | |
| if not raw_in and not raw_out: | |
| return "", gr.update(visible=False), gr.update(visible=True) | |
| if raw_out.startswith("⚠"): | |
| warning_html = ( | |
| f'<div style="color:#b885ad;font-size:0.95rem;font-weight:600;' | |
| f'padding:2rem 1rem;line-height:1.6;">{html_mod.escape(raw_out)}</div>' | |
| ) | |
| return warning_html, gr.update(visible=True), gr.update(visible=False) | |
| out_lines = raw_out.split("\n") | |
| b64 = base64.b64encode(json.dumps(out_lines).encode()).decode() | |
| data_div = f'<div id="rawOutData" style="display:none" data-lines="{b64}"></div>' | |
| table_html = build_table(raw_in, raw_out) + data_div | |
| return table_html, gr.update(visible=True), gr.update(visible=False) | |
| btn_normalize.click( | |
| fn=_do, | |
| inputs=[input_text, mp_state, cp_state], | |
| outputs=[comparison_output, output_card, input_card], | |
| ) | |
| # ---- Clear ---- | |
| def _clear(): | |
| return "", "", gr.update(visible=True), gr.update(visible=False) | |
| btn_clear_all.click(fn=_clear, outputs=[comparison_output, input_text, input_card, output_card]) | |
| # ---- Revert to input (triggered when user clears output) ---- | |
| with gr.Column(elem_classes=["revert-col"]): | |
| btn_revert = gr.Button(elem_id="revertBtn") | |
| def _revert(): | |
| return "", "", gr.update(visible=False), gr.update(visible=True) | |
| btn_revert.click( | |
| fn=_revert, | |
| outputs=[comparison_output, input_text, output_card, input_card], | |
| ) | |
| # ---- Show input without clearing (for file upload) ---- | |
| with gr.Column(elem_classes=["revert-col"]): | |
| btn_show_input = gr.Button(elem_id="showInputBtn") | |
| def _show_input_only(): | |
| return gr.update(visible=False), gr.update(visible=True) | |
| btn_show_input.click( | |
| fn=_show_input_only, | |
| outputs=[output_card, input_card], | |
| ) | |
| # ---- Copy / Download (JS) ---- | |
| btn_copy.click(js="window._copyOut()") | |
| btn_dl.click(js="window._dlOut()") | |
| # ---- Footer ---- | |
| logo_path = Path(__file__).parent / "static" / "logo.png" | |
| logo_src = "" | |
| if logo_path.exists(): | |
| import base64 as _b64 | |
| logo_data = logo_path.read_bytes() | |
| logo_src = f"data:image/png;base64,{_b64.b64encode(logo_data).decode()}" | |
| gr.HTML( | |
| f'<div class="app-footer" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.8rem;padding:2rem 1rem 1rem;margin-top:1rem;text-align:center;">' | |
| f'<span style="font-size:0.85rem;color:#999;">Brought to you by</span>' | |
| f'<a href="https://uni-freiburg.de/inqdialog/" target="_blank" style="display:inline-block;text-decoration:none;">' | |
| f'<img src="{logo_src}" alt="inqdialog" class="footer-logo" style="height:96px;width:auto;max-height:96px;max-width:300px;display:block;" />' | |
| f'</a></div>' | |
| ) | |
| return demo | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| def _ensure_model_downloaded(model_path: str) -> str: | |
| """If model_path is a Hub repo ID, download it to a local cache. | |
| On HF Spaces the model is not bundled, so we must download it at runtime. | |
| For local paths, returns the path unchanged. | |
| """ | |
| p = Path(model_path) | |
| if p.exists(): | |
| return str(p) | |
| # Treat as Hub repo ID and download | |
| from huggingface_hub import snapshot_download | |
| print(f"Model not found locally — downloading '{model_path}' from Hub …") | |
| cached = snapshot_download(repo_id=model_path, cache_dir=None) | |
| print(f" Downloaded to: {cached}") | |
| return cached | |
| def main(): | |
| # Respect HF Spaces $PORT env var (defaults to 7860) | |
| default_port = int(os.environ.get("PORT", 7860)) | |
| default_host = os.environ.get("HOST", "0.0.0.0") | |
| parser = argparse.ArgumentParser(description="normaere") | |
| parser.add_argument("--model_path", default=None) | |
| parser.add_argument("--config_path", default="config.yaml") | |
| parser.add_argument("--port", type=int, default=default_port) | |
| parser.add_argument("--host", default=default_host) | |
| parser.add_argument("--share", action="store_true") | |
| args = parser.parse_args() | |
| import yaml | |
| config_path = Path(args.config_path) if args.config_path else None | |
| model_path = args.model_path | |
| if model_path is None and config_path and config_path.exists(): | |
| try: | |
| with open(config_path, "r") as f: | |
| cfg = yaml.safe_load(f) | |
| model_path = cfg.get("inference", {}).get("model_path") | |
| except Exception: | |
| pass | |
| if model_path is None: | |
| model_path = "JonasHermann/normaere-model" | |
| model_path = _ensure_model_downloaded(model_path) | |
| cp = str(config_path) if config_path else None | |
| # Preload the model at startup (loads weights to CPU RAM as BF16). | |
| # The slow disk I/O + deserialization happens here, OUTSIDE the | |
| # ZeroGPU duration budget. The CPU→GPU transfer is deferred to | |
| # run_normalize (inside @spaces.GPU) via _ensure_on_device(). | |
| print("Preloading model to CPU …") | |
| _get_normalizer(model_path, cp) | |
| print("Model preloaded.\n") | |
| print(f"Starting normaere on http://{args.host}:{args.port}") | |
| print(f"Model: {model_path}\n") | |
| demo = build_app(model_path, cp) | |
| demo.launch( | |
| server_name=args.host, | |
| server_port=args.port, | |
| share=args.share, | |
| css=_CSS, | |
| head=_HEAD_JS, | |
| ) | |
| if __name__ == "__main__": | |
| main() |