phucsd's picture
feat(studio): set Cham V24 as default model and temporarily hide V25
c81c228 verified
Raw History Blame Contribute Delete
214 kB
<!DOCTYPE html>
<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 !important;
}
.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()">&times;</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 &lt; 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()">&times;</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>