Spaces:
Running
Running
Download ocr-studio/index.html from phucsd/cham-ocr-studio: direct link, hf CLI and curl.
- Browser
- Download file 214 kB
-
https://huggingface.co/spaces/phucsd/cham-ocr-studio/resolve/main/ocr-studio/index.html
- Command line
-
hf download hf://spaces/phucsd/cham-ocr-studio/ocr-studio/index.html
-
curl -L -o index.html https://huggingface.co/spaces/phucsd/cham-ocr-studio/resolve/main/ocr-studio/index.html
214 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Cham OCR Studio — Paleographic Transcription & Diagnostic Workbench</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:wght@100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Noto+Sans+Cham:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| /* Claude Warm Light Color System */ | |
| --bg-base: #F7F1EA; /* Canvas background */ | |
| --bg-app: #FAF7F2; /* Main app body background */ | |
| --bg-surface: #FBF5EE; /* Sidebar & toolbar background */ | |
| --bg-panel: #FFFCF7; /* Continuous editor & modal background */ | |
| --border-color: #E8DED2; /* Subtle sand borders */ | |
| --border-medium: #D8C9BA; /* Focus & input borders */ | |
| --border-strong: #BFAE9D; /* Darker border */ | |
| --text-main: #2B2118; /* Primary dark brown text */ | |
| --text-muted: #6F6257; /* Secondary lighter brown text */ | |
| --text-quiet: #9A8D80; /* Muted/quiet text */ | |
| --accent-primary: #C96442; /* Main terracotta/coral accent */ | |
| --accent-hover: #B55336; /* Darker terracotta for hover */ | |
| --accent-glow: rgba(201, 100, 66, 0.12); | |
| --active-bg: #FFF0E8; /* Warm highlighted background */ | |
| --accent-soft-2: #FBE1D4; /* Hover warm selection */ | |
| /* OCR confidence */ | |
| --accent-green: #2F7D5C; /* Green for high conf */ | |
| --accent-green-bg: #EAF6EF; | |
| --accent-orange: #B7791F; /* Yellow/orange for med conf */ | |
| --accent-orange-bg: #FFF4D8; | |
| --accent-red: #C4513B; /* Red for low conf */ | |
| --accent-red-bg: #FDE8E2; | |
| /* Shadows & Corners */ | |
| --shadow-sm: 0 1px 2px rgba(43, 33, 24, 0.05); | |
| --shadow-md: 0 4px 12px rgba(43, 33, 24, 0.08); | |
| --shadow-lg: 0 8px 24px rgba(43, 33, 24, 0.12); | |
| --radius-sm: 6px; | |
| --radius-md: 10px; | |
| --radius-lg: 14px; | |
| /* Typography */ | |
| --font-sans: "Google Sans Flex", "Plus Jakarta Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| --font-cham: 'Noto Sans Cham', 'Google Sans Flex', sans-serif; | |
| /* Bottom Dock Height */ | |
| --dock-height: 0px; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: var(--font-sans); | |
| background-color: var(--bg-app); | |
| color: var(--text-main); | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* ------------------------------------------------------------- | |
| Header - Command Center | |
| ------------------------------------------------------------- */ | |
| header { | |
| background-color: var(--bg-panel); | |
| border-bottom: 1px solid var(--border-color); | |
| padding: 0 16px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| height: 52px; | |
| z-index: 100; | |
| box-shadow: var(--shadow-sm); | |
| flex-shrink: 0; | |
| gap: 16px; | |
| } | |
| .header-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| min-width: 0; | |
| } | |
| .logo { | |
| font-size: 17px; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| white-space: nowrap; | |
| letter-spacing: -0.2px; | |
| text-decoration: none; | |
| } | |
| .logo span { | |
| font-weight: 400; | |
| color: var(--text-muted); | |
| font-size: 15px; | |
| } | |
| .doc-status-pill { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 3px 10px; | |
| background-color: var(--bg-surface); | |
| border: 1px solid var(--border-color); | |
| border-radius: 20px; | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 240px; | |
| } | |
| .doc-status-dot { | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| background-color: var(--border-strong); | |
| flex-shrink: 0; | |
| } | |
| .doc-status-dot.active { | |
| background-color: var(--accent-green); | |
| } | |
| .header-center { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-shrink: 0; | |
| } | |
| .model-picker-wrap { | |
| display: flex; | |
| align-items: center; | |
| background-color: var(--bg-surface); | |
| border: 1px solid var(--border-medium); | |
| border-radius: var(--radius-sm); | |
| padding: 2px 6px; | |
| transition: border-color 0.2s; | |
| } | |
| .model-picker-wrap:focus-within { | |
| border-color: var(--accent-primary); | |
| } | |
| .model-picker-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| margin-right: 6px; | |
| white-space: nowrap; | |
| } | |
| .model-select-compact { | |
| border: none; | |
| background: transparent; | |
| font-family: inherit; | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--text-main); | |
| outline: none; | |
| cursor: pointer; | |
| padding: 3px 0; | |
| } | |
| .header-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-shrink: 0; | |
| } | |
| /* Buttons */ | |
| .btn { | |
| background-color: var(--bg-panel); | |
| color: var(--text-main); | |
| border: 1px solid var(--border-medium); | |
| padding: 6px 12px; | |
| font-size: 13px; | |
| border-radius: var(--radius-sm); | |
| cursor: pointer; | |
| transition: all 0.15s ease; | |
| font-weight: 500; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-family: inherit; | |
| box-shadow: var(--shadow-sm); | |
| white-space: nowrap; | |
| user-select: none; | |
| text-decoration: none; | |
| } | |
| .btn:hover { | |
| background-color: var(--active-bg); | |
| border-color: var(--border-strong); | |
| color: var(--text-main); | |
| } | |
| .btn:active { | |
| transform: translateY(1px); | |
| } | |
| .btn-sm { | |
| padding: 3px 8px; | |
| font-size: 11.5px; | |
| border-radius: 5px; | |
| } | |
| .btn-primary { | |
| background-color: var(--accent-primary); | |
| color: #FFFFFF; | |
| border-color: var(--accent-primary); | |
| box-shadow: 0 2px 6px rgba(201, 100, 66, 0.25); | |
| } | |
| .btn-primary:hover { | |
| background-color: var(--accent-hover); | |
| border-color: var(--accent-hover); | |
| color: #FFFFFF; | |
| } | |
| .btn-primary:disabled { | |
| background-color: var(--border-medium); | |
| border-color: var(--border-medium); | |
| color: var(--text-quiet); | |
| cursor: not-allowed; | |
| box-shadow: none; | |
| transform: none; | |
| } | |
| .badge { | |
| background-color: var(--active-bg); | |
| border: 1px solid var(--border-medium); | |
| color: var(--accent-primary); | |
| padding: 2px 7px; | |
| font-size: 11px; | |
| border-radius: 4px; | |
| font-weight: 600; | |
| } | |
| /* Dropdown */ | |
| .dropdown { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| .dropdown-menu { | |
| display: none; | |
| position: absolute; | |
| right: 0; | |
| top: calc(100% + 4px); | |
| background-color: var(--bg-panel); | |
| min-width: 220px; | |
| box-shadow: var(--shadow-md); | |
| border: 1px solid var(--border-medium); | |
| border-radius: var(--radius-sm); | |
| z-index: 1000; | |
| padding: 4px 0; | |
| } | |
| .dropdown-menu.show { | |
| display: block; | |
| } | |
| .dropdown-item { | |
| padding: 8px 14px; | |
| font-size: 12.5px; | |
| color: var(--text-main); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| cursor: pointer; | |
| transition: background-color 0.15s ease; | |
| } | |
| .dropdown-item:hover { | |
| background-color: var(--active-bg); | |
| color: var(--accent-primary); | |
| } | |
| .dropdown-divider { | |
| height: 1px; | |
| background-color: var(--border-color); | |
| margin: 4px 0; | |
| } | |
| /* ------------------------------------------------------------- | |
| Workspace (3-Pane Split View) | |
| ------------------------------------------------------------- */ | |
| .workspace { | |
| display: flex; | |
| flex: 1; | |
| min-height: 0; | |
| overflow: hidden; | |
| position: relative; | |
| margin-bottom: var(--dock-height); | |
| } | |
| /* 1. Sidebar (Document Queue) */ | |
| .sidebar { | |
| width: 260px; | |
| background-color: var(--bg-surface); | |
| border-right: 1px solid var(--border-color); | |
| display: flex; | |
| flex-direction: column; | |
| flex-shrink: 0; | |
| transition: width 0.25s cubic-bezier(0.16, 1, 0.3, 1), margin-left 0.25s cubic-bezier(0.16, 1, 0.3, 1); | |
| position: relative; | |
| z-index: 20; | |
| } | |
| .sidebar.collapsed { | |
| margin-left: -260px; | |
| } | |
| .sidebar-header { | |
| padding: 10px 14px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid var(--border-color); | |
| background-color: var(--bg-panel); | |
| font-size: 13px; | |
| font-weight: 600; | |
| } | |
| .sidebar-title { | |
| color: var(--text-main); | |
| } | |
| .sidebar-upload-bar { | |
| padding: 8px 12px; | |
| border-bottom: 1px solid var(--border-color); | |
| display: flex; | |
| gap: 6px; | |
| } | |
| .queue-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 8px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .queue-item { | |
| background-color: var(--bg-panel); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-sm); | |
| padding: 8px 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| cursor: pointer; | |
| transition: all 0.15s ease; | |
| position: relative; | |
| } | |
| .queue-item:hover { | |
| border-color: var(--border-strong); | |
| background-color: var(--active-bg); | |
| } | |
| .queue-item.active { | |
| border-color: var(--accent-primary); | |
| background-color: var(--active-bg); | |
| box-shadow: 0 0 0 1px var(--accent-primary); | |
| } | |
| .queue-thumb { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 4px; | |
| background-color: var(--bg-base); | |
| border: 1px solid var(--border-color); | |
| overflow: hidden; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .queue-thumb img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .queue-info { | |
| flex: 1; | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| } | |
| .queue-name { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--text-main); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .queue-meta { | |
| font-size: 10.5px; | |
| color: var(--text-muted); | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .status-badge { | |
| font-size: 9.5px; | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| } | |
| .status-verified { | |
| background-color: var(--accent-green-bg); | |
| color: var(--accent-green); | |
| } | |
| .status-needs_review { | |
| background-color: var(--accent-orange-bg); | |
| color: var(--accent-orange); | |
| } | |
| .status-processing { | |
| background-color: var(--accent-glow); | |
| color: var(--accent-primary); | |
| } | |
| .status-not_processed { | |
| background-color: var(--bg-surface); | |
| color: var(--text-quiet); | |
| border: 1px solid var(--border-color); | |
| } | |
| /* 2. Center Pane (Document Viewer) */ | |
| .viewer-pane { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| background-color: var(--bg-base); | |
| position: relative; | |
| min-width: 0; | |
| overflow: hidden; | |
| } | |
| .viewer-toolbar { | |
| position: absolute; | |
| top: 12px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 50; | |
| background-color: var(--bg-panel); | |
| border: 1px solid var(--border-medium); | |
| border-radius: var(--radius-md); | |
| padding: 4px 8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| box-shadow: var(--shadow-md); | |
| backdrop-filter: blur(8px); | |
| } | |
| .toolbar-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 3px; | |
| } | |
| .toolbar-divider { | |
| width: 1px; | |
| height: 18px; | |
| background-color: var(--border-color); | |
| margin: 0 4px; | |
| } | |
| .toolbar-btn { | |
| background: transparent; | |
| border: none; | |
| color: var(--text-main); | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 4px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.12s ease; | |
| } | |
| .toolbar-btn:hover { | |
| background-color: var(--active-bg); | |
| color: var(--accent-primary); | |
| } | |
| .toolbar-btn.active { | |
| background-color: var(--active-bg); | |
| color: var(--accent-primary); | |
| box-shadow: inset 0 0 0 1px var(--accent-primary); | |
| } | |
| .zoom-badge { | |
| font-size: 11.5px; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| min-width: 44px; | |
| text-align: center; | |
| user-select: none; | |
| } | |
| .viewer-container { | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| cursor: grab; | |
| user-select: none; | |
| outline: none; | |
| } | |
| .viewer-container:active { | |
| cursor: grabbing; | |
| } | |
| .viewer-container.draw-mode-active { | |
| cursor: crosshair ; | |
| } | |
| .viewer-content { | |
| position: absolute; | |
| transform-origin: 0 0; | |
| left: 0; | |
| top: 0; | |
| pointer-events: auto; | |
| } | |
| .document-image { | |
| display: block; | |
| max-width: none; | |
| box-shadow: var(--shadow-lg); | |
| border-radius: 2px; | |
| } | |
| .bbox-overlay { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| } | |
| .bbox-rect { | |
| position: absolute; | |
| border: 1.5px solid var(--accent-primary); | |
| background-color: rgba(201, 100, 66, 0.08); | |
| cursor: pointer; | |
| pointer-events: auto; | |
| transition: background-color 0.15s ease, border-color 0.15s ease; | |
| box-sizing: border-box; | |
| border-radius: 2px; | |
| } | |
| .bbox-rect:hover { | |
| background-color: rgba(201, 100, 66, 0.22); | |
| border-width: 2px; | |
| } | |
| .bbox-rect.active { | |
| border: 2px solid var(--accent-primary); | |
| background-color: rgba(201, 100, 66, 0.28); | |
| box-shadow: 0 0 0 2px var(--accent-glow); | |
| } | |
| .bbox-rect.needs-review { | |
| border-color: var(--accent-orange); | |
| background-color: rgba(183, 121, 31, 0.12); | |
| } | |
| .char-bbox-rect { | |
| position: absolute; | |
| border: 1.5px solid var(--accent-green); | |
| background-color: rgba(47, 125, 92, 0.25); | |
| pointer-events: none; | |
| border-radius: 2px; | |
| box-shadow: 0 0 6px rgba(47, 125, 92, 0.4); | |
| z-index: 10; | |
| } | |
| .drawing-bbox-rect { | |
| position: absolute; | |
| border: 2px dashed var(--accent-primary); | |
| background-color: rgba(201, 100, 66, 0.18); | |
| pointer-events: none; | |
| z-index: 20; | |
| box-sizing: border-box; | |
| } | |
| .ocr-loading-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(247, 241, 234, 0.65); | |
| backdrop-filter: blur(2px); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 80; | |
| gap: 12px; | |
| } | |
| .ocr-spinner { | |
| width: 32px; | |
| height: 32px; | |
| border: 3px solid var(--border-color); | |
| border-top-color: var(--accent-primary); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| .ocr-loading-text { | |
| font-size: 13.5px; | |
| font-weight: 600; | |
| color: var(--text-main); | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* 3. Right Pane (Continuous Text Editor) */ | |
| .editor-pane { | |
| width: 480px; | |
| background-color: var(--bg-panel); | |
| border-left: 1px solid var(--border-color); | |
| display: flex; | |
| flex-direction: column; | |
| flex-shrink: 0; | |
| transition: width 0.25s cubic-bezier(0.16, 1, 0.3, 1), margin-right 0.25s cubic-bezier(0.16, 1, 0.3, 1); | |
| position: relative; | |
| z-index: 20; | |
| } | |
| .editor-pane.collapsed { | |
| margin-right: -480px; | |
| } | |
| .editor-header { | |
| padding: 10px 14px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid var(--border-color); | |
| background-color: var(--bg-surface); | |
| flex-shrink: 0; | |
| } | |
| .editor-header-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .editor-title { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--text-main); | |
| } | |
| .editor-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 12px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .editor-line-row { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 8px; | |
| padding: 6px 8px; | |
| border-radius: var(--radius-sm); | |
| background-color: var(--bg-panel); | |
| border: 1px solid var(--border-color); | |
| transition: all 0.12s ease; | |
| } | |
| .editor-line-row:hover { | |
| border-color: var(--border-strong); | |
| background-color: var(--active-bg); | |
| } | |
| .editor-line-row.active { | |
| border-color: var(--accent-primary); | |
| background-color: var(--active-bg); | |
| box-shadow: 0 0 0 1px var(--accent-primary); | |
| } | |
| .line-gutter { | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(--text-quiet); | |
| min-width: 22px; | |
| text-align: right; | |
| padding-top: 4px; | |
| user-select: none; | |
| } | |
| .line-status { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| margin-top: 8px; | |
| flex-shrink: 0; | |
| } | |
| .line-status.high { | |
| background-color: var(--accent-green); | |
| } | |
| .line-status.medium { | |
| background-color: var(--accent-orange); | |
| } | |
| .line-status.low { | |
| background-color: var(--accent-red); | |
| } | |
| .line-text-container { | |
| flex: 1; | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .line-text-view { | |
| padding: 2px 4px; | |
| font-size: 16px; | |
| line-height: 1.55; | |
| min-height: 26px; | |
| cursor: text; | |
| word-break: break-all; | |
| font-family: inherit; | |
| } | |
| .line-text-edit { | |
| display: none; | |
| width: 100%; | |
| background-color: #FFFFFF; | |
| border: 1.5px solid var(--accent-primary); | |
| color: var(--text-main); | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 16px; | |
| line-height: 1.55; | |
| outline: none; | |
| word-break: break-all; | |
| font-family: inherit; | |
| box-shadow: 0 0 0 2px var(--accent-glow); | |
| } | |
| .char-span { | |
| cursor: pointer; | |
| padding: 0 1px; | |
| border-radius: 2px; | |
| transition: background-color 0.15s ease; | |
| } | |
| .char-span:hover { | |
| background-color: var(--accent-soft-2); | |
| } | |
| .char-span.active { | |
| background-color: var(--accent-green); | |
| color: #FFFFFF; | |
| outline: 2px solid var(--accent-green); | |
| } | |
| .line-span.low-confidence { | |
| border-bottom: 2px wavy var(--accent-red); | |
| } | |
| .line-crop-preview { | |
| margin-bottom: 4px; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| background: #FFFFFF; | |
| border: 1px solid var(--border-color); | |
| display: inline-flex; | |
| align-items: center; | |
| max-height: 36px; | |
| padding: 2px 4px; | |
| cursor: pointer; | |
| } | |
| .line-crop-preview:hover { | |
| border-color: var(--accent-primary); | |
| } | |
| .line-crop-preview img { | |
| max-height: 32px; | |
| width: auto; | |
| display: block; | |
| } | |
| /* ------------------------------------------------------------- | |
| Line Action Strip (Slim Sticky Bar) | |
| ------------------------------------------------------------- */ | |
| .line-action-strip { | |
| height: 42px; | |
| background-color: var(--bg-surface); | |
| border-top: 1px solid var(--border-color); | |
| padding: 0 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-shrink: 0; | |
| gap: 10px; | |
| } | |
| .action-strip-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 12px; | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .action-strip-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| flex-shrink: 0; | |
| } | |
| /* ------------------------------------------------------------- | |
| Cham Paleographic Virtual Keyboard (Floating & Rotate-in) | |
| ------------------------------------------------------------- */ | |
| .cham-kb-fab-container { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 24px; | |
| z-index: 1000; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .cham-kb-fab { | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #C96442 0%, #B55336 100%); | |
| color: #FFFFFF; | |
| border: 2px solid rgba(255, 255, 255, 0.9); | |
| box-shadow: 0 4px 16px rgba(201, 100, 66, 0.42), 0 2px 6px rgba(43, 33, 24, 0.16); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 22px; | |
| transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); | |
| outline: none; | |
| user-select: none; | |
| } | |
| .cham-kb-fab:hover { | |
| transform: scale(1.08) translateY(-2px); | |
| box-shadow: 0 8px 24px rgba(201, 100, 66, 0.52); | |
| } | |
| .cham-kb-fab.active { | |
| transform: rotate(90deg) scale(1.02); | |
| background: linear-gradient(135deg, #2B2118 0%, #4A3B2C 100%); | |
| box-shadow: 0 4px 16px rgba(43, 33, 24, 0.35); | |
| } | |
| .cham-kb-panel { | |
| position: fixed; | |
| bottom: 76px; | |
| right: 24px; | |
| z-index: 1000; | |
| width: var(--cham-kb-width, 715px); | |
| max-width: calc(100vw - 32px); | |
| max-height: calc(100vh - 96px); | |
| background-color: var(--bg-panel); | |
| border: 1px solid var(--border-medium); | |
| border-radius: var(--radius-md); | |
| box-shadow: 0 16px 40px rgba(43, 33, 24, 0.18), 0 4px 12px rgba(43, 33, 24, 0.08); | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| transform-origin: bottom right; | |
| transition: transform 0.32s cubic-bezier(0.34, 1.35, 0.64, 1), opacity 0.26s ease, visibility 0.32s, width 0.2s ease; | |
| } | |
| .cham-kb-panel.closed { | |
| transform: scale(0.2) rotate(18deg) translate(80px, 80px); | |
| opacity: 0; | |
| pointer-events: none; | |
| visibility: hidden; | |
| } | |
| .cham-kb-panel.open { | |
| transform: scale(1) rotate(0deg) translate(0, 0); | |
| opacity: 1; | |
| pointer-events: auto; | |
| visibility: visible; | |
| } | |
| .cham-kb-header { | |
| height: 38px; | |
| padding: 0 12px; | |
| background-color: var(--bg-surface); | |
| border-bottom: 1px solid var(--border-color); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| cursor: move; | |
| user-select: none; | |
| gap: 8px; | |
| flex-shrink: 0; | |
| } | |
| .cham-kb-title-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| min-width: 0; | |
| } | |
| .cham-kb-drag-handle { | |
| color: var(--text-quiet); | |
| cursor: grab; | |
| font-size: 13px; | |
| letter-spacing: -2px; | |
| margin-right: 2px; | |
| } | |
| .cham-kb-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-shrink: 0; | |
| } | |
| .cham-kb-control-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| } | |
| .cham-kb-control-item select { | |
| background-color: #FFFFFF; | |
| border: 1px solid var(--border-medium); | |
| border-radius: 4px; | |
| padding: 2px 4px; | |
| font-size: 11px; | |
| font-family: inherit; | |
| color: var(--text-main); | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| .cham-kb-btn-icon { | |
| background: none; | |
| border: none; | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| line-height: 1; | |
| transition: all 0.15s ease; | |
| } | |
| .cham-kb-btn-icon:hover { | |
| background-color: var(--active-bg); | |
| color: var(--accent-primary); | |
| } | |
| .cham-kb-toolbar { | |
| padding: 6px 10px; | |
| background-color: var(--bg-app); | |
| border-bottom: 1px solid var(--border-color); | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| flex-shrink: 0; | |
| } | |
| .cham-kb-scratchpad-wrap { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| width: 100%; | |
| } | |
| .cham-kb-scratchpad { | |
| flex: 1; | |
| height: 30px; | |
| padding: 3px 8px; | |
| font-family: var(--font-cham); | |
| font-size: 16px; | |
| color: var(--text-main); | |
| background-color: #FFFFFF; | |
| border: 1px solid var(--border-medium); | |
| border-radius: 6px; | |
| outline: none; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| } | |
| .cham-kb-scratchpad:focus { | |
| border-color: var(--accent-primary); | |
| box-shadow: 0 0 0 2px var(--accent-glow); | |
| } | |
| .cham-kb-body { | |
| padding: 6px 8px 8px; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| background-color: var(--bg-panel); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| max-height: calc(100vh - 210px); | |
| } | |
| .cham-kb-row { | |
| display: flex; | |
| gap: 3.5px; | |
| justify-content: space-between; | |
| width: 100%; | |
| } | |
| .cham-key-btn { | |
| flex: 1; | |
| min-width: 0; | |
| height: 37px; | |
| background: linear-gradient(180deg, #FFFFFF 0%, #F9FAFB 100%); | |
| border: 1px solid #D1D5DB; | |
| border-radius: 6px; | |
| box-shadow: 0 1.5px 3px rgba(43, 33, 24, 0.06), 0 1px 0 #CBD5E1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 1px 0px; | |
| cursor: pointer; | |
| user-select: none; | |
| transition: all 0.12s ease; | |
| position: relative; | |
| } | |
| .cham-key-btn:hover { | |
| background: var(--active-bg); | |
| border-color: var(--accent-primary); | |
| transform: translateY(-1px); | |
| box-shadow: 0 3px 6px rgba(201, 100, 66, 0.2), 0 1px 0 var(--accent-primary); | |
| } | |
| .cham-key-btn:active { | |
| transform: translateY(1px); | |
| box-shadow: 0 1px 2px rgba(43, 33, 24, 0.1); | |
| background: #FCE8DE; | |
| } | |
| .cham-char { | |
| font-family: var(--font-cham); | |
| font-size: var(--kb-font-size, 18px); | |
| font-weight: 600; | |
| color: #103B7B; | |
| line-height: 1.1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| text-rendering: optimizeLegibility; | |
| } | |
| .dotted-circle { | |
| opacity: 0.38; | |
| font-size: 0.85em; | |
| color: #64748B; | |
| margin: 0 0.5px; | |
| } | |
| .cham-latin { | |
| font-family: var(--font-sans); | |
| font-size: 8.5px; | |
| color: var(--text-muted); | |
| font-weight: 500; | |
| line-height: 1; | |
| margin-top: 0px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 100%; | |
| } | |
| .cham-key-btn.key-space { | |
| flex: 2.3; | |
| background: #FFFFFF; | |
| } | |
| .cham-key-btn.key-space .cham-char { | |
| font-size: 14px; | |
| letter-spacing: 0.5px; | |
| } | |
| .cham-key-btn.key-back { | |
| flex: 1.15; | |
| background: #F8FAFC; | |
| color: var(--accent-red); | |
| } | |
| .cham-key-btn.key-enter { | |
| flex: 1.15; | |
| background: #F8FAFC; | |
| color: var(--accent-green); | |
| } | |
| /* ------------------------------------------------------------- | |
| Modals (Academic Research Methodology & System Logs) | |
| ------------------------------------------------------------- */ | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| z-index: 2000; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: auto; | |
| background-color: rgba(43, 33, 24, 0.48); | |
| backdrop-filter: blur(5px); | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .modal-content { | |
| background-color: var(--bg-panel); | |
| color: var(--text-main); | |
| margin: auto; | |
| padding: 24px; | |
| border: 1px solid var(--border-medium); | |
| width: 90%; | |
| max-width: 620px; | |
| border-radius: var(--radius-md); | |
| box-shadow: var(--shadow-lg); | |
| position: relative; | |
| } | |
| .modal-content.modal-academic { | |
| max-width: 940px; | |
| width: 95%; | |
| max-height: 90vh; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 24px 28px; | |
| } | |
| .modal-academic-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| border-bottom: 1px solid var(--border-color); | |
| padding-bottom: 12px; | |
| margin-bottom: 12px; | |
| flex-shrink: 0; | |
| } | |
| .modal-academic-header h2 { | |
| color: var(--accent-primary); | |
| font-size: 18px; | |
| font-weight: 700; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin: 0; | |
| } | |
| .modal-academic-header .sub-desc { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| margin-top: 4px; | |
| font-weight: 400; | |
| } | |
| .modal-tabs { | |
| display: flex; | |
| gap: 5px; | |
| border-bottom: 1px solid var(--border-color); | |
| margin-bottom: 14px; | |
| padding-bottom: 8px; | |
| overflow-x: auto; | |
| flex-shrink: 0; | |
| } | |
| .modal-tab-btn { | |
| background: transparent; | |
| border: 1px solid transparent; | |
| padding: 6px 12px; | |
| border-radius: var(--radius-sm); | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| font-family: inherit; | |
| transition: all 0.15s ease; | |
| white-space: nowrap; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .modal-tab-btn:hover { | |
| background: var(--active-bg); | |
| color: var(--text-main); | |
| border-color: var(--border-color); | |
| } | |
| .modal-tab-btn.active { | |
| background: var(--accent-primary); | |
| border-color: var(--accent-primary); | |
| color: #FFFFFF; | |
| font-weight: 600; | |
| } | |
| .modal-tab-body { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding-right: 10px; | |
| font-size: 13.5px; | |
| line-height: 1.68; | |
| color: var(--text-main); | |
| } | |
| .modal-tab-pane { | |
| display: none; | |
| } | |
| .modal-tab-pane.active { | |
| display: block; | |
| } | |
| .academic-h3 { | |
| color: var(--accent-primary); | |
| font-size: 14.5px; | |
| font-weight: 700; | |
| margin-top: 16px; | |
| margin-bottom: 8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .academic-card { | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border-color); | |
| border-radius: var(--radius-sm); | |
| padding: 12px 16px; | |
| margin: 10px 0 14px 0; | |
| } | |
| .academic-badge { | |
| display: inline-block; | |
| padding: 2px 7px; | |
| background: var(--active-bg); | |
| color: var(--accent-primary); | |
| border: 1px solid var(--accent-soft-2); | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| } | |
| .academic-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 14px 0; | |
| font-size: 12px; | |
| } | |
| .academic-table th, .academic-table td { | |
| padding: 8px 12px; | |
| border: 1px solid var(--border-color); | |
| text-align: left; | |
| } | |
| .academic-table th { | |
| background: var(--bg-surface); | |
| font-weight: 600; | |
| color: var(--text-main); | |
| } | |
| .academic-code-box { | |
| background: var(--bg-surface); | |
| border: 1px solid var(--border-color); | |
| border-radius: 6px; | |
| padding: 12px 14px; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; | |
| font-size: 12px; | |
| overflow-x: auto; | |
| margin: 10px 0; | |
| white-space: pre-wrap; | |
| color: var(--text-main); | |
| line-height: 1.5; | |
| } | |
| .close-btn { | |
| position: absolute; | |
| right: 18px; | |
| top: 14px; | |
| color: var(--text-muted); | |
| font-size: 26px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| line-height: 1; | |
| } | |
| .close-btn:hover { | |
| color: var(--accent-primary); | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: var(--bg-base); | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--border-color); | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--border-strong); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 1. Header (Command Center) --> | |
| <header> | |
| <div class="header-left"> | |
| <a href="#" class="logo" onclick="event.preventDefault()" title="Cham OCR Studio — Paleographic Transcription & Diagnostic Workbench"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--accent-primary);"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> | |
| Cham OCR <span>Studio</span> | |
| </a> | |
| <div class="doc-status-pill" id="doc-status-pill" title="Current active manuscript document"> | |
| <div class="doc-status-dot" id="doc-status-dot"></div> | |
| <span id="header-doc-title">No Active Manuscript</span> | |
| </div> | |
| </div> | |
| <div class="header-center"> | |
| <div class="model-picker-wrap" title="Select neural OCR recognition architecture"> | |
| <span class="model-picker-label">Model:</span> | |
| <select id="model-select" class="model-select-compact" onchange="updateModelBadge()"> | |
| <optgroup label="Paleographic Cham Models"> | |
| <!-- <option value="v25">Cham V25 (Recommended SOTA — 90.96% Acc)</option> --> | |
| <option value="v24" selected>Cham V24 (Recommended — Logical Order)</option> | |
| <option value="v23">Cham V23 (Visual Order Baseline)</option> | |
| </optgroup> | |
| <optgroup label="Bilingual & Modern Text"> | |
| <option value="auto">Auto Dual-Engine (Cham & Vietnamese)</option> | |
| <option value="vi">Vietnamese (Fine-Tuned PP-OCRv5)</option> | |
| </optgroup> | |
| </select> | |
| </div> | |
| <button class="btn btn-primary" id="btn-run" onclick="runOCR()" disabled title="Execute OCR transcription on active manuscript"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polygon points="5 3 19 12 5 21 5 3"/></svg> | |
| <span>Execute Transcription</span> | |
| </button> | |
| </div> | |
| <div class="header-right"> | |
| <!-- Export Dropdown --> | |
| <div class="dropdown"> | |
| <button class="btn" id="btn-export" onclick="toggleExportMenu(event)" title="Export transcription corpus"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| Export ▾ | |
| </button> | |
| <div class="dropdown-menu" id="export-menu"> | |
| <div class="dropdown-item" onclick="copyAllText()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> | |
| Copy All Plaintext (TXT) | |
| </div> | |
| <div class="dropdown-item" onclick="exportJSON()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> | |
| Export Structured JSON (.json) | |
| </div> | |
| <div class="dropdown-divider"></div> | |
| <div class="dropdown-item" onclick="exportTrainingPairs()" style="color: var(--accent-primary);"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> | |
| Export Training Manifest Pairs | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Public GitHub Link --> | |
| <a href="https://github.com/phucsd/Cham-OCR" target="_blank" rel="noopener noreferrer" class="btn" id="btn-github" title="View Source Code & Models on GitHub"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path> | |
| </svg> | |
| <span>GitHub</span> | |
| </a> | |
| <!-- Contact Link --> | |
| <a href="mailto:phucsd@gmail.com" class="btn" id="btn-contact" title="Contact Maintainer (phucsd@gmail.com)"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> | |
| <span>Contact</span> | |
| </a> | |
| <button class="btn" onclick="openLogModal()" id="btn-logs" title="System Runtime Logs"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h7"/></svg> | |
| <span>Logs</span> | |
| </button> | |
| <a href="/research" target="_blank" rel="noopener noreferrer" class="btn" id="btn-research" title="Read Full Academic Research Paper & Methodology"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg> | |
| <span>Research Paper</span> | |
| </a> | |
| <button class="btn" onclick="openAboutModal()" id="btn-docs" title="Executive Summary & Architecture Overview"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> | |
| <span>Overview & Docs</span> | |
| </button> | |
| </div> | |
| </header> | |
| <!-- 2. Main Workspace (3-Pane Split View) --> | |
| <div class="workspace" id="main-workspace"> | |
| <!-- Left Sidebar: Manuscript Queue --> | |
| <div class="sidebar" id="sidebar-pane"> | |
| <div class="sidebar-header"> | |
| <span class="sidebar-title">Manuscript Corpus Queue</span> | |
| <button class="toolbar-btn" onclick="clearQueue()" title="Clear manuscript queue"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> | |
| </button> | |
| </div> | |
| <div class="sidebar-upload-bar"> | |
| <button class="btn btn-sm" style="flex: 1; justify-content: center;" onclick="document.getElementById('file-input').click()"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| Upload Manuscript Image | |
| </button> | |
| <input type="file" id="file-input" style="display: none;" accept="image/*" onchange="handleFileSelect(event)"> | |
| </div> | |
| <div class="queue-container" id="queue-list"> | |
| <!-- Queue Items dynamically populated --> | |
| </div> | |
| </div> | |
| <!-- Center Pane: Document Viewer --> | |
| <div class="viewer-pane" id="viewer-pane"> | |
| <!-- Viewer HUD Toolbar --> | |
| <div class="viewer-toolbar"> | |
| <div class="toolbar-group"> | |
| <button class="toolbar-btn" id="btn-toggle-sidebar" onclick="toggleSidebar()" title="Toggle Manuscript Queue Sidebar"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg> | |
| </button> | |
| <div class="toolbar-divider"></div> | |
| <button class="toolbar-btn" onclick="zoomToFit()" title="Fit Viewport (Ctrl+0)"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg> | |
| </button> | |
| <button class="toolbar-btn" onclick="zoomToWidth()" title="Fit Page Width"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h16M4 4v16M20 4v16"/></svg> | |
| </button> | |
| <button class="toolbar-btn" onclick="zoomTo100()" title="Actual Size (1:1)"> | |
| <span style="font-size: 9.5px; font-weight: 700;">1:1</span> | |
| </button> | |
| <div class="toolbar-divider"></div> | |
| <button class="toolbar-btn" onclick="zoomOut()" title="Zoom Out (-)"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| </button> | |
| <span class="zoom-badge" id="zoom-value">100%</span> | |
| <button class="toolbar-btn" onclick="zoomIn()" title="Zoom In (+)"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| </button> | |
| </div> | |
| <div class="toolbar-group"> | |
| <button class="toolbar-btn" id="btn-draw-bbox" onclick="toggleDrawBboxMode()" title="Manual Bounding Box Annotation (Drag mouse across canvas)"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> | |
| </button> | |
| <button class="toolbar-btn active" id="btn-toggle-overlay" onclick="toggleOverlay()" title="Toggle Bounding Box Overlays"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg> | |
| </button> | |
| <div class="toolbar-divider"></div> | |
| <button class="toolbar-btn" id="btn-toggle-editor" onclick="toggleEditor()" title="Toggle Transcription Editor"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Viewer Container for Pan/Zoom --> | |
| <div class="viewer-container" id="viewer-container" tabindex="0" | |
| onmousedown="startDragging(event)" | |
| onmousemove="dragImage(event)" | |
| onmouseup="stopDragging()" | |
| onmouseleave="stopDragging()" | |
| onwheel="handleZoomWheel(event)"> | |
| <div class="viewer-content" id="viewer-content"> | |
| <img id="document-image" class="document-image" src="" alt=""> | |
| <div class="bbox-overlay" id="bbox-overlay"></div> | |
| <div id="drawing-bbox-rect" class="drawing-bbox-rect" style="display: none;"></div> | |
| </div> | |
| <!-- Loading Overlay --> | |
| <div id="ocr-loading-overlay" class="ocr-loading-overlay" style="display: none;"> | |
| <div class="ocr-spinner"></div> | |
| <div class="ocr-loading-text" id="ocr-loading-text">Transcribing manuscript text...</div> | |
| </div> | |
| <!-- Empty state --> | |
| <div id="no-image-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-muted); gap: 12px; pointer-events: none;"> | |
| <svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--border-strong);"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> | |
| <p style="font-size: 13.5px;">Drag & drop manuscript image or click <strong>Upload Manuscript Image</strong> to begin</p> | |
| <p style="font-size: 11.5px; color: var(--text-quiet);">Supports direct clipboard image pasting (Ctrl+V)</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Pane: Continuous Text Editor --> | |
| <div class="editor-pane" id="editor-pane"> | |
| <div class="editor-header"> | |
| <div class="editor-header-left"> | |
| <span class="editor-title">Transcription Editor</span> | |
| <span class="badge" id="line-count-badge">0 lines</span> | |
| </div> | |
| <div style="display: flex; align-items: center; gap: 4px;"> | |
| <button class="btn btn-sm" id="btn-toggle-crops" onclick="toggleLineCrops()" title="Toggle individual textline cutout image previews"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg> | |
| Line Crops | |
| </button> | |
| <button class="toolbar-btn" onclick="navLowConfidence('prev')" title="Navigate to previous low-confidence textline"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg> | |
| </button> | |
| <button class="toolbar-btn" onclick="navLowConfidence('next')" title="Navigate to next low-confidence textline"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg> | |
| </button> | |
| <button class="btn btn-sm" style="color: var(--accent-green); border-color: var(--accent-green); background-color: var(--accent-green-bg);" onclick="acceptAllHighConfidence()" title="Batch approve all lines with high recognition confidence (≥ 75%)"> | |
| Batch Approve ✓ | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Continuous Editor List (Full Height Workspace) --> | |
| <div class="editor-container" id="editor-container"> | |
| <!-- Lines inserted dynamically --> | |
| </div> | |
| <!-- Line Action Strip (Slim Sticky Bar) --> | |
| <div class="line-action-strip" id="line-action-strip"> | |
| <div class="action-strip-left" id="action-strip-info"> | |
| <span style="color: var(--text-muted); font-size: 11.5px; font-style: italic;">Select a textline to execute merge, split, or verification</span> | |
| </div> | |
| <div class="action-strip-right" id="action-strip-buttons" style="display: none;"> | |
| <button class="btn btn-sm" style="color: var(--accent-green); border-color: var(--accent-green);" onclick="acceptSelectedLine()" title="Approve line transcription (Key: A)">Approve (A)</button> | |
| <button class="btn btn-sm" onclick="mergeSelectedWithNextLine()" title="Merge with succeeding line below (Key: M)">Merge Below (M)</button> | |
| <button class="btn btn-sm" onclick="splitSelectedLine()" title="Split line into two halves (Key: S)">Split (S)</button> | |
| <button class="btn btn-sm" style="color: var(--accent-red); border-color: var(--accent-red);" onclick="markSelectedWrong()" title="Flag erroneous transcription for fine-tuning (Key: W)">Flag Error (W)</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 3. Floating Cham Paleographic Virtual Keyboard & FAB Trigger --> | |
| <div class="cham-kb-fab-container" id="cham-kb-fab-container"> | |
| <button class="cham-kb-fab" id="cham-kb-fab" onclick="toggleChamKeyboardFloating()" title="Cham Paleographic Virtual Keyboard (Alt+K)"> | |
| <span id="cham-kb-fab-icon">⌨️</span> | |
| </button> | |
| </div> | |
| <div class="cham-kb-panel closed" id="cham-kb-panel"> | |
| <!-- Draggable Header --> | |
| <div class="cham-kb-header" id="cham-kb-header"> | |
| <div class="cham-kb-title-group"> | |
| <span class="cham-kb-drag-handle" title="Drag to reposition">⋮⋮</span> | |
| <span>Bàn phím Cham</span> | |
| <span class="badge" style="font-size: 9.5px; padding: 1px 6px;">Akhar Thrah</span> | |
| </div> | |
| <div class="cham-kb-controls"> | |
| <div class="cham-kb-control-item"> | |
| <label for="select-row-layout">Bố cục:</label> | |
| <select id="select-row-layout" onchange="changeRowLayout(this.value)"> | |
| <option value="5" selected>5 hàng (Gọn)</option> | |
| <option value="6">6 hàng (Cân đối)</option> | |
| <option value="9">9 hàng (Gốc)</option> | |
| </select> | |
| </div> | |
| <div class="cham-kb-control-item"> | |
| <label for="select-pronunciation">Phonetic:</label> | |
| <select id="select-pronunciation" onchange="changePhoneticMode(this.value)"> | |
| <option value="traditional" selected>Traditional</option> | |
| <option value="sanskrit">Sanskrit</option> | |
| <option value="none">None</option> | |
| </select> | |
| </div> | |
| <div class="cham-kb-control-item"> | |
| <label for="change-font-size">Size:</label> | |
| <select id="change-font-size" onchange="changeKeyFontSize(this.value)"> | |
| <option value="16">16</option> | |
| <option value="18" selected>18</option> | |
| <option value="20">20</option> | |
| <option value="22">22</option> | |
| </select> | |
| </div> | |
| <button class="cham-kb-btn-icon" onclick="resetKeyboardPosition()" title="Reset keyboard position">⟲</button> | |
| <button class="cham-kb-btn-icon" onclick="toggleChamKeyboardFloating(false)" title="Close virtual keyboard (Esc / Alt+K)">✕</button> | |
| </div> | |
| </div> | |
| <!-- Scratchpad & Action Toolbar (Streamlined Single Row) --> | |
| <div class="cham-kb-toolbar"> | |
| <div class="cham-kb-scratchpad-wrap"> | |
| <input type="text" id="cham-kb-scratchpad" class="cham-kb-scratchpad" placeholder="Gõ thử hoặc gõ trực tiếp vào dòng OCR đang chọn..." /> | |
| <button class="btn btn-sm" onclick="copyScratchpadText()" title="Copy text to clipboard">📋 Copy</button> | |
| <button class="btn btn-sm" onclick="insertScratchpadToLine()" title="Insert text into selected OCR line">↵ Chèn</button> | |
| <button class="btn btn-sm" onclick="clearScratchpadText()" title="Clear scratchpad">🗑</button> | |
| <button class="btn btn-sm" onclick="undoText()" title="Undo edit">↶</button> | |
| <button class="btn btn-sm" onclick="redoText()" title="Redo edit">↷</button> | |
| </div> | |
| </div> | |
| <!-- Dynamic 9-Row Keyboard Grid --> | |
| <div class="cham-kb-body" id="cham-kb-body"> | |
| <!-- Rendered dynamically by JavaScript --> | |
| </div> | |
| </div> | |
| <!-- 4. Academic Research Methodology & Architecture Modal --> | |
| <div id="about-modal" class="modal"> | |
| <div class="modal-content modal-academic"> | |
| <span class="close-btn" onclick="closeAboutModal()">×</span> | |
| <div class="modal-academic-header"> | |
| <div> | |
| <h2> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> | |
| Cham OCR Paleographic System | |
| </h2> | |
| <div class="sub-desc">Comprehensive Research Methodology, Deep Learning Architecture, Training Protocol & Deployment Infrastructure</div> | |
| </div> | |
| </div> | |
| <!-- Callout to full academic paper --> | |
| <div style="background: var(--bg-surface-elevated); border: 1px solid var(--border-color); border-left: 4px solid var(--accent-primary); border-radius: var(--radius-sm); padding: 10px 16px; margin: 12px 0 14px; display: flex; justify-content: space-between; align-items: center; gap: 12px;"> | |
| <div> | |
| <div style="font-weight: 700; font-size: 13px; color: var(--text-primary);">📄 Dedicated Academic Research Paper Available</div> | |
| <div style="font-size: 11.5px; color: var(--text-secondary);">Read our complete publication with paleographic analysis, mathematical formulations, breaking points, and multi-GPU scaling tables.</div> | |
| </div> | |
| <a href="/research" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm" style="white-space: nowrap; text-decoration: none; font-size: 11.5px; padding: 4px 12px;">Read Full Paper ↗</a> | |
| </div> | |
| <!-- Modal Navigation Tabs --> | |
| <div class="modal-tabs"> | |
| <button class="modal-tab-btn active" id="tab-btn-overview" onclick="switchModalTab('overview')">1. Overview & Paleography</button> | |
| <button class="modal-tab-btn" id="tab-btn-segmentation" onclick="switchModalTab('segmentation')">2. Line Segmentation & DBNet</button> | |
| <button class="modal-tab-btn" id="tab-btn-recognition" onclick="switchModalTab('recognition')">3. Neural Recognition & Normalization</button> | |
| <button class="modal-tab-btn" id="tab-btn-training" onclick="switchModalTab('training')">4. Synthetic Pipeline & Training</button> | |
| <button class="modal-tab-btn" id="tab-btn-benchmarks" onclick="switchModalTab('benchmarks')">5. Quantitative Benchmarks</button> | |
| <button class="modal-tab-btn" id="tab-btn-deployment" onclick="switchModalTab('deployment')">6. Serving & Architecture</button> | |
| <button class="modal-tab-btn" id="tab-btn-citation" onclick="switchModalTab('citation')">7. Repository & Citation</button> | |
| </div> | |
| <!-- Modal Tab Content Panes --> | |
| <div class="modal-tab-body"> | |
| <!-- Tab 1: Overview & Paleography --> | |
| <div class="modal-tab-pane active" id="tab-pane-overview"> | |
| <p> | |
| <strong>Cham OCR Studio</strong> is an open-source, specialized paleographic transcription and diagnostic platform engineered specifically for the digitized preservation and optical character recognition of historical <strong>Cham manuscripts</strong> (comprising traditional <em>Akhar Thrah</em> of Eastern Cham and <em>Cam Srak</em> of Western Cham). | |
| </p> | |
| <div class="academic-h3">📜 Paleographic & Orthographic Characteristics</div> | |
| <p> | |
| The Cham script belongs to the Brahmic family of Southern Indic scripts (descended historically from the Grantha and Pallava writing systems of ancient Champa). As an <strong>abugida</strong>, its visual and computational representation poses significant challenges: | |
| </p> | |
| <ul style="padding-left: 20px; margin: 8px 0;"> | |
| <li><strong>Independent Vowels & Consonants</strong>: The Unicode Cham block (<code>U+AA00</code>–<code>U+AA5F</code>) specifies 6 independent vowels (<code>U+AA00</code>–<code>U+AA05</code>: A, I, U, E, AI, O) and 35 consonants (<code>U+AA06</code>–<code>U+AA28</code>) possessing an inherent vowel (/a/ or /ɔ/), structured into low-tone (Dom) and high-tone (Dal) series.</li> | |
| <li><strong>Subjoined Medials (Pang Ka-ak)</strong>: Four medial signs attach beneath or wrap around initial consonants: <code>ꨳ</code> (U+AA33: Medial Ya), <code>ꨴ</code> (U+AA34: Medial Ra), <code>ꨵ</code> (U+AA35: Medial La), and <code>ꨶ</code> (U+AA36: Medial Wa).</li> | |
| <li><strong>Multi-Directional Dependent Vowels</strong>: Vowel diacritics occupy positions above (<code>ꨪ</code>, <code>ꨫ</code>, <code>ꨬ</code>), below (<code>ꨭ</code> U, <code>ꨲ</code> UE), post-base right (<code>ꨩ</code> AA, <code>ꨮ</code> OE, <code>ꨱ</code> AU), or pre-base left (<code>ꨯ</code> O, <code>ꨰ</code> AI).</li> | |
| <li><strong>Final Consonants & Signs ([<code>U+AA40</code>–<code>U+AA4D</code>, <code>U+AA25</code>])</strong>: Explicit codas with extended terminal strokes, combining coda marks (<code>ꩃ</code> Final Ng, <code>ꩌ</code> Final M, <code>ꩍ</code> Final H), and base consonant <code>ꨥ</code> (U+AA25: VA) serving as syllable-final consonant per Unicode Table 16-16.</li> | |
| <li><strong>Scriptio Continua & Punctuation</strong>: Classical manuscripts predominantly omit whitespace between words, demarcating syntax via Danda (<code>꩝</code> U+AA5D), Double Danda (<code>꩞</code> U+AA5E), or Triple Danda (<code>꩟</code> U+AA5F). Modern printed Cham publications frequently introduce word-level spacing.</li> | |
| </ul> | |
| <div class="academic-h3">🔤 Canonical Unicode & Logical Order Standardization</div> | |
| <p> | |
| Encoded in Unicode Standard 5.1 within the range <code>U+AA00</code>–<code>U+AA5F</code>, Cham text processing requires strict differentiation between visual rendering order and <strong>Brahmic Logical Order</strong>. The canonical storage sequence enforced in Cham OCR Studio is: | |
| </p> | |
| <div class="academic-code-box">Base Consonant + Medials (Ya/Ra/La/Wa) + Pre-Vowels (O/AI) + Dependent Vowels + Vowel Lengthener AA + Final Consonants / Signs (incl. Final VA)</div> | |
| </div> | |
| <!-- Tab 2: Line Segmentation & DBNet --> | |
| <div class="modal-tab-pane" id="tab-pane-segmentation"> | |
| <div class="academic-h3">🔍 Dual-Stage Hybrid Segmentation Pipeline</div> | |
| <p> | |
| Historical palm-leaf and handmade paper manuscripts frequently exhibit undulating line trajectories, uneven inter-line spacing, and ink bleed-through. Cham OCR Studio employs a two-tier hybrid segmentation architecture: | |
| </p> | |
| <div class="academic-card"> | |
| <strong>Stage 1: Fine-Tuned DBNet (Differentiable Binarization Network)</strong> | |
| <p style="font-size: 12.5px; color: var(--text-muted); margin-top: 4px;"> | |
| Operates with an adaptive unclip ratio of <strong>1.8</strong>. DBNet predicts a continuous probability map and adaptive threshold surface, outputting polygon boundaries that conform snugly to curvilinear text baselines while encompassing ascenders and descenders. | |
| </p> | |
| </div> | |
| <div class="academic-card"> | |
| <strong>Stage 2: Indic Valley-Cut Heuristic Engine (Fallback)</strong> | |
| <p style="font-size: 12.5px; color: var(--text-muted); margin-top: 4px;"> | |
| When deep detection yields ambiguous boundaries, a horizontal projection profile is generated across adaptive sliding windows. Valleys in projection intensity indicate candidate baseline cutpoints, combined with Connected Component Analysis (CCA) to prevent cutting across intersecting glyph stems. | |
| </p> | |
| </div> | |
| <div class="academic-h3">🛡️ Boundary Safeguards & Stroke Protection</div> | |
| <ul style="padding-left: 20px; margin: 8px 0;"> | |
| <li><strong>Connected Component Exclusion (<code>difference_update</code>)</strong>: When establishing an erasing mask for neighboring lines, components belonging to the current textline are strictly excluded, eliminating stroke truncation.</li> | |
| <li><strong>Safe Vertical Padding Safeguards</strong>: Every line crop is guaranteed a minimum buffer of <code>0.40 × median_line_height</code> above and below the core band, clipped at 2px from adjacent textbands.</li> | |
| <li><strong>Legacy-First / No-Change Gate</strong>: Clean manuscript folios with line pitch gap ≥ <code>0.35 × median_line_height</code> and initial recognition confidence ≥ <code>0.75</code> bypass multi-pass deformation to prevent regression.</li> | |
| </ul> | |
| </div> | |
| <!-- Tab 3: Recognition Network & Normalization --> | |
| <div class="modal-tab-pane" id="tab-pane-recognition"> | |
| <div class="academic-h3">🧠 Neural Recognition: PP-OCRv4 SVTR-LCNet</div> | |
| <p> | |
| The recognition engine leverages a customized <strong>SVTR-LCNet</strong> backbone optimized for high-density historical scripts: | |
| </p> | |
| <div class="academic-card"> | |
| <ul style="padding-left: 18px; margin: 4px 0; font-size: 12.5px;"> | |
| <li><strong>Depthwise Separable Convolutions</strong>: Maximizes representation capacity while sustaining lightweight inference speeds on commodity CPU hardware.</li> | |
| <li><strong>Squeeze-and-Excitation (SE) Attention</strong>: Dynamically recalibrates channel-wise feature responses, emphasizing delicate diacritic strokes against noisy paper backgrounds.</li> | |
| <li><strong>Expanded Input Geometry</strong>: Receptive dimension configured to <code>[3, 48, 480]</code> (expanded from 320px) to prevent spatial compression of dense character clusters and bilingual verses.</li> | |
| </ul> | |
| </div> | |
| <div class="academic-h3">🎯 Joint CTC & NRTR Auxiliary Decoding</div> | |
| <p> | |
| Sequence prediction employs dual-head multi-task learning: | |
| </p> | |
| <ul style="padding-left: 20px; margin: 8px 0;"> | |
| <li><strong>Connectionist Temporal Classification (CTC) Head</strong>: Computes alignment-free token probabilities with forward-backward dynamic programming, optimizing real-time throughput.</li> | |
| <li><strong>NRTR Cross-Attention Auxiliary Head</strong>: Utilizes multi-head self-attention transformer blocks during training to penalize character omissions on ambiguous minimal pairs.</li> | |
| </ul> | |
| <div class="academic-h3">⚙️ Post-OCR Canonical Unicode Cluster Normalization</div> | |
| <p> | |
| To resolve local model hallucinations where visual rendering order flips adjacent diacritics, the system applies an algorithmic Unicode cluster re-ordering stage (<code>normalize_unicode</code>): | |
| </p> | |
| <div class="academic-code-box">Input Artifact: ꨙ (Nda) + ꨯ (Vowel O) + ꨳ (Medial Ya) + ꨮ (Vowel Oe) | |
| Canonical Brahmic Cluster: ꨙ (Nda) + ꨳ (Medial Ya) + ꨯ (Vowel O) + ꨮ (Vowel Oe)</div> | |
| </div> | |
| <!-- Tab 4: Synthetic Pipeline & Training --> | |
| <div class="modal-tab-pane" id="tab-pane-training"> | |
| <div class="academic-h3">🏭 Synthetic Dataset Synthesis (150,000 Textlines)</div> | |
| <p> | |
| Due to the extreme scarcity of annotated historical Cham ground truth, training data is synthetically synthesized with strict quality controls via <code>scripts/generate_data.py</code>: | |
| </p> | |
| <table class="academic-table"> | |
| <thead> | |
| <tr> | |
| <th>Dataset Bucket</th> | |
| <th>Volume</th> | |
| <th>Share</th> | |
| <th>Target Paleographic Focus</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td><strong>Bucket 1: Classical Corpus</strong></td> | |
| <td>70,000</td> | |
| <td>50.0%</td> | |
| <td>Po Klong Garai chronicles, epic poetry, palm-leaf folk literature</td> | |
| </tr> | |
| <tr> | |
| <td><strong>Bucket 2: Multilingual Code-Switching</strong></td> | |
| <td>25,000</td> | |
| <td>17.9%</td> | |
| <td>Inline mixed Cham, Vietnamese, and Latin glosses/dates</td> | |
| </tr> | |
| <tr> | |
| <td><strong>Bucket 3: Verse Numerals & Section Marks</strong></td> | |
| <td>25,000</td> | |
| <td>17.9%</td> | |
| <td>Numeral sequences 1–99 (<code>꩑꩞</code>..<code>꩙꩙꩞</code>), boundary punctuation (<code>꩞</code>, <code>:</code>, <code>–</code>)</td> | |
| </tr> | |
| <tr> | |
| <td><strong>Bucket 4: Adversarial Minimal Pairs</strong></td> | |
| <td>20,000</td> | |
| <td>14.3%</td> | |
| <td>Hard pairs: <code>ꨲ</code> (U+AA32 UE) vs <code>ꨶ</code> (U+AA36 WA); variable-gap Double Danda <code>꩝꩝</code></td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <div class="academic-h3">⚡ Dynamic In-RAM Augmentation & Training Safeguards</div> | |
| <ul style="padding-left: 20px; margin: 8px 0;"> | |
| <li><strong>Zero-Baked-Blur Policy</strong>: Clean textlines are rendered to disk. Photometric noise, Gaussian defocus, and directional motion blur (kernels 7×7 to 13×13, θ ∈ [0°, 180°]) are applied dynamically in RAM (35% probability) during batch loading.</li> | |
| <li><strong>Font CMAP Tofu Filtering</strong>: <code>fontTools</code> inspects character maps before generation, discarding unsupported glyphs to prevent square placeholder (tofu) corruptions.</li> | |
| <li><strong>Character-Mapped Weight Surgery (<code>surgery_v25_weights.py</code>)</strong>: Re-maps neural projection heads across expanding lexicons without shape mismatch.</li> | |
| <li><strong>Distributed Multi-GPU Architecture</strong>: Trained on Kaggle Nvidia Tesla T4x2 (DDP) and Lightning Cloud A100 SXM4 using AMP O1 mixed precision with 3-stage checkpoint resilience.</li> | |
| </ul> | |
| </div> | |
| <!-- Tab 5: Quantitative Benchmarks --> | |
| <div class="modal-tab-pane" id="tab-pane-benchmarks"> | |
| <div class="academic-h3">📊 Empirical Evaluation on Stratified Test Corpus</div> | |
| <p> | |
| Performance quantitatively validated across a <strong>50-test stratified benchmark</strong> and a <strong>controlled 200-page synthetic stress-test suite</strong> (903 textlines): | |
| </p> | |
| <div class="academic-h3">1. Controlled 50-Test Benchmark (Model V23 vs V24 Validated Baseline)</div> | |
| <table class="academic-table"> | |
| <thead> | |
| <tr> | |
| <th>Evaluation Category</th> | |
| <th>V23 CER</th> | |
| <th>V24 CER</th> | |
| <th>Gain</th> | |
| <th>V23 Pass</th> | |
| <th>V24 Pass</th> | |
| <th>V24 Conf</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td>Cat 1: Clean Baseline</td> | |
| <td>14.84%</td> | |
| <td><strong>6.20%</strong></td> | |
| <td style="color:#2E7D32;">-8.64%</td> | |
| <td>20.0%</td> | |
| <td><strong>80.0%</strong></td> | |
| <td>0.958</td> | |
| </tr> | |
| <tr> | |
| <td>Cat 2: Single-Digit Stanzas</td> | |
| <td>18.42%</td> | |
| <td><strong>11.71%</strong></td> | |
| <td style="color:#2E7D32;">-6.71%</td> | |
| <td>0.0%</td> | |
| <td><strong>40.0%</strong></td> | |
| <td>0.944</td> | |
| </tr> | |
| <tr> | |
| <td>Cat 3: Two-Digit Stanzas</td> | |
| <td>17.59%</td> | |
| <td><strong>7.65%</strong></td> | |
| <td style="color:#2E7D32;">-9.94%</td> | |
| <td>20.0%</td> | |
| <td><strong>60.0%</strong></td> | |
| <td>0.956</td> | |
| </tr> | |
| <tr> | |
| <td>Cat 5: Blur Degradation</td> | |
| <td>41.78%</td> | |
| <td><strong>35.56%</strong></td> | |
| <td style="color:#2E7D32;">-6.22%</td> | |
| <td>0.0%</td> | |
| <td>0.0%</td> | |
| <td>0.839</td> | |
| </tr> | |
| <tr> | |
| <td>Cat 6: Noise & Grain</td> | |
| <td>50.59%</td> | |
| <td><strong>8.82%</strong></td> | |
| <td style="color:#2E7D32;">-41.77%</td> | |
| <td>0.0%</td> | |
| <td><strong>80.0%</strong></td> | |
| <td>0.944</td> | |
| </tr> | |
| <tr> | |
| <td>Cat 8: Tilt & Perspective</td> | |
| <td>22.14%</td> | |
| <td><strong>0.71%</strong></td> | |
| <td style="color:#2E7D32;">-21.43%</td> | |
| <td>0.0%</td> | |
| <td><strong>100.0%</strong></td> | |
| <td>0.982</td> | |
| </tr> | |
| <tr style="font-weight: 700; background: var(--bg-surface-elevated);"> | |
| <td>Overall Benchmark Average (50 Tests)</td> | |
| <td>28.17%</td> | |
| <td><strong>13.88%</strong></td> | |
| <td style="color:#2E7D32;">-14.29%</td> | |
| <td>4.0%</td> | |
| <td><strong>50.0%</strong></td> | |
| <td>0.923</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <div class="academic-h3" style="margin-top: 14px;">2. Controlled 200-Page Synthetic Document Stress-Test (903 Textlines)</div> | |
| <table class="academic-table"> | |
| <thead> | |
| <tr> | |
| <th>Difficulty Tier</th> | |
| <th>Degradation Profile</th> | |
| <th>Lines</th> | |
| <th>Det Rate</th> | |
| <th>Mean CER</th> | |
| <th>Mean WER</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td>Level 1: Standard</td> | |
| <td>Rectilinear, standard gap 25–35px</td> | |
| <td>201</td> | |
| <td>100.0%</td> | |
| <td>14.43%</td> | |
| <td>45.50%</td> | |
| </tr> | |
| <tr> | |
| <td>Level 2: Aged Paper</td> | |
| <td>Yellowed paper texture, mild noise</td> | |
| <td>189</td> | |
| <td>100.0%</td> | |
| <td>14.44%</td> | |
| <td>45.86%</td> | |
| </tr> | |
| <tr> | |
| <td>Level 3: Narrow Gap</td> | |
| <td>Tight line spacing 8–14px, waviness</td> | |
| <td>170</td> | |
| <td>100.0%</td> | |
| <td>12.77%</td> | |
| <td>43.52%</td> | |
| </tr> | |
| <tr> | |
| <td>Level 4: Wavy Sinusoid</td> | |
| <td>Sine wave amp 3–5.5px, gap 5–10px</td> | |
| <td>177</td> | |
| <td>99.44%</td> | |
| <td>14.53%</td> | |
| <td>45.64%</td> | |
| </tr> | |
| <tr> | |
| <td>Level 5: Extreme Overlap</td> | |
| <td>Gap 2–6px < wave amp 5.5–8px (Breaking Point)</td> | |
| <td>166</td> | |
| <td>61.45%</td> | |
| <td>60.02%</td> | |
| <td>77.63%</td> | |
| </tr> | |
| <tr style="font-weight: 700; background: var(--bg-surface-elevated);"> | |
| <td>Total Suite Summary</td> | |
| <td>200 synthetic document images</td> | |
| <td>903</td> | |
| <td>92.80%</td> | |
| <td>23.24%</td> | |
| <td>51.63%</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <div class="academic-h3" style="margin-top: 14px;">3. Model Classification: V25 Validated SOTA vs V24 Logical Baseline</div> | |
| <p style="font-size: 12.5px; color: var(--text-secondary); margin-bottom: 8px;"> | |
| Evaluation across 10,000 frozen validation samples establishes <strong>Version 25</strong> as the new production SOTA model (Sequence Accuracy <strong>90.96%</strong>, Normalized Edit Distance <strong>99.28%</strong>, CER <strong>0.72%</strong>) following 40 epochs of multi-stage training on Dual Tesla T4x2 GPUs. <strong>Version 24</strong> serves as the logical order baseline (Accuracy 88.94%), while <strong>Version 23</strong> is maintained for legacy visual order comparison. | |
| </p> | |
| <div class="academic-h3">🔍 Error Metrics Definition</div> | |
| <p style="font-size: 12.5px; color: var(--text-muted);"> | |
| Character Error Rate (CER) computed as normalized Levenshtein edit distance: <code>CER = (Substitutions + Deletions + Insertions) / Total Ground-Truth Characters</code>. | |
| </p> | |
| </div> | |
| <!-- Tab 6: Production Deployment & Serving --> | |
| <div class="modal-tab-pane" id="tab-pane-deployment"> | |
| <div class="academic-h3">🚀 Cloud Serving & Microservice Architecture</div> | |
| <p> | |
| Cham OCR Studio is architected for zero-latency, high-availability public access: | |
| </p> | |
| <div class="academic-card"> | |
| <strong>Containerized Microservice (Docker)</strong> | |
| <p style="font-size: 12.5px; color: var(--text-muted); margin-top: 4px;"> | |
| Packaged via a multi-stage Docker container deployed directly on <strong>Hugging Face Spaces</strong> (<code>phucsd/cham-ocr-studio</code>) and routed through Cloudflare to the official production domain: <strong><a href="https://ocr.cham.asia" target="_blank" style="color: var(--accent-primary);">https://ocr.cham.asia</a></strong>. | |
| </p> | |
| </div> | |
| <div class="academic-h3">🛡️ Runtime Concurrency & Thread Isolation</div> | |
| <ul style="padding-left: 20px; margin: 8px 0;"> | |
| <li><strong>OpenMP & MKL Deadlock Safeguards</strong>: Multi-threaded OpenMP and OneDNN execution are explicitly pinned to single-thread mode inside HTTP socket worker threads to eliminate deadlock states on CPU runtimes.</li> | |
| <li><strong>NumPy 2.x Compatibility Patch</strong>: Automated runtime monkeypatching for legacy <code>np.sctypes</code>, <code>np.bool</code>, and <code>np.typeDict</code> ensuring seamless operation under modern Python 3.13+ environments.</li> | |
| <li><strong>Client-Side IndexedDB Storage</strong>: Manuscript queues and multi-megabyte folios persist locally in client browser storage, minimizing network payloads and server compute strain.</li> | |
| </ul> | |
| </div> | |
| <!-- Tab 7: Repository & Citation --> | |
| <div class="modal-tab-pane" id="tab-pane-citation"> | |
| <div class="academic-h3">🐙 Open-Source GitHub Repository</div> | |
| <p> | |
| All source code, training notebooks, synthetic generators, evaluation scripts, and model weights are publicly accessible under the MIT License: | |
| </p> | |
| <div class="academic-card"> | |
| <strong>Full Research Publication</strong>: <a href="/research" target="_blank" rel="noopener noreferrer" style="color: var(--accent-primary); font-weight: 600;">Read Whitepaper Online (/research)</a> | <a href="https://github.com/phucsd/Cham-OCR/blob/main/RESEARCH.md" target="_blank" rel="noopener noreferrer" style="color: var(--accent-primary); font-weight: 600;">RESEARCH.md</a><br> | |
| <strong>Repository Link</strong>: <a href="https://github.com/phucsd/Cham-OCR" target="_blank" rel="noopener noreferrer" style="color: var(--accent-primary); font-weight: 600;">https://github.com/phucsd/Cham-OCR</a><br> | |
| <strong>Online Service</strong>: <a href="https://ocr.cham.asia" target="_blank" rel="noopener noreferrer" style="color: var(--accent-primary); font-weight: 600;">https://ocr.cham.asia</a><br> | |
| <strong>Contact / Inquiries</strong>: <a href="mailto:phucsd@gmail.com" style="color: var(--accent-primary); font-weight: 600;">phucsd@gmail.com</a> | |
| </div> | |
| <div class="academic-h3">⌨️ Quick Clone & Local Setup</div> | |
| <div class="academic-code-box">git clone https://github.com/phucsd/Cham-OCR.git | |
| cd Cham-OCR | |
| pip install -r requirements.txt | |
| python ocr-studio/app.py</div> | |
| <div class="academic-h3">📑 Academic BibTeX Citation</div> | |
| <div class="academic-code-box">@software{cham_ocr_studio, | |
| author = {Nguyen, Phuc}, | |
| title = {Cham OCR Studio: Deep Learning Pipeline and Paleographic Workbench for Historical Cham Manuscripts}, | |
| year = {2026}, | |
| publisher = {GitHub}, | |
| url = {https://github.com/phucsd/Cham-OCR}, | |
| note = {Official web service: https://ocr.cham.asia} | |
| }</div> | |
| </div> | |
| </div> | |
| <!-- Modal Footer --> | |
| <div style="margin-top: 14px; text-align: right; border-top: 1px solid var(--border-color); padding-top: 12px; display: flex; justify-content: space-between; align-items: center;"> | |
| <div style="display: flex; gap: 8px;"> | |
| <a href="/research" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm" style="text-decoration: none;"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg> | |
| Full Research Paper | |
| </a> | |
| <a href="https://github.com/phucsd/Cham-OCR" target="_blank" rel="noopener noreferrer" class="btn btn-sm" style="text-decoration: none;"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg> | |
| GitHub Repository | |
| </a> | |
| <a href="mailto:phucsd@gmail.com" class="btn btn-sm" style="text-decoration: none;" title="Email Author (phucsd@gmail.com)"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> | |
| Email Contact | |
| </a> | |
| </div> | |
| <button class="btn btn-secondary" onclick="closeAboutModal()">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 5. System Logs Modal --> | |
| <div id="logs-modal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="close-btn" onclick="closeLogModal()">×</span> | |
| <h2 style="color: var(--accent-primary); margin-top: 0; border-bottom: 1px solid var(--border-color); padding-bottom: 12px; display: flex; align-items: center; gap: 8px; font-size: 18px;"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h7"/></svg> | |
| System Runtime Logs | |
| </h2> | |
| <div style="max-height: 380px; overflow-y: auto; padding-right: 8px;"> | |
| <pre id="system-logs-container" style="font-family: monospace; font-size: 11.5px; background: var(--bg-surface); color: var(--text-main); border: 1px solid var(--border-color); padding: 12px; border-radius: 6px; white-space: pre-wrap; word-wrap: break-word;"></pre> | |
| </div> | |
| <div style="margin-top: 16px; text-align: right; border-top: 1px solid var(--border-color); padding-top: 12px; display: flex; justify-content: space-between;"> | |
| <button class="btn" onclick="clearLogs()">Clear Logs</button> | |
| <button class="btn btn-primary" onclick="closeLogModal()">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Application JavaScript Engine --> | |
| <script> | |
| // Global error logging for remote debug | |
| window.onerror = function(message, source, lineno, colno, error) { | |
| const errStr = `${message} at ${source}:${lineno}:${colno}`; | |
| if (typeof appendLog === 'function') { | |
| appendLog(`Client Interface Error: ${errStr}`, 'error'); | |
| } | |
| fetch('/log', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: errStr }) | |
| }).catch(() => {}); | |
| return false; | |
| }; | |
| window.onunhandledrejection = function(event) { | |
| if (typeof appendLog === 'function') { | |
| appendLog(`Unhandled Promise Rejection: ${event.reason}`, 'error'); | |
| } | |
| fetch('/log', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: 'Unhandled rejection: ' + event.reason }) | |
| }).catch(() => {}); | |
| }; | |
| // Core Selection Model | |
| let currentSelection = { | |
| level: "line", // "line" | "char" | |
| lineId: null, | |
| charId: null, | |
| bboxId: null | |
| }; | |
| // State variables for Viewer & Image Queue | |
| let scale = 1.0; | |
| let panX = 0; | |
| let panY = 0; | |
| let isDragging = false; | |
| let startX, startY; | |
| let showBboxOverlay = true; | |
| let overlayMode = "line"; | |
| // State variables for Tools | |
| let isDrawBboxMode = false; | |
| let isDrawingBbox = false; | |
| let drawStartPoint = null; | |
| let showLineCrops = false; | |
| let isChamKeyboardOpen = false; | |
| let isKeyboardDockExpanded = false; // legacy alias | |
| let chamKeyboardRowLayout = localStorage.getItem('cham_kb_row_layout') || '5'; // '5', '6', '9' | |
| let chamKeyboardPhoneticMode = 'traditional'; // 'traditional', 'sanskrit', 'none' | |
| let chamKeyboardFontSize = 18; | |
| let lastActiveInputElement = null; | |
| let currentImageBase64 = ""; | |
| let originalImageWidth = 0; | |
| let originalImageHeight = 0; | |
| let responseLines = []; // OCR results | |
| let imageQueue = []; // Recently scanned queue | |
| // HTML elements cached | |
| const container = document.getElementById('viewer-container'); | |
| const content = document.getElementById('viewer-content'); | |
| const imgEl = document.getElementById('document-image'); | |
| const bboxOverlay = document.getElementById('bbox-overlay'); | |
| // IndexedDB Configuration for robust large image storage | |
| const dbName = "ChamOCRStudioDB"; | |
| const storeName = "images"; | |
| function initDB() { | |
| return new Promise((resolve, reject) => { | |
| const request = indexedDB.open(dbName, 1); | |
| request.onupgradeneeded = (e) => { | |
| const db = e.target.result; | |
| if (!db.objectStoreNames.contains(storeName)) { | |
| db.createObjectStore(storeName, { keyPath: "id" }); | |
| } | |
| }; | |
| request.onsuccess = (e) => resolve(e.target.result); | |
| request.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| async function saveItemToDB(item) { | |
| try { | |
| const db = await initDB(); | |
| const tx = db.transaction(storeName, "readwrite"); | |
| const store = tx.objectStore(storeName); | |
| const dbItem = { ...item }; | |
| store.put(dbItem); | |
| return new Promise((resolve, reject) => { | |
| tx.oncomplete = () => resolve(); | |
| tx.onerror = () => reject(tx.error); | |
| }); | |
| } catch (e) { | |
| console.error("IndexedDB save error:", e); | |
| } | |
| } | |
| async function loadItemFromDB(id) { | |
| try { | |
| const db = await initDB(); | |
| const tx = db.transaction(storeName, "readonly"); | |
| const store = tx.objectStore(storeName); | |
| const request = store.get(id); | |
| return new Promise((resolve, reject) => { | |
| request.onsuccess = () => resolve(request.result || null); | |
| request.onerror = () => reject(request.error); | |
| }); | |
| } catch (e) { | |
| console.error("IndexedDB load error:", e); | |
| return null; | |
| } | |
| } | |
| async function deleteImageFromDB(id) { | |
| try { | |
| const db = await initDB(); | |
| const tx = db.transaction(storeName, "readwrite"); | |
| const store = tx.objectStore(storeName); | |
| store.delete(id); | |
| } catch (e) { | |
| console.error("IndexedDB delete error:", e); | |
| } | |
| } | |
| async function clearAllImagesFromDB() { | |
| try { | |
| const db = await initDB(); | |
| const tx = db.transaction(storeName, "readwrite"); | |
| const store = tx.objectStore(storeName); | |
| store.clear(); | |
| } catch (e) { | |
| console.error("IndexedDB clear error:", e); | |
| } | |
| } | |
| function saveQueueToStorage() { | |
| try { | |
| const cleanQueue = imageQueue.map(item => { | |
| const copy = { ...item }; | |
| delete copy.image; | |
| delete copy.lines; | |
| return copy; | |
| }); | |
| localStorage.setItem('cham_ocr_image_queue', JSON.stringify(cleanQueue)); | |
| } catch (e) { | |
| console.error("Failed to save queue to localStorage:", e); | |
| } | |
| } | |
| function loadQueueFromStorage() { | |
| try { | |
| const data = localStorage.getItem('cham_ocr_image_queue'); | |
| if (data) { | |
| imageQueue = JSON.parse(data); | |
| imageQueue.forEach(item => item.active = false); | |
| renderQueue(); | |
| } else { | |
| imageQueue = []; | |
| } | |
| } catch (e) { | |
| console.error("Failed to load queue:", e); | |
| imageQueue = []; | |
| try { | |
| localStorage.removeItem('cham_ocr_image_queue'); | |
| } catch(ex) {} | |
| } | |
| } | |
| function clearQueue() { | |
| if (confirm("Are you sure you want to clear the entire manuscript queue?")) { | |
| imageQueue = []; | |
| saveQueueToStorage(); | |
| clearAllImagesFromDB(); | |
| renderQueue(); | |
| imgEl.src = ""; | |
| imgEl.style.display = "none"; | |
| document.getElementById('no-image-placeholder').style.display = 'flex'; | |
| document.getElementById('btn-run').setAttribute('disabled', 'true'); | |
| document.getElementById('header-doc-title').innerText = 'No Active Manuscript'; | |
| document.getElementById('doc-status-dot').classList.remove('active'); | |
| responseLines = []; | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| showToast("Manuscript queue cleared"); | |
| } | |
| } | |
| // Initial launch | |
| document.addEventListener('DOMContentLoaded', () => { | |
| loadQueueFromStorage(); | |
| renderChamKeyboardBody(); | |
| }); | |
| // ------------------------------------------------------------- | |
| // Pan & Zoom Controller | |
| // ------------------------------------------------------------- | |
| function updateTransform() { | |
| content.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; | |
| document.getElementById('zoom-value').innerText = Math.round(scale * 100) + "%"; | |
| } | |
| function toggleSidebar() { | |
| const sb = document.getElementById('sidebar-pane'); | |
| const btn = document.getElementById('btn-toggle-sidebar'); | |
| sb.classList.toggle('collapsed'); | |
| if (btn) btn.classList.toggle('active', sb.classList.contains('collapsed')); | |
| setTimeout(zoomToFit, 260); | |
| } | |
| function toggleEditor() { | |
| const ed = document.getElementById('editor-pane'); | |
| const btn = document.getElementById('btn-toggle-editor'); | |
| ed.classList.toggle('collapsed'); | |
| if (btn) btn.classList.toggle('active', ed.classList.contains('collapsed')); | |
| setTimeout(zoomToFit, 260); | |
| } | |
| function toggleDrawBboxMode() { | |
| isDrawBboxMode = !isDrawBboxMode; | |
| const btn = document.getElementById('btn-draw-bbox'); | |
| const vContainer = document.getElementById('viewer-container'); | |
| if (isDrawBboxMode) { | |
| btn.classList.add('active'); | |
| vContainer.classList.add('draw-mode-active'); | |
| showToast("Bounding box tool: Drag mouse across canvas to delineate a new textline"); | |
| } else { | |
| btn.classList.remove('active'); | |
| vContainer.classList.remove('draw-mode-active'); | |
| const preview = document.getElementById('drawing-bbox-rect'); | |
| if (preview) preview.style.display = 'none'; | |
| isDrawingBbox = false; | |
| } | |
| } | |
| function startDragging(e) { | |
| if (isDrawBboxMode) { | |
| startDrawingBbox(e); | |
| return; | |
| } | |
| if (e.target.classList.contains('bbox-rect')) return; | |
| isDragging = true; | |
| startX = e.clientX - panX; | |
| startY = e.clientY - panY; | |
| e.preventDefault(); | |
| } | |
| function dragImage(e) { | |
| if (isDrawBboxMode) { | |
| drawingBbox(e); | |
| return; | |
| } | |
| if (!isDragging) return; | |
| panX = e.clientX - startX; | |
| panY = e.clientY - startY; | |
| updateTransform(); | |
| } | |
| function stopDragging(e) { | |
| if (isDrawBboxMode) { | |
| finishDrawingBbox(e); | |
| return; | |
| } | |
| isDragging = false; | |
| } | |
| function startDrawingBbox(e) { | |
| if (!currentImageBase64) return; | |
| const rect = container.getBoundingClientRect(); | |
| const imgX = (e.clientX - rect.left - panX) / scale; | |
| const imgY = (e.clientY - rect.top - panY) / scale; | |
| if (imgX < 0 || imgY < 0 || imgX > originalImageWidth || imgY > originalImageHeight) { | |
| return; | |
| } | |
| isDrawingBbox = true; | |
| drawStartPoint = { x: imgX, y: imgY }; | |
| const preview = document.getElementById('drawing-bbox-rect'); | |
| if (preview) { | |
| preview.style.left = imgX + 'px'; | |
| preview.style.top = imgY + 'px'; | |
| preview.style.width = '0px'; | |
| preview.style.height = '0px'; | |
| preview.style.display = 'block'; | |
| } | |
| e.preventDefault(); | |
| } | |
| function drawingBbox(e) { | |
| if (!isDrawingBbox || !drawStartPoint) return; | |
| const rect = container.getBoundingClientRect(); | |
| const curX = Math.max(0, Math.min(originalImageWidth, (e.clientX - rect.left - panX) / scale)); | |
| const curY = Math.max(0, Math.min(originalImageHeight, (e.clientY - rect.top - panY) / scale)); | |
| const x1 = Math.min(drawStartPoint.x, curX); | |
| const y1 = Math.min(drawStartPoint.y, curY); | |
| const w = Math.abs(curX - drawStartPoint.x); | |
| const h = Math.abs(curY - drawStartPoint.y); | |
| const preview = document.getElementById('drawing-bbox-rect'); | |
| if (preview) { | |
| preview.style.left = x1 + 'px'; | |
| preview.style.top = y1 + 'px'; | |
| preview.style.width = w + 'px'; | |
| preview.style.height = h + 'px'; | |
| } | |
| } | |
| async function finishDrawingBbox(e) { | |
| if (!isDrawingBbox || !drawStartPoint) { | |
| isDrawingBbox = false; | |
| return; | |
| } | |
| isDrawingBbox = false; | |
| const preview = document.getElementById('drawing-bbox-rect'); | |
| if (preview) preview.style.display = 'none'; | |
| const rect = container.getBoundingClientRect(); | |
| const curX = Math.max(0, Math.min(originalImageWidth, (e.clientX - rect.left - panX) / scale)); | |
| const curY = Math.max(0, Math.min(originalImageHeight, (e.clientY - rect.top - panY) / scale)); | |
| const x1 = Math.round(Math.min(drawStartPoint.x, curX)); | |
| const y1 = Math.round(Math.min(drawStartPoint.y, curY)); | |
| const x2 = Math.round(Math.max(drawStartPoint.x, curX)); | |
| const y2 = Math.round(Math.max(drawStartPoint.y, curY)); | |
| drawStartPoint = null; | |
| if ((x2 - x1) < 10 || (y2 - y1) < 6) { | |
| return; | |
| } | |
| showToast("Transcribing delineated region..."); | |
| try { | |
| const modelSelect = document.getElementById('model-select'); | |
| const modelVer = modelSelect ? modelSelect.value : 'v24'; | |
| const res = await fetch('/ocr_crop', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| image: currentImageBase64, | |
| bbox: [x1, y1, x2, y2], | |
| model: modelVer | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (data.error) { | |
| showToast("Cropped region transcription failed: " + data.error); | |
| return; | |
| } | |
| const newLine = { | |
| index: responseLines.length + 1, | |
| text: data.text || "", | |
| confidence: data.confidence !== undefined ? data.confidence : 1.0, | |
| coords: [y1, y2], | |
| bbox: [x1, y1, x2, y2], | |
| image: data.image || "", | |
| segmentation: { | |
| overlap_prev_px: 0, | |
| overlap_next_px: 0, | |
| crop_confidence: data.confidence || 1.0, | |
| selected_crop_type: 'manual_draw', | |
| crop_sanity_score: 1.0, | |
| needs_review: false, | |
| reason: 'manual_drawn' | |
| } | |
| }; | |
| // Insert maintaining vertical position | |
| const midY = (y1 + y2) / 2; | |
| let insertIdx = responseLines.length; | |
| for (let i = 0; i < responseLines.length; i++) { | |
| const b = responseLines[i].bbox; | |
| if (b && (b[1] + b[3]) / 2 > midY) { | |
| insertIdx = i; | |
| break; | |
| } | |
| } | |
| responseLines.splice(insertIdx, 0, newLine); | |
| responseLines.forEach((l, idx) => l.index = idx + 1); | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| selectLine(insertIdx, true); | |
| enterEditMode(insertIdx); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Verified'); | |
| saveCorrectionToBackend(insertIdx, newLine.text); | |
| showToast(`Added new textline #${insertIdx + 1}!`); | |
| toggleDrawBboxMode(); | |
| } catch (err) { | |
| console.error("Failed to OCR drawn bbox:", err); | |
| showToast("Network error submitting delineated bounding box"); | |
| } | |
| } | |
| function handleZoomWheel(e) { | |
| e.preventDefault(); | |
| const rect = container.getBoundingClientRect(); | |
| const mouseX = e.clientX - rect.left; | |
| const mouseY = e.clientY - rect.top; | |
| const imageX = (mouseX - panX) / scale; | |
| const imageY = (mouseY - panY) / scale; | |
| const delta = e.deltaY < 0 ? 1.15 : 0.85; | |
| const newScale = Math.min(Math.max(0.1, scale * delta), 10); | |
| panX = mouseX - imageX * newScale; | |
| panY = mouseY - imageY * newScale; | |
| scale = newScale; | |
| updateTransform(); | |
| } | |
| function zoomIn() { | |
| const center = getContainerCenter(); | |
| const imageX = (center.x - panX) / scale; | |
| const imageY = (center.y - panY) / scale; | |
| scale = Math.min(scale * 1.2, 10); | |
| panX = center.x - imageX * scale; | |
| panY = center.y - imageY * scale; | |
| updateTransform(); | |
| } | |
| function zoomOut() { | |
| const center = getContainerCenter(); | |
| const imageX = (center.x - panX) / scale; | |
| const imageY = (center.y - panY) / scale; | |
| scale = Math.max(scale / 1.2, 0.1); | |
| panX = center.x - imageX * scale; | |
| panY = center.y - imageY * scale; | |
| updateTransform(); | |
| } | |
| function zoomTo100() { | |
| const center = getContainerCenter(); | |
| const imageX = (center.x - panX) / scale; | |
| const imageY = (center.y - panY) / scale; | |
| scale = 1.0; | |
| panX = center.x - imageX * scale; | |
| panY = center.y - imageY * scale; | |
| updateTransform(); | |
| } | |
| function zoomToFit() { | |
| if (!imgEl.naturalWidth) return; | |
| const cw = container.clientWidth; | |
| const ch = container.clientHeight; | |
| const iw = imgEl.naturalWidth; | |
| const ih = imgEl.naturalHeight; | |
| const scaleW = cw / iw; | |
| const scaleH = ch / ih; | |
| scale = Math.min(scaleW, scaleH) * 0.95; | |
| panX = (cw - iw * scale) / 2; | |
| panY = (ch - ih * scale) / 2; | |
| updateTransform(); | |
| } | |
| function zoomToWidth() { | |
| if (!imgEl.naturalWidth) return; | |
| const cw = container.clientWidth; | |
| const iw = imgEl.naturalWidth; | |
| scale = (cw / iw) * 0.95; | |
| panX = (cw - iw * scale) / 2; | |
| panY = 10; | |
| updateTransform(); | |
| } | |
| function getContainerCenter() { | |
| return { | |
| x: container.clientWidth / 2, | |
| y: container.clientHeight / 2 | |
| }; | |
| } | |
| function toggleOverlay() { | |
| showBboxOverlay = !showBboxOverlay; | |
| const btn = document.getElementById('btn-toggle-overlay'); | |
| if (showBboxOverlay) { | |
| btn.classList.add('active'); | |
| bboxOverlay.style.display = 'block'; | |
| } else { | |
| btn.classList.remove('active'); | |
| bboxOverlay.style.display = 'none'; | |
| } | |
| } | |
| // ------------------------------------------------------------- | |
| // Core Selection and 2-way Synchronization | |
| // ------------------------------------------------------------- | |
| function selectLine(lineId, focusEditor = true) { | |
| currentSelection.level = "line"; | |
| currentSelection.lineId = lineId; | |
| currentSelection.charId = null; | |
| currentSelection.bboxId = `bbox-line-${lineId}`; | |
| document.querySelectorAll('.bbox-rect').forEach(b => b.classList.remove('active')); | |
| document.querySelectorAll('.editor-line-row').forEach(row => row.classList.remove('active')); | |
| removeEstimatedCharBbox(); | |
| const bboxEl = document.getElementById(`bbox-line-${lineId}`); | |
| if (bboxEl) bboxEl.classList.add('active'); | |
| const rowEl = document.getElementById(`line-row-${lineId}`); | |
| if (rowEl) { | |
| rowEl.classList.add('active'); | |
| if (focusEditor) { | |
| rowEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| } | |
| // Update Line Action Strip | |
| updateLineActionStrip(lineId); | |
| } | |
| function updateLineActionStrip(lineId) { | |
| const stripInfo = document.getElementById('action-strip-info'); | |
| const stripButtons = document.getElementById('action-strip-buttons'); | |
| if (lineId === null || !responseLines[lineId]) { | |
| stripInfo.innerHTML = '<span style="color: var(--text-muted); font-size: 11.5px; font-style: italic;">Select a textline to execute merge, split, or verification</span>'; | |
| stripButtons.style.display = 'none'; | |
| return; | |
| } | |
| const item = responseLines[lineId]; | |
| const conf = Math.round((item.confidence || 0) * 100); | |
| const confColor = conf >= 75 ? 'var(--accent-green)' : (conf >= 45 ? 'var(--accent-orange)' : 'var(--accent-red)'); | |
| const confBg = conf >= 75 ? 'var(--accent-green-bg)' : (conf >= 45 ? 'var(--accent-orange-bg)' : 'var(--accent-red-bg)'); | |
| stripInfo.innerHTML = ` | |
| <strong style="color: var(--accent-primary);">Line #${lineId + 1}</strong> | |
| <span class="badge" style="background-color: ${confBg}; color: ${confColor}; border-color: ${confColor}; font-size: 10px;">${conf}%</span> | |
| <span style="color: var(--text-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px;">${item.text || '(Empty)'}</span> | |
| `; | |
| stripButtons.style.display = 'flex'; | |
| } | |
| function selectCharacter(lineId, charIdx, focusEditor = true) { | |
| currentSelection.level = "char"; | |
| currentSelection.lineId = lineId; | |
| currentSelection.charId = charIdx; | |
| currentSelection.bboxId = `bbox-line-${lineId}-char-${charIdx}`; | |
| document.querySelectorAll('.char-span').forEach(s => s.classList.remove('active')); | |
| document.querySelectorAll('.bbox-rect').forEach(b => b.classList.remove('active')); | |
| removeEstimatedCharBbox(); | |
| const charSpan = document.getElementById(`char-${lineId}-${charIdx}`); | |
| if (charSpan) { | |
| charSpan.classList.add('active'); | |
| if (focusEditor) { | |
| charSpan.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| } | |
| document.querySelectorAll('.editor-line-row').forEach(row => row.classList.remove('active')); | |
| const rowEl = document.getElementById(`line-row-${lineId}`); | |
| if (rowEl) rowEl.classList.add('active'); | |
| const lineBbox = document.getElementById(`bbox-line-${lineId}`); | |
| if (lineBbox) lineBbox.classList.add('active'); | |
| // Estimated char bbox calculation | |
| const lineData = responseLines[lineId]; | |
| if (lineData && lineData.bbox) { | |
| const box = lineData.bbox; | |
| const xmin = box[0], ymin = box[1], xmax = box[2], ymax = box[3]; | |
| const text = lineData.text || ""; | |
| const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); | |
| const segments = Array.from(segmenter.segment(text)); | |
| const charCount = segments.length; | |
| if (charCount > 0) { | |
| const canvas = document.createElement("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| ctx.font = "20px var(--font-sans), 'Google Sans Flex', sans-serif"; | |
| let totalWidth = 0; | |
| const widths = segments.map(seg => { | |
| const w = ctx.measureText(seg.segment).width; | |
| totalWidth += w; | |
| return w; | |
| }); | |
| let xOffset = 0; | |
| for (let i = 0; i < charIdx; i++) { | |
| xOffset += widths[i]; | |
| } | |
| const boxWidth = xmax - xmin; | |
| const finalCharXmin = Math.round(xmin + boxWidth * (xOffset / (totalWidth || 1))); | |
| const finalCharXmax = Math.round(xmin + boxWidth * ((xOffset + widths[charIdx]) / (totalWidth || 1))); | |
| let visualTop = (lineData.coords && lineData.coords.length === 2) | |
| ? Math.max(0, lineData.coords[0] - 2) | |
| : Math.max(0, ymin - 2); | |
| let visualHeight = (lineData.coords && lineData.coords.length === 2) | |
| ? (lineData.coords[1] - lineData.coords[0]) + 4 | |
| : (ymax - ymin) + 4; | |
| const charBbox = document.createElement('div'); | |
| charBbox.className = 'char-bbox-rect'; | |
| charBbox.id = 'char-bbox-indicator'; | |
| charBbox.style.left = finalCharXmin + "px"; | |
| charBbox.style.top = visualTop + "px"; | |
| charBbox.style.width = (finalCharXmax - finalCharXmin) + "px"; | |
| charBbox.style.height = visualHeight + "px"; | |
| bboxOverlay.appendChild(charBbox); | |
| const cx = (finalCharXmin + finalCharXmax) / 2; | |
| const cy = visualTop + visualHeight / 2; | |
| const containerRect = container.getBoundingClientRect(); | |
| const targetScale = Math.max(scale, 1.8); | |
| panX = containerRect.width / 2 - cx * targetScale; | |
| panY = containerRect.height / 2 - cy * targetScale; | |
| scale = targetScale; | |
| updateTransform(); | |
| } | |
| } | |
| updateLineActionStrip(lineId); | |
| } | |
| function removeEstimatedCharBbox() { | |
| const ind = document.getElementById('char-bbox-indicator'); | |
| if (ind) ind.remove(); | |
| } | |
| // ------------------------------------------------------------- | |
| // Core Rendering Functions | |
| // ------------------------------------------------------------- | |
| function renderBboxes() { | |
| bboxOverlay.innerHTML = ""; | |
| if (!responseLines || responseLines.length === 0) return; | |
| responseLines.forEach((item, idx) => { | |
| const box = item.bbox; | |
| if (!box) return; | |
| const left = box[0]; | |
| const width = box[2] - box[0]; | |
| let top = (item.coords && item.coords.length === 2) | |
| ? Math.max(0, item.coords[0] - 2) | |
| : Math.max(0, box[1] - 2); | |
| let height = (item.coords && item.coords.length === 2) | |
| ? (item.coords[1] - item.coords[0]) + 4 | |
| : (box[3] - box[1]) + 4; | |
| const rect = document.createElement('div'); | |
| rect.className = 'bbox-rect'; | |
| if (item.segmentation && item.segmentation.needs_review) { | |
| rect.classList.add('needs-review'); | |
| } | |
| rect.id = `bbox-line-${idx}`; | |
| rect.style.left = left + "px"; | |
| rect.style.top = top + "px"; | |
| rect.style.width = width + "px"; | |
| rect.style.height = height + "px"; | |
| rect.onclick = (e) => { | |
| e.stopPropagation(); | |
| selectLine(idx, true); | |
| }; | |
| bboxOverlay.appendChild(rect); | |
| }); | |
| if (currentSelection.lineId !== null) { | |
| if (currentSelection.level === "line") { | |
| selectLine(currentSelection.lineId, false); | |
| } else if (currentSelection.level === "char" && currentSelection.charId !== null) { | |
| selectCharacter(currentSelection.lineId, currentSelection.charId, false); | |
| } | |
| } | |
| } | |
| function toggleLineCrops() { | |
| showLineCrops = !showLineCrops; | |
| const btn = document.getElementById('btn-toggle-crops'); | |
| if (btn) { | |
| btn.classList.toggle('btn-primary', showLineCrops); | |
| } | |
| renderEditor(); | |
| } | |
| function zoomToLineBbox(lineIdx) { | |
| const item = responseLines[lineIdx]; | |
| if (!item || !item.bbox) return; | |
| const box = item.bbox; | |
| const cx = (box[0] + box[2]) / 2; | |
| const cy = (box[1] + box[3]) / 2; | |
| const containerRect = container.getBoundingClientRect(); | |
| const targetScale = Math.max(scale, 1.4); | |
| panX = containerRect.width / 2 - cx * targetScale; | |
| panY = containerRect.height / 2 - cy * targetScale; | |
| scale = targetScale; | |
| updateTransform(); | |
| } | |
| function updateLineCountBadge() { | |
| const badge = document.getElementById('line-count-badge'); | |
| if (badge) { | |
| const count = responseLines ? responseLines.length : 0; | |
| badge.innerText = `${count} lines`; | |
| } | |
| } | |
| function renderEditor() { | |
| const editorContainer = document.getElementById('editor-container'); | |
| editorContainer.innerHTML = ""; | |
| if (!responseLines || responseLines.length === 0) { | |
| editorContainer.innerHTML = '<div style="color: var(--text-muted); text-align: center; margin-top: 50px; font-size: 13px;">No transcription data available.<br>Upload a manuscript image and click <strong>Execute Transcription</strong>.</div>'; | |
| updateLineActionStrip(null); | |
| return; | |
| } | |
| responseLines.forEach((item, idx) => { | |
| const score = item.confidence || 0.0; | |
| const confLevel = score >= 0.75 ? "high" : (score >= 0.45 ? "medium" : "low"); | |
| const row = document.createElement('div'); | |
| row.className = 'editor-line-row'; | |
| row.id = `line-row-${idx}`; | |
| row.onclick = () => selectLine(idx, false); | |
| const gutter = document.createElement('div'); | |
| gutter.className = 'line-gutter'; | |
| gutter.innerText = idx + 1; | |
| const status = document.createElement('div'); | |
| status.className = `line-status ${confLevel}`; | |
| status.title = `Confidence: ${Math.round(score * 100)}%`; | |
| const textContainer = document.createElement('div'); | |
| textContainer.className = 'line-text-container'; | |
| // Optional Line Crop Preview | |
| if (showLineCrops && item.image) { | |
| const cropPreview = document.createElement('div'); | |
| cropPreview.className = 'line-crop-preview'; | |
| cropPreview.title = `Textline cutout #${idx + 1} (Click to zoom)`; | |
| cropPreview.onclick = (e) => { | |
| e.stopPropagation(); | |
| selectLine(idx, true); | |
| zoomToLineBbox(idx); | |
| }; | |
| cropPreview.innerHTML = `<img src="data:image/png;base64,${item.image}" alt="Line #${idx + 1}">`; | |
| textContainer.appendChild(cropPreview); | |
| } | |
| // Normal Mode View (spans click-to-highlight) | |
| const textView = document.createElement('div'); | |
| textView.className = 'line-text-view'; | |
| textView.id = `line-text-view-${idx}`; | |
| textView.ondblclick = () => enterEditMode(idx); | |
| const text = item.text || ""; | |
| if (text === "") { | |
| textView.innerHTML = '<span style="color: var(--text-muted); font-style: italic; font-size: 13px;">(Empty line — Double-click to transcribe)</span>'; | |
| } else { | |
| const lineSpan = document.createElement('span'); | |
| lineSpan.className = 'line-span'; | |
| if (score < 0.60) { | |
| lineSpan.classList.add('low-confidence'); | |
| } | |
| const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); | |
| const segments = Array.from(segmenter.segment(text)); | |
| segments.forEach((seg, c) => { | |
| const charSpan = document.createElement('span'); | |
| charSpan.className = 'char-span'; | |
| charSpan.id = `char-${idx}-${c}`; | |
| charSpan.innerText = seg.segment; | |
| charSpan.onclick = (e) => { | |
| e.stopPropagation(); | |
| selectCharacter(idx, c); | |
| }; | |
| lineSpan.appendChild(charSpan); | |
| }); | |
| textView.appendChild(lineSpan); | |
| } | |
| // Edit Mode View | |
| const textEdit = document.createElement('input'); | |
| textEdit.className = 'line-text-edit'; | |
| textEdit.type = 'text'; | |
| textEdit.id = `line-text-edit-${idx}`; | |
| textEdit.value = text; | |
| textEdit.onfocus = () => { | |
| lastActiveInputElement = textEdit; | |
| }; | |
| textEdit.onblur = () => exitEditMode(idx, true); | |
| textEdit.onkeydown = (e) => { | |
| if (e.key === 'Enter') exitEditMode(idx, true); | |
| if (e.key === 'Escape') exitEditMode(idx, false); | |
| }; | |
| textContainer.appendChild(textView); | |
| textContainer.appendChild(textEdit); | |
| row.appendChild(gutter); | |
| row.appendChild(status); | |
| row.appendChild(textContainer); | |
| editorContainer.appendChild(row); | |
| }); | |
| if (currentSelection.lineId !== null) { | |
| updateLineActionStrip(currentSelection.lineId); | |
| } | |
| } | |
| function enterEditMode(idx) { | |
| const viewEl = document.getElementById(`line-text-view-${idx}`); | |
| const editEl = document.getElementById(`line-text-edit-${idx}`); | |
| if (viewEl && editEl) { | |
| viewEl.style.display = 'none'; | |
| editEl.style.display = 'block'; | |
| lastActiveInputElement = editEl; | |
| editEl.focus(); | |
| } | |
| } | |
| function exitEditMode(idx, saveChanges) { | |
| const viewEl = document.getElementById(`line-text-view-${idx}`); | |
| const editEl = document.getElementById(`line-text-edit-${idx}`); | |
| if (!viewEl || !editEl) return; | |
| if (saveChanges) { | |
| const newVal = editEl.value; | |
| if (responseLines[idx] && responseLines[idx].text !== newVal) { | |
| responseLines[idx].text = newVal; | |
| responseLines[idx].confidence = 1.0; | |
| renderEditor(); | |
| renderBboxes(); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Verified'); | |
| saveCorrectionToBackend(idx, newVal); | |
| } | |
| } | |
| editEl.style.display = 'none'; | |
| viewEl.style.display = 'block'; | |
| } | |
| function saveCorrectionToBackend(lineIdx, text) { | |
| if (!text || !text.trim()) return; | |
| const activeItem = imageQueue.find(x => x.active === true); | |
| const imageName = activeItem ? activeItem.name : "unknown.png"; | |
| fetch('/save_correction', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| text: text.trim(), | |
| line_idx: lineIdx + 1, | |
| image_name: imageName | |
| }) | |
| }).then(res => res.json()) | |
| .then(data => { | |
| appendLog(`Correction recorded for line #${lineIdx + 1} in ocr_corrections.txt`, 'success'); | |
| }).catch(err => { | |
| console.warn("Failed to save correction to backend:", err); | |
| }); | |
| } | |
| // ------------------------------------------------------------- | |
| // Queue and File Upload Management | |
| // ------------------------------------------------------------- | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| processImageFile(file); | |
| } | |
| container.addEventListener('dragover', (e) => e.preventDefault()); | |
| container.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith('image/')) { | |
| processImageFile(file); | |
| } | |
| }); | |
| function generateThumbnail(base64Str, maxWidth = 120, maxHeight = 120) { | |
| return new Promise((resolve) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| let width = img.width; | |
| let height = img.height; | |
| if (width > maxWidth || height > maxHeight) { | |
| const ratio = Math.min(maxWidth / width, maxHeight / height); | |
| width = width * ratio; | |
| height = height * ratio; | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| resolve(canvas.toDataURL('image/jpeg', 0.6).split(',')[1]); | |
| }; | |
| img.src = 'data:image/png;base64,' + base64Str; | |
| }); | |
| } | |
| async function processImageFile(file) { | |
| appendLog(`Loaded manuscript image: ${file.name || 'Clipboard image'} (${(file.size / 1024).toFixed(1)} KB)`, 'info'); | |
| const reader = new FileReader(); | |
| reader.onload = async function(event) { | |
| currentImageBase64 = event.target.result.split(',')[1]; | |
| const thumbB64 = await generateThumbnail(currentImageBase64); | |
| imgEl.onload = function() { | |
| originalImageWidth = imgEl.naturalWidth; | |
| originalImageHeight = imgEl.naturalHeight; | |
| document.getElementById('no-image-placeholder').style.display = 'none'; | |
| imgEl.style.display = 'block'; | |
| zoomToFit(); | |
| const fileName = file.name || ('clipboard_' + Date.now() + '.png'); | |
| const queueItem = { | |
| id: 'img_' + Date.now(), | |
| name: fileName, | |
| image: currentImageBase64, | |
| thumb: thumbB64, | |
| time: new Date().toLocaleTimeString(), | |
| model: document.getElementById('model-select').value, | |
| status: 'Not processed', | |
| lines: [], | |
| active: true | |
| }; | |
| imageQueue.forEach(item => item.active = false); | |
| imageQueue.unshift(queueItem); | |
| saveItemToDB(queueItem); | |
| saveQueueToStorage(); | |
| renderQueue(); | |
| document.getElementById('btn-run').removeAttribute('disabled'); | |
| document.getElementById('header-doc-title').innerText = fileName; | |
| document.getElementById('doc-status-dot').classList.add('active'); | |
| responseLines = []; | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| }; | |
| imgEl.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function runOCR() { | |
| const btn = document.getElementById('btn-run'); | |
| btn.setAttribute('disabled', 'true'); | |
| btn.innerHTML = '<div class="ocr-spinner" style="width:14px;height:14px;border-width:2px;border-top-color:#fff;"></div><span>Transcribing...</span>'; | |
| const modelSelect = document.getElementById('model-select'); | |
| const modelName = modelSelect.options[modelSelect.selectedIndex].text; | |
| const loader = document.getElementById('ocr-loading-overlay'); | |
| const loaderText = document.getElementById('ocr-loading-text'); | |
| loaderText.innerText = `Transcribing manuscript with ${modelName}...`; | |
| loader.style.display = 'flex'; | |
| updateActiveQueueItemStatus('Processing'); | |
| const payload = { | |
| image: currentImageBase64, | |
| model: modelSelect.value, | |
| method: 'dbnet' | |
| }; | |
| appendLog(`Executing OCR transcription with: ${modelName} (DBNet Text Detector)`, 'info'); | |
| fetch('/ocr', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| if (data.error) { | |
| showToast('Error: ' + data.error); | |
| appendLog('Server Error: ' + data.error, 'error'); | |
| updateActiveQueueItemStatus('Not processed'); | |
| } else { | |
| responseLines = data.lines || []; | |
| let needsReview = responseLines.some(line => line.segmentation && line.segmentation.needs_review); | |
| let finalStatus = needsReview ? 'Needs review' : 'Verified'; | |
| updateActiveQueueItemStatus(finalStatus, responseLines); | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| showToast(`Transcription completed: ${responseLines.length} lines extracted!`); | |
| appendLog(`Extracted ${responseLines.length} textlines. Status: ${finalStatus}`, 'success'); | |
| } | |
| }) | |
| .catch(err => { | |
| showToast('Server connection error'); | |
| appendLog('Server connection error: ' + err, 'error'); | |
| updateActiveQueueItemStatus('Not processed'); | |
| }) | |
| .finally(() => { | |
| loader.style.display = 'none'; | |
| btn.removeAttribute('disabled'); | |
| btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polygon points="5 3 19 12 5 21 5 3"/></svg><span>Execute Transcription</span>'; | |
| }); | |
| } | |
| function updateActiveQueueItemStatus(status, lines = null) { | |
| const idx = imageQueue.findIndex(x => x.active === true); | |
| if (idx !== -1) { | |
| updateQueueItemStatus(idx, status, lines); | |
| } | |
| } | |
| function updateQueueItemStatus(idx, status, lines = null) { | |
| if (idx !== -1 && imageQueue[idx]) { | |
| imageQueue[idx].status = status; | |
| if (lines) { | |
| imageQueue[idx].lines = lines; | |
| } | |
| saveItemToDB(imageQueue[idx]); | |
| saveQueueToStorage(); | |
| renderQueue(); | |
| } | |
| } | |
| async function selectQueueItem(id) { | |
| const item = imageQueue.find(x => x.id === id); | |
| if (!item) return; | |
| imageQueue.forEach(x => x.active = (x.id === id)); | |
| renderQueue(); | |
| document.getElementById('header-doc-title').innerText = item.name; | |
| document.getElementById('doc-status-dot').classList.add('active'); | |
| if (!item.image) { | |
| const dbItem = await loadItemFromDB(id); | |
| if (dbItem && dbItem.image) { | |
| item.image = dbItem.image; | |
| item.lines = dbItem.lines || []; | |
| } | |
| } | |
| if (item.image) { | |
| currentImageBase64 = item.image; | |
| document.getElementById('no-image-placeholder').style.display = 'none'; | |
| imgEl.style.display = 'block'; | |
| imgEl.onload = function() { | |
| originalImageWidth = imgEl.naturalWidth; | |
| originalImageHeight = imgEl.naturalHeight; | |
| zoomToFit(); | |
| responseLines = item.lines || []; | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| }; | |
| imgEl.src = 'data:image/png;base64,' + item.image; | |
| document.getElementById('btn-run').removeAttribute('disabled'); | |
| } | |
| } | |
| function renderQueue() { | |
| const qList = document.getElementById('queue-list'); | |
| qList.innerHTML = ""; | |
| if (!imageQueue || imageQueue.length === 0) { | |
| qList.innerHTML = '<div style="color: var(--text-muted); padding: 24px 12px; text-align: center; font-size: 11.5px;">Manuscript queue empty.<br>Click <strong>Upload Manuscript</strong> to begin.</div>'; | |
| return; | |
| } | |
| imageQueue.forEach(item => { | |
| const row = document.createElement('div'); | |
| row.className = `queue-item ${item.active ? 'active' : ''}`; | |
| row.onclick = () => selectQueueItem(item.id); | |
| const thumb = document.createElement('div'); | |
| thumb.className = 'queue-thumb'; | |
| if (item.thumb) { | |
| thumb.innerHTML = `<img src="data:image/jpeg;base64,${item.thumb}" alt="">`; | |
| } else if (item.image) { | |
| thumb.innerHTML = `<img src="data:image/png;base64,${item.image}" alt="">`; | |
| } else { | |
| thumb.innerHTML = `⚠️`; | |
| } | |
| const info = document.createElement('div'); | |
| info.className = 'queue-info'; | |
| const name = document.createElement('div'); | |
| name.className = 'queue-name'; | |
| name.innerText = item.name; | |
| name.title = item.name; | |
| const meta = document.createElement('div'); | |
| meta.className = 'queue-meta'; | |
| const timeSpan = document.createElement('span'); | |
| timeSpan.innerText = item.time; | |
| const statusSpan = document.createElement('span'); | |
| statusSpan.className = `status-badge status-${item.status.toLowerCase().replace(' ', '_')}`; | |
| statusSpan.innerText = item.status; | |
| meta.appendChild(timeSpan); | |
| meta.appendChild(statusSpan); | |
| info.appendChild(name); | |
| info.appendChild(meta); | |
| row.appendChild(thumb); | |
| row.appendChild(info); | |
| qList.appendChild(row); | |
| }); | |
| } | |
| // ------------------------------------------------------------- | |
| // Quick Review, Line Actions & Shortcuts | |
| // ------------------------------------------------------------- | |
| function acceptSelectedLine() { | |
| if (currentSelection.lineId !== null) { | |
| const lineId = currentSelection.lineId; | |
| responseLines[lineId].confidence = 1.0; | |
| if (responseLines[lineId].segmentation) { | |
| responseLines[lineId].segmentation.needs_review = false; | |
| } | |
| renderEditor(); | |
| renderBboxes(); | |
| showToast(`Verified line #${lineId + 1}`); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Verified'); | |
| saveCorrectionToBackend(lineId, responseLines[lineId].text); | |
| if (lineId + 1 < responseLines.length) { | |
| selectLine(lineId + 1); | |
| } | |
| } else { | |
| showToast("Please select a textline first"); | |
| } | |
| } | |
| function markSelectedWrong() { | |
| if (currentSelection.lineId !== null) { | |
| const lineId = currentSelection.lineId; | |
| responseLines[lineId].confidence = 0.0; | |
| if (responseLines[lineId].segmentation) { | |
| responseLines[lineId].segmentation.needs_review = true; | |
| if (!responseLines[lineId].segmentation.reason.includes('user_flagged_wrong')) { | |
| responseLines[lineId].segmentation.reason.push('user_flagged_wrong'); | |
| } | |
| } | |
| renderEditor(); | |
| renderBboxes(); | |
| showToast(`Flagged line #${lineId + 1} as erroneous`); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Needs review'); | |
| } else { | |
| showToast("Please select a textline first"); | |
| } | |
| } | |
| function splitSelectedLine() { | |
| if (currentSelection.lineId !== null) { | |
| const lineId = currentSelection.lineId; | |
| const text = responseLines[lineId].text || ""; | |
| if (text.length <= 1) { | |
| showToast("Textline is too short to split"); | |
| return; | |
| } | |
| const half = Math.ceil(text.length / 2); | |
| const firstHalf = text.substring(0, half); | |
| const secondHalf = text.substring(half); | |
| const box = responseLines[lineId].bbox || [0, 0, 0, 0]; | |
| const y_mid = Math.round((box[1] + box[3]) / 2); | |
| const line1 = JSON.parse(JSON.stringify(responseLines[lineId])); | |
| line1.text = firstHalf; | |
| line1.bbox[3] = y_mid; | |
| line1.confidence = 1.0; | |
| const line2 = JSON.parse(JSON.stringify(responseLines[lineId])); | |
| line2.text = secondHalf; | |
| line2.bbox[1] = y_mid; | |
| line2.confidence = 1.0; | |
| responseLines.splice(lineId, 1, line1, line2); | |
| responseLines.forEach((l, idx) => l.index = idx + 1); | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| showToast(`Split line #${lineId + 1} into two segments`); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Verified'); | |
| } else { | |
| showToast("Please select a textline first"); | |
| } | |
| } | |
| function mergeSelectedWithNextLine() { | |
| if (currentSelection.lineId === null) { | |
| showToast("Please select a textline first"); | |
| return; | |
| } | |
| const lineId = currentSelection.lineId; | |
| if (lineId + 1 >= responseLines.length) { | |
| showToast("Selected line is the last line; cannot merge downward"); | |
| return; | |
| } | |
| const lineA = responseLines[lineId]; | |
| const lineB = responseLines[lineId + 1]; | |
| const textA = (lineA.text || "").trim(); | |
| const textB = (lineB.text || "").trim(); | |
| const mergedText = textA && textB ? (textA + " " + textB) : (textA || textB); | |
| const boxA = lineA.bbox || [0, 0, 0, 0]; | |
| const boxB = lineB.bbox || [0, 0, 0, 0]; | |
| const mergedBbox = [ | |
| Math.min(boxA[0], boxB[0]), | |
| Math.min(boxA[1], boxB[1]), | |
| Math.max(boxA[2], boxB[2]), | |
| Math.max(boxA[3], boxB[3]) | |
| ]; | |
| const coordsA = lineA.coords || [boxA[1], boxA[3]]; | |
| const coordsB = lineB.coords || [boxB[1], boxB[3]]; | |
| const mergedCoords = [ | |
| Math.min(coordsA[0], coordsB[0]), | |
| Math.max(coordsA[1], coordsB[1]) | |
| ]; | |
| lineA.text = mergedText; | |
| lineA.bbox = mergedBbox; | |
| lineA.coords = mergedCoords; | |
| lineA.confidence = Math.min(1.0, (Number(lineA.confidence || 0.8) + Number(lineB.confidence || 0.8)) / 2); | |
| if (lineA.segmentation) { | |
| lineA.segmentation.needs_review = false; | |
| } | |
| responseLines.splice(lineId + 1, 1); | |
| responseLines.forEach((l, idx) => l.index = idx + 1); | |
| renderEditor(); | |
| renderBboxes(); | |
| updateLineCountBadge(); | |
| selectLine(lineId, true); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Verified'); | |
| saveCorrectionToBackend(lineId, mergedText); | |
| showToast(`Merged line #${lineId + 1} and #${lineId + 2}`); | |
| } | |
| // ------------------------------------------------------------- | |
| // Floating Cham Virtual Keyboard (Akhar Thrah) Engine | |
| // ------------------------------------------------------------- | |
| const CHAM_KEYBOARD_LAYOUTS = { | |
| '5': [ | |
| // Row 1: Digits (10) + Independent Vowels (6) + Apostrophe (1) = 17 keys | |
| [ | |
| { char: '꩑', display: '꩑', trad: '1', sanskrit: '1', title: 'U+AA51 Cham Digit One' }, | |
| { char: '꩒', display: '꩒', trad: '2', sanskrit: '2', title: 'U+AA52 Cham Digit Two' }, | |
| { char: '꩓', display: '꩓', trad: '3', sanskrit: '3', title: 'U+AA53 Cham Digit Three' }, | |
| { char: '꩔', display: '꩔', trad: '4', sanskrit: '4', title: 'U+AA54 Cham Digit Four' }, | |
| { char: '꩕', display: '꩕', trad: '5', sanskrit: '5', title: 'U+AA55 Cham Digit Five' }, | |
| { char: '꩖', display: '꩖', trad: '6', sanskrit: '6', title: 'U+AA56 Cham Digit Six' }, | |
| { char: '꩗', display: '꩗', trad: '7', sanskrit: '7', title: 'U+AA57 Cham Digit Seven' }, | |
| { char: '꩘', display: '꩘', trad: '8', sanskrit: '8', title: 'U+AA58 Cham Digit Eight' }, | |
| { char: '꩙', display: '꩙', trad: '9', sanskrit: '9', title: 'U+AA59 Cham Digit Nine' }, | |
| { char: '꩐', display: '꩐', trad: '0', sanskrit: '0', title: 'U+AA50 Cham Digit Zero' }, | |
| { char: 'ꨀ', display: 'ꨀ', trad: 'a', sanskrit: 'a', title: 'U+AA00 Cham Letter A' }, | |
| { char: 'ꨁ', display: 'ꨁ', trad: 'i', sanskrit: 'i', title: 'U+AA01 Cham Letter I' }, | |
| { char: 'ꨂ', display: 'ꨂ', trad: 'u', sanskrit: 'u', title: 'U+AA02 Cham Letter U' }, | |
| { char: 'ꨃ', display: 'ꨃ', trad: 'e', sanskrit: 'e', title: 'U+AA03 Cham Letter E' }, | |
| { char: 'ꨄ', display: 'ꨄ', trad: 'ai', sanskrit: 'ai', title: 'U+AA04 Cham Letter Ai' }, | |
| { char: 'ꨅ', display: 'ꨅ', trad: 'o', sanskrit: 'o', title: 'U+AA05 Cham Letter O' }, | |
| { char: "'", display: "'", trad: "'", sanskrit: "'", title: 'Apostrophe' } | |
| ], | |
| // Row 2: Velars (6), Palatals (6), Retroflex & Dentals 1 (6) = 18 keys | |
| [ | |
| { char: 'ꨆ', display: 'ꨆ', trad: 'ka', sanskrit: 'ka', title: 'U+AA06 Cham Letter Ka' }, | |
| { char: 'ꨇ', display: 'ꨇ', trad: 'kha', sanskrit: 'kha', title: 'U+AA07 Cham Letter Kha' }, | |
| { char: 'ꨈ', display: 'ꨈ', trad: 'ga', sanskrit: 'ga', title: 'U+AA08 Cham Letter Ga' }, | |
| { char: 'ꨉ', display: 'ꨉ', trad: 'gha', sanskrit: 'gha', title: 'U+AA09 Cham Letter Gha' }, | |
| { char: 'ꨊ', display: 'ꨊ', trad: 'nga', sanskrit: 'ṅa', title: 'U+AA0A Cham Letter Nga' }, | |
| { char: 'ꨋ', display: 'ꨋ', trad: 'nga', sanskrit: 'ṅga', title: 'U+AA0B Cham Letter Nga-final' }, | |
| { char: 'ꨌ', display: 'ꨌ', trad: 'ca', sanskrit: 'ca', title: 'U+AA0C Cham Letter Ca' }, | |
| { char: 'ꨍ', display: 'ꨍ', trad: 'cha', sanskrit: 'cha', title: 'U+AA0D Cham Letter Cha' }, | |
| { char: 'ꨎ', display: 'ꨎ', trad: 'ja', sanskrit: 'ja', title: 'U+AA0E Cham Letter Ja' }, | |
| { char: 'ꨏ', display: 'ꨏ', trad: 'jha', sanskrit: 'jha', title: 'U+AA0F Cham Letter Jha' }, | |
| { char: 'ꨐ', display: 'ꨐ', trad: 'nya', sanskrit: 'ña', title: 'U+AA10 Cham Letter Nya' }, | |
| { char: 'ꨑ', display: 'ꨑ', trad: 'nya', sanskrit: 'ñja', title: 'U+AA11 Cham Letter Nya-final' }, | |
| { char: 'ꨒ', display: 'ꨒ', trad: 'nja', sanskrit: 'ṇḍa', title: 'U+AA12 Cham Letter Nja' }, | |
| { char: 'ꨓ', display: 'ꨓ', trad: 'ta', sanskrit: 'ta', title: 'U+AA13 Cham Letter Ta' }, | |
| { char: 'ꨔ', display: 'ꨔ', trad: 'tha', sanskrit: 'tha', title: 'U+AA14 Cham Letter Tha' }, | |
| { char: 'ꨕ', display: 'ꨕ', trad: 'da', sanskrit: 'da', title: 'U+AA15 Cham Letter Da' }, | |
| { char: 'ꨖ', display: 'ꨖ', trad: 'dha', sanskrit: 'dha', title: 'U+AA16 Cham Letter Dha' }, | |
| { char: 'ꨗ', display: 'ꨗ', trad: 'na', sanskrit: 'na', title: 'U+AA17 Cham Letter Na' } | |
| ], | |
| // Row 3: Dentals 2 (2), Labials (8), Semivowels, Sibilants, Ha (7), Backspace (1) = 18 keys | |
| [ | |
| { char: 'ꨘ', display: 'ꨘ', trad: 'na', sanskrit: 'ṇa', title: 'U+AA18 Cham Letter Na-final' }, | |
| { char: 'ꨙ', display: 'ꨙ', trad: 'nda', sanskrit: 'nda', title: 'U+AA19 Cham Letter Nda' }, | |
| { char: 'ꨚ', display: 'ꨚ', trad: 'pa', sanskrit: 'pa', title: 'U+AA1A Cham Letter Pa' }, | |
| { char: 'ꨛ', display: 'ꨛ', trad: 'pa', sanskrit: 'pha', title: 'U+AA1B Cham Letter Pa-final' }, | |
| { char: 'ꨜ', display: 'ꨜ', trad: 'pha', sanskrit: 'ba', title: 'U+AA1C Cham Letter Pha' }, | |
| { char: 'ꨝ', display: 'ꨝ', trad: 'ba', sanskrit: 'bha', title: 'U+AA1D Cham Letter Ba' }, | |
| { char: 'ꨞ', display: 'ꨞ', trad: 'bha', sanskrit: 'ma', title: 'U+AA1E Cham Letter Bha' }, | |
| { char: 'ꨟ', display: 'ꨟ', trad: 'ma', sanskrit: 'ma', title: 'U+AA1F Cham Letter Ma' }, | |
| { char: 'ꨠ', display: 'ꨠ', trad: 'ma', sanskrit: 'mba', title: 'U+AA20 Cham Letter Ma-final' }, | |
| { char: 'ꨡ', display: 'ꨡ', trad: 'mba', sanskrit: 'ya', title: 'U+AA21 Cham Letter Mba' }, | |
| { char: 'ꨢ', display: 'ꨢ', trad: 'ya', sanskrit: 'ya', title: 'U+AA22 Cham Letter Ya' }, | |
| { char: 'ꨣ', display: 'ꨣ', trad: 'ra', sanskrit: 'ra', title: 'U+AA23 Cham Letter Ra' }, | |
| { char: 'ꨤ', display: 'ꨤ', trad: 'la', sanskrit: 'la', title: 'U+AA24 Cham Letter La' }, | |
| { char: 'ꨥ', display: 'ꨥ', trad: 'wa', sanskrit: 'va', title: 'U+AA25 Cham Letter Wa' }, | |
| { char: 'ꨦ', display: 'ꨦ', trad: 'sa', sanskrit: 'śa', title: 'U+AA26 Cham Letter Sa/Sha' }, | |
| { char: 'ꨧ', display: 'ꨧ', trad: 'sa', sanskrit: 'sa', title: 'U+AA27 Cham Letter Sa' }, | |
| { char: 'ꨨ', display: 'ꨨ', trad: 'ha', sanskrit: 'ha', title: 'U+AA28 Cham Letter Ha' }, | |
| { type: 'backspace', display: '⌫', trad: 'Back', sanskrit: 'Back', title: 'Backspace (Delete previous character)' } | |
| ], | |
| // Row 4: Dependent Vowels (10), Medials (4), Cham Punctuation (4) = 18 keys | |
| [ | |
| { char: 'ꨩ', display: '<span class="dotted-circle">◌</span>ꨩ', trad: '-a', sanskrit: '-ā', title: 'U+AA29 Vowel Sign Aa' }, | |
| { char: 'ꨪ', display: '<span class="dotted-circle">◌</span>ꨪ', trad: '-i', sanskrit: '-i', title: 'U+AA2A Vowel Sign I' }, | |
| { char: 'ꨫ', display: '<span class="dotted-circle">◌</span>ꨫ', trad: '-i', sanskrit: '-ī', title: 'U+AA2B Vowel Sign Ii' }, | |
| { char: 'ꨬ', display: '<span class="dotted-circle">◌</span>ꨬ', trad: '-ei', sanskrit: '-ei', title: 'U+AA2C Vowel Sign Ei' }, | |
| { char: 'ꨭ', display: '<span class="dotted-circle">◌</span>ꨭ', trad: '-u', sanskrit: '-u', title: 'U+AA2D Vowel Sign U' }, | |
| { char: 'ꨮ', display: '<span class="dotted-circle">◌</span>ꨮ', trad: '-e', sanskrit: '-ē', title: 'U+AA2E Vowel Sign E' }, | |
| { char: 'ꨯ', display: 'ꨯ<span class="dotted-circle">◌</span>', trad: '-o', sanskrit: '-o', title: 'U+AA2F Vowel Sign O' }, | |
| { char: 'ꨰ', display: 'ꨰ<span class="dotted-circle">◌</span>', trad: '-ai', sanskrit: '-ai', title: 'U+AA30 Vowel Sign Ai' }, | |
| { char: 'ꨱ', display: '<span class="dotted-circle">◌</span>ꨱ', trad: '-ao', sanskrit: '-ao', title: 'U+AA31 Vowel Sign Ao' }, | |
| { char: 'ꨲ', display: '<span class="dotted-circle">◌</span>ꨲ', trad: '-â', sanskrit: '-au', title: 'U+AA32 Vowel Sign Au' }, | |
| { char: 'ꨳ', display: '<span class="dotted-circle">◌</span>ꨳ', trad: '-ia', sanskrit: '-ya', title: 'U+AA33 Medial Ya' }, | |
| { char: 'ꨴ', display: '<span class="dotted-circle">◌</span>ꨴ', trad: '-r', sanskrit: '-ra', title: 'U+AA34 Medial Ra' }, | |
| { char: 'ꨵ', display: '<span class="dotted-circle">◌</span>ꨵ', trad: '-l', sanskrit: '-la', title: 'U+AA35 Medial La' }, | |
| { char: 'ꨶ', display: '<span class="dotted-circle">◌</span>ꨶ', trad: '-ua', sanskrit: '-va', title: 'U+AA36 Medial Wa' }, | |
| { char: '꩜', display: '꩜', trad: '@', sanskrit: '@', title: 'U+AA5C Cham Spiral' }, | |
| { char: '꩝', display: '꩝', trad: ',', sanskrit: ',', title: 'U+AA5D Cham Danda' }, | |
| { char: '꩞', display: '꩞', trad: ';', sanskrit: ';', title: 'U+AA5E Cham Double Danda' }, | |
| { char: '꩟', display: '꩟', trad: '.', sanskrit: '.', title: 'U+AA5F Cham Triple Danda' } | |
| ], | |
| // Row 5: Finals (15), Parentheses (2), Space (1), Enter (1) = 19 keys | |
| [ | |
| { char: 'ꩀ', display: 'ꩀ', trad: 'k', sanskrit: '-k', title: 'U+AA40 Final K' }, | |
| { char: 'ꩁ', display: 'ꩁ', trad: 'g', sanskrit: '-g', title: 'U+AA41 Final G' }, | |
| { char: 'ꩂ', display: 'ꩂ', trad: 'ng', sanskrit: '-ṅ', title: 'U+AA42 Final Ng' }, | |
| { char: 'ꩃ', display: '<span class="dotted-circle">◌</span>ꩃ', trad: 'ng', sanskrit: '-ṅg', title: 'U+AA43 Consonant Sign Kang' }, | |
| { char: 'ꩄ', display: 'ꩄ', trad: 'c', sanskrit: '-c', title: 'U+AA44 Final C' }, | |
| { char: 'ꩅ', display: 'ꩅ', trad: 't', sanskrit: '-t', title: 'U+AA45 Final T' }, | |
| { char: 'ꩆ', display: 'ꩆ', trad: 'n', sanskrit: '-n', title: 'U+AA46 Final N' }, | |
| { char: 'ꩇ', display: 'ꩇ', trad: 'p', sanskrit: '-p', title: 'U+AA47 Final P' }, | |
| { char: 'ꩈ', display: 'ꩈ', trad: 'y', sanskrit: '-y', title: 'U+AA48 Final Y' }, | |
| { char: 'ꩉ', display: 'ꩉ', trad: 'r', sanskrit: '-r', title: 'U+AA49 Final R' }, | |
| { char: 'ꩊ', display: 'ꩊ', trad: 'l', sanskrit: '-l', title: 'U+AA4A Final L' }, | |
| { char: 'ꩋ', display: 'ꩋ', trad: 's', sanskrit: '-s', title: 'U+AA4B Final S' }, | |
| { char: 'ꩌ', display: '<span class="dotted-circle">◌</span>ꩌ', trad: 'm', sanskrit: '-ṃ', title: 'U+AA4C Sign Final M (Anusvara)' }, | |
| { char: 'ꩍ', display: '<span class="dotted-circle">◌</span>ꩍ', trad: 'h', sanskrit: '-ḥ', title: 'U+AA4D Sign Final H (Visarga)' }, | |
| { char: 'ꨥ', display: 'ꨥ', trad: 'w', sanskrit: '-v', title: 'U+AA25 Final Wa' }, | |
| { char: '(', display: '(', trad: '(', sanskrit: '(', title: 'Left Parenthesis' }, | |
| { char: ')', display: ')', trad: ')', sanskrit: ')', title: 'Right Parenthesis' }, | |
| { type: 'space', char: ' ', display: 'ꨌꩌꨛꨩ', trad: 'Space', sanskrit: 'Space', title: 'Space (ꨌꩌꨛꨩ / Champa)' }, | |
| { type: 'enter', display: '↵', trad: 'Enter', sanskrit: 'Enter', title: 'Enter (Submit / Next line)' } | |
| ] | |
| ], | |
| '6': [ | |
| // Row 1: Digits (10) + Independent Vowels (4) = 14 keys | |
| [ | |
| { char: '꩑', display: '꩑', trad: '1', sanskrit: '1', title: 'U+AA51 Cham Digit One' }, | |
| { char: '꩒', display: '꩒', trad: '2', sanskrit: '2', title: 'U+AA52 Cham Digit Two' }, | |
| { char: '꩓', display: '꩓', trad: '3', sanskrit: '3', title: 'U+AA53 Cham Digit Three' }, | |
| { char: '꩔', display: '꩔', trad: '4', sanskrit: '4', title: 'U+AA54 Cham Digit Four' }, | |
| { char: '꩕', display: '꩕', trad: '5', sanskrit: '5', title: 'U+AA55 Cham Digit Five' }, | |
| { char: '꩖', display: '꩖', trad: '6', sanskrit: '6', title: 'U+AA56 Cham Digit Six' }, | |
| { char: '꩗', display: '꩗', trad: '7', sanskrit: '7', title: 'U+AA57 Cham Digit Seven' }, | |
| { char: '꩘', display: '꩘', trad: '8', sanskrit: '8', title: 'U+AA58 Cham Digit Eight' }, | |
| { char: '꩙', display: '꩙', trad: '9', sanskrit: '9', title: 'U+AA59 Cham Digit Nine' }, | |
| { char: '꩐', display: '꩐', trad: '0', sanskrit: '0', title: 'U+AA50 Cham Digit Zero' }, | |
| { char: 'ꨀ', display: 'ꨀ', trad: 'a', sanskrit: 'a', title: 'U+AA00 Cham Letter A' }, | |
| { char: 'ꨁ', display: 'ꨁ', trad: 'i', sanskrit: 'i', title: 'U+AA01 Cham Letter I' }, | |
| { char: 'ꨂ', display: 'ꨂ', trad: 'u', sanskrit: 'u', title: 'U+AA02 Cham Letter U' }, | |
| { char: 'ꨃ', display: 'ꨃ', trad: 'e', sanskrit: 'e', title: 'U+AA03 Cham Letter E' } | |
| ], | |
| // Row 2: Vowels (2) + Velars (6) + Palatals (6) = 14 keys | |
| [ | |
| { char: 'ꨄ', display: 'ꨄ', trad: 'ai', sanskrit: 'ai', title: 'U+AA04 Cham Letter Ai' }, | |
| { char: 'ꨅ', display: 'ꨅ', trad: 'o', sanskrit: 'o', title: 'U+AA05 Cham Letter O' }, | |
| { char: 'ꨆ', display: 'ꨆ', trad: 'ka', sanskrit: 'ka', title: 'U+AA06 Cham Letter Ka' }, | |
| { char: 'ꨇ', display: 'ꨇ', trad: 'kha', sanskrit: 'kha', title: 'U+AA07 Cham Letter Kha' }, | |
| { char: 'ꨈ', display: 'ꨈ', trad: 'ga', sanskrit: 'ga', title: 'U+AA08 Cham Letter Ga' }, | |
| { char: 'ꨉ', display: 'ꨉ', trad: 'gha', sanskrit: 'gha', title: 'U+AA09 Cham Letter Gha' }, | |
| { char: 'ꨊ', display: 'ꨊ', trad: 'nga', sanskrit: 'ṅa', title: 'U+AA0A Cham Letter Nga' }, | |
| { char: 'ꨋ', display: 'ꨋ', trad: 'nga', sanskrit: 'ṅga', title: 'U+AA0B Cham Letter Nga-final' }, | |
| { char: 'ꨌ', display: 'ꨌ', trad: 'ca', sanskrit: 'ca', title: 'U+AA0C Cham Letter Ca' }, | |
| { char: 'ꨍ', display: 'ꨍ', trad: 'cha', sanskrit: 'cha', title: 'U+AA0D Cham Letter Cha' }, | |
| { char: 'ꨎ', display: 'ꨎ', trad: 'ja', sanskrit: 'ja', title: 'U+AA0E Cham Letter Ja' }, | |
| { char: 'ꨏ', display: 'ꨏ', trad: 'jha', sanskrit: 'jha', title: 'U+AA0F Cham Letter Jha' }, | |
| { char: 'ꨐ', display: 'ꨐ', trad: 'nya', sanskrit: 'ña', title: 'U+AA10 Cham Letter Nya' }, | |
| { char: 'ꨑ', display: 'ꨑ', trad: 'nya', sanskrit: 'ñja', title: 'U+AA11 Cham Letter Nya-final' } | |
| ], | |
| // Row 3: Retroflex & Dentals (8) + Labials (6) = 14 keys | |
| [ | |
| { char: 'ꨒ', display: 'ꨒ', trad: 'nja', sanskrit: 'ṇḍa', title: 'U+AA12 Cham Letter Nja' }, | |
| { char: 'ꨓ', display: 'ꨓ', trad: 'ta', sanskrit: 'ta', title: 'U+AA13 Cham Letter Ta' }, | |
| { char: 'ꨔ', display: 'ꨔ', trad: 'tha', sanskrit: 'tha', title: 'U+AA14 Cham Letter Tha' }, | |
| { char: 'ꨕ', display: 'ꨕ', trad: 'da', sanskrit: 'da', title: 'U+AA15 Cham Letter Da' }, | |
| { char: 'ꨖ', display: 'ꨖ', trad: 'dha', sanskrit: 'dha', title: 'U+AA16 Cham Letter Dha' }, | |
| { char: 'ꨗ', display: 'ꨗ', trad: 'na', sanskrit: 'na', title: 'U+AA17 Cham Letter Na' }, | |
| { char: 'ꨘ', display: 'ꨘ', trad: 'na', sanskrit: 'ṇa', title: 'U+AA18 Cham Letter Na-final' }, | |
| { char: 'ꨙ', display: 'ꨙ', trad: 'nda', sanskrit: 'nda', title: 'U+AA19 Cham Letter Nda' }, | |
| { char: 'ꨚ', display: 'ꨚ', trad: 'pa', sanskrit: 'pa', title: 'U+AA1A Cham Letter Pa' }, | |
| { char: 'ꨛ', display: 'ꨛ', trad: 'pa', sanskrit: 'pha', title: 'U+AA1B Cham Letter Pa-final' }, | |
| { char: 'ꨜ', display: 'ꨜ', trad: 'pha', sanskrit: 'ba', title: 'U+AA1C Cham Letter Pha' }, | |
| { char: 'ꨝ', display: 'ꨝ', trad: 'ba', sanskrit: 'bha', title: 'U+AA1D Cham Letter Ba' }, | |
| { char: 'ꨞ', display: 'ꨞ', trad: 'bha', sanskrit: 'ma', title: 'U+AA1E Cham Letter Bha' }, | |
| { char: 'ꨟ', display: 'ꨟ', trad: 'ma', sanskrit: 'ma', title: 'U+AA1F Cham Letter Ma' } | |
| ], | |
| // Row 4: Labials/Semivowels/Ha (9) + Medials (4) + Backspace (1) = 14 keys | |
| [ | |
| { char: 'ꨠ', display: 'ꨠ', trad: 'ma', sanskrit: 'mba', title: 'U+AA20 Cham Letter Ma-final' }, | |
| { char: 'ꨡ', display: 'ꨡ', trad: 'mba', sanskrit: 'ya', title: 'U+AA21 Cham Letter Mba' }, | |
| { char: 'ꨢ', display: 'ꨢ', trad: 'ya', sanskrit: 'ya', title: 'U+AA22 Cham Letter Ya' }, | |
| { char: 'ꨣ', display: 'ꨣ', trad: 'ra', sanskrit: 'ra', title: 'U+AA23 Cham Letter Ra' }, | |
| { char: 'ꨤ', display: 'ꨤ', trad: 'la', sanskrit: 'la', title: 'U+AA24 Cham Letter La' }, | |
| { char: 'ꨥ', display: 'ꨥ', trad: 'wa', sanskrit: 'va', title: 'U+AA25 Cham Letter Wa' }, | |
| { char: 'ꨦ', display: 'ꨦ', trad: 'sa', sanskrit: 'śa', title: 'U+AA26 Cham Letter Sa/Sha' }, | |
| { char: 'ꨧ', display: 'ꨧ', trad: 'sa', sanskrit: 'sa', title: 'U+AA27 Cham Letter Sa' }, | |
| { char: 'ꨨ', display: 'ꨨ', trad: 'ha', sanskrit: 'ha', title: 'U+AA28 Cham Letter Ha' }, | |
| { char: 'ꨳ', display: '<span class="dotted-circle">◌</span>ꨳ', trad: '-ia', sanskrit: '-ya', title: 'U+AA33 Medial Ya' }, | |
| { char: 'ꨴ', display: '<span class="dotted-circle">◌</span>ꨴ', trad: '-r', sanskrit: '-ra', title: 'U+AA34 Medial Ra' }, | |
| { char: 'ꨵ', display: '<span class="dotted-circle">◌</span>ꨵ', trad: '-l', sanskrit: '-la', title: 'U+AA35 Medial La' }, | |
| { char: 'ꨶ', display: '<span class="dotted-circle">◌</span>ꨶ', trad: '-ua', sanskrit: '-va', title: 'U+AA36 Medial Wa' }, | |
| { type: 'backspace', display: '⌫', trad: 'Back', sanskrit: 'Back', title: 'Backspace (Delete previous character)' } | |
| ], | |
| // Row 5: Dependent Vowels (10) + Cham Punctuation (4) = 14 keys | |
| [ | |
| { char: 'ꨩ', display: '<span class="dotted-circle">◌</span>ꨩ', trad: '-a', sanskrit: '-ā', title: 'U+AA29 Vowel Sign Aa' }, | |
| { char: 'ꨪ', display: '<span class="dotted-circle">◌</span>ꨪ', trad: '-i', sanskrit: '-i', title: 'U+AA2A Vowel Sign I' }, | |
| { char: 'ꨫ', display: '<span class="dotted-circle">◌</span>ꨫ', trad: '-i', sanskrit: '-ī', title: 'U+AA2B Vowel Sign Ii' }, | |
| { char: 'ꨬ', display: '<span class="dotted-circle">◌</span>ꨬ', trad: '-ei', sanskrit: '-ei', title: 'U+AA2C Vowel Sign Ei' }, | |
| { char: 'ꨭ', display: '<span class="dotted-circle">◌</span>ꨭ', trad: '-u', sanskrit: '-u', title: 'U+AA2D Vowel Sign U' }, | |
| { char: 'ꨮ', display: '<span class="dotted-circle">◌</span>ꨮ', trad: '-e', sanskrit: '-ē', title: 'U+AA2E Vowel Sign E' }, | |
| { char: 'ꨯ', display: 'ꨯ<span class="dotted-circle">◌</span>', trad: '-o', sanskrit: '-o', title: 'U+AA2F Vowel Sign O' }, | |
| { char: 'ꨰ', display: 'ꨰ<span class="dotted-circle">◌</span>', trad: '-ai', sanskrit: '-ai', title: 'U+AA30 Vowel Sign Ai' }, | |
| { char: 'ꨱ', display: '<span class="dotted-circle">◌</span>ꨱ', trad: '-ao', sanskrit: '-ao', title: 'U+AA31 Vowel Sign Ao' }, | |
| { char: 'ꨲ', display: '<span class="dotted-circle">◌</span>ꨲ', trad: '-â', sanskrit: '-au', title: 'U+AA32 Vowel Sign Au' }, | |
| { char: '꩜', display: '꩜', trad: '@', sanskrit: '@', title: 'U+AA5C Cham Spiral' }, | |
| { char: '꩝', display: '꩝', trad: ',', sanskrit: ',', title: 'U+AA5D Cham Danda' }, | |
| { char: '꩞', display: '꩞', trad: ';', sanskrit: ';', title: 'U+AA5E Cham Double Danda' }, | |
| { char: '꩟', display: '꩟', trad: '.', sanskrit: '.', title: 'U+AA5F Cham Triple Danda' } | |
| ], | |
| // Row 6: Finals (15), Parentheses (2), Apostrophe (1), Space (1), Enter (1) = 20 keys | |
| [ | |
| { char: 'ꩀ', display: 'ꩀ', trad: 'k', sanskrit: '-k', title: 'U+AA40 Final K' }, | |
| { char: 'ꩁ', display: 'ꩁ', trad: 'g', sanskrit: '-g', title: 'U+AA41 Final G' }, | |
| { char: 'ꩂ', display: 'ꩂ', trad: 'ng', sanskrit: '-ṅ', title: 'U+AA42 Final Ng' }, | |
| { char: 'ꩃ', display: '<span class="dotted-circle">◌</span>ꩃ', trad: 'ng', sanskrit: '-ṅg', title: 'U+AA43 Consonant Sign Kang' }, | |
| { char: 'ꩄ', display: 'ꩄ', trad: 'c', sanskrit: '-c', title: 'U+AA44 Final C' }, | |
| { char: 'ꩅ', display: 'ꩅ', trad: 't', sanskrit: '-t', title: 'U+AA45 Final T' }, | |
| { char: 'ꩆ', display: 'ꩆ', trad: 'n', sanskrit: '-n', title: 'U+AA46 Final N' }, | |
| { char: 'ꩇ', display: 'ꩇ', trad: 'p', sanskrit: '-p', title: 'U+AA47 Final P' }, | |
| { char: 'ꩈ', display: 'ꩈ', trad: 'y', sanskrit: '-y', title: 'U+AA48 Final Y' }, | |
| { char: 'ꩉ', display: 'ꩉ', trad: 'r', sanskrit: '-r', title: 'U+AA49 Final R' }, | |
| { char: 'ꩊ', display: 'ꩊ', trad: 'l', sanskrit: '-l', title: 'U+AA4A Final L' }, | |
| { char: 'ꩋ', display: 'ꩋ', trad: 's', sanskrit: '-s', title: 'U+AA4B Final S' }, | |
| { char: 'ꩌ', display: '<span class="dotted-circle">◌</span>ꩌ', trad: 'm', sanskrit: '-ṃ', title: 'U+AA4C Sign Final M (Anusvara)' }, | |
| { char: 'ꩍ', display: '<span class="dotted-circle">◌</span>ꩍ', trad: 'h', sanskrit: '-ḥ', title: 'U+AA4D Sign Final H (Visarga)' }, | |
| { char: 'ꨥ', display: 'ꨥ', trad: 'w', sanskrit: '-v', title: 'U+AA25 Final Wa' }, | |
| { char: '(', display: '(', trad: '(', sanskrit: '(', title: 'Left Parenthesis' }, | |
| { char: ')', display: ')', trad: ')', sanskrit: ')', title: 'Right Parenthesis' }, | |
| { char: "'", display: "'", trad: "'", sanskrit: "'", title: 'Apostrophe' }, | |
| { type: 'space', char: ' ', display: 'ꨌꩌꨛꨩ', trad: 'Space', sanskrit: 'Space', title: 'Space (ꨌꩌꨛꨩ / Champa)' }, | |
| { type: 'enter', display: '↵', trad: 'Enter', sanskrit: 'Enter', title: 'Enter (Submit / Next line)' } | |
| ] | |
| ], | |
| '9': [ | |
| // Row 1: Digits (10 keys) | |
| [ | |
| { char: '꩑', display: '꩑', trad: '1', sanskrit: '1', title: 'U+AA51 Cham Digit One' }, | |
| { char: '꩒', display: '꩒', trad: '2', sanskrit: '2', title: 'U+AA52 Cham Digit Two' }, | |
| { char: '꩓', display: '꩓', trad: '3', sanskrit: '3', title: 'U+AA53 Cham Digit Three' }, | |
| { char: '꩔', display: '꩔', trad: '4', sanskrit: '4', title: 'U+AA54 Cham Digit Four' }, | |
| { char: '꩕', display: '꩕', trad: '5', sanskrit: '5', title: 'U+AA55 Cham Digit Five' }, | |
| { char: '꩖', display: '꩖', trad: '6', sanskrit: '6', title: 'U+AA56 Cham Digit Six' }, | |
| { char: '꩗', display: '꩗', trad: '7', sanskrit: '7', title: 'U+AA57 Cham Digit Seven' }, | |
| { char: '꩘', display: '꩘', trad: '8', sanskrit: '8', title: 'U+AA58 Cham Digit Eight' }, | |
| { char: '꩙', display: '꩙', trad: '9', sanskrit: '9', title: 'U+AA59 Cham Digit Nine' }, | |
| { char: '꩐', display: '꩐', trad: '0', sanskrit: '0', title: 'U+AA50 Cham Digit Zero' } | |
| ], | |
| // Row 2: Independent Vowels & Velar Consonants (10 keys) | |
| [ | |
| { char: 'ꨀ', display: 'ꨀ', trad: 'a', sanskrit: 'a', title: 'U+AA00 Cham Letter A' }, | |
| { char: 'ꨁ', display: 'ꨁ', trad: 'i', sanskrit: 'i', title: 'U+AA01 Cham Letter I' }, | |
| { char: 'ꨂ', display: 'ꨂ', trad: 'u', sanskrit: 'u', title: 'U+AA02 Cham Letter U' }, | |
| { char: 'ꨃ', display: 'ꨃ', trad: 'e', sanskrit: 'e', title: 'U+AA03 Cham Letter E' }, | |
| { char: 'ꨄ', display: 'ꨄ', trad: 'ai', sanskrit: 'ai', title: 'U+AA04 Cham Letter Ai' }, | |
| { char: 'ꨅ', display: 'ꨅ', trad: 'o', sanskrit: 'o', title: 'U+AA05 Cham Letter O' }, | |
| { char: 'ꨆ', display: 'ꨆ', trad: 'ka', sanskrit: 'ka', title: 'U+AA06 Cham Letter Ka' }, | |
| { char: 'ꨇ', display: 'ꨇ', trad: 'kha', sanskrit: 'kha', title: 'U+AA07 Cham Letter Kha' }, | |
| { char: 'ꨈ', display: 'ꨈ', trad: 'ga', sanskrit: 'ga', title: 'U+AA08 Cham Letter Ga' }, | |
| { char: 'ꨉ', display: 'ꨉ', trad: 'gha', sanskrit: 'gha', title: 'U+AA09 Cham Letter Gha' } | |
| ], | |
| // Row 3: Palatals & Retroflex/Dentals (10 keys) | |
| [ | |
| { char: 'ꨊ', display: 'ꨊ', trad: 'nga', sanskrit: 'ṅa', title: 'U+AA0A Cham Letter Nga' }, | |
| { char: 'ꨋ', display: 'ꨋ', trad: 'nga', sanskrit: 'ṅga', title: 'U+AA0B Cham Letter Nga-final' }, | |
| { char: 'ꨌ', display: 'ꨌ', trad: 'ca', sanskrit: 'ca', title: 'U+AA0C Cham Letter Ca' }, | |
| { char: 'ꨍ', display: 'ꨍ', trad: 'cha', sanskrit: 'cha', title: 'U+AA0D Cham Letter Cha' }, | |
| { char: 'ꨎ', display: 'ꨎ', trad: 'ja', sanskrit: 'ja', title: 'U+AA0E Cham Letter Ja' }, | |
| { char: 'ꨏ', display: 'ꨏ', trad: 'jha', sanskrit: 'jha', title: 'U+AA0F Cham Letter Jha' }, | |
| { char: 'ꨐ', display: 'ꨐ', trad: 'nya', sanskrit: 'ña', title: 'U+AA10 Cham Letter Nya' }, | |
| { char: 'ꨑ', display: 'ꨑ', trad: 'nya', sanskrit: 'ñja', title: 'U+AA11 Cham Letter Nya-final' }, | |
| { char: 'ꨒ', display: 'ꨒ', trad: 'nja', sanskrit: 'ṇḍa', title: 'U+AA12 Cham Letter Nja' }, | |
| { char: 'ꨓ', display: 'ꨓ', trad: 'ta', sanskrit: 'ta', title: 'U+AA13 Cham Letter Ta' } | |
| ], | |
| // Row 4: Dentals & Labials (10 keys) | |
| [ | |
| { char: 'ꨔ', display: 'ꨔ', trad: 'tha', sanskrit: 'tha', title: 'U+AA14 Cham Letter Tha' }, | |
| { char: 'ꨕ', display: 'ꨕ', trad: 'da', sanskrit: 'da', title: 'U+AA15 Cham Letter Da' }, | |
| { char: 'ꨖ', display: 'ꨖ', trad: 'dha', sanskrit: 'dha', title: 'U+AA16 Cham Letter Dha' }, | |
| { char: 'ꨗ', display: 'ꨗ', trad: 'na', sanskrit: 'na', title: 'U+AA17 Cham Letter Na' }, | |
| { char: 'ꨘ', display: 'ꨘ', trad: 'na', sanskrit: 'ṇa', title: 'U+AA18 Cham Letter Na-final' }, | |
| { char: 'ꨙ', display: 'ꨙ', trad: 'nda', sanskrit: 'nda', title: 'U+AA19 Cham Letter Nda' }, | |
| { char: 'ꨚ', display: 'ꨚ', trad: 'pa', sanskrit: 'pa', title: 'U+AA1A Cham Letter Pa' }, | |
| { char: 'ꨛ', display: 'ꨛ', trad: 'pa', sanskrit: 'pha', title: 'U+AA1B Cham Letter Pa-final' }, | |
| { char: 'ꨜ', display: 'ꨜ', trad: 'pha', sanskrit: 'ba', title: 'U+AA1C Cham Letter Pha' }, | |
| { char: 'ꨝ', display: 'ꨝ', trad: 'ba', sanskrit: 'bha', title: 'U+AA1D Cham Letter Ba' } | |
| ], | |
| // Row 5: Labials, Semivowels, Sibilants (10 keys) | |
| [ | |
| { char: 'ꨞ', display: 'ꨞ', trad: 'bha', sanskrit: 'ma', title: 'U+AA1E Cham Letter Bha' }, | |
| { char: 'ꨟ', display: 'ꨟ', trad: 'ma', sanskrit: 'ma', title: 'U+AA1F Cham Letter Ma' }, | |
| { char: 'ꨠ', display: 'ꨠ', trad: 'ma', sanskrit: 'mba', title: 'U+AA20 Cham Letter Ma-final' }, | |
| { char: 'ꨡ', display: 'ꨡ', trad: 'mba', sanskrit: 'ya', title: 'U+AA21 Cham Letter Mba' }, | |
| { char: 'ꨢ', display: 'ꨢ', trad: 'ya', sanskrit: 'ya', title: 'U+AA22 Cham Letter Ya' }, | |
| { char: 'ꨣ', display: 'ꨣ', trad: 'ra', sanskrit: 'ra', title: 'U+AA23 Cham Letter Ra' }, | |
| { char: 'ꨤ', display: 'ꨤ', trad: 'la', sanskrit: 'la', title: 'U+AA24 Cham Letter La' }, | |
| { char: 'ꨥ', display: 'ꨥ', trad: 'wa', sanskrit: 'va', title: 'U+AA25 Cham Letter Wa' }, | |
| { char: 'ꨦ', display: 'ꨦ', trad: 'sa', sanskrit: 'śa', title: 'U+AA26 Cham Letter Sa/Sha' }, | |
| { char: 'ꨧ', display: 'ꨧ', trad: 'sa', sanskrit: 'sa', title: 'U+AA27 Cham Letter Sa' } | |
| ], | |
| // Row 6: Ha & Dependent Vowels with Dotted Circle (10 keys) | |
| [ | |
| { char: 'ꨨ', display: 'ꨨ', trad: 'ha', sanskrit: 'ha', title: 'U+AA28 Cham Letter Ha' }, | |
| { char: 'ꨩ', display: '<span class="dotted-circle">◌</span>ꨩ', trad: '-a', sanskrit: '-ā', title: 'U+AA29 Vowel Sign Aa' }, | |
| { char: 'ꨪ', display: '<span class="dotted-circle">◌</span>ꨪ', trad: '-i', sanskrit: '-i', title: 'U+AA2A Vowel Sign I' }, | |
| { char: 'ꨫ', display: '<span class="dotted-circle">◌</span>ꨫ', trad: '-i', sanskrit: '-ī', title: 'U+AA2B Vowel Sign Ii' }, | |
| { char: 'ꨬ', display: '<span class="dotted-circle">◌</span>ꨬ', trad: '-ei', sanskrit: '-ei', title: 'U+AA2C Vowel Sign Ei' }, | |
| { char: 'ꨭ', display: '<span class="dotted-circle">◌</span>ꨭ', trad: '-u', sanskrit: '-u', title: 'U+AA2D Vowel Sign U' }, | |
| { char: 'ꨮ', display: '<span class="dotted-circle">◌</span>ꨮ', trad: '-e', sanskrit: '-ē', title: 'U+AA2E Vowel Sign E' }, | |
| { char: 'ꨯ', display: 'ꨯ<span class="dotted-circle">◌</span>', trad: '-o', sanskrit: '-o', title: 'U+AA2F Vowel Sign O' }, | |
| { char: 'ꨰ', display: 'ꨰ<span class="dotted-circle">◌</span>', trad: '-ai', sanskrit: '-ai', title: 'U+AA30 Vowel Sign Ai' }, | |
| { char: 'ꨱ', display: '<span class="dotted-circle">◌</span>ꨱ', trad: '-ao', sanskrit: '-ao', title: 'U+AA31 Vowel Sign Ao' } | |
| ], | |
| // Row 7: Medials, Dependent Vowels & Finals (10 keys) | |
| [ | |
| { char: 'ꨲ', display: '<span class="dotted-circle">◌</span>ꨲ', trad: '-â', sanskrit: '-au', title: 'U+AA32 Vowel Sign Au' }, | |
| { char: 'ꨳ', display: '<span class="dotted-circle">◌</span>ꨳ', trad: '-ia', sanskrit: '-ya', title: 'U+AA33 Medial Ya' }, | |
| { char: 'ꨴ', display: '<span class="dotted-circle">◌</span>ꨴ', trad: '-r', sanskrit: '-ra', title: 'U+AA34 Medial Ra' }, | |
| { char: 'ꨵ', display: '<span class="dotted-circle">◌</span>ꨵ', trad: '-l', sanskrit: '-la', title: 'U+AA35 Medial La' }, | |
| { char: 'ꨶ', display: '<span class="dotted-circle">◌</span>ꨶ', trad: '-ua', sanskrit: '-va', title: 'U+AA36 Medial Wa' }, | |
| { char: 'ꩀ', display: 'ꩀ', trad: 'k', sanskrit: '-k', title: 'U+AA40 Final K' }, | |
| { char: 'ꩁ', display: 'ꩁ', trad: 'g', sanskrit: '-g', title: 'U+AA41 Final G' }, | |
| { char: 'ꩂ', display: 'ꩂ', trad: 'ng', sanskrit: '-ṅ', title: 'U+AA42 Final Ng' }, | |
| { char: 'ꩃ', display: '<span class="dotted-circle">◌</span>ꩃ', trad: 'ng', sanskrit: '-ṅg', title: 'U+AA43 Consonant Sign Kang' }, | |
| { char: 'ꩄ', display: 'ꩄ', trad: 'c', sanskrit: '-c', title: 'U+AA44 Final C' } | |
| ], | |
| // Row 8: Finals, Signs & Backspace (11 keys) | |
| [ | |
| { char: 'ꩅ', display: 'ꩅ', trad: 't', sanskrit: '-t', title: 'U+AA45 Final T' }, | |
| { char: 'ꩆ', display: 'ꩆ', trad: 'n', sanskrit: '-n', title: 'U+AA46 Final N' }, | |
| { char: 'ꩇ', display: 'ꩇ', trad: 'p', sanskrit: '-p', title: 'U+AA47 Final P' }, | |
| { char: 'ꩈ', display: 'ꩈ', trad: 'y', sanskrit: '-y', title: 'U+AA48 Final Y' }, | |
| { char: 'ꩉ', display: 'ꩉ', trad: 'r', sanskrit: '-r', title: 'U+AA49 Final R' }, | |
| { char: 'ꩊ', display: 'ꩊ', trad: 'l', sanskrit: '-l', title: 'U+AA4A Final L' }, | |
| { char: 'ꩋ', display: 'ꩋ', trad: 's', sanskrit: '-s', title: 'U+AA4B Final S' }, | |
| { char: 'ꩌ', display: '<span class="dotted-circle">◌</span>ꩌ', trad: 'm', sanskrit: '-ṃ', title: 'U+AA4C Sign Final M (Anusvara)' }, | |
| { char: 'ꩍ', display: '<span class="dotted-circle">◌</span>ꩍ', trad: 'h', sanskrit: '-ḥ', title: 'U+AA4D Sign Final H (Visarga)' }, | |
| { char: 'ꨥ', display: 'ꨥ', trad: 'w', sanskrit: '-v', title: 'U+AA25 Final Wa' }, | |
| { type: 'backspace', display: '⌫', trad: 'Back', sanskrit: 'Back', title: 'Backspace (Delete previous character)' } | |
| ], | |
| // Row 9: Punctuation, Space, Parentheses & Enter (9 keys) | |
| [ | |
| { char: '꩜', display: '꩜', trad: '@', sanskrit: '@', title: 'U+AA5C Cham Spiral' }, | |
| { char: '꩝', display: '꩝', trad: ',', sanskrit: ',', title: 'U+AA5D Cham Danda' }, | |
| { char: '꩞', display: '꩞', trad: ';', sanskrit: ';', title: 'U+AA5E Cham Double Danda' }, | |
| { char: '꩟', display: '꩟', trad: '.', sanskrit: '.', title: 'U+AA5F Cham Triple Danda' }, | |
| { type: 'space', char: ' ', display: 'ꨌꩌꨛꨩ', trad: 'Space', sanskrit: 'Space', title: 'Space (ꨌꩌꨛꨩ / Champa)' }, | |
| { char: '(', display: '(', trad: '(', sanskrit: '(', title: 'Left Parenthesis' }, | |
| { char: ')', display: ')', trad: ')', sanskrit: ')', title: 'Right Parenthesis' }, | |
| { char: "'", display: "'", trad: "'", sanskrit: "'", title: 'Apostrophe' }, | |
| { type: 'enter', display: '↵', trad: 'Enter', sanskrit: 'Enter', title: 'Enter (Submit / Next line)' } | |
| ] | |
| ] | |
| }; | |
| const CHAM_KEYBOARD_ROWS = CHAM_KEYBOARD_LAYOUTS['5']; | |
| let hasKeyboardRendered = false; | |
| function toggleChamKeyboardFloating(forceState) { | |
| const panel = document.getElementById('cham-kb-panel'); | |
| const fab = document.getElementById('cham-kb-fab'); | |
| const fabIcon = document.getElementById('cham-kb-fab-icon'); | |
| if (!panel || !fab) return; | |
| if (typeof forceState === 'boolean') { | |
| isChamKeyboardOpen = forceState; | |
| } else { | |
| isChamKeyboardOpen = !isChamKeyboardOpen; | |
| } | |
| isKeyboardDockExpanded = isChamKeyboardOpen; | |
| if (isChamKeyboardOpen) { | |
| if (!hasKeyboardRendered) { | |
| renderChamKeyboardLayout(); | |
| initDraggableKeyboard(); | |
| initScratchpadListener(); | |
| hasKeyboardRendered = true; | |
| } | |
| panel.classList.remove('closed'); | |
| panel.classList.add('open'); | |
| fab.classList.add('active'); | |
| if (fabIcon) fabIcon.innerText = '✕'; | |
| fab.setAttribute('title', 'Close Cham Keyboard (Alt+K / Esc)'); | |
| } else { | |
| panel.classList.remove('open'); | |
| panel.classList.add('closed'); | |
| fab.classList.remove('active'); | |
| if (fabIcon) fabIcon.innerText = '⌨️'; | |
| fab.setAttribute('title', 'Open Cham Virtual Keyboard (Alt+K)'); | |
| } | |
| } | |
| // Legacy compatibility alias | |
| function toggleChamKeyboardDock(forceState) { | |
| toggleChamKeyboardFloating(forceState); | |
| } | |
| function changeRowLayout(layout) { | |
| chamKeyboardRowLayout = layout || '5'; | |
| localStorage.setItem('cham_kb_row_layout', chamKeyboardRowLayout); | |
| renderChamKeyboardLayout(); | |
| } | |
| function renderChamKeyboardLayout() { | |
| const kbBody = document.getElementById('cham-kb-body'); | |
| const panel = document.getElementById('cham-kb-panel'); | |
| const layoutSelect = document.getElementById('select-row-layout'); | |
| if (!kbBody) return; | |
| if (layoutSelect) { | |
| layoutSelect.value = chamKeyboardRowLayout; | |
| } | |
| if (panel) { | |
| panel.style.setProperty('--kb-font-size', `${chamKeyboardFontSize}px`); | |
| if (chamKeyboardRowLayout === '5') { | |
| panel.style.setProperty('--cham-kb-width', '740px'); | |
| } else if (chamKeyboardRowLayout === '6') { | |
| panel.style.setProperty('--cham-kb-width', '700px'); | |
| } else { | |
| panel.style.setProperty('--cham-kb-width', '590px'); | |
| } | |
| } | |
| kbBody.innerHTML = ''; | |
| const currentLayout = CHAM_KEYBOARD_LAYOUTS[chamKeyboardRowLayout] || CHAM_KEYBOARD_LAYOUTS['5']; | |
| currentLayout.forEach(rowKeys => { | |
| const rowEl = document.createElement('div'); | |
| rowEl.className = 'cham-kb-row'; | |
| rowKeys.forEach(item => { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'cham-key-btn'; | |
| btn.title = item.title || `${item.char} (${item.trad || ''})`; | |
| if (item.type === 'space') { | |
| btn.classList.add('key-space'); | |
| } else if (item.type === 'backspace') { | |
| btn.classList.add('key-back'); | |
| } else if (item.type === 'enter') { | |
| btn.classList.add('key-enter'); | |
| } | |
| // Prevent losing active focus when pressing keys | |
| btn.onmousedown = (e) => e.preventDefault(); | |
| // Key click action | |
| btn.onclick = () => { | |
| if (item.type === 'backspace') { | |
| deleteChamChar(); | |
| } else if (item.type === 'enter') { | |
| handleEnterCham(); | |
| } else { | |
| insertChamChar(item.char); | |
| } | |
| }; | |
| const charSpan = document.createElement('span'); | |
| charSpan.className = 'cham-char'; | |
| charSpan.innerHTML = item.display || item.char; | |
| btn.appendChild(charSpan); | |
| if (chamKeyboardPhoneticMode !== 'none') { | |
| const labelText = (chamKeyboardPhoneticMode === 'sanskrit' && item.sanskrit) | |
| ? item.sanskrit | |
| : (item.trad || ''); | |
| if (labelText) { | |
| const latinSpan = document.createElement('span'); | |
| latinSpan.className = 'cham-latin'; | |
| latinSpan.innerText = labelText; | |
| btn.appendChild(latinSpan); | |
| } | |
| } | |
| rowEl.appendChild(btn); | |
| }); | |
| kbBody.appendChild(rowEl); | |
| }); | |
| } | |
| function changePhoneticMode(mode) { | |
| chamKeyboardPhoneticMode = mode; | |
| renderChamKeyboardLayout(); | |
| } | |
| function changeKeyFontSize(size) { | |
| chamKeyboardFontSize = Number(size) || 20; | |
| const panel = document.getElementById('cham-kb-panel'); | |
| if (panel) { | |
| panel.style.setProperty('--kb-font-size', `${chamKeyboardFontSize}px`); | |
| } | |
| } | |
| function initScratchpadListener() { | |
| const scratchpad = document.getElementById('cham-kb-scratchpad'); | |
| if (scratchpad) { | |
| scratchpad.addEventListener('focus', () => { | |
| lastActiveInputElement = scratchpad; | |
| }); | |
| } | |
| } | |
| function insertChamChar(char) { | |
| // Case 1: An active input/textarea is currently focused | |
| if (lastActiveInputElement && document.body.contains(lastActiveInputElement) && lastActiveInputElement.style.display !== 'none') { | |
| const input = lastActiveInputElement; | |
| const start = input.selectionStart !== null ? input.selectionStart : input.value.length; | |
| const end = input.selectionEnd !== null ? input.selectionEnd : input.value.length; | |
| const oldVal = input.value; | |
| input.value = oldVal.substring(0, start) + char + oldVal.substring(end); | |
| input.selectionStart = input.selectionEnd = start + char.length; | |
| input.focus(); | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| return; | |
| } | |
| // Case 2: A transcription line is selected in the review workbench | |
| if (currentSelection.lineId !== null && responseLines[currentSelection.lineId]) { | |
| const lineId = currentSelection.lineId; | |
| enterEditMode(lineId); | |
| const editInput = document.getElementById(`line-text-edit-${lineId}`); | |
| if (editInput) { | |
| lastActiveInputElement = editInput; | |
| const start = editInput.selectionStart !== null ? editInput.selectionStart : editInput.value.length; | |
| const end = editInput.selectionEnd !== null ? editInput.selectionEnd : editInput.value.length; | |
| const oldVal = editInput.value; | |
| editInput.value = oldVal.substring(0, start) + char + oldVal.substring(end); | |
| editInput.selectionStart = editInput.selectionEnd = start + char.length; | |
| editInput.focus(); | |
| editInput.dispatchEvent(new Event('input', { bubbles: true })); | |
| return; | |
| } | |
| } | |
| // Case 3: Fallback into the scratchpad | |
| const scratchpad = document.getElementById('cham-kb-scratchpad'); | |
| if (scratchpad) { | |
| lastActiveInputElement = scratchpad; | |
| const start = scratchpad.selectionStart !== null ? scratchpad.selectionStart : scratchpad.value.length; | |
| const end = scratchpad.selectionEnd !== null ? scratchpad.selectionEnd : scratchpad.value.length; | |
| const oldVal = scratchpad.value; | |
| scratchpad.value = oldVal.substring(0, start) + char + oldVal.substring(end); | |
| scratchpad.selectionStart = scratchpad.selectionEnd = start + char.length; | |
| scratchpad.focus(); | |
| scratchpad.dispatchEvent(new Event('input', { bubbles: true })); | |
| } else { | |
| showToast(`Copied glyph ${char} to clipboard`); | |
| navigator.clipboard.writeText(char).catch(() => {}); | |
| } | |
| } | |
| function insertSpaceCham() { | |
| insertChamChar(' '); | |
| } | |
| function deleteChamChar() { | |
| if (lastActiveInputElement && document.body.contains(lastActiveInputElement) && lastActiveInputElement.style.display !== 'none') { | |
| const input = lastActiveInputElement; | |
| const start = input.selectionStart !== null ? input.selectionStart : input.value.length; | |
| const end = input.selectionEnd !== null ? input.selectionEnd : input.value.length; | |
| if (start === end && start > 0) { | |
| input.value = input.value.substring(0, start - 1) + input.value.substring(end); | |
| input.selectionStart = input.selectionEnd = start - 1; | |
| } else if (start !== end) { | |
| input.value = input.value.substring(0, start) + input.value.substring(end); | |
| input.selectionStart = input.selectionEnd = start; | |
| } | |
| input.focus(); | |
| input.dispatchEvent(new Event('input', { bubbles: true })); | |
| return; | |
| } | |
| // If a line is selected, enter edit and delete last character | |
| if (currentSelection.lineId !== null && responseLines[currentSelection.lineId]) { | |
| const lineId = currentSelection.lineId; | |
| enterEditMode(lineId); | |
| const editInput = document.getElementById(`line-text-edit-${lineId}`); | |
| if (editInput) { | |
| lastActiveInputElement = editInput; | |
| if (editInput.value.length > 0) { | |
| editInput.value = editInput.value.slice(0, -1); | |
| editInput.focus(); | |
| editInput.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| } | |
| } | |
| } | |
| function handleEnterCham() { | |
| if (currentSelection.lineId !== null) { | |
| exitEditMode(currentSelection.lineId, true); | |
| showToast(`Saved text for line #${currentSelection.lineId + 1}`); | |
| } else { | |
| insertScratchpadToLine(); | |
| } | |
| } | |
| function copyScratchpadText() { | |
| const scratchpad = document.getElementById('cham-kb-scratchpad'); | |
| if (scratchpad && scratchpad.value) { | |
| navigator.clipboard.writeText(scratchpad.value).then(() => { | |
| showToast(`Copied "${scratchpad.value}" to clipboard`); | |
| }).catch(() => { | |
| showToast("Failed to copy to clipboard"); | |
| }); | |
| } else { | |
| showToast("Scratchpad is empty"); | |
| } | |
| } | |
| function insertScratchpadToLine() { | |
| const scratchpad = document.getElementById('cham-kb-scratchpad'); | |
| if (!scratchpad || !scratchpad.value) { | |
| showToast("Scratchpad is empty"); | |
| return; | |
| } | |
| if (currentSelection.lineId !== null && responseLines[currentSelection.lineId]) { | |
| const lineId = currentSelection.lineId; | |
| enterEditMode(lineId); | |
| const editInput = document.getElementById(`line-text-edit-${lineId}`); | |
| if (editInput) { | |
| editInput.value += scratchpad.value; | |
| editInput.focus(); | |
| exitEditMode(lineId, true); | |
| showToast(`Inserted scratchpad into line #${lineId + 1}`); | |
| } | |
| } else { | |
| copyScratchpadText(); | |
| } | |
| } | |
| function clearScratchpadText() { | |
| const scratchpad = document.getElementById('cham-kb-scratchpad'); | |
| if (scratchpad) { | |
| scratchpad.value = ''; | |
| scratchpad.focus(); | |
| } | |
| } | |
| function undoText() { | |
| if (lastActiveInputElement) { | |
| lastActiveInputElement.focus(); | |
| document.execCommand('undo', false, null); | |
| } | |
| } | |
| function redoText() { | |
| if (lastActiveInputElement) { | |
| lastActiveInputElement.focus(); | |
| document.execCommand('redo', false, null); | |
| } | |
| } | |
| function resetKeyboardPosition() { | |
| const panel = document.getElementById('cham-kb-panel'); | |
| if (panel) { | |
| panel.style.top = ''; | |
| panel.style.left = ''; | |
| panel.style.bottom = '76px'; | |
| panel.style.right = '24px'; | |
| showToast("Keyboard position reset"); | |
| } | |
| } | |
| function initDraggableKeyboard() { | |
| const header = document.getElementById('cham-kb-header'); | |
| const panel = document.getElementById('cham-kb-panel'); | |
| if (!header || !panel) return; | |
| let isDragging = false; | |
| let startX = 0, startY = 0; | |
| let initialPanelLeft = 0, initialPanelTop = 0; | |
| header.addEventListener('mousedown', (e) => { | |
| if (e.target.tagName === 'SELECT' || e.target.tagName === 'BUTTON' || e.target.closest('.cham-kb-controls')) { | |
| return; | |
| } | |
| isDragging = true; | |
| const rect = panel.getBoundingClientRect(); | |
| startX = e.clientX; | |
| startY = e.clientY; | |
| initialPanelLeft = rect.left; | |
| initialPanelTop = rect.top; | |
| panel.style.bottom = 'auto'; | |
| panel.style.right = 'auto'; | |
| panel.style.left = `${rect.left}px`; | |
| panel.style.top = `${rect.top}px`; | |
| panel.style.transition = 'none'; // temporary disable transition during drag | |
| document.body.style.userSelect = 'none'; | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isDragging) return; | |
| const dx = e.clientX - startX; | |
| const dy = e.clientY - startY; | |
| let newLeft = initialPanelLeft + dx; | |
| let newTop = initialPanelTop + dy; | |
| // Restrict within viewport boundaries | |
| const maxLeft = window.innerWidth - panel.offsetWidth - 10; | |
| const maxTop = window.innerHeight - panel.offsetHeight - 10; | |
| newLeft = Math.max(10, Math.min(newLeft, maxLeft)); | |
| newTop = Math.max(10, Math.min(newTop, maxTop)); | |
| panel.style.left = `${newLeft}px`; | |
| panel.style.top = `${newTop}px`; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (isDragging) { | |
| isDragging = false; | |
| panel.style.transition = ''; | |
| document.body.style.userSelect = ''; | |
| } | |
| }); | |
| } | |
| function navLowConfidence(direction) { | |
| if (!responseLines || responseLines.length === 0) return; | |
| const targets = []; | |
| responseLines.forEach((item, idx) => { | |
| const isLow = (item.confidence < 0.60) || (item.segmentation && item.segmentation.needs_review); | |
| if (isLow) targets.push(idx); | |
| }); | |
| if (targets.length === 0) { | |
| showToast("No ambiguous or low-confidence lines found"); | |
| return; | |
| } | |
| let nextTargetIdx = 0; | |
| if (currentSelection.lineId !== null) { | |
| const currentIdx = currentSelection.lineId; | |
| if (direction === 'next') { | |
| const found = targets.find(idx => idx > currentIdx); | |
| nextTargetIdx = (found !== undefined) ? found : targets[0]; | |
| } else { | |
| const rev = [...targets].reverse(); | |
| const found = rev.find(idx => idx < currentIdx); | |
| nextTargetIdx = (found !== undefined) ? found : targets[targets.length - 1]; | |
| } | |
| } else { | |
| nextTargetIdx = targets[0]; | |
| } | |
| selectLine(nextTargetIdx, true); | |
| zoomToLineBbox(nextTargetIdx); | |
| } | |
| function acceptAllHighConfidence() { | |
| if (!responseLines || responseLines.length === 0) return; | |
| let approved = 0; | |
| responseLines.forEach(item => { | |
| if (item.confidence >= 0.75) { | |
| item.confidence = 1.0; | |
| if (item.segmentation) item.segmentation.needs_review = false; | |
| approved++; | |
| } | |
| }); | |
| renderEditor(); | |
| renderBboxes(); | |
| showToast(`Batch approved ${approved} high-confidence lines`); | |
| updateQueueItemStatus(imageQueue.findIndex(x => x.active === true), 'Verified'); | |
| } | |
| // Keyboard Shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| if (e.altKey && (e.key === 'k' || e.key === 'K')) { | |
| e.preventDefault(); | |
| toggleChamKeyboardFloating(); | |
| return; | |
| } | |
| if (e.key === 'Escape' && isChamKeyboardOpen) { | |
| e.preventDefault(); | |
| toggleChamKeyboardFloating(false); | |
| return; | |
| } | |
| if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; | |
| if (e.key === 'a' || e.key === 'A') { | |
| acceptSelectedLine(); | |
| } else if (e.key === 'w' || e.key === 'W') { | |
| markSelectedWrong(); | |
| } else if (e.key === 's' || e.key === 'S') { | |
| splitSelectedLine(); | |
| } else if (e.key === 'm' || e.key === 'M') { | |
| mergeSelectedWithNextLine(); | |
| } else if (e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| if (currentSelection.lineId !== null && currentSelection.lineId + 1 < responseLines.length) { | |
| selectLine(currentSelection.lineId + 1); | |
| } | |
| } else if (e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| if (currentSelection.lineId !== null && currentSelection.lineId - 1 >= 0) { | |
| selectLine(currentSelection.lineId - 1); | |
| } | |
| } | |
| }); | |
| // Clipboard Paste | |
| document.addEventListener('paste', (e) => { | |
| const clipboardData = e.clipboardData || window.clipboardData; | |
| if (!clipboardData) return; | |
| const items = clipboardData.items; | |
| for (let i = 0; i < items.length; i++) { | |
| if (items[i].type.indexOf('image') !== -1) { | |
| const file = items[i].getAsFile(); | |
| if (file) { | |
| processImageFile(file); | |
| e.preventDefault(); | |
| showToast("Pasting manuscript image from clipboard..."); | |
| break; | |
| } | |
| } | |
| } | |
| }); | |
| // ------------------------------------------------------------- | |
| // Export Utilities & Dropdown | |
| // ------------------------------------------------------------- | |
| function toggleExportMenu(e) { | |
| if (e) e.stopPropagation(); | |
| const menu = document.getElementById('export-menu'); | |
| menu.classList.toggle('show'); | |
| } | |
| document.addEventListener('click', (e) => { | |
| const menu = document.getElementById('export-menu'); | |
| const btn = document.getElementById('btn-export'); | |
| if (menu && menu.classList.contains('show') && !menu.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { | |
| menu.classList.remove('show'); | |
| } | |
| }); | |
| function copyAllText() { | |
| toggleExportMenu(); | |
| if (!responseLines || responseLines.length === 0) { | |
| showToast("No text available to copy"); | |
| return; | |
| } | |
| const text = responseLines.map(x => x.text).join('\n'); | |
| navigator.clipboard.writeText(text) | |
| .then(() => showToast("All text transcribed copied to clipboard")) | |
| .catch(() => showToast("Unable to copy text to clipboard")); | |
| } | |
| function exportJSON() { | |
| toggleExportMenu(); | |
| if (!responseLines || responseLines.length === 0) { | |
| showToast("No transcription data to export"); | |
| return; | |
| } | |
| const payload = { | |
| image_name: imageQueue.find(x => x.active === true)?.name || "manuscript.png", | |
| lines: responseLines | |
| }; | |
| const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(payload, null, 2)); | |
| const a = document.createElement('a'); | |
| a.href = dataStr; | |
| a.download = "cham_ocr_transcription.json"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| showToast("Exported structured JSON transcription file"); | |
| } | |
| function exportTrainingPairs() { | |
| toggleExportMenu(); | |
| if (!responseLines || responseLines.length === 0) { | |
| showToast("No transcription data to export"); | |
| return; | |
| } | |
| const manifest = { | |
| image_name: imageQueue.find(x => x.active === true)?.name || "manuscript.png", | |
| pairs: responseLines.map(x => ({ | |
| line_idx: x.index, | |
| text: x.text, | |
| bbox: x.bbox, | |
| crop_confidence: x.confidence | |
| })) | |
| }; | |
| const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(manifest, null, 2)); | |
| const a = document.createElement('a'); | |
| a.href = dataStr; | |
| a.download = "training_pairs_manifest.json"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| showToast("Exported training manifest dataset pairs"); | |
| } | |
| // ------------------------------------------------------------- | |
| // Helper Utils & Modals | |
| // ------------------------------------------------------------- | |
| function updateModelBadge() { | |
| // Dropdown selection itself indicates active model | |
| } | |
| function showToast(msg) { | |
| const t = document.createElement('div'); | |
| t.style.position = 'fixed'; | |
| t.style.bottom = '45px'; | |
| t.style.left = '50%'; | |
| t.style.transform = 'translateX(-50%)'; | |
| t.style.backgroundColor = 'var(--text-main)'; | |
| t.style.color = 'var(--bg-app)'; | |
| t.style.border = '1px solid var(--accent-primary)'; | |
| t.style.padding = '8px 18px'; | |
| t.style.borderRadius = '8px'; | |
| t.style.fontSize = '12.5px'; | |
| t.style.fontWeight = '500'; | |
| t.style.boxShadow = 'var(--shadow-lg)'; | |
| t.style.zIndex = '3000'; | |
| t.innerText = msg; | |
| document.body.appendChild(t); | |
| setTimeout(() => { | |
| t.style.opacity = '0'; | |
| t.style.transition = 'opacity 0.4s'; | |
| setTimeout(() => t.remove(), 400); | |
| }, 2600); | |
| } | |
| function switchModalTab(tabId) { | |
| document.querySelectorAll('.modal-tab-btn').forEach(btn => btn.classList.remove('active')); | |
| document.querySelectorAll('.modal-tab-pane').forEach(pane => pane.classList.remove('active')); | |
| const activeBtn = document.getElementById(`tab-btn-${tabId}`); | |
| const activePane = document.getElementById(`tab-pane-${tabId}`); | |
| if (activeBtn) activeBtn.classList.add('active'); | |
| if (activePane) activePane.classList.add('active'); | |
| } | |
| function openAboutModal() { | |
| document.getElementById('about-modal').style.display = 'flex'; | |
| } | |
| function closeAboutModal() { | |
| document.getElementById('about-modal').style.display = 'none'; | |
| } | |
| function openLogModal() { | |
| document.getElementById('logs-modal').style.display = 'flex'; | |
| } | |
| function closeLogModal() { | |
| document.getElementById('logs-modal').style.display = 'none'; | |
| } | |
| function clearLogs() { | |
| document.getElementById('system-logs-container').innerText = ''; | |
| appendLog('Logs cleared.', 'info'); | |
| } | |
| function appendLog(message, type = 'info') { | |
| const container = document.getElementById('system-logs-container'); | |
| if (!container) return; | |
| const time = new Date().toLocaleTimeString(); | |
| let color = 'var(--text-main)'; | |
| if (type === 'error') color = 'var(--accent-red)'; | |
| if (type === 'success') color = 'var(--accent-green)'; | |
| if (type === 'warn') color = 'var(--accent-orange)'; | |
| const span = document.createElement('span'); | |
| span.style.color = color; | |
| span.innerText = `[${time}] ${message}\n`; | |
| container.appendChild(span); | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| window.onclick = function(e) { | |
| const aboutModal = document.getElementById('about-modal'); | |
| const logModal = document.getElementById('logs-modal'); | |
| if (e.target === aboutModal) aboutModal.style.display = 'none'; | |
| if (e.target === logModal) logModal.style.display = 'none'; | |
| }; | |
| </script> | |
| </body> | |
| </html> | |