<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RapidStructure · 本地评估台</title>
<style nonce="__CSP_NONCE__">
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #f2f2f0;
--surface: #fafaf8;
--fg: #141414;
--fg2: #666668;
--fg3: #9c9c9e;
--line: #dfdfdc;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
}
html[data-theme="dark"] {
--bg: #0e0e0e;
--surface: #161616;
--fg: #eaeae8;
--fg2: #9a9a9c;
--fg3: #5c5c5e;
--line: #242424;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
html {
color-scheme: light;
}
html[data-theme="dark"] {
color-scheme: dark;
}
*,
*::before,
*::after {
transition:
background-color 0.35s ease,
border-color 0.35s ease,
color 0.35s ease;
}
body {
font-family:
"Noto Sans SC", "Source Han Sans SC", "PingFang SC",
"Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--fg);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.mono {
font-family:
"JetBrains Mono", "Cascadia Mono", Consolas, "Courier New", monospace;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--line);
}
::-webkit-scrollbar-track {
background: transparent;
}
:focus-visible {
outline: 2px solid var(--fg);
outline-offset: 2px;
}
button {
font-family: inherit;
color: inherit;
}
[hidden] {
display: none !important;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes scanMove {
from {
top: -2px;
}
to {
top: 100%;
}
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(16px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateX(12px);
}
}
@keyframes ind {
from {
margin-left: 0;
}
to {
margin-left: 70%;
}
}
@keyframes flashB {
0%,
100% {
border-color: var(--line);
}
50% {
border-color: var(--fg);
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.spin {
animation: spin 0.9s linear infinite;
}
.ic {
width: 1em;
height: 1em;
display: inline-block;
vertical-align: -0.14em;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
header {
height: 58px;
flex: none;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 26px;
animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.brand {
display: flex;
align-items: center;
gap: 13px;
}
.brand-mark {
width: 26px;
height: 26px;
background: var(--fg);
color: var(--bg);
display: grid;
place-items: center;
font-size: 15px;
}
.brand-name {
font-family:
"Noto Serif SC", "Source Han Serif SC", "Songti SC", SimSun, serif;
font-weight: 700;
font-size: 16px;
letter-spacing: 0.05em;
}
.brand-en {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 9.5px;
letter-spacing: 0.3em;
color: var(--fg3);
margin-top: 2px;
}
.head-right {
display: flex;
align-items: center;
gap: 16px;
}
.engine-pill {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--fg2);
border: 1px solid var(--line);
padding: 7px 13px;
}
.engine-pill .ic {
font-size: 14px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--fg3);
display: inline-block;
}
.dot.ok {
background: var(--fg);
animation: blink 2.4s ease infinite;
}
.head-sep {
width: 1px;
height: 20px;
background: var(--line);
}
.icon-btn {
width: 36px;
height: 36px;
border: 1px solid var(--line);
background: transparent;
color: var(--fg2);
display: grid;
place-items: center;
cursor: pointer;
position: relative;
overflow: hidden;
}
.icon-btn:hover {
border-color: var(--fg2);
color: var(--fg);
}
.icon-btn .ic {
position: absolute;
font-size: 17px;
transition:
transform 0.5s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.25s;
}
html[data-theme="light"] .icon-btn .i-moon {
opacity: 1;
transform: none;
}
html[data-theme="light"] .icon-btn .i-sun {
opacity: 0;
transform: rotate(120deg) scale(0.5);
}
html[data-theme="dark"] .icon-btn .i-moon {
opacity: 0;
transform: rotate(-120deg) scale(0.5);
}
html[data-theme="dark"] .icon-btn .i-sun {
opacity: 1;
transform: none;
}
.demo-strip {
border-bottom: 1px solid var(--line);
padding: 7px 26px;
font-size: 12px;
color: var(--fg2);
display: flex;
align-items: center;
gap: 8px;
}
.demo-strip .ic {
font-size: 14px;
flex: none;
}
.banner {
border-bottom: 1px solid var(--line);
padding: 16px 26px;
}
.banner.flash {
animation: flashB 0.5s ease 2;
}
.banner-main {
display: flex;
gap: 16px;
align-items: flex-start;
}
.banner-ic {
font-size: 22px;
color: var(--fg2);
flex: none;
margin-top: 2px;
}
.banner-info {
flex: 1;
min-width: 0;
}
.banner-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 14px;
}
.banner-title b {
font-family: "Noto Serif SC", "Songti SC", SimSun, serif;
font-size: 16px;
font-weight: 700;
}
.banner-title span {
font-size: 12px;
color: var(--fg2);
}
.banner-files {
margin-top: 10px;
border-top: 1px solid var(--line);
}
.bf-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 4px 14px;
align-items: baseline;
padding: 7px 2px;
border-bottom: 1px solid var(--line);
font-size: 11.5px;
}
.bf-state {
border: 1px solid var(--fg2);
padding: 1px 6px;
font-size: 10px;
letter-spacing: 0.1em;
}
.bf-state.bad {
border-style: dashed;
}
.bf-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
}
.bf-role {
color: var(--fg3);
}
.bf-size {
color: var(--fg2);
}
.bf-state {
display: inline-flex;
align-items: center;
gap: 4px;
}
.bf-state.present {
border-style: solid;
border-color: var(--fg2);
color: var(--fg2);
}
.bf-state.missing {
border-style: dashed;
color: var(--fg3);
}
.bf-state.corrupt {
border-style: double;
border-color: var(--fg);
color: var(--fg);
font-weight: 600;
}
.bf-state .ic {
font-size: 11px;
}
.bf-sha-row {
border-bottom: 1px solid var(--line);
padding: 4px 2px 6px;
font-size: 10.5px;
color: var(--fg3);
}
.bf-sha-row > summary {
cursor: pointer;
font-family: "JetBrains Mono", Consolas, monospace;
}
.bf-sha-row > code {
display: block;
margin-top: 4px;
word-break: break-all;
color: var(--fg2);
}
.msets {
margin-top: 10px;
border-top: 1px solid var(--line);
}
.mset {
border-bottom: 1px solid var(--line);
padding: 8px 2px 10px;
}
.mset > summary {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
cursor: pointer;
}
.mset > summary::marker {
color: var(--fg3);
}
.ms-state {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
letter-spacing: 0.1em;
border: 1px solid var(--fg2);
padding: 1px 6px;
color: var(--fg2);
flex: none;
}
.ms-state.ok {
border-style: solid;
}
.ms-state.bad {
border-style: dashed;
border-color: var(--fg);
color: var(--fg);
font-weight: 600;
}
.ms-state .ic {
font-size: 11px;
}
.ms-id {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 13px;
}
.ms-meta {
font-size: 11.5px;
color: var(--fg3);
}
.ms-act {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.ms-act .banner-note {
margin-top: 0;
}
.plan {
margin-top: 14px;
border-top: 1px solid var(--line);
padding-top: 10px;
}
.plan-title {
font-size: 11.5px;
letter-spacing: 0.08em;
color: var(--fg3);
margin-bottom: 6px;
}
.plan-wrap {
overflow-x: auto;
}
.plan-wrap table {
border-collapse: collapse;
font-size: 11.5px;
width: 100%;
}
.plan-wrap th,
.plan-wrap td {
border-bottom: 1px solid var(--line);
padding: 5px 8px 5px 0;
text-align: left;
vertical-align: top;
}
.plan-wrap th {
font-size: 10.5px;
letter-spacing: 0.08em;
color: var(--fg3);
font-weight: 500;
}
.plan-wrap .pn {
font-family: "JetBrains Mono", Consolas, monospace;
word-break: break-all;
}
.plan-wrap .ps-missing,
.plan-wrap .ps-corrupt {
font-weight: 600;
}
.banner-note {
font-size: 11.5px;
color: var(--fg3);
margin-top: 8px;
line-height: 1.7;
}
.banner-act {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.banner-dl {
margin-top: 12px;
}
.btn-mini {
height: auto;
padding: 8px 13px;
font-size: 11.5px;
letter-spacing: 0.08em;
flex: none;
}
.banner-act .btn-mini {
margin: 0;
}
.btn-mini[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
border-style: dashed;
}
.dl-line {
font-size: 12px;
color: var(--fg2);
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.verify-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 8px 0 2px;
}
.verify-hint {
font-size: 11px;
color: var(--fg3);
margin: 0;
line-height: 1.6;
}
.verify-out {
margin-top: 8px;
border: 1px solid var(--line);
padding: 9px 11px;
font-size: 11.5px;
color: var(--fg2);
line-height: 1.75;
}
.verify-out b {
color: var(--fg);
}
.verify-out .vo-line {
display: block;
}
.verify-out .vo-files {
margin-top: 6px;
border-top: 1px solid var(--line);
padding-top: 6px;
}
.verify-out .vo-row {
display: flex;
gap: 10px;
align-items: baseline;
padding: 3px 0;
}
.verify-out .vo-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
}
.verify-out .vo-state {
flex: none;
letter-spacing: 0.08em;
}
.verify-out .vo-state.ok {
color: var(--fg2);
}
.verify-out .vo-state.bad {
color: var(--fg);
border-bottom: 1px dashed var(--fg);
}
.dl-line b {
font-weight: 500;
color: var(--fg);
}
.bar {
height: 2px;
background: var(--line);
position: relative;
overflow: hidden;
}
.bar i {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: var(--fg);
transition: width 0.18s linear;
}
.bar.ind i {
width: 30% !important;
animation: ind 1.1s ease-in-out infinite alternate;
}
.btn-primary {
height: 48px;
border: none;
background: var(--fg);
color: var(--bg);
cursor: pointer;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.14em;
padding: 0 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.3s;
}
.btn-primary:not(:disabled):hover {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.28;
cursor: not-allowed;
}
.btn-ghost {
height: 44px;
border: 1px solid var(--line);
background: transparent;
color: var(--fg2);
font-size: 13px;
letter-spacing: 0.1em;
cursor: pointer;
padding: 0 18px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--fg2);
color: var(--fg);
}
.btn-ghost:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.text-btn {
border: none;
background: none;
color: var(--fg3);
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 6px;
}
.text-btn:hover:not(:disabled) {
color: var(--fg);
}
.text-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.text-btn .ic {
font-size: 13px;
}
.sm {
height: 40px;
font-size: 12.5px;
padding: 0 16px;
}
main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(400px, 46fr) 54fr;
}
.left {
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 14px;
padding: 22px 24px;
min-height: 0;
animation: rise 0.6s 0.08s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.right {
overflow-y: auto;
padding: 24px 32px 44px;
min-height: 0;
animation: rise 0.6s 0.16s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.right-inner {
max-width: 720px;
}
.sec-label {
display: flex;
align-items: center;
gap: 12px;
font-size: 11px;
letter-spacing: 0.2em;
color: var(--fg2);
font-weight: 500;
white-space: nowrap;
min-width: 0;
}
.sec-label span {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 9px;
letter-spacing: 0.26em;
color: var(--fg3);
}
.sec-label::after {
content: "";
flex: 1;
height: 1px;
background: var(--line);
}
.gap {
margin-top: 30px;
}
.panel-head {
display: flex;
align-items: center;
gap: 10px;
}
.panel-head .sec-label {
flex: 1;
}
.zoom-group {
display: flex;
border: 1px solid var(--line);
}
.zoom-group > * + * {
border-left: 1px solid var(--line);
}
.vt-btn {
width: 34px;
height: 32px;
border: none;
background: transparent;
color: var(--fg3);
cursor: pointer;
display: grid;
place-items: center;
}
.vt-btn.txt {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10.5px;
}
.vt-btn:hover:not([aria-pressed="true"]) {
color: var(--fg);
}
.vt-btn[aria-pressed="true"] {
background: var(--fg);
color: var(--bg);
}
.vt-btn .ic {
font-size: 15px;
}
.zoom-lbl {
min-width: 70px;
padding: 0 8px;
display: grid;
place-items: center;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10.5px;
color: var(--fg2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.drop {
flex: 1;
min-height: 0;
position: relative;
border: 1px solid var(--line);
cursor: pointer;
outline: none;
overflow: hidden;
}
.drop:hover,
.drop:focus-visible {
border-color: var(--fg3);
}
.drop.dragging {
border-color: var(--fg);
background: color-mix(in srgb, var(--fg) 4%, transparent);
}
.drop.no-click {
cursor: default;
}
.corner {
position: absolute;
width: 15px;
height: 15px;
border: 0 solid var(--fg);
transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1);
z-index: 2;
pointer-events: none;
}
.c-tl {
top: -1px;
left: -1px;
border-top-width: 1.5px;
border-left-width: 1.5px;
}
.c-tr {
top: -1px;
right: -1px;
border-top-width: 1.5px;
border-right-width: 1.5px;
}
.c-bl {
bottom: -1px;
left: -1px;
border-bottom-width: 1.5px;
border-left-width: 1.5px;
}
.c-br {
bottom: -1px;
right: -1px;
border-bottom-width: 1.5px;
border-right-width: 1.5px;
}
.drop:hover .corner,
.drop.dragging .corner {
width: 20px;
height: 20px;
}
.drop:hover .c-tl,
.drop.dragging .c-tl {
transform: translate(-5px, -5px);
}
.drop:hover .c-tr,
.drop.dragging .c-tr {
transform: translate(5px, -5px);
}
.drop:hover .c-bl,
.drop.dragging .c-bl {
transform: translate(-5px, 5px);
}
.drop:hover .c-br,
.drop.dragging .c-br {
transform: translate(5px, 5px);
}
.drop.has-image:hover .corner {
transform: none;
}
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 13px;
text-align: center;
padding: 24px;
pointer-events: none;
}
.empty-state > .ic {
font-size: 44px;
color: var(--fg3);
transition:
color 0.3s,
transform 0.3s;
}
.drop:hover .empty-state > .ic,
.drop.dragging .empty-state > .ic {
color: var(--fg);
transform: translateY(-3px);
}
.drop.dragging .empty-state > .ic {
transform: scale(1.1);
}
.empty-title {
font-size: 14.5px;
font-weight: 500;
letter-spacing: 0.04em;
}
.empty-sub {
font-size: 12.5px;
color: var(--fg2);
}
.empty-sub kbd {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10.5px;
border: 1px solid var(--line);
padding: 1px 5px;
color: var(--fg2);
}
.empty-meta {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg3);
margin-top: 5px;
}
.stage-wrap {
position: absolute;
inset: 16px;
display: grid;
overflow: auto;
scrollbar-gutter: stable;
}
.stage {
position: relative;
margin: auto;
line-height: 0;
}
.stage img {
display: block;
width: 100%;
height: 100%;
animation: rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stage canvas {
position: absolute;
inset: 0;
cursor: crosshair;
}
.text-layer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.text-layer[hidden] {
display: none;
}
.tl-line {
position: absolute;
inset: 0;
pointer-events: none;
}
.tl-word,
.tl-probe {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
font-weight: 400;
letter-spacing: 0;
white-space: pre;
}
.tl-word {
position: absolute;
left: 0;
top: 0;
margin: 0;
padding: 0;
border: 0;
line-height: 1;
transform-origin: 0 0;
color: transparent;
cursor: text;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
.tl-word::selection {
background: color-mix(in srgb, var(--fg) 34%, transparent);
}
.tl-probe {
position: absolute;
left: 0;
top: 0;
font-size: 100px;
line-height: 1;
visibility: hidden;
pointer-events: none;
}
.scanline {
position: absolute;
left: 0;
right: 0;
top: -2px;
height: 1.5px;
background: var(--fg);
opacity: 0;
pointer-events: none;
z-index: 2;
box-shadow: 0 0 14px 1px color-mix(in srgb, var(--fg) 45%, transparent);
}
.stage.scanning .scanline {
opacity: 0.9;
animation: scanMove 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
}
.file-meta {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11.5px;
color: var(--fg2);
}
.file-meta > .ic {
font-size: 14px;
color: var(--fg3);
flex: none;
}
.file-meta .meta-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta .meta-text b {
color: var(--fg);
font-weight: 500;
}
.prog {
display: flex;
flex-direction: column;
gap: 8px;
}
.prog-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12.5px;
color: var(--fg2);
}
.prog-row b {
font-family: "JetBrains Mono", Consolas, monospace;
font-weight: 500;
color: var(--fg);
}
.job-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid var(--line);
padding: 10px 14px;
font-size: 12.5px;
color: var(--fg2);
}
.job-line .jl-state {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.job-line .ic {
font-size: 14px;
flex: none;
}
.job-line b {
color: var(--fg);
font-weight: 500;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.actions .btn-primary {
flex: 1;
}
.engine-panel {
margin-top: 14px;
}
.eng-state {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
padding: 13px 0 12px;
}
.eng-state .ic {
font-size: 18px;
}
.eng-state b {
font-family: "Noto Serif SC", "Songti SC", SimSun, serif;
font-weight: 700;
letter-spacing: 0.04em;
}
.eng-state .eng-note {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10.5px;
color: var(--fg3);
letter-spacing: 0.08em;
margin-left: 4px;
}
.eng-warn {
font-size: 12px;
color: var(--fg2);
line-height: 1.8;
border-left: 2px solid var(--fg);
padding: 2px 0 2px 12px;
margin: 2px 0 6px;
display: flex;
gap: 8px;
align-items: flex-start;
}
.eng-warn .ic {
font-size: 14px;
flex: none;
margin-top: 3px;
}
.eng-kv {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px 20px;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.eng-kv > div {
min-width: 0;
}
.eng-kv dt {
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg3);
margin-bottom: 4px;
}
.eng-kv dd {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eng-kv dd.warn-dd {
font-weight: 600;
color: var(--fg);
}
.eng-kv dd.warn-dd .ic {
font-size: 13px;
}
.eng-kv dd.ep-unknown {
color: var(--fg3);
border-bottom: 1px dashed var(--line);
}
.eng-retry {
margin-top: 14px;
}
.eng-q {
display: flex;
align-items: flex-start;
gap: 9px;
margin-top: 14px;
border-left: 2px solid var(--line);
padding: 7px 0 7px 12px;
font-size: 12px;
line-height: 1.7;
}
.eng-q .ic {
font-size: 14px;
flex: none;
margin-top: 3px;
}
.eng-q-txt {
min-width: 0;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11.5px;
}
.eng-q-txt b {
font-family: "Noto Sans SC", "Microsoft YaHei", sans-serif;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.04em;
}
.eng-q-ok {
border-left-style: solid;
border-left-color: var(--fg);
}
.eng-q-warn {
border-left-style: dashed;
border-left-color: var(--fg);
}
.eng-q-off {
color: var(--fg2);
}
.eng-q-off .ic {
color: var(--fg3);
}
.mode-row {
margin-top: 14px;
border: 1px dashed var(--line);
padding: 10px 14px 12px;
min-width: 0;
}
.mode-legend {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--fg2);
padding: 0 6px;
}
.mode-opts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
margin-top: 4px;
}
.mode-opt {
display: flex;
align-items: flex-start;
gap: 8px;
text-align: left;
border: 1px solid var(--line);
background: none;
color: var(--fg);
padding: 8px 10px;
cursor: pointer;
min-width: 0;
font: inherit;
}
.mode-opt .mo-dot {
flex: none;
width: 9px;
height: 9px;
margin-top: 4px;
border: 1px solid var(--fg3);
border-radius: 50%;
}
.mode-opt .mo-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mode-opt .mo-name {
font-size: 12.5px;
font-weight: 600;
}
.mode-opt .mo-desc {
font-size: 11px;
color: var(--fg2);
line-height: 1.5;
}
.mode-opt[aria-checked="true"] {
border-color: var(--fg);
border-style: solid;
}
.mode-opt[aria-checked="true"] .mo-dot {
background: var(--fg);
border-color: var(--fg);
box-shadow: inset 0 0 0 2px var(--bg);
}
.mode-opt[aria-disabled="true"] {
border-style: dashed;
cursor: not-allowed;
opacity: 0.62;
}
.mode-cap {
display: block;
margin-top: 8px;
font-size: 11.5px;
color: var(--fg2);
line-height: 1.7;
}
.region-list {
margin-top: 12px;
}
.region-item {
display: flex;
gap: 14px;
width: 100%;
text-align: left;
border: none;
background: none;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--line);
cursor: pointer;
padding: 12px 10px 12px 12px;
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.region-item:first-child {
border-top: 1px solid var(--line);
}
.region-item:hover {
background: color-mix(in srgb, var(--fg) 3%, transparent);
}
.region-item.sel {
border-left-color: var(--fg);
background: var(--surface);
}
.ri-idx {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11px;
color: var(--fg3);
padding-top: 2px;
min-width: 22px;
}
.region-item.sel .ri-idx {
background: var(--fg);
color: var(--bg);
padding: 1px 4px;
}
.ri-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ri-line {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.tag {
font-style: normal;
font-size: 10px;
letter-spacing: 0.12em;
border: 1px solid var(--fg2);
padding: 1px 6px;
color: var(--fg2);
flex: none;
}
.tag-fx {
border-style: dashed;
}
.ri-text {
font-size: 14px;
line-height: 1.6;
word-break: break-all;
}
.ri-sub {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10.5px;
color: var(--fg3);
}
.tag-tbl {
border-style: solid;
border-width: 2px;
}
.tag-tbl-in {
border-style: dotted;
border-color: var(--fg3);
color: var(--fg3);
}
.rt-badge {
font-style: normal;
font-size: 10px;
letter-spacing: 0.08em;
border: 1px solid var(--fg2);
padding: 1px 6px;
color: var(--fg2);
flex: none;
}
.rt-badge-low {
border-width: 2px;
border-color: var(--fg);
background: var(--fg);
color: var(--bg);
font-weight: 600;
}
.region-table.rt-low {
border-left: 3px dashed var(--fg2);
}
.rt-wrap {
overflow-x: auto;
max-width: 100%;
padding: 2px 0;
}
.rt-wrap table {
border-collapse: collapse;
font-size: 12.5px;
line-height: 1.5;
}
.rt-wrap td,
.rt-wrap th {
border: 1px solid var(--line);
padding: 3px 7px;
vertical-align: top;
white-space: pre-wrap;
}
.rt-wrap thead td,
.rt-wrap thead th {
background: var(--surface);
}
.rt-actions {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 0;
}
.rt-btn {
font: inherit;
font-size: 12px;
line-height: 1.6;
padding: 1px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--fg2);
cursor: pointer;
}
.rt-btn:hover {
border-color: var(--fg2);
color: var(--fg);
}
.rt-hint {
font-size: 11px;
color: var(--fg3);
}
.rt-raw {
margin: 6px 0 0;
padding: 8px 10px;
max-height: 220px;
overflow: auto;
border-radius: 6px;
background: var(--surface);
color: var(--fg2);
font-size: 11px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
.rt-raw > code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.rt-wrap[hidden],
.rt-raw[hidden] {
display: none;
}
.ri-fold-wrap {
display: flex;
flex-direction: column;
}
.ri-folded {
opacity: 0.72;
border-bottom: none;
}
.ri-fold {
border-bottom: 1px solid var(--line);
padding: 0 10px 10px 12px;
}
.ri-fold > summary {
cursor: pointer;
font-size: 11px;
color: var(--fg3);
padding: 2px 0;
}
.ri-fold-body {
font-size: 13px;
line-height: 1.6;
word-break: break-all;
color: var(--fg2);
padding: 6px 0 2px;
}
.empty-inline {
padding: 52px 20px;
text-align: center;
color: var(--fg3);
font-size: 13px;
line-height: 2;
}
.empty-inline .ic {
font-size: 36px;
display: block;
margin: 0 auto 14px;
}
.region-item:nth-child(1) {
animation-delay: 0.04s;
}
.region-item:nth-child(2) {
animation-delay: 0.1s;
}
.region-item:nth-child(3) {
animation-delay: 0.16s;
}
.region-item:nth-child(4) {
animation-delay: 0.22s;
}
.region-item:nth-child(5) {
animation-delay: 0.28s;
}
.region-item:nth-child(6) {
animation-delay: 0.34s;
}
.region-item:nth-child(7) {
animation-delay: 0.4s;
}
.region-item:nth-child(8) {
animation-delay: 0.46s;
}
.region-item:nth-child(n + 9) {
animation-delay: 0.52s;
}
.export-row {
display: flex;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
}
.export-row .btn-ghost {
flex: 1;
min-width: 120px;
}
.eval-row {
display: flex;
gap: 10px;
margin-top: 14px;
align-items: stretch;
}
.eval-input {
flex: 1;
min-width: 0;
background: transparent;
color: var(--fg);
border: 1px solid var(--line);
border-radius: 2px;
padding: 8px 10px;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11.5px;
}
.eval-input:focus-visible {
outline: 2px solid var(--fg);
outline-offset: 1px;
}
.eval-out {
margin-top: 12px;
border: 1px solid var(--line);
padding: 12px 14px;
}
.eval-out .diag-table td:first-child {
text-align: left;
}
.eval-sum {
font-size: 12px;
color: var(--fg);
margin: 0 0 10px;
line-height: 1.8;
}
.eval-sum b {
font-weight: 600;
}
.diag {
margin-top: 22px;
border: 1px solid var(--line);
}
.diag summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
font-size: 13px;
color: var(--fg2);
user-select: none;
}
.diag summary::-webkit-details-marker {
display: none;
}
.diag summary:hover {
color: var(--fg);
}
.diag summary .ic {
font-size: 15px;
}
.diag .chev {
margin-left: auto;
transition: transform 0.3s;
}
.diag[open] .chev {
transform: rotate(180deg);
}
.diag-body {
border-top: 1px solid var(--line);
padding: 18px 18px 20px;
}
.diag-sec {
font-size: 10.5px;
letter-spacing: 0.18em;
color: var(--fg3);
margin: 16px 0 10px;
}
.diag-sec:first-child {
margin-top: 0;
}
.diag-table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
}
.diag-table td {
border-bottom: 1px solid var(--line);
padding: 7px 2px;
color: var(--fg2);
}
.diag-table td:last-child {
text-align: right;
color: var(--fg);
}
.diag-table tr.total td {
color: var(--fg);
font-weight: 600;
border-bottom: none;
}
.diag-line {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11.5px;
color: var(--fg2);
line-height: 1.9;
word-break: break-all;
}
.diag-note {
font-size: 11.5px;
color: var(--fg3);
line-height: 1.9;
margin-top: 16px;
border-left: 2px solid var(--line);
padding-left: 12px;
}
.flow-ok {
color: var(--fg2);
}
.flow-bad {
color: var(--fg);
font-weight: 600;
}
.flow-body {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11px;
color: var(--fg2);
background: var(--surface);
border: 1px solid var(--line);
padding: 10px 12px;
margin: 0 0 10px;
white-space: pre-wrap;
word-break: break-all;
max-height: 220px;
overflow: auto;
}
.diag-line label {
cursor: pointer;
}
.foot-note {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg3);
margin-top: 30px;
}
.toasts {
position: fixed;
right: 20px;
bottom: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 80;
max-width: min(360px, calc(100vw - 40px));
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
background: var(--surface);
border: 1px solid var(--line);
padding: 12px 16px;
font-size: 13px;
line-height: 1.6;
box-shadow: var(--shadow);
animation: toastIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast .ic {
font-size: 15px;
flex: none;
margin-top: 2px;
}
.toast.out {
animation: toastOut 0.35s ease forwards;
}
@media (max-width: 960px) {
body {
height: auto;
overflow: auto;
}
main {
grid-template-columns: 1fr;
}
.left {
border-right: none;
border-bottom: 1px solid var(--line);
}
.drop {
min-height: 340px;
}
.eng-kv {
grid-template-columns: repeat(2, 1fr);
}
.banner-main {
flex-direction: column;
}
.banner-act {
align-items: flex-start;
}
}
.fx-render-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 11.5px;
color: var(--fg2);
}
.fxr-txt {
letter-spacing: 0.06em;
}
.fxr-hint {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 10.5px;
color: var(--fg3);
}
.seg {
display: flex;
border: 1px solid var(--line);
}
.seg-btn {
height: 26px;
padding: 0 11px;
border: none;
background: transparent;
color: var(--fg3);
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
font-family: inherit;
display: inline-flex;
align-items: center;
}
.seg-btn + .seg-btn {
border-left: 1px solid var(--line);
}
.seg-btn:hover:not([aria-pressed="true"]) {
color: var(--fg);
}
.seg-btn[aria-pressed="true"] {
background: var(--fg);
color: var(--bg);
}
.seg-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ri-text.fx-slot {
display: block;
overflow-x: auto;
overflow-y: hidden;
word-break: normal;
overflow-wrap: anywhere;
padding: 1px 0;
}
.ri-text.fx-slot .katex {
color: var(--fg);
}
.ri-text.fx-slot .katex-display {
text-align: left;
margin: 0.15em 0;
}
.ri-text.fx-slot mjx-container {
color: var(--fg);
}
.ri-text.fx-slot.fx-error {
border-left: 2px solid var(--fg);
padding-left: 8px;
}
</style>
<script nonce="__CSP_NONCE__">
window.__RROCR__ = { token: "__SRV_TOKEN__" };
</script>
</head>
<body>
<svg width="0" height="0" aria-hidden="true" focusable="false">
<symbol id="i-scan" viewBox="0 0 24 24">
<path d="M4 8V6a2 2 0 0 1 2-2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M20 16v2a2 2 0 0 1-2 2h-2" />
<path d="M8 20H6a2 2 0 0 1-2-2v-2" />
<path d="M4 12h16" />
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24">
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
<path d="M7 9l5-5 5 5" />
<path d="M12 4v12" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24">
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
<path d="M7 10l5 5 5-5" />
<path d="M12 15V3" />
</symbol>
<symbol id="i-image" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="8.5" cy="10" r="1.5" />
<path d="M21 15.5l-4.5-4.5L7 20" />
</symbol>
<symbol id="i-file" viewBox="0 0 24 24">
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"
/>
</symbol>
<symbol id="i-x" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24">
<path d="M5 12l5 5L20 7" />
</symbol>
<symbol id="i-copy" viewBox="0 0 24 24">
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</symbol>
<symbol id="i-sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path
d="M3 12h1M20 12h1M12 3v1M12 20v1M5.6 5.6l.7.7M17.7 17.7l.7.7M18.4 5.6l-.7.7M6.3 17.7l-.7.7"
/>
</symbol>
<symbol id="i-moon" viewBox="0 0 24 24">
<path
d="M12 3c.13 0 .26 0 .39 0a7.5 7.5 0 0 0 7.92 12.44A9 9 0 1 1 12 3z"
/>
</symbol>
<symbol id="i-cloud-off" viewBox="0 0 24 24">
<path d="M3 3l18 18" />
<path
d="M6 17a4.5 4.5 0 0 1-.3-9A6 6 0 0 1 16.7 6.5c.22 0 .48.02.7.07"
/>
<path d="M10.5 17H15" />
</symbol>
<symbol id="i-package" viewBox="0 0 24 24">
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z" />
<path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24">
<path
d="M10.24 3.96L2.5 17.5a1.9 1.9 0 0 0 1.65 2.85h15.7a1.9 1.9 0 0 0 1.65-2.85L13.76 3.96a2 2 0 0 0-3.52 0z"
/>
<path d="M12 9v4M12 17h.01" />
</symbol>
<symbol id="i-loader" viewBox="0 0 24 24">
<path d="M12 3a9 9 0 1 0 9 9" />
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24">
<path d="M20 12a8 8 0 1 1-2.34-5.66" />
<path d="M20 4v4h-4" />
</symbol>
<symbol id="i-clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</symbol>
<symbol id="i-chevron" viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6" />
</symbol>
<symbol id="i-fit" viewBox="0 0 24 24">
<path d="M4 8V6a2 2 0 0 1 2-2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M20 16v2a2 2 0 0 1-2 2h-2" />
<path d="M8 20H6a2 2 0 0 1-2-2v-2" />
<path d="M9 12h6" />
</symbol>
<symbol id="i-zoom-in" viewBox="0 0 24 24">
<circle cx="10" cy="10" r="7" />
<path d="M21 21l-6-6" />
<path d="M10 7v6M7 10h6" />
</symbol>
<symbol id="i-zoom-out" viewBox="0 0 24 24">
<circle cx="10" cy="10" r="7" />
<path d="M21 21l-6-6" />
<path d="M7 10h6" />
</symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24">
<path
d="M9 4H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-2"
/>
<rect x="9" y="2" width="6" height="4" rx="1" />
</symbol>
<symbol id="i-info" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 8h.01M11 12h1v4h1" />
</symbol>
</svg>
<header>
<div class="brand">
<div class="brand-mark">
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-scan" />
</svg>
</div>
<div>
<div class="brand-name">RapidStructure · 本地评估台</div>
<div class="brand-en">
LOCAL EVAL CONSOLE · LOOPBACK ONLY
</div>
</div>
</div>
<div class="head-right">
<div class="engine-pill" id="enginePill" aria-live="polite"></div>
<div class="head-sep"></div>
<button class="icon-btn" id="themeBtn" aria-label="切换深浅色主题">
<svg class="ic i-moon" aria-hidden="true" focusable="false">
<use href="#i-moon" />
</svg>
<svg class="ic i-sun" aria-hidden="true" focusable="false">
<use href="#i-sun" />
</svg>
</button>
</div>
</header>
<div class="demo-strip" id="demoStrip" hidden>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-info" />
</svg>
离线预览模式 — 本页未经 serve
注入令牌:界面与交互完整可用,数据为内置演示数据。
</div>
<div
class="banner"
id="banner"
role="region"
aria-label="模型状态"
hidden
></div>
<main>
<section class="left" aria-label="输入图像">
<div class="panel-head">
<div class="sec-label">输入图像 <span>INPUT</span></div>
<div
class="zoom-group"
id="zoomGroup"
role="group"
aria-label="缩放与视图"
hidden
>
<button
class="vt-btn"
id="zoomOut"
type="button"
title="缩小(-)"
aria-label="缩小"
>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-zoom-out" />
</svg>
</button>
<span
class="zoom-lbl"
id="zoomLbl"
aria-live="polite"
title="当前显示比例"
>100%</span
>
<button
class="vt-btn"
id="zoomIn"
type="button"
title="放大(+)"
aria-label="放大"
>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-zoom-in" />
</svg>
</button>
<button
class="vt-btn"
id="zoomFit"
type="button"
aria-pressed="true"
title="适应窗口"
aria-label="适应窗口"
>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-fit" />
</svg>
</button>
<button
class="vt-btn txt"
id="zoomActual"
type="button"
aria-pressed="false"
title="原始尺寸 100%(0)"
aria-label="原始尺寸(100%)"
>
1:1
</button>
</div>
<button class="text-btn" id="clearBtn" hidden title="移除图像">
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-x" /></svg
>移除
</button>
</div>
<div
class="drop"
id="drop"
tabindex="0"
role="button"
aria-label="拖入图像,或按 Enter 选择文件"
>
<span class="corner c-tl"></span><span class="corner c-tr"></span>
<span class="corner c-bl"></span><span class="corner c-br"></span>
<div class="empty-state" id="emptyState">
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-image" />
</svg>
<div class="empty-title">拖拽图像至此处</div>
<div class="empty-sub">
或点击选择文件 · 支持粘贴 <kbd>Ctrl</kbd> + <kbd>V</kbd>
</div>
<div class="empty-meta">IMAGE/* · 识别全程在本机完成</div>
</div>
<div class="stage-wrap" id="stageWrap" hidden>
<div class="stage" id="stage">
<img id="view" alt="待识别图像预览" />
<canvas id="overlay" aria-hidden="true"></canvas>
<div class="text-layer" id="textLayer" hidden aria-hidden="true"></div>
<div class="scanline"></div>
</div>
</div>
</div>
<div class="file-meta" id="fileMeta" hidden>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-file" />
</svg>
<span class="meta-text" id="metaText"></span>
<button
class="text-btn"
id="annotatedBtn"
hidden
title="下载叠加检测框的标注图"
>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-download" /></svg
>下载标注图
</button>
</div>
<div class="prog" id="uploadProg" hidden>
<div class="prog-row">
<span>上传中 <b id="upPct">0%</b></span>
<button class="text-btn" id="abortUpBtn">
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-x" /></svg
>取消上传
</button>
</div>
<div class="bar"><i id="upBar"></i></div>
</div>
<div class="job-line" id="jobLine" hidden aria-live="polite">
<span class="jl-state" id="jlText"></span>
<button class="text-btn" id="cancelJobBtn">取消识别</button>
</div>
<fieldset class="mode-row" id="modeRow" hidden>
<legend class="mode-legend">识别模式</legend>
<div class="mode-opts" id="modeOpts" role="radiogroup" aria-describedby="modeCap"></div>
<span class="mode-cap" id="modeCap" aria-live="polite"></span>
</fieldset>
<fieldset class="mode-row" id="wordsRow" hidden>
<legend class="mode-legend">框粒度</legend>
<div class="mode-opts" id="wordsOpts" role="radiogroup" aria-describedby="wordsCap"></div>
<span class="mode-cap" id="wordsCap" aria-live="polite"></span>
</fieldset>
<div class="actions">
<button
class="btn-ghost"
id="pasteBtn"
type="button"
title="从剪贴板读取图像(等同 Ctrl+V)"
>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-clipboard" /></svg
>从剪贴板
</button>
<button class="btn-primary" id="ocrBtn" disabled>
<svg class="ic" aria-hidden="true" focusable="false" id="ocrIco">
<use href="#i-scan" />
</svg>
<span id="ocrLbl">等待图像输入</span>
</button>
</div>
</section>
<section class="right" aria-label="识别结果">
<div class="right-inner">
<div class="sec-label">引擎状态 <span>ENGINE</span></div>
<div class="engine-panel" id="enginePanel"></div>
<div class="verify-row">
<button
class="btn-ghost sm"
id="verifyBtn"
type="button"
title="清掉校验缓存,重新读盘核对本次运行会用到的每个模型文件,并重建引擎会话"
>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-refresh" /></svg
>重新校验
</button>
<p class="verify-hint" id="verifyHint">
POST /api/models/reverify · 重新读盘核对模型并重建引擎会话
</p>
</div>
<div class="verify-out" id="verifyOut" role="status" hidden></div>
<div class="sec-label gap">
识别区域 <span>REGIONS · 阅读顺序</span>
<button class="text-btn" id="copyAllBtn" disabled>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-copy" /></svg
>复制全文
</button>
</div>
<div
class="fx-render-bar"
id="fxRenderBar"
role="group"
aria-label="公式渲染模式"
hidden
>
<span class="fxr-txt">公式渲染</span>
<div class="seg">
<button
class="seg-btn"
type="button"
data-fx-mode="off"
aria-pressed="true"
>
原文
</button>
<button
class="seg-btn"
type="button"
data-fx-mode="katex"
aria-pressed="false"
>
KaTeX
</button>
<button
class="seg-btn"
type="button"
data-fx-mode="mathjax"
aria-pressed="false"
>
MathJax
</button>
</div>
<span class="fxr-hint" id="fxRenderHint"
>首次切换会从同源路径懒加载所选引擎</span
>
</div>
<div class="region-list" id="regionList"></div>
<div class="sec-label gap">导出 <span>EXPORT</span></div>
<div class="export-row">
<button class="btn-ghost" id="expJson" disabled>JSON</button>
<button class="btn-ghost" id="expMd" disabled>Markdown</button>
<button
class="btn-ghost"
id="expHtml"
disabled
title="静态报告,不含脚本"
>
HTML(静态)
</button>
</div>
<div class="sec-label gap">
评估 <span>EVALUATE · CER</span>
</div>
<div class="eval-row">
<input
type="text"
id="evalPath"
class="eval-input"
spellcheck="false"
placeholder="已标注清单的绝对路径(与 rapidstruct evaluate --manifest 同格式)"
aria-label="标注清单路径"
/>
<button class="btn-ghost" id="evalBtn">评估</button>
</div>
<div class="eval-out" id="evalOut" aria-live="polite" hidden></div>
<details class="diag" id="diag">
<summary>
<svg class="ic" aria-hidden="true" focusable="false">
<use href="#i-clock" />
</svg>
诊断面板
<svg class="ic chev" aria-hidden="true" focusable="false">
<use href="#i-chevron" />
</svg>
</summary>
<div class="diag-body" id="diagBody"></div>
</details>
<p class="foot-note">
RAPIDSTRUCTURE SERVE · 127.0.0.1 · 图像与模型不出本机
</p>
</div>
</section>
</main>
<div class="toasts" id="toasts" aria-live="polite"></div>
<input type="file" id="fileInput" accept="image/*" hidden />
<script nonce="__CSP_NONCE__">
(function () {
"use strict";
const INJ = window.__RROCR__ || {};
const TOKEN = INJ.token || "";
const DEMO = !TOKEN || TOKEN.indexOf("__") === 0;
const $ = (id) => document.getElementById(id);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) =>
String(s).replace(
/[&<>"']/g,
(c) =>
({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[c],
);
const ic = (n, cls) =>
`<svg class="ic ${cls || ""}" aria-hidden="true" focusable="false"><use href="#i-${n}"/></svg>`;
const ERR_TEXT = {
unauthorized: "令牌校验失败,请通过 rapidstruct serve --open 打开本页",
bad_origin: "Origin 校验失败(非本机来源)",
bad_host: "Host 校验失败(防 DNS rebinding)",
payload_too_large:
"请求体或下载总量超出上限(--max-body-mb / --max-download-mb)",
result_too_large: "结果序列化超出上限(--max-result-mb)",
export_too_large:
"导出文档超出上限(--max-export-mb),请改用“下载标注图”(annotated.png 不受该预算限制)",
original_evicted:
"该任务的原图已被保留预算释放(410),标注图无法再生成;识别结果仍可查看,重新上传即可再标注",
busy: "队列已满(503 busy),请稍后重试",
models_missing: "模型文件缺失,请先在横幅中下载",
models_corrupt: "模型文件损坏,建议重新下载",
downloads_disabled: "服务未开启 --allow-download,无法下载模型",
insufficient_disk_space: "磁盘空间不足(507)",
unsupported_input: "无法解码该图像或超出像素上限(24 Mpx)",
job_not_finished: "识别尚未完成",
job_evicted: "结果已被淘汰(410),请重新识别",
not_cancellable: "推理进行中,当前版本不支持中断",
job_finished: "任务已结束,无法取消",
download_failed: "模型下载失败",
download_timeout: "模型下载超时",
internal: "服务内部错误",
};
const ROLE_ZH = {
detector: "检测",
recognizer: "识别",
classifier: "方向分类",
dictionary: "字典",
tokenizer: "词表",
formula_detector: "公式检测",
formula_recognizer: "公式识别",
table_structure: "表格结构",
};
const state = {
status: null ,
models: null ,
image: null ,
view: {
mode: "fit",
scale: 1,
} ,
uploading: false,
xhr: null ,
job: null ,
result: null ,
selected: -1,
dlActive: false ,
dlSetId:
null ,
dlJob:
null ,
mode: "text",
words: "off",
diag: [] ,
verifying: false ,
verifyOut:
null ,
netFail: false ,
verbose: false,
flow: [] ,
flowSeq: 0,
fx: {
mode: "off",
} ,
};
const demo = {
phase:
"boot" ,
upAbort: false,
dlActive: false,
dlPct: 0,
dlFile: "",
dlSetId: null,
textReady: true,
buildMs: 1500,
};
function fmtBytes(b) {
if (b == null) return "—";
if (b < 1024) return b + " B";
const u = ["KB", "MB", "GB"];
let i = -1;
do {
b /= 1024;
i++;
} while (b >= 1024 && i < 2);
return b.toFixed(b >= 100 ? 0 : 1) + " " + u[i];
}
function hostOf(u) {
try {
return new URL(u).host;
} catch {
return "";
}
}
const firstText = (...vals) => {
for (const v of vals)
if (v != null && String(v).trim() !== "") return String(v);
return null;
};
const firstBool = (...vals) => {
for (const v of vals) {
if (typeof v === "boolean") return v;
if (v === "true") return true;
if (v === "false") return false;
}
return null;
};
function emptyStatus(engState) {
return {
state: engState || "unknown",
requested: null,
selected_ep: null,
fallback: null,
ort: "—",
fingerprint: null,
memory: null,
queues: null,
limits: null,
};
}
function isTextField(el) {
if (!el || !el.tagName) return false;
const tag = el.tagName.toUpperCase();
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
el.isContentEditable === true
);
}
function zhQueue(q) {
return q === "formula" ? "公式" : "文本";
}
function jobActive() {
return !!(
state.job &&
(state.job.state === "queued" || state.job.state === "running")
);
}
const QUEUE_ROLES = {
text: ["detector", "recognizer", "dictionary"],
formula: ["formula_recognizer"],
};
function modelFiles() {
const m = state.models;
if (!m || !Array.isArray(m.sets))
return null;
if (m.error) return null;
return m.sets.flatMap((s) => s.files || []);
}
function modelsReadyFor(queue) {
const files = modelFiles();
if (files === null)
return { ready: false, missing: [], fatal: true, reason: null };
if (queue === "table") {
const t = state.models && state.models.table;
if (!t)
return {
ready: false,
missing: [],
fatal: false,
reason: "服务端未返回表格路由状态(/api/models.table)",
};
if (t.routing === false)
return {
ready: false,
missing: [],
fatal: false,
reason: t.reason || defaultRouteReason("table"),
};
const bad = [];
if (t.inPlan === false) bad.push("不在本次运行计划里");
(t.missing || []).forEach((n) => bad.push(String(n)));
(t.corrupt || []).forEach((n) => bad.push(String(n) + "(损坏)"));
(t.blocked || []).forEach((n) => bad.push(String(n)));
if (!t.complete || bad.length)
return {
ready: false,
missing: bad,
fatal: false,
reason: t.reason || null,
};
return { ready: true, missing: [], fatal: false, reason: null };
}
const need = QUEUE_ROLES[queue] || QUEUE_ROLES.text;
const missing = [];
need.forEach((role) => {
const f = files.find((x) => x.role === role);
const zh = ROLE_ZH[role] || role;
if (!f) missing.push(zh);
else if (f.state === "corrupt") missing.push(zh + "(损坏)");
else if (f.state !== "present") missing.push(zh);
});
if (missing.length)
return { ready: false, missing, fatal: false, reason: null };
if (queue === "formula") {
const route = state.models && state.models.formula;
if (route && route.routing === false) {
return {
ready: false,
missing: [],
fatal: false,
reason: route.reason || defaultRouteReason(queue),
};
}
}
return { ready: true, missing: [], fatal: false, reason: null };
}
function defaultRouteReason(queue) {
return queue === "table"
? "服务端未启用表格路由(--table-model)"
: "服务端未启用公式路由(--formula-detector)";
}
function routeBlockedText(st) {
if (!st || st.ready) return "";
const missing = (st.missing || []).join("、");
if (missing) return `缺少 ${missing}`;
return st.reason || "模型状态(/api/models 尚未返回)";
}
function formulaBlockedText(st) {
return routeBlockedText(st);
}
function tableBlockedText(st) {
return routeBlockedText(st);
}
const MODES = [
{
id: "text",
name: "文本",
desc: "queue=text",
queue: "text",
table: false,
},
{
id: "formula",
name: "文本 + 公式",
desc: "queue=formula",
queue: "formula",
table: false,
},
{
id: "text-table",
name: "文本 + 表格",
desc: "queue=text&table=1",
queue: "text",
table: true,
},
{
id: "formula-table",
name: "文本 + 公式 + 表格",
desc: "queue=formula&table=1",
queue: "formula",
table: true,
},
];
function modeById(id) {
return MODES.find((m) => m.id === id) || MODES[0];
}
const WORDS_MODES = [
{
id: "off",
name: "仅行框",
desc: "不发 words 参数",
hint: "不请求逐词/逐字框:响应体与后处理开销都不增加,图上也不能拖选文本。",
},
{
id: "words",
name: "逐词框",
desc: "words=words",
hint: "请求 kind=token 的词级框,文本层用词级 span。切词会丢弃空白字符,因此跨 token 拖选可能少掉空格。",
},
{
id: "chars",
name: "逐字框",
desc: "words=chars",
hint: "请求 kind=char 的字级框,文本层用字符级 span。坐标由 CTC forced alignment 的帧区间铺格得出(比例字体下每字宽度不同),仍不是字符检测模型的像素级边界;对齐装不下时退回整行平均字宽估计。",
},
];
const WORDS_KEY = "rrocr-words-mode";
function wordsModeById(id) {
return WORDS_MODES.find((m) => m.id === id) || WORDS_MODES[0];
}
function wordsQuery() {
return state.words === "off" ? "" : "&words=" + state.words;
}
(function initWordsMode() {
try {
const m = localStorage.getItem(WORDS_KEY);
if (m === "off" || m === "words" || m === "chars") state.words = m;
} catch {}
})();
function modeAvailability(mode) {
const text = modelsReadyFor("text");
if (!text.ready)
return {
ok: false,
why: text.fatal
? "尚未取得 /api/models 模型状态"
: `文本队列不可用:${routeBlockedText(text)}`,
};
if (mode.queue === "formula") {
const fx = modelsReadyFor("formula");
if (!fx.ready)
return { ok: false, why: `公式队列不可用:${routeBlockedText(fx)}` };
}
if (mode.table) {
const tb = modelsReadyFor("table");
if (!tb.ready)
return { ok: false, why: `表格路由不可用:${routeBlockedText(tb)}` };
}
return { ok: true, why: "" };
}
function toast(msg, type, icon) {
const el = document.createElement("div");
el.className = "toast " + (type || "ok");
el.innerHTML =
ic(icon || (type === "err" ? "alert" : "check")) +
`<span>${esc(msg)}</span>`;
$("toasts").appendChild(el);
setTimeout(() => {
el.classList.add("out");
setTimeout(() => el.remove(), 400);
}, 3400);
}
function dismissToasts() {
[...$("toasts").children].forEach((t) => {
t.classList.add("out");
setTimeout(() => t.remove(), 350);
});
}
function errText(e) {
if (!e) return "请求失败";
if (e.aborted) return "已取消上传";
if (e.status === 0)
return "无法连接本地服务,请确认 rapidstruct serve 正在运行";
if (e.code && ERR_TEXT[e.code]) return ERR_TEXT[e.code];
return "请求失败" + (e.status ? `(HTTP ${e.status})` : "");
}
function failText(what, e) {
const ids = [
e && e.status ? `HTTP ${e.status}` : null,
(e && e.code) || null,
]
.filter(Boolean)
.join(" ");
return (
(what ? what + ":" : "") + errText(e) + (ids ? ` — ${ids}` : "")
);
}
function toastErr(e) {
toast(failText("请求失败", e), "err");
}
function diagPush(line) {
state.diag = [line].concat(state.diag).slice(0, 12);
renderDiag();
}
function saveBlob(blob, name) {
const a = document.createElement("a");
const u = URL.createObjectURL(blob);
a.href = u;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(u), 4000);
}
const VERBOSE_KEY = "rrocr-verbose";
function flowVerboseInitial() {
let q = null;
try {
q = new URLSearchParams(location.search).get("verbose");
} catch {}
if (q === "1" || q === "true") return true;
if (q === "0" || q === "false") return false;
try {
return localStorage.getItem(VERBOSE_KEY) === "1";
} catch {
return false;
}
}
function setFlowVerbose(on, quiet) {
state.verbose = !!on;
try {
localStorage.setItem(VERBOSE_KEY, state.verbose ? "1" : "0");
} catch {}
renderDiag();
if (!quiet)
toast(
state.verbose
? "详细模式已开启:诊断面板展示最近请求与失败响应体"
: "详细模式已关闭",
"ok",
"info",
);
}
function flowPush(rec) {
state.flowSeq += 1;
rec.seq = state.flowSeq;
state.flow = [rec].concat(state.flow).slice(0, 20);
if (state.verbose) renderDiag();
else if (rec.status === 0 || rec.status >= 400)
renderDiag();
}
function flowRowHtml(r, withBody) {
const ok = r.status >= 200 && r.status < 300;
const cls = r.status === 0 ? "flow-bad" : ok ? "flow-ok" : "flow-bad";
const when = new Date().toLocaleTimeString();
const head =
`<span class="${cls}">${esc(String(r.status || "—"))}</span> ` +
`<b>${esc(r.method)}</b> ${esc(r.url)} · ${r.ms}ms` +
(r.note ? ` · ${esc(r.note)}` : "") +
(r.code ? ` · ${esc(r.code)}` : "");
const body =
withBody && r.body
? `<pre class="flow-body">${esc(String(r.body).slice(0, 4000))}</pre>`
: "";
return `<p class="diag-line">${when} · ${head}</p>${body}`;
}
function flowHtml() {
const failed = state.flow.find(
(r) => r.status === 0 || r.status >= 400,
);
const rows = state.verbose
? state.flow.slice(0, 12)
: failed
? [failed]
: [];
const toggle =
`<p class="diag-line"><label><input type="checkbox" id="flowToggle"` +
(state.verbose ? " checked" : "") +
`> 详细模式(?verbose=1 或 Ctrl+Alt+L):` +
`显示最近请求与失败响应体</label></p>`;
if (!rows.length) {
return (
`<div class="diag-sec">最近请求</div>${toggle}` +
`<p class="diag-line">${
state.verbose
? "尚无请求记录。"
: "详细模式关闭;最近一次请求失败时会自动在此列出。"
}</p>`
);
}
return (
`<div class="diag-sec">最近请求${state.verbose ? " · 详细模式" : " · 最近一次失败"}</div>` +
toggle +
rows.map((r) => flowRowHtml(r, true)).join("") +
`<p class="diag-note">这里的每一行都与服务端的流动日志
(<code>RAPID_STRUCTURE_SERVE_LOG=flow</code>,行内有请求 id)对应:同一次上传可以先看这里的
URL/状态/响应体,再按请求 id 到服务端日志里追同一个请求的准入与任务生命周期。</p>`
);
}
state.verbose = flowVerboseInitial();
function recordFlow(method, url, started, status, code, text, note) {
flowPush({
method,
url,
status,
code: code || null,
ms: Date.now() - started,
note: note || null,
body: text == null ? null : String(text),
});
}
function xhrSend(method, url, body, opts) {
opts = opts || {};
const started = Date.now();
return new Promise((resolve, reject) => {
const x = new XMLHttpRequest();
x.open(method, url, true);
x.setRequestHeader("X-RapidStructure-Token", TOKEN);
if (opts.contentType)
x.setRequestHeader("Content-Type", opts.contentType);
else if (opts.octet)
x.setRequestHeader("Content-Type", "application/octet-stream");
if (opts.onProgress)
x.upload.onprogress = (ev) => {
if (ev.lengthComputable) opts.onProgress(ev.loaded / ev.total);
};
x.onload = () => {
let data = null;
try {
data = JSON.parse(x.responseText);
} catch {}
if (x.status >= 200 && x.status < 300) {
recordFlow(method, url, started, x.status, null, null);
resolve(data);
} else {
recordFlow(
method,
url,
started,
x.status,
data && data.code,
x.responseText,
);
reject({
status: x.status,
code: data && data.code,
body: data,
raw: x.responseText,
});
}
};
x.onerror = () => {
recordFlow(method, url, started, 0, null, null, "网络错误");
reject({ status: 0 });
};
x.onabort = () => {
recordFlow(method, url, started, 0, null, null, "已取消");
reject({ aborted: true });
};
x.ontimeout = () => {
recordFlow(method, url, started, 0, null, null, "超时");
reject({ status: 0 });
};
if (opts.getXhr) opts.getXhr(x);
x.send(body || null);
});
}
const apiJson = (m, p, b) => xhrSend(m, p, b);
const apiSendJson = (m, p, obj) =>
xhrSend(m, p, JSON.stringify(obj), {
contentType: "application/json",
});
function apiBlob(path) {
const started = Date.now();
return new Promise((resolve, reject) => {
const x = new XMLHttpRequest();
x.open("GET", path, true);
x.responseType = "blob";
x.setRequestHeader("X-RapidStructure-Token", TOKEN);
x.onload = async () => {
if (x.status >= 200 && x.status < 300) {
recordFlow("GET", path, started, x.status, null, null);
return resolve(x.response);
}
let code = null,
body = null,
text = null;
try {
text = await x.response.text();
body = JSON.parse(text);
code = body.code;
} catch {}
recordFlow("GET", path, started, x.status, code, text);
reject({ status: x.status, code, body });
};
x.onerror = () => {
recordFlow("GET", path, started, 0, null, null, "网络错误");
reject({ status: 0 });
};
x.send(null);
});
}
function pget(o, k) {
return o && typeof o === "object" ? o[k] : undefined;
}
function checkFinite(v, path, errors) {
if (typeof v !== "number" || !Number.isFinite(v)) {
errors.push(path);
return null;
}
return v;
}
function isInputRegion(src) {
if (src === "Input") return true;
if (src && typeof src === "object" && !Array.isArray(src))
return Object.prototype.hasOwnProperty.call(src, "Input");
return false;
}
function parseQuad(pts, path, errors) {
if (!Array.isArray(pts) || pts.length !== 4) {
errors.push(`${path}.points`);
return null;
}
const out = [];
for (let k = 0; k < 4; k++) {
const p = pts[k];
if (!Array.isArray(p) || p.length !== 2) {
errors.push(`${path}.points[${k}]`);
return null;
}
const x = checkFinite(p[0], `${path}.points[${k}][0]`, errors);
const y = checkFinite(p[1], `${path}.points[${k}][1]`, errors);
if (x === null || y === null) return null;
out.push([x, y]);
}
return out;
}
function parsePolygon(r, i, errors) {
if (r == null || r.polygon === undefined || r.polygon === null) {
if (!isInputRegion(r && r.source))
errors.push(`regions[${i}].polygon`);
return null;
}
const pts = pget(r.polygon, "points");
return parseQuad(pts, `regions[${i}].polygon`, errors);
}
function parseWords(rec, i, errors) {
const words = pget(rec, "words");
if (words === undefined || words === null) return null;
if (!Array.isArray(words)) {
errors.push(`regions[${i}].recognition.words`);
return null;
}
const out = [];
for (let k = 0; k < words.length; k++) {
const w = words[k];
const path = `regions[${i}].recognition.words[${k}]`;
if (w === null || typeof w !== "object" || Array.isArray(w)) {
errors.push(path);
continue;
}
const text = pget(w, "text");
if (typeof text !== "string") errors.push(`${path}.text`);
const kind = pget(w, "kind");
if (kind !== "token" && kind !== "char") errors.push(`${path}.kind`);
const score = checkFinite(
pget(w, "score"),
`${path}.score`,
errors,
);
if (w.polygon === undefined || w.polygon === null) {
errors.push(`${path}.polygon`);
continue;
}
const quad = parseQuad(
pget(w.polygon, "points"),
`${path}.polygon`,
errors,
);
if (typeof text !== "string" || score === null || quad === null)
continue;
out.push({ text, score, kind, polygon: quad });
}
return out;
}
function parseRecognition(r, i, errors) {
const rec = r && r.recognition;
if (rec === undefined || rec === null) {
errors.push(`regions[${i}].recognition`);
return null;
}
const text = pget(rec, "text");
if (typeof text !== "string")
errors.push(`regions[${i}].recognition.text`);
const score = checkFinite(
pget(rec, "score"),
`regions[${i}].recognition.score`,
errors,
);
const words =
typeof text === "string" && score !== null
? parseWords(rec, i, errors)
: null;
if (typeof text !== "string" || score === null) return null;
return { text, score, words };
}
function parseFormula(r, i, errors) {
const f = r && r.formula;
if (f === undefined || f === null) {
errors.push(`regions[${i}].formula`);
return null;
}
const latex = pget(f, "latex");
if (typeof latex !== "string")
errors.push(`regions[${i}].formula.latex`);
if (typeof latex !== "string") return null;
return { latex: latex, model_id: pget(f, "model_id") };
}
const TABLE_ALLOWED_TAGS = ["html", "body", "table", "thead", "tbody", "tfoot", "caption", "colgroup", "col", "tr", "th", "td", "br", "sub", "sup", "b", "i", "u", "em", "strong", "span", "p", "div", "ul", "ol", "li", "code", "small"];
const TABLE_ALLOWED_ATTRS = ["colspan", "rowspan"];
const TABLE_MIN_SPAN = 1;
const TABLE_MAX_SPAN = 64;
function sanitizeTableHtml(html) {
if (typeof html !== "string" || html === "") return "";
let doc;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch (_) {
return "";
}
const out = [];
const walk = (node) => {
node.childNodes.forEach((child) => {
if (child.nodeType === 3) {
out.push(esc(child.nodeValue || ""));
return;
}
if (child.nodeType !== 1) return;
const tag = child.tagName.toLowerCase();
if (TABLE_ALLOWED_TAGS.indexOf(tag) < 0) {
walk(child);
return;
}
if (tag === "br") {
out.push("<br>");
return;
}
let attrs = "";
if (tag === "td" || tag === "th") {
TABLE_ALLOWED_ATTRS.forEach((name) => {
const raw = child.getAttribute(name);
if (raw === null) return;
const n = Number(raw);
if (Number.isInteger(n) && n >= TABLE_MIN_SPAN && n <= TABLE_MAX_SPAN)
attrs += " " + name + '="' + n + '"';
});
}
out.push("<" + tag + attrs + ">");
walk(child);
out.push("</" + tag + ">");
});
};
walk(doc.body);
return out.join("");
}
function parseTable(r, i, errors) {
const t = r && r.table;
if (t === undefined || t === null) {
errors.push(`regions[${i}].table`);
return null;
}
const html = pget(t, "html");
if (typeof html !== "string") errors.push(`regions[${i}].table.html`);
const cells = pget(t, "cells");
if (!Array.isArray(cells)) errors.push(`regions[${i}].table.cells`);
if (typeof html !== "string" || !Array.isArray(cells)) return null;
const tableHtml = pget(t, "table_html");
const rows = pget(t, "rows"),
cols = pget(t, "cols");
const degradations = pget(t, "degradations");
const lineIdx = pget(t, "source_line_indices");
const confidence = pget(t, "confidence");
return {
html:
typeof tableHtml === "string" && tableHtml !== ""
? tableHtml
: html,
cells: cells,
rows: Number.isInteger(rows) ? rows : null,
cols: Number.isInteger(cols) ? cols : null,
confidence: confidence === "high" || confidence === "low" ? confidence : null,
degradations: Array.isArray(degradations) ? degradations : [],
holes: Number.isInteger(pget(t, "holes")) ? pget(t, "holes") : null,
sourceLineIndices: Array.isArray(lineIdx)
? lineIdx.filter((n) => Number.isInteger(n))
: [],
modelId: pget(t, "model_id") || null,
markdown: typeof pget(t, "markdown") === "string" ? pget(t, "markdown") : "",
};
}
function degradationText(d) {
if (!d || typeof d !== "object") return "未知降级";
if (d.kind === "grid_holes")
return Number.isInteger(d.holes)
? `网格空洞 ${d.holes} 格`
: "网格空洞";
if (d.kind === "truncated") return "结构被截断(未见到结束符就用尽步数)";
return `未知降级(${String(d.kind)})`;
}
function normalizeResult(r) {
const errors = [];
if (r === null || typeof r !== "object" || Array.isArray(r)) {
contractFail(["(响应体不是 JSON 对象)"]);
return null;
}
if (!Array.isArray(r.regions)) {
contractFail(["regions"]);
return null;
}
const regions = r.regions
.map((x, i) => {
if (x === null || typeof x !== "object") {
errors.push(`regions[${i}]`);
return null;
}
const kind = pget(x, "kind");
if (kind !== "text" && kind !== "formula" && kind !== "table")
errors.push(`regions[${i}].kind`);
const polygon = parsePolygon(x, i, errors);
let text = "",
conf = null,
words = null,
formula = null,
table = null;
if (kind === "formula") {
formula = parseFormula(x, i, errors);
if (formula) text = formula.latex;
} else if (kind === "table") {
table = parseTable(x, i, errors);
} else {
const rec = parseRecognition(x, i, errors);
if (rec) {
text = rec.text;
conf = rec.score;
words = rec.words;
}
}
return kind === "text" || kind === "formula" || kind === "table"
? { text, conf, words, polygon, kind, formula, table, order: i + 1 }
: null;
})
.filter(Boolean);
if (errors.length) {
contractFail(errors);
return null;
}
return {
regions,
plain:
typeof r.plain_text === "string"
? r.plain_text
: regions
.map((x) => x.text)
.filter((s) => s !== "")
.join("\n"),
timings:
r.timings && typeof r.timings === "object" ? r.timings : {},
ledger:
r.timing_ledger && typeof r.timing_ledger === "object"
? r.timing_ledger
: null,
raw: r,
};
}
function contractFail(paths) {
const list = paths.slice(0, 4);
const more =
paths.length > list.length ? ` 等 ${paths.length} 处` : "";
state.result = null;
list
.map((p) => `响应字段缺失: ${p}`)
.reverse()
.forEach(diagPush);
renderDiag();
toast(
`响应字段缺失: ${list.join("、")}${more} — 前端按冻结契约解析 /api/jobs/{id}/result,不做别名兼容`,
"err",
"alert",
);
}
function normalizeStatus(s) {
const eng = s.engine || {},
prov = s.provider || {};
return {
state: eng.state || s.state || "unknown",
requested: firstText(
prov.requested,
s.requested_provider,
) ,
selected_ep: firstText(
prov.selected_ep,
s.selected_ep,
) ,
fallback: firstBool(
prov.fallback_to_cpu,
s.fallback_to_cpu,
) ,
ort: (s.ort && s.ort.version) || s.ort_version || "—",
fingerprint:
(s.ort && s.ort.fingerprint) || s.ort_fingerprint || null,
memory:
(s.memory && s.memory.peak_working_set_bytes) != null
? s.memory.peak_working_set_bytes
: s.peak_memory_bytes,
memorySource: (s.memory && s.memory.source) || null,
queues: s.queues || null,
limits:
s.limits ||
null ,
};
}
function normalizeModels(m) {
const route = (b) =>
b
? {
complete: !!b.complete,
missing: b.missing || [],
corrupt: b.corrupt || [],
blocked: b.blocked || [],
routing: b.routing !== false,
reason: b.disabled_reason || null,
inPlan: !!b.in_plan,
}
: null;
return {
source: m.source || "default_table",
downloadsAllowed: !!m.downloads_allowed,
formula: m.formula
? Object.assign(route(m.formula), {
detector: m.formula.detector || null,
})
: null,
table: m.table
? Object.assign(route(m.table), {
structure: m.table.structure || null,
})
: null,
pipelines: m.pipelines || null,
sets: (m.sets || []).map((s) => ({
id: s.id,
complete: !!s.complete,
bytes: s.download_bytes_total,
family: s.family || null,
version: s.version || null,
files: (s.files || []).map((f) => ({
name: f.name,
role: f.role || "",
state: f.state || "missing",
size: f.size_bytes,
url: f.source_url || "",
sha: f.sha256 || "",
})),
})),
};
}
const saved = localStorage.getItem("rrocr-theme");
const t =
saved ||
(matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light");
document.documentElement.dataset.theme = t;
$("themeBtn").addEventListener("click", (e) => {
const next =
document.documentElement.dataset.theme === "light"
? "dark"
: "light";
const apply = () => {
document.documentElement.dataset.theme = next;
localStorage.setItem("rrocr-theme", next);
};
const redraw = () => {
if (state.result) drawOverlays();
};
if (!document.startViewTransition) {
apply();
redraw();
return;
}
const x = e.clientX || innerWidth - 40,
y = e.clientY || 40;
const r = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
);
const vt = document.startViewTransition(apply);
vt.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${r}px at ${x}px ${y}px)`,
],
},
{
duration: 600,
easing: "cubic-bezier(.4,0,.2,1)",
pseudoElement: "::view-transition-new(root)",
},
);
});
vt.finished.then(redraw);
});
const imgEl = $("view"),
canvas = $("overlay"),
stage = $("stage"),
stageWrap = $("stageWrap"),
drop = $("drop");
const ctx = canvas.getContext("2d");
function setImage(file) {
if (!file || state.uploading) return false;
if (jobActive()) {
toast("当前任务进行中,请等待完成后再更换图像", "err", "info");
return false;
}
if (state.image)
URL.revokeObjectURL(state.image.url);
clearJob();
clearResult(false);
const url = URL.createObjectURL(file);
state.image = { file, url, w: 0, h: 0 };
imgEl.onerror = () => {
if (!state.image) return;
toast("无法解码该图像,请更换文件", "err");
clearImage();
};
imgEl.onload = () => {
state.image.w = imgEl.naturalWidth;
state.image.h = imgEl.naturalHeight;
state.view = {
mode: "fit",
scale: 1,
};
applyZoom();
renderMeta();
renderButton();
};
imgEl.src = url;
$("emptyState").hidden = true;
$("stageWrap").hidden = false;
$("clearBtn").hidden = false;
$("zoomGroup").hidden = false;
$("fileMeta").hidden = false;
drop.classList.add("has-image");
return true;
}
function clearJob() {
state.job = null;
scanOff();
renderJob();
renderButton();
}
function beginJob(submitted, queueFallback, poll, mode) {
state.job = {
id: submitted.job_id,
kind: submitted.kind || "text",
queue: submitted.queue || queueFallback || "text",
mode: mode || null,
state: submitted.state || "queued",
position: submitted.position,
queued_ms: 0,
elapsed_ms: 0,
};
pollFails = 0;
renderJob();
renderButton();
scanOn();
if (poll !== false) startPolling();
}
function endJob(message, type, icon) {
scanOff();
state.job = null;
renderJob();
renderButton();
if (message) toast(message, type || "err", icon);
}
function clearResult(resetList) {
state.result = null;
state.selected = -1;
clearTextLayer();
drawOverlays();
["copyAllBtn", "expJson", "expMd", "expHtml"].forEach(
(id) => ($(id).disabled = true),
);
$("annotatedBtn").hidden = true;
if (resetList !== false) {
renderRegions();
renderDiag();
}
}
function clearImage() {
if (state.uploading || jobActive()) return;
if (state.image) URL.revokeObjectURL(state.image.url);
state.image = null;
imgEl.onerror = null;
imgEl.removeAttribute("src");
clearJob();
clearResult();
stage.style.width =
"";
stage.style.height = "";
state.view = { mode: "fit", scale: 1 };
$("emptyState").hidden = false;
$("stageWrap").hidden = true;
$("clearBtn").hidden = true;
$("zoomGroup").hidden = true;
$("fileMeta").hidden = true;
drop.classList.remove("has-image");
renderButton();
}
addEventListener("beforeunload", () => {
if (state.image) URL.revokeObjectURL(state.image.url);
});
const CANVAS_MAX_PX = 24e6;
function layoutCanvas() {
const w = stage.clientWidth,
h = stage.clientHeight;
if (!w || !h) return;
const q = Math.max(
0.1,
Math.min(devicePixelRatio || 1, Math.sqrt(CANVAS_MAX_PX / (w * h))),
);
canvas.width = Math.max(1, Math.round(w * q));
canvas.height = Math.max(1, Math.round(h * q));
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(canvas.width / w, 0, 0, canvas.height / h, 0, 0);
drawOverlays();
layoutTextLayer();
}
function fgRGBA(a) {
const c = getComputedStyle(document.documentElement)
.getPropertyValue("--fg")
.trim();
const n = parseInt(c.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
function drawOverlays() {
const w = stage.clientWidth,
h = stage.clientHeight;
if (!w) return;
ctx.clearRect(0, 0, w, h);
if (!state.result || !state.image || !state.image.w) return;
const sx = w / state.image.w,
sy = h / state.image.h;
state.result.regions.forEach((r, i) => {
if (!r.polygon) return;
const pts = r.polygon.map((p) => [p[0] * sx, p[1] * sy]);
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++)
ctx.lineTo(pts[k][0], pts[k][1]);
ctx.closePath();
if (i === state.selected) {
ctx.fillStyle = fgRGBA(0.1);
ctx.fill();
ctx.setLineDash([]);
ctx.lineWidth = 2;
ctx.strokeStyle = fgRGBA(0.95);
} else {
ctx.setLineDash(
r.kind === "formula" ? [5, 4] : [],
);
ctx.lineWidth = 1;
ctx.strokeStyle = fgRGBA(0.45);
}
ctx.stroke();
ctx.setLineDash([]);
if (i === state.selected) {
ctx.fillStyle = fgRGBA(0.95);
pts.forEach((p) => ctx.fillRect(p[0] - 2.5, p[1] - 2.5, 5, 5));
}
});
}
new ResizeObserver(() => layoutCanvas()).observe(stage);
new ResizeObserver(() => {
if (state.image) applyZoom();
else layoutCanvas();
}).observe(stageWrap);
canvas.addEventListener("click", (e) => {
if (!state.result || !state.image) return;
const r = canvas.getBoundingClientRect();
const px = ((e.clientX - r.left) / r.width) * state.image.w;
const py = ((e.clientY - r.top) / r.height) * state.image.h;
let hit = -1;
state.result.regions.forEach((g, i) => {
if (hit >= 0 || !g.polygon) return;
if (pointInPoly(px, py, g.polygon)) hit = i;
});
selectRegion(hit);
});
function pointInPoly(x, y, p) {
let inside = false;
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
if (
p[i][1] > y !== p[j][1] > y &&
x <
((p[j][0] - p[i][0]) * (y - p[i][1])) / (p[j][1] - p[i][1]) +
p[i][0]
)
inside = !inside;
}
return inside;
}
function selectRegion(i) {
state.selected = i;
const items = [...document.querySelectorAll(".region-item")];
items.forEach((el, idx) => {
el.classList.toggle("sel", idx === i);
el.setAttribute("aria-pressed", idx === i ? "true" : "false");
});
drawOverlays();
if (i >= 0 && items[i])
items[i].scrollIntoView({ block: "nearest", behavior: "smooth" });
}
const textLayer = $("textLayer");
const TL_PROBE_EM = 100;
let tlProbe = null;
const tlAdvance = new Map();
let tlItems = [];
function tlProbeEl() {
if (!tlProbe) {
tlProbe = document.createElement("span");
tlProbe.className = "tl-probe";
tlProbe.setAttribute("aria-hidden", "true");
stage.appendChild(tlProbe);
}
return tlProbe;
}
function tlAdvanceRatio(text) {
const hit = tlAdvance.get(text);
if (hit !== undefined) return hit;
const p = tlProbeEl();
p.textContent = text;
const w = p.getBoundingClientRect().width;
const ratio = w > 0 ? w / TL_PROBE_EM : 0;
tlAdvance.set(text, ratio);
return ratio;
}
function clearTextLayer() {
tlItems = [];
textLayer.textContent = "";
textLayer.hidden = true;
}
function renderTextLayer() {
clearTextLayer();
const res = state.result;
if (!res || !state.image || !state.image.w || !state.image.h) return;
const frag = document.createDocumentFragment();
res.regions.forEach((r, ri) => {
if (
!r ||
r.kind !== "text" ||
!Array.isArray(r.words) ||
r.words.length === 0
)
return;
const line = document.createElement("div");
line.className = "tl-line";
line.setAttribute("data-ri", String(ri));
let made = 0;
r.words.forEach((w) => {
if (
!w ||
typeof w.text !== "string" ||
w.text === "" ||
!Array.isArray(w.polygon) ||
w.polygon.length !== 4
)
return;
const span = document.createElement("span");
span.className = "tl-word";
span.setAttribute("data-ri", String(ri));
span.setAttribute("data-kind", w.kind === "char" ? "char" : "token");
span.textContent = w.text;
line.appendChild(span);
tlItems.push({ span, quad: w.polygon });
made += 1;
});
if (made) frag.appendChild(line);
});
if (tlItems.length === 0) return;
textLayer.appendChild(frag);
textLayer.hidden = false;
layoutTextLayer();
}
function layoutTextLayer() {
if (textLayer.hidden || !state.image || !state.image.w || !state.image.h)
return;
const w = stage.clientWidth,
h = stage.clientHeight;
if (!w || !h) return;
const sx = w / state.image.w,
sy = h / state.image.h;
for (const item of tlItems) {
const q = item.quad;
const x0 = q[0][0] * sx,
y0 = q[0][1] * sy;
const ux = q[1][0] * sx - x0,
uy = q[1][1] * sy - y0;
const dx = q[3][0] * sx - x0,
dy = q[3][1] * sy - y0;
const boxH = Math.hypot(dx, dy);
const boxW = tlAdvanceRatio(item.span.textContent) * boxH;
const s = item.span.style;
if (!(boxH > 0.5) || !(boxW > 0.5)) {
s.display = "none";
continue;
}
s.display = "";
s.fontSize = boxH.toFixed(2) + "px";
s.transform =
"matrix(" +
[ux / boxW, uy / boxW, dx / boxH, dy / boxH, x0, y0]
.map((v) => v.toFixed(5))
.join(",") +
")";
}
}
textLayer.addEventListener("click", (e) => {
const el = e.target.closest("[data-ri]");
if (!el) return;
const sel = document.getSelection && document.getSelection();
if (sel && !sel.isCollapsed) return;
const i = Number(el.getAttribute("data-ri"));
if (!Number.isInteger(i) || i < 0) return;
selectRegion(i);
});
function checkWordsDelivery() {
if (DEMO || state.words === "off" || !state.result) return;
const got = state.result.regions.some(
(r) => r && r.kind === "text" && Array.isArray(r.words) && r.words.length,
);
if (got) return;
const msg =
"请求了 words=" +
state.words +
",但响应里没有任何 recognition.words(本图没有文本区域,或识别阶段未产出词框)";
diagPush(msg);
toast(msg, "err", "alert");
}
let dragDepth = 0;
drop.addEventListener("dragenter", (e) => {
e.preventDefault();
dragDepth++;
drop.classList.add("dragging");
});
drop.addEventListener("dragover", (e) => e.preventDefault());
drop.addEventListener("dragleave", () => {
if (--dragDepth <= 0) {
dragDepth = 0;
drop.classList.remove("dragging");
}
});
drop.addEventListener("drop", (e) => {
e.preventDefault();
dragDepth = 0;
drop.classList.remove("dragging");
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) handleFile(f);
});
document.addEventListener("dragover", (e) => e.preventDefault());
document.addEventListener("drop", (e) => e.preventDefault());
drop.addEventListener("click", () => {
if (!state.uploading) $("fileInput").click();
});
drop.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
$("fileInput").click();
}
});
$("fileInput").addEventListener("change", (e) => {
if (e.target.files[0]) handleFile(e.target.files[0]);
e.target.value = "";
});
document.addEventListener("paste", (e) => {
if (isTextField(e.target)) return;
const items = e.clipboardData ? [...e.clipboardData.items] : [];
const item = items.find((i) => i.type.startsWith("image/"));
if (!item)
return toast(
"剪贴板中没有图像",
"err",
"info",
);
intakePaste(item.getAsFile(), "key");
});
$("pasteBtn").addEventListener("click", async () => {
if (!navigator.clipboard || !navigator.clipboard.read)
return toast(
"当前浏览器不支持读取剪贴板,请改用 Ctrl+V 粘贴",
"err",
"info",
);
try {
for (const it of await navigator.clipboard.read()) {
const type = it.types.find((t) => t.startsWith("image/"));
if (type) return intakePaste(await it.getType(type), "button");
}
toast(
"剪贴板中没有图像",
"err",
"info",
);
} catch {
toast(
"无法读取剪贴板(需本页聚焦且已授权),请改用 Ctrl+V 粘贴",
"err",
"info",
);
}
});
function intakePaste(f, from) {
if (state.uploading)
return toast("上传进行中,请等待完成后再粘贴", "err", "info");
if (handleFile(f))
toast(
from === "button" ? "已从剪贴板载入图像" : "已粘贴剪贴板图像",
"ok",
"image",
);
}
$("clearBtn").addEventListener("click", clearImage);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
selectRegion(-1);
dismissToasts();
return;
}
if (e.ctrlKey && e.altKey && (e.key === "l" || e.key === "L")) {
e.preventDefault();
setFlowVerbose(!state.verbose);
return;
}
if (
!state.image ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
isTextField(e.target)
)
return;
if (e.key === "+" || e.key === "=") {
e.preventDefault();
zoomBy(ZOOM_STEP);
} else if (e.key === "-" || e.key === "_") {
e.preventDefault();
zoomBy(1 / ZOOM_STEP);
} else if (e.key === "0") {
e.preventDefault();
setZoom(1, "custom");
}
});
function handleFile(f) {
if (!f) return false;
if (!f.type.startsWith("image/")) {
toast("仅支持图像文件", "err");
return false;
}
const max =
state.status &&
state.status.limits &&
state.status.limits.max_body_bytes;
if (max && f.size > max) {
toast(
`图像 ${fmtBytes(f.size)} 超出请求体上限 ${fmtBytes(max)}`,
"err",
);
return false;
}
return setImage(f);
}
const ZOOM_MAX = 4;
const ZOOM_STEP = 1.25;
function fitScale() {
const i = state.image;
if (!i || !i.w || !i.h) return 1;
const cw = stageWrap.clientWidth,
ch = stageWrap.clientHeight;
if (!cw || !ch)
return (
state.view.scale || 1
);
return Math.min(
1,
cw / i.w,
ch / i.h,
);
}
function applyZoom() {
const i = state.image;
if (!i || !i.w || !i.h) return;
if (state.view.mode === "fit") state.view.scale = fitScale();
stage.style.width =
Math.max(1, Math.round(i.w * state.view.scale)) + "px";
stage.style.height =
Math.max(1, Math.round(i.h * state.view.scale)) + "px";
updateZoomUI();
layoutCanvas();
}
function setZoom(scale, mode) {
const lo =
fitScale() / 4;
state.view = {
mode: mode || "custom",
scale: Math.min(Math.max(scale, lo), ZOOM_MAX),
};
applyZoom();
}
function zoomBy(f) {
setZoom(state.view.scale * f);
}
function updateZoomUI() {
const i = state.image;
if (!i) return;
const pct = Math.round(state.view.scale * 100);
const lbl = $("zoomLbl");
lbl.textContent =
(state.view.mode === "fit" ? "适应 · " : "") + pct + "%";
lbl.title =
`显示 ${Math.round(i.w * state.view.scale)}×${Math.round(i.h * state.view.scale)} px · ` +
`原图 ${i.w}×${i.h} px`;
$("zoomFit").setAttribute(
"aria-pressed",
state.view.mode === "fit" ? "true" : "false",
);
$("zoomActual").setAttribute(
"aria-pressed",
state.view.mode === "custom" && pct === 100 ? "true" : "false",
);
}
$("zoomIn").addEventListener("click", () => zoomBy(ZOOM_STEP));
$("zoomOut").addEventListener("click", () => zoomBy(1 / ZOOM_STEP));
$("zoomFit").addEventListener("click", () =>
setZoom(1, "fit"),
);
$("zoomActual").addEventListener("click", () =>
setZoom(1, "custom"),
);
const ENG_MAP = {
loading: ["loader", "引擎加载中", "load"],
ready: ["ok", "引擎就绪", "ok"],
rebuilding: ["refresh", "引擎重建中", "load"],
failed: ["alert", "引擎不可用", "fail"],
waiting_models: ["cloud-off", "等待模型就绪", "warn"],
blocked_models_missing: ["cloud-off", "等待模型就绪", "warn"],
unknown: ["info", "状态未知", "dim"],
};
function engIcon(icn) {
if (icn === "ok") return '<span class="dot ok"></span>';
return ic(icn, icn === "loader" || icn === "refresh" ? "spin" : "");
}
function renderPill() {
const s = state.status || emptyStatus("loading");
const m = ENG_MAP[s.state] || ENG_MAP.unknown;
const label = state.netFail ? "服务未连接" : m[1];
$("enginePill").innerHTML =
(state.netFail ? ic("alert") : engIcon(m[0])) +
`<span>${label}</span>`;
}
const EP_UNKNOWN = "— · 未知";
function epDd(id, v, what) {
return v == null
? `<dd id="${id}" class="ep-unknown" title="响应未提供 ${what} 字段(旧版服务 / 演示数据缺失)">${EP_UNKNOWN}</dd>`
: `<dd id="${id}">${esc(v)}</dd>`;
}
function epDl(s, extra) {
const fb = s.fallback;
const fbCell =
fb === true
? `<dd id="epFallback" class="warn-dd" title="请求的 provider 不可用,实际在 CPU 上执行">${ic("alert")} 是 · 已回退 CPU</dd>`
: fb === false
? '<dd id="epFallback" title="实际使用的 EP 与请求一致">否 · 未回退</dd>'
: `<dd id="epFallback" class="ep-unknown" title="响应未提供 fallback_to_cpu 字段(旧版服务 / 演示数据缺失)">${EP_UNKNOWN}</dd>`;
return `<dl class="eng-kv">
<div><dt>REQUESTED</dt>${epDd("epRequested", s.requested, "requested")}</div>
<div><dt>SELECTED_EP</dt>${epDd("epSelected", s.selected_ep, "selected_ep")}</div>
<div><dt>FALLBACK_TO_CPU</dt>${fbCell}</div>${extra || ""}
</dl>`;
}
function queueReadyHtml() {
const t = modelsReadyFor("text"),
f = modelsReadyFor("formula"),
b = modelsReadyFor("table");
if (t.fatal)
return `<div class="eng-q eng-q-warn" id="qReady">
${ic("alert")}<span class="eng-q-txt"><b>模型状态未知</b><br>尚未取得 /api/models 的模型集状态,无法判断任何队列是否可提交。</span></div>`;
const textCell = t.ready
? "<b>文本队列 · 就绪</b><br>detector + recognizer + dictionary 齐备 → POST /api/ocr 可提交。"
: `<b>文本队列 · 不可用</b><br>缺少 ${esc(t.missing.join("、"))} → POST /api/ocr 会被服务端以 409 models_missing 拒绝。`;
const fxCell = f.ready
? "<b>公式队列 · 就绪</b><br>公式模型集齐备,可在左侧开启公式识别路由。"
: `<b>公式队列 · 不可用(不影响普通 OCR)</b><br>${esc(formulaBlockedText(f))};` +
"公式路由默认关闭(§10.8),普通文本 OCR 照常可用。";
const tblCell = b.ready
? "<b>表格队列 · 就绪</b><br>表格结构模型集齐备,可在左侧选择「文本 + 表格」或「文本 + 公式 + 表格」。"
: `<b>表格队列 · 不可用(不影响普通 OCR)</b><br>${esc(tableBlockedText(b))};` +
"表格路由默认关闭,普通文本 OCR 照常可用。";
return `<div class="eng-q ${t.ready ? "eng-q-ok" : "eng-q-warn"}" id="qReady">
${ic(t.ready ? "check" : "alert")}<span class="eng-q-txt">${textCell}</span></div>
<div class="eng-q eng-q-off ${f.ready ? "eng-q-ok" : ""}" id="qReadyFormula">
${ic(f.ready ? "check" : "info")}<span class="eng-q-txt">${fxCell}</span></div>
<div class="eng-q eng-q-off ${b.ready ? "eng-q-ok" : ""}" id="qReadyTable">
${ic(b.ready ? "check" : "info")}<span class="eng-q-txt">${tblCell}</span></div>`;
}
function renderEngine() {
const s = state.status,
ep = $("enginePanel");
if (!s) {
ep.innerHTML =
'<div class="eng-state">' +
ic("loader", "spin") +
"<b>正在连接本地服务…</b></div>" +
epDl(emptyStatus("loading"));
return;
}
if (state.netFail) {
ep.innerHTML =
'<div class="eng-state">' +
ic("alert") +
"<b>无法连接本地服务</b></div>" +
'<p class="eng-warn">' +
ic("info") +
"确认 rapidstruct serve 正在运行(默认 127.0.0.1:8760),且本页由 serve --open 打开。</p>" +
epDl(s) +
'<div class="eng-retry"><button class="btn-ghost sm" id="retryStatus">' +
ic("refresh") +
"重试连接</button></div>";
return;
}
const m = ENG_MAP[s.state] || ENG_MAP.unknown;
const qs = s.queues;
const queueTxt =
qs && qs.text
? `${qs.text.used != null ? qs.text.used : "—"}/${qs.text.capacity != null ? qs.text.capacity : "—"} · ` +
`${qs.formula && qs.formula.used != null ? qs.formula.used : "—"}/${qs.formula && qs.formula.capacity != null ? qs.formula.capacity : "—"}`
: "—";
const tq = modelsReadyFor("text");
const waiting =
s.state === "waiting_models" ||
s.state === "blocked_models_missing";
const head =
tq.ready && waiting
? { icn: "info", txt: "引擎就绪(仅部分模型集不齐备)" }
: { icn: m[0], txt: m[1] };
ep.innerHTML =
`<div class="eng-state">${engIcon(head.icn)}<b>${head.txt}</b>` +
(DEMO && s.state === "ready"
? `<span class="eng-note">ENGINE BUILD ${(demo.buildMs / 1000).toFixed(1)}S</span>`
: "") +
`</div>` +
(s.fallback === true
? `<p class="eng-warn">${ic("alert")}<span>已回退至 CPU 执行 — 仅表示请求的 provider 不可用,<b>不代表性能结论</b>;请以本页任务的实测耗时为准。</span></p>`
: "") +
queueReadyHtml() +
epDl(
s,
`<div><dt>ORT RUNTIME</dt><dd>${esc(s.ort)}</dd></div>` +
`<div><dt>峰值内存</dt><dd>${s.memory != null ? fmtBytes(s.memory) : "—"}</dd></div>` +
`<div><dt>队列(文/式)</dt><dd>${queueTxt}</dd></div>`,
);
}
$("enginePanel").addEventListener("click", (e) => {
if (e.target.closest("#retryStatus")) statusOnce();
});
function renderVerify() {
const box = $("verifyOut"),
hint = $("verifyHint");
if (!box || !hint) return;
const busy = state.verifying;
const btn = $("verifyBtn");
if (btn) btn.disabled = !!busy;
hint.textContent = busy
? "正在进行:清缓存 → 重新读盘核对 → 重建引擎会话…"
: "POST /api/models/reverify · 重新读盘核对模型并重建引擎会话";
const o = state.verifyOut;
if (!o) {
box.hidden = true;
box.innerHTML = "";
return;
}
box.hidden = false;
if (o.error) {
const e = o.error;
const code = e.code || (e.body && e.body.code);
const msg = e.message || (e.body && e.body.message) || "";
const base =
ERR_TEXT[code] ||
"请求失败" + (e.status ? `(HTTP ${e.status})` : "");
box.innerHTML =
`<span class="vo-line"><b>重新校验失败</b> — ${esc(base)}</span>` +
(msg
? `<span class="vo-line">服务端原文:${esc(msg)}</span>`
: "") +
`<span class="vo-line">本次失败不改写已经在跑的引擎状态(以 /api/status 为准)。</span>`;
return;
}
const c = o.value || {};
const files = Array.isArray(c.files) ? c.files : [];
const bad = files.filter((f) => f.state !== "present");
const changed = Array.isArray(c.content_changed)
? c.content_changed
: [];
const outcome = c.outcome != null ? String(c.outcome) : "—";
const pipes =
c.pipelines && typeof c.pipelines === "object" ? c.pipelines : null;
const fp = pipes && pipes.formula ? pipes.formula : null;
const tp = pipes && pipes.table ? pipes.table : null;
const blocked = (arr) => (Array.isArray(arr) ? arr : []);
const badText = fp
? files.filter(
(f) => f.pipeline !== "formula" && f.state !== "present",
)
: bad;
const badFormula = fp
? files.filter(
(f) => f.pipeline === "formula" && f.state !== "present",
)
: [];
const badTable = tp
? files.filter(
(f) => f.pipeline === "table" && f.state !== "present",
)
: [];
box.innerHTML =
`<span class="vo-line"><b>引擎 ${esc(outcome)}</b>` +
(c.load_ms != null
? ` · 建会话 ${esc(String(c.load_ms))} ms`
: "") +
(c.error ? ` · ${esc(errText(c.error))}` : "") +
`</span>` +
`<span class="vo-line">本次重算摘要 <b>${c.computed != null ? esc(String(c.computed)) : "—"}</b> 个` +
` · 缺失(文本) <b>${Array.isArray(c.missing) ? c.missing.length : "—"}</b>` +
` · 损坏(文本) <b>${Array.isArray(c.corrupt) ? c.corrupt.length : "—"}</b>` +
` · 内容变化 <b>${changed.length}</b></span>` +
(fp
? `<span class="vo-line">公式管线:<b>${
!fp.in_plan
? "不属于本次运行"
: blocked(fp.blocked).length
? "阻塞 " + esc(blocked(fp.blocked).join("、"))
: "正常"
}</b>${fp.routing === false ? " · 路由未启用" : ""}${
fp.detector && fp.detector.file
? ` · 检测模型 ${esc(fp.detector.file)}(${esc(fp.detector.state || "—")})`
: ""
}</span>`
: "") +
(tp
? `<span class="vo-line">表格管线:<b>${
!tp.in_plan
? "不属于本次运行"
: blocked(tp.blocked).length
? "阻塞 " + esc(blocked(tp.blocked).join("、"))
: "正常"
}</b>${tp.routing === false ? " · 路由未启用" : ""}${
tp.structure && tp.structure.file
? ` · 结构模型 ${esc(tp.structure.file)}(${esc(tp.structure.state || "—")})`
: ""
}</span>`
: "") +
(changed.length
? `<span class="vo-line">内容变化(stat 身份未变、首尾 64 KiB 不同):${esc(changed.join("、"))}</span>`
: "") +
(files.length
? `<div class="vo-files">${files
.map(
(f) => `<div class="vo-row">
<span class="vo-name" title="${esc(f.name)}">${esc(f.name)}</span>
<span class="vo-state ${f.state === "present" ? "ok" : "bad"}">${esc(f.state || "—")}</span>
<span class="vo-hint">${esc(ROLE_ZH[f.role] || f.role || "")}${f.cause ? " · " + esc(f.cause) : ""}</span>
</div>`,
)
.join("")}</div>`
: "") +
(badText.length
? `<span class="vo-line">服务端现在处于可定位的错误态:普通 OCR 会以 409 拒绝,直到这些文本管线文件恢复。</span>`
: badFormula.length
? `<span class="vo-line">普通 OCR 不受影响;公式队列会以 409(detail.scope=formula)拒绝,直到这些公式文件恢复。</span>`
: badTable.length
? `<span class="vo-line">普通 OCR 不受影响;表格队列会以 409(detail.scope=table)拒绝,直到这些表格文件恢复。</span>`
: `<span class="vo-line">全部文件状态正常:报告的状态与内存里的会话都已按磁盘上的当前内容刷新。</span>`);
}
function reverifyModels() {
if (state.verifying) return;
if (DEMO) {
state.verifyOut = {
error: {
code: "unavailable",
message:
"离线预览模式没有服务端:重新校验需要通过 rapidstruct serve 打开本页",
},
};
renderVerify();
toast("离线预览模式没有服务端,无法执行重新校验", "err", "refresh");
return;
}
state.verifying = true;
state.verifyOut = null;
renderVerify();
xhrSend("POST", "/api/models/reverify", "")
.then((v) => {
state.verifying = false;
state.verifyOut = { value: v };
renderVerify();
refreshModels();
statusOnce();
const n = v && v.computed != null ? v.computed : "—";
const outcome = v && v.outcome ? v.outcome : "—";
toast(
`重新校验完成 · 重算 ${n} 个摘要 · 引擎 ${outcome}`,
outcome === "ready" ? "ok" : "err",
"refresh",
);
})
.catch((e) => {
state.verifying = false;
state.verifyOut = { error: e };
renderVerify();
toastErr(e);
});
}
$("verifyBtn").addEventListener("click", reverifyModels);
const PIPE_ZH = { text: "文本", formula: "公式", table: "表格" };
const FILE_STATE_ZH = { present: "就绪", missing: "缺失", corrupt: "损坏" };
function fileStateCell(st) {
const s = st || "missing";
const icon =
s === "present" ? "check" : s === "corrupt" ? "alert" : "cloud-off";
return `<span class="bf-state ${esc(s)}">${ic(icon)}${esc(FILE_STATE_ZH[s] || s)}</span>`;
}
function planRowsHtml() {
const m = state.models;
const pipes = m && m.pipelines;
if (!pipes) return "";
const sets = m.sets || [];
const rows = [];
["text", "formula", "table"].forEach((key) => {
const p = pipes[key];
if (!p || !Array.isArray(p.plan) || !p.plan.length) return;
p.plan.forEach((f) => {
const owner = sets.find((s) =>
(s.files || []).some(
(x) => x.name === f.name && x.role === f.role,
),
);
const st = f.state || "missing";
rows.push(
`<tr><td>${esc(PIPE_ZH[key] || key)}</td>` +
`<td class="pn">${esc(f.name)}</td>` +
`<td>${esc(ROLE_ZH[f.role] || f.role || "—")}</td>` +
`<td class="ps ps-${esc(st)}">${esc(FILE_STATE_ZH[st] || st)}</td>` +
`<td>${f.has_declared_digest ? "已声明" : "未声明"}</td>` +
`<td>${owner ? esc(owner.id) : "命令行指定"}</td></tr>`,
);
});
});
if (!rows.length) return "";
return `<div class="plan">
<div class="plan-title">模型计划(服务端解析,页面不拼路径)</div>
<div class="plan-wrap"><table>
<thead><tr><th>管线</th><th>文件</th><th>角色</th><th>状态</th><th>摘要</th><th>声明来源</th></tr></thead>
<tbody>${rows.join("")}</tbody>
</table></div>
</div>`;
}
function renderBanner() {
const box = $("banner");
const m = state.models;
if (m && m.error) {
box.hidden = false;
box.innerHTML = `<div class="banner-main"><span class="banner-ic">${ic("alert")}</span>
<div class="banner-info"><div class="banner-title"><b>模型状态获取失败</b><span>${esc(errText(m.error))}</span></div></div>
<div class="banner-act"><button class="btn-ghost sm" id="retryModels">${ic("refresh")}重试</button></div></div>`;
return;
}
if (!m || !m.sets) {
box.hidden = true;
return;
}
const sets = m.sets;
const bad = sets.filter((s) => !s.complete);
box.hidden = false;
let html = `<div class="banner-main">
<span class="banner-ic">${ic(bad.length ? "alert" : "check")}</span>
<div class="banner-info">
<div class="banner-title">
<b>模型清单 · ${sets.length} 个集合</b>
<span>来源 ${esc(m.source)} · ${
m.downloadsAllowed
? "服务已允许下载(--allow-download)"
: "服务未以 --allow-download 启动,页面不能下载模型"
} · ${bad.length ? `${bad.length} 个集合不齐备` : "全部齐备"}</span>
</div>
<div class="msets">`;
sets.forEach((set) => {
const files = set.files || [];
const pend = files.filter((f) => f.state !== "present");
const corrupt = pend.filter((f) => f.state === "corrupt").length;
const missing = pend.length - corrupt;
const hosts =
[...new Set(files.map((f) => hostOf(f.url)).filter(Boolean))].join(
" / ",
) || "—";
const bytes = files.reduce((a, f) => a + (f.size || 0), 0);
const busy = state.dlSetId === set.id;
const hasUrl = pend.length > 0 && pend.every((f) => !!f.url);
let act;
if (set.complete) {
act = `<button class="btn-ghost sm btn-mini" data-reverify="1" title="重新读取磁盘上的文件并重算 SHA-256">${ic("refresh")}重新校验</button>`;
} else if (busy) {
act = `<button class="btn-primary sm btn-mini" disabled>${ic("loader", "spin")}下载中…</button>`;
} else {
const why = !hasUrl
? "该集合有文件缺少 source_url,服务端无法构造下载请求"
: !m.downloadsAllowed
? "服务未以 --allow-download 启动,请重启服务并添加该参数"
: "";
act =
`<button class="btn-primary sm btn-mini" data-set-id="${esc(set.id)}"
aria-disabled="${why ? "true" : "false"}"
title="${why ? esc(why) : "仅下载「" + esc(set.id) + "」这一个集合"}">
${ic("download")}下载「${esc(set.id)}」· ${fmtBytes(bytes)}</button>` +
(why ? `<p class="banner-note">${esc(why)}</p>` : "");
}
const stTxt = set.complete
? "齐备"
: corrupt
? `损坏 ${corrupt}${missing ? " · 缺失 " + missing : ""}`
: `缺失 ${missing}`;
html += `<details class="mset${set.complete ? "" : " mset-bad"}" ${set.complete ? "" : "open"}>
<summary>
<span class="ms-state ${set.complete ? "ok" : "bad"}">${ic(set.complete ? "check" : corrupt ? "alert" : "cloud-off")}${esc(stTxt)}</span>
<b class="ms-id">${esc(set.id)}</b>
<span class="ms-meta">${esc(set.family || "—")} ${esc(set.version || "")} · ${files.length} 个文件 · ${fmtBytes(bytes)} · 来源 ${esc(hosts)}</span>
</summary>
<div class="banner-files">${files
.map(
(f) => `
<div class="bf-row">
${fileStateCell(f.state)}
<span class="bf-name" title="${esc(f.name)}">${esc(f.name)}</span>
<span class="bf-role">${esc(ROLE_ZH[f.role] || f.role || "—")}</span>
<span class="bf-size">${fmtBytes(f.size)}</span>
</div>${
f.sha
? `<details class="bf-sha-row"><summary>sha256 ${esc(f.sha.slice(0, 12))}…</summary><code>${esc(f.sha)}</code></details>`
: ""
}`,
)
.join("")}</div>
<div class="ms-act">${act}</div>
${corrupt ? `<p class="banner-note">损坏文件的哈希校验未通过,建议重新下载;下载完成并通过校验后将以原子方式替换原文件。</p>` : ""}
</details>`;
});
html += `</div>${planRowsHtml()}</div></div>`;
if (state.dlJob) {
const st = state.dlJob.state || "queued";
const el =
state.dlJob.elapsed_ms != null
? ` · ${(state.dlJob.elapsed_ms / 1000).toFixed(1)}s`
: "";
const dp = state.dlJob.download;
let pct = null,
cur = "";
if (dp && dp.files_total > 0) {
pct =
dp.bytes_total > 0
? (dp.bytes_done / dp.bytes_total) * 100
: (dp.files_done / dp.files_total) * 100;
pct = Math.max(0, Math.min(100, pct));
cur = dp.current_file ? ` · 当前 ${esc(dp.current_file)}` : "";
cur += ` · ${dp.files_done}/${dp.files_total} 个文件`;
}
const pctTxt = pct == null ? "…" : `${pct.toFixed(0)}%`;
html += `<div class="banner-dl">
<div class="dl-line"><span>${ic("package")} 下载任务 <b>${esc(state.dlJob.id)}</b> · 集合「${esc(state.dlJob.set_id)}」· 状态 <b>${esc(modelJobZh(st))}</b>${el}${cur}(逐文件下载,完成后自动校验 SHA-256)</span><b>${pctTxt}</b></div>
<div class="bar${pct == null ? " ind" : ""}"><i id="dlJobBar"></i></div>
<div class="dl-line"><span>取消下载 = POST /api/jobs/{id}/cancel,与取消识别同一套 job 语义(运行中的下载在<b>文件边界</b>生效)</span>
<button class="btn-ghost sm" id="dlCancelBtn">${ic("x")}取消下载</button></div></div>`;
} else if (demo.dlActive) {
html += `<div class="banner-dl">
<div class="dl-line"><span>${ic("package")} 正在下载 ${esc(demo.dlFile)} · 逐文件下载,完成后自动校验 SHA-256</span><b>${demo.dlPct.toFixed(0)}%</b></div>
<div class="bar"><i id="dlBar"></i></div></div>`;
}
box.innerHTML = html;
const bar = $("dlBar");
if (bar)
bar.style.width = demo.dlPct + "%";
const jobBar = $("dlJobBar");
if (
jobBar &&
state.dlJob &&
state.dlJob.download &&
state.dlJob.download.files_total > 0
) {
const d = state.dlJob.download;
const p =
d.bytes_total > 0
? (d.bytes_done / d.bytes_total) * 100
: (d.files_done / d.files_total) * 100;
jobBar.style.width = Math.max(0, Math.min(100, p)) + "%";
}
}
function modelJobZh(st) {
return (
{
queued: "排队中",
running: "下载中",
succeeded: "已完成",
failed: "失败",
cancelled: "已取消",
}[st] || st
);
}
$("banner").addEventListener("click", (e) => {
const retry = e.target.closest("#retryModels");
if (retry) return refreshModels();
const cancel = e.target.closest("#dlCancelBtn");
if (cancel) return cancelDownload();
if (e.target.closest("[data-reverify]")) return reverifyModels();
const dl = e.target.closest("[data-set-id]");
if (!dl) return;
if (dl.getAttribute("aria-disabled") === "true")
return toast(
dl.title || "当前不可下载该模型集",
"err",
"cloud-off",
);
startDownload(dl.dataset.setId);
});
function flashBanner() {
const b = $("banner");
b.scrollIntoView({ block: "nearest", behavior: "smooth" });
b.classList.remove("flash");
void b.offsetWidth;
b.classList.add("flash");
}
function startDownload(setId) {
if (demo.dlActive || state.dlJob) return;
const id = setId == null ? "" : String(setId).trim();
if (!id) {
const msg =
"未指定模型集 set_id,已本地拒绝该下载请求(不发送缺 set_id 的请求)";
state.diag = [msg].concat(state.diag).slice(0, 12);
renderDiag();
flashBanner();
return toast(msg, "err", "cloud-off");
}
if (DEMO) return demoDownload(id);
const set =
state.models && state.models.sets
? state.models.sets.find((s) => s.id === id)
: null;
if (!set) {
const msg = `模型集 ${id} 不在 /api/models 的 sets 中,已本地拒绝该下载请求`;
state.diag = [msg].concat(state.diag).slice(0, 12);
renderDiag();
return toast(msg, "err", "cloud-off");
}
state.dlSetId = id;
state.dlJob = {
id: null,
set_id: id,
state: "queued",
elapsed_ms: 0,
};
renderBanner();
apiSendJson("POST", "/api/models/download", { set_id: id })
.then((d) => {
const jobId = d && d.job_id;
if (!jobId) {
throw {
code: "internal",
message: "响应未返回 job_id(下载任务必须可轮询、可取消)",
};
}
state.dlJob.id = jobId;
if (d.state) state.dlJob.state = d.state;
toast(
`模型集「${id}」下载任务已启动 · job ${jobId}`,
"ok",
"package",
);
renderBanner();
pollDownloadJob();
})
.catch((e) => {
state.dlJob = null;
state.dlSetId = null;
renderBanner();
toastErr(e);
});
}
let dlPollTimer = null;
function pollDownloadJob() {
clearTimeout(dlPollTimer);
const j = state.dlJob;
if (!j || !j.id) return;
const tick = async () => {
if (!state.dlJob || state.dlJob.id !== j.id) return;
try {
const d = await apiJson("GET", "/api/jobs/" + j.id);
Object.assign(j, d);
renderBanner();
if (j.state === "queued" || j.state === "running") {
dlPollTimer = setTimeout(tick, document.hidden ? 2000 : 800);
return;
}
} catch (e) {
if (e && (e.status === 410 || e.status === 404)) {
toast(
e.status === 410
? "下载任务记录已被淘汰(410)"
: "下载任务不存在(404)",
"err",
);
}
dlPollTimer = setTimeout(tick, 1600);
return;
}
const st = j.state;
state.dlJob = null;
state.dlSetId = null;
await refreshModels();
renderBanner();
renderButton();
if (st === "succeeded")
toast(
`模型集「${j.set_id}」下载完成,引擎将在下次识别时惰性创建`,
"ok",
"package",
);
else if (st === "failed")
toast(
`模型集「${j.set_id}」下载失败:${j.error || "未知错误"}`,
"err",
);
else if (st === "cancelled")
toast(
`模型集「${j.set_id}」下载已取消(临时文件已清理,已校验完成的文件保留)`,
"err",
"info",
);
};
tick();
}
function cancelDownload() {
const j = state.dlJob;
if (!j) return;
if (DEMO) {
demo.dlActive = false;
state.dlJob = null;
state.dlSetId = null;
renderBanner();
return toast("已取消下载", "err", "info");
}
if (!j.id) {
return toast("下载任务尚未取得 job_id,无法取消", "err");
}
return apiJson("POST", "/api/jobs/" + j.id + "/cancel")
.then(() =>
toast(
"已请求取消下载" +
(j.state === "running" ? "(取消在文件边界生效)" : ""),
"ok",
),
)
.catch((e) =>
toastErr(e),
);
}
function renderMeta() {
if (!state.image) return;
const i = state.image;
$("metaText").innerHTML =
`<b>${esc(i.file.name || "clipboard.png")}</b> · ${i.w}×${i.h} · ${fmtBytes(i.file.size)}`;
$("annotatedBtn").hidden = !state.result;
}
function renderButton() {
const btn = $("ocrBtn"),
lbl = $("ocrLbl"),
ico = $("ocrIco");
const use = (n) => {
ico.innerHTML = `<use href="#i-${n}"/>`;
};
ico.classList.remove("spin");
if (state.uploading) {
btn.disabled = true;
lbl.textContent = "上传中…";
use("loader");
ico.classList.add("spin");
return;
}
if (jobActive()) {
btn.disabled = true;
lbl.textContent = "识别中…";
use("loader");
ico.classList.add("spin");
return;
}
if (!state.image) {
btn.disabled = true;
lbl.textContent = "等待图像输入";
use("scan");
return;
}
const q = modelsReadyFor("text");
if (!q.ready) {
btn.disabled = false;
lbl.textContent = q.fatal
? "模型状态未知 · 点击查看"
: `模型缺失 · 点击查看(${q.missing.join("、")})`;
use("cloud-off");
return;
}
btn.disabled = false;
if (state.result) {
lbl.textContent = "重新识别";
use("refresh");
} else {
lbl.textContent = "开始识别";
use("scan");
}
}
function renderModeRow() {
const row = $("modeRow"),
opts = $("modeOpts"),
cap = $("modeCap");
row.hidden = false;
const before = modeById(state.mode);
if (before !== MODES[0] && !modeAvailability(before).ok) {
state.mode = MODES[0].id;
}
opts.innerHTML = MODES.map((m) => {
const av = modeAvailability(m);
const on = m.id === state.mode;
const hint = av.ok ? m.desc : av.why;
return `<button type="button" class="mode-opt" role="radio" data-mode="${m.id}" aria-checked="${on}" aria-disabled="${!av.ok}" title="${esc(hint)}"><span class="mo-dot" aria-hidden="true"></span><span class="mo-body"><span class="mo-name">${esc(m.name)}</span><span class="mo-desc">${esc(m.desc)}</span></span></button>`;
}).join("");
const cur = modeById(state.mode);
const av = modeAvailability(cur);
cap.textContent = av.ok
? `${cur.name}:请求 ${cur.desc}。公式与表格区域各自独立处理,正文文本 OCR 不受影响。`
: `不可选:${av.why}。可在上方模型清单里按集合下载,或由服务端加 --formula-detector / --table-model 启用。`;
renderWordsRow();
}
function renderWordsRow() {
const row = $("wordsRow"),
opts = $("wordsOpts"),
cap = $("wordsCap");
row.hidden = false;
if (!WORDS_MODES.some((m) => m.id === state.words)) state.words = "off";
opts.innerHTML = WORDS_MODES.map((m) => {
const on = m.id === state.words;
return `<button type="button" class="mode-opt" role="radio" data-words="${m.id}" aria-checked="${on}" title="${esc(m.hint)}"><span class="mo-dot" aria-hidden="true"></span><span class="mo-body"><span class="mo-name">${esc(m.name)}</span><span class="mo-desc">${esc(m.desc)}</span></span></button>`;
}).join("");
const cur = wordsModeById(state.words);
cap.textContent =
cur.id === "off"
? "仅行框:不发 words 参数,响应体里没有逐词/逐字框,图上也没有可选中的文本层(叠框与右栏列表照旧)。"
: `${cur.name}:请求 ${cur.desc}。${cur.hint} 切换档位后需重新识别才会生效。`;
}
$("wordsOpts").addEventListener("click", (e) => {
const btn = e.target.closest("[data-words]");
if (!btn) return;
const m = wordsModeById(btn.getAttribute("data-words"));
if (state.words === m.id) return;
state.words = m.id;
try {
localStorage.setItem(WORDS_KEY, m.id);
} catch {}
renderWordsRow();
toast(
m.id === "off"
? "框粒度:仅行框(重新识别后文本层会消失)"
: `框粒度:${m.name}(${m.desc})· 重新识别后生效`,
"ok",
"check",
);
});
$("modeOpts").addEventListener("click", (e) => {
const btn = e.target.closest("[data-mode]");
if (!btn) return;
const m = modeById(btn.getAttribute("data-mode"));
const av = modeAvailability(m);
if (!av.ok) {
toast(`${m.name} 不可选:${av.why}`, "err", "cloud-off");
return;
}
if (state.mode === m.id) return;
state.mode = m.id;
renderModeRow();
toast(`识别模式:${m.name}(${m.desc})`, "ok", "check");
});
$("ocrBtn").addEventListener("click", () => {
if (state.uploading || jobActive() || !state.image) return;
const q = modelsReadyFor("text");
if (!q.ready) {
toast(
q.fatal
? "尚未取得 /api/models 模型状态,无法确认文本模型是否齐备"
: `普通 OCR 模型集不齐备:缺少 ${q.missing.join("、")},请先在横幅中按集合下载`,
"err",
"cloud-off",
);
flashBanner();
return;
}
const m = modeById(state.mode);
const av = modeAvailability(m);
if (!av.ok) {
toast(`${m.name} 不可用:${av.why}`, "err", "cloud-off");
renderModeRow();
flashBanner();
return;
}
if (DEMO && demo.phase === "waitmodels" && !demo.textReady) {
toast("演示模式:文本模型尚未就绪", "err", "cloud-off");
flashBanner();
return;
}
submitImage();
});
function setUpProgress(p) {
$("upPct").textContent = Math.round(p * 100) + "%";
$("upBar").style.width = (p * 100).toFixed(1) + "%";
}
$("abortUpBtn").addEventListener("click", () => {
demo.upAbort = true;
if (state.xhr) state.xhr.abort();
});
function showUp(v) {
$("uploadProg").hidden = !v;
}
function submitImage() {
if (DEMO) return demoUpload();
state.uploading = true;
showUp(true);
setUpProgress(0);
renderButton();
drop.classList.add("no-click");
const mode = modeById(state.mode);
const queue = mode.queue;
const url = "/api/ocr?queue=" + queue + (mode.table ? "&table=1" : "") + wordsQuery();
xhrSend("POST", url, state.image.file, {
octet: true,
onProgress: setUpProgress,
getXhr: (x) => {
state.xhr = x;
},
})
.then((data) => {
state.xhr = null;
state.uploading = false;
showUp(false);
drop.classList.remove("no-click");
renderButton();
if (
!data ||
typeof data.job_id !== "string" ||
data.job_id === ""
) {
state.result = null;
diagPush("响应字段缺失: job_id");
toast(
"响应字段缺失: job_id — 前端按冻结契约解析 POST /api/ocr(202 {job_id,queue,position,state})",
"err",
"alert",
);
endJob();
return;
}
clearResult(false);
toast(
"任务已提交(202)· " +
mode.name +
" · " +
zhQueue(data.queue || queue) +
"队列" +
(mode.table ? " + 表格" : ""),
"ok",
);
beginJob(data, queue, undefined, mode.id);
})
.catch((e) => {
state.xhr = null;
state.uploading = false;
showUp(false);
drop.classList.remove("no-click");
endJob(failText("提交失败", e), "err");
});
}
let pollTimer = null;
let pollFails = 0;
const POLL_FAIL_LIMIT = 8;
function pollBackoff() {
return Math.min(4000, 480 * Math.pow(2, Math.max(0, pollFails - 1)));
}
function startPolling() {
clearTimeout(pollTimer);
const tick = async () => {
const j = state.job;
if (!j) return;
let d;
try {
d = await apiJson("GET", "/api/jobs/" + j.id);
} catch (e) {
if (e.status === 410 || e.status === 404) {
diagPush(
`任务终结: ${e.status === 410 ? "结果已被淘汰" : "任务不存在"}(HTTP ${e.status}${e.code ? " " + e.code : ""})`,
);
endJob(
failText(
e.status === 410
? "结果已被淘汰,请重新识别"
: "任务不存在",
e,
),
"err",
);
return;
}
if (
e.status === 401 ||
e.status === 403 ||
e.status === 400 ||
e.status === 409
) {
diagPush(`轮询被拒绝: ${failText("", e).replace(/^:/, "")}`);
endJob(failText("轮询被拒绝", e), "err");
return;
}
pollFails += 1;
if (pollFails >= POLL_FAIL_LIMIT) {
diagPush(
`轮询连续失败 ${pollFails} 次: ${failText("", e).replace(/^:/, "")}`,
);
endJob(
failText(`轮询连续失败 ${pollFails} 次,已停止`, e),
"err",
);
return;
}
pollTimer = setTimeout(
tick,
document.hidden ? 1600 : pollBackoff(),
);
return;
}
pollFails = 0;
Object.assign(j, d);
renderJob();
if (j.state === "queued" || j.state === "running") {
pollTimer = setTimeout(tick, document.hidden ? 1600 : 480);
} else if (j.state === "succeeded") {
await fetchResult();
} else {
onJobEnd();
}
};
tick();
}
async function fetchResult() {
const j = state.job;
if (!j) return;
let r;
try {
r = await apiJson("GET", "/api/jobs/" + j.id + "/result");
} catch (e) {
if (e.code === "job_not_finished") {
pollTimer = setTimeout(startPolling, 480);
return;
}
diagPush(`结果获取失败: ${failText("", e).replace(/^:/, "")}`);
endJob(failText("结果获取失败", e), "err");
return;
}
const parsed = normalizeResult(r);
if (!parsed) {
endJob(
"响应不符合冻结契约,结果未渲染(诊断面板已点名具体字段路径)",
"err",
);
return;
}
state.result = parsed;
scanOff();
finishJob();
}
function onJobEnd() {
const j = state.job;
if (j && j.state === "failed")
endJob("识别失败:" + (j.error || "未知错误"), "err");
else if (j && j.state === "cancelled")
endJob("任务已取消", "err", "info");
else endJob();
}
function finishJob() {
renderJob();
renderRegions();
checkWordsDelivery();
renderDiag();
renderMeta();
renderButton();
["copyAllBtn", "expJson", "expMd", "expHtml"].forEach(
(id) => ($(id).disabled = false),
);
$("annotatedBtn").hidden = false;
layoutCanvas();
renderTextLayer();
const t = state.result.timings;
const total =
t.total_ms != null ? t.total_ms : t.total != null ? t.total : null;
toast(
"识别完成" +
(total != null ? ` · ${(total / 1000).toFixed(2)} s` : ""),
"ok",
);
}
function renderJob() {
const jl = $("jobLine"),
jt = $("jlText"),
cb = $("cancelJobBtn");
const j = state.job;
if (!j) {
jl.hidden = true;
return;
}
jl.hidden = false;
let html = "";
if (j.state === "queued")
html =
ic("clock") +
`<span>排队中 · <b>${zhQueue(j.queue)}队列</b>` +
(j.mode ? ` · ${esc(modeById(j.mode).name)}` : "") +
` · 位置 ${j.position != null ? j.position : "—"}` +
(j.queued_ms != null
? ` · 已等待 ${(j.queued_ms / 1000).toFixed(1)}s`
: "") +
"</span>";
else if (j.state === "running")
html =
ic("loader", "spin") +
`<span>识别中 · 已进行 <b>${((j.elapsed_ms || 0) / 1000).toFixed(1)}s</b></span>`;
else if (j.state === "succeeded")
html =
ic("check") +
`<span>识别完成 · 耗时 <b>${((j.elapsed_ms || 0) / 1000).toFixed(2)}s</b></span>`;
else if (j.state === "failed")
html =
ic("alert") +
`<span>失败 · <b>${esc(j.error || "未知错误")}</b></span>`;
else html = ic("x") + `<span>已取消</span>`;
jt.innerHTML = html;
const cancellable = j.state === "queued" || j.state === "running";
cb.hidden = !cancellable;
cb.disabled =
j.state ===
"running";
cb.title =
j.state === "running"
? "推理进行中,当前版本不支持中断(not_cancellable)"
: "从队列中移除该任务";
}
$("cancelJobBtn").addEventListener("click", async () => {
const j = state.job;
if (!j) return;
if (DEMO) {
if (j.state === "queued") {
j.state = "cancelled";
onJobEnd();
} else toast("推理进行中,当前版本不支持中断", "err");
return;
}
try {
await apiJson("POST", "/api/jobs/" + j.id + "/cancel");
toast("已请求取消", "ok");
} catch (e) {
toastErr(e);
if (e.code === "not_cancellable") $("cancelJobBtn").disabled = true;
}
});
function scanOn() {
stage.classList.add("scanning");
}
function scanOff() {
stage.classList.remove("scanning");
}
const FX_LIBS = {
katex: {
label: "KaTeX",
js: "/static/katex/katex.min.js",
css: "/static/katex/katex.min.css",
ready: () =>
typeof window.katex !== "undefined" &&
typeof window.katex.render === "function",
},
mathjax: {
label: "MathJax",
js: "/static/mathjax/tex-svg.js" ,
css: null ,
ready: () =>
!!(
window.MathJax &&
window.MathJax.startup &&
(typeof window.MathJax.tex2svgPromise === "function" ||
typeof window.MathJax.typesetPromise === "function")
),
},
};
const FX_MODE_KEY = "rrocr-fx-mode";
const FX_NONCE =
(document.currentScript && document.currentScript.nonce) || "";
if (FX_NONCE) {
const fxStampNonce = (node) => {
if (
node &&
node.nodeType === 1 &&
!node.nonce &&
(node.tagName === "STYLE" ||
(node.tagName === "LINK" &&
String(node.rel).toLowerCase() === "stylesheet"))
) {
node.nonce = FX_NONCE;
}
return node;
};
const fxOrigAppend = Node.prototype.appendChild;
Node.prototype.appendChild = function (node) {
return fxOrigAppend.call(this, fxStampNonce(node));
};
const fxOrigInsert = Node.prototype.insertBefore;
Node.prototype.insertBefore = function (node, ref) {
return fxOrigInsert.call(this, fxStampNonce(node), ref);
};
}
const fxLoading =
{};
function ensureMathJaxConfig() {
if (window.MathJax && window.MathJax.startup) return;
window.MathJax = {
tex: {
inlineMath: [["\\(", "\\)"]],
displayMath: [
["$$", "$$"],
["\\[", "\\]"],
],
processEscapes: true,
},
options: {
skipHtmlTags: [
"script",
"noscript",
"style",
"textarea",
"pre",
"code",
"svg",
],
ignoreHtmlClass: "tex2jax_ignore",
processHtmlClass: "tex2jax_process",
},
startup: { typeset: false },
};
}
function loadFxLibrary(name) {
if (fxLoading[name]) return fxLoading[name];
const lib = FX_LIBS[name];
if (!lib) return Promise.reject(new Error("未知公式引擎: " + name));
const p = new Promise((resolve, reject) => {
if (lib.ready()) return resolve();
if (name === "mathjax") ensureMathJaxConfig();
if (
lib.css &&
!document.querySelector(`link[data-fx-css="${name}"]`)
) {
const lk = document.createElement("link");
lk.rel = "stylesheet";
lk.href = lib.css;
lk.dataset.fxCss = name;
if (FX_NONCE) lk.nonce = FX_NONCE;
document.head.appendChild(lk);
}
const s = document.createElement("script");
s.src = lib.js;
s.async = true;
if (FX_NONCE) s.nonce = FX_NONCE;
s.dataset.fxJs = name;
s.onerror = () =>
reject(new Error(lib.label + " 加载失败:" + lib.js));
s.onload = () => {
if (name !== "mathjax") {
return lib.ready()
? resolve()
: reject(new Error(lib.label + " 已加载但未挂载到 window"));
}
const t0 = Date.now();
const check = () => {
if (lib.ready()) return resolve();
if (Date.now() - t0 > 10000)
return reject(new Error("MathJax 启动超时"));
setTimeout(check, 50);
};
check();
};
document.head.appendChild(s);
});
fxLoading[name] = p;
p.catch(() => {
fxLoading[name] = null;
});
return p;
}
async function renderFormula(el, latex, engine) {
el.classList.remove("fx-rendered", "fx-error");
if (!latex || !latex.trim()) return;
if (engine === "katex") {
try {
katex.render(latex, el, {
displayMode: true,
throwOnError: true,
strict: "ignore",
output: "html",
});
el.classList.add("fx-rendered");
} catch (e) {
el.textContent = latex;
el.classList.add("fx-error");
throw e;
}
return;
}
if (engine === "mathjax") {
const MJ = window.MathJax;
try {
if (MJ.startup && MJ.startup.promise) {
try {
await MJ.startup.promise;
} catch (_) {
}
}
let node = null;
if (typeof MJ.tex2svg === "function") {
try {
node = MJ.tex2svg(latex, { display: true });
} catch (_) {
node = null;
}
}
if (!node && typeof MJ.tex2svgPromise === "function") {
node = await MJ.tex2svgPromise(latex, { display: true });
}
if (!node) throw new Error("MathJax 未产出可渲染节点");
el.replaceChildren(node);
el.classList.add("fx-rendered");
} catch (e) {
el.textContent = latex;
el.classList.add("fx-error");
throw e;
}
}
}
let fxRenderSeq = 0;
async function applyFormulaRender() {
const seq = ++fxRenderSeq;
const slots = document.querySelectorAll(".fx-slot");
if (!slots.length) return;
const mode = state.fx.mode;
slots.forEach((el) => {
el.classList.remove("fx-rendered", "fx-error");
el.textContent = el.dataset.latex || "";
});
if (mode === "off") return;
try {
await loadFxLibrary(mode);
} catch (err) {
if (seq !== fxRenderSeq) return;
toast(err.message + " — 公式以 LaTeX 原文显示", "err", "alert");
const hint = $("fxRenderHint");
if (hint) hint.textContent = err.message;
return;
}
if (seq !== fxRenderSeq) return;
const hint = $("fxRenderHint");
if (hint)
hint.textContent = "已启用 " + FX_LIBS[mode].label + "(同源加载)";
for (const el of slots) {
if (seq !== fxRenderSeq) return;
const latex = el.dataset.latex || "";
if (!latex) continue;
try {
await renderFormula(el, latex, mode);
} catch (e) {
}
}
}
function updateFxBarUI() {
const bar = $("fxRenderBar");
if (!bar) return;
const regions = (state.result && state.result.regions) || [];
const hasFormula = regions.some((r) => r.kind === "formula");
bar.querySelectorAll("[data-fx-mode]").forEach((b) => {
b.hidden = !hasFormula;
b.setAttribute(
"aria-pressed",
b.dataset.fxMode === state.fx.mode ? "true" : "false",
);
});
const label = bar.querySelector(".fxr-txt");
if (label) label.textContent = hasFormula ? "公式渲染" : "表格渲染";
const hint = $("fxRenderHint");
if (hint)
hint.textContent = hasFormula
? "首次切换会从同源路径懒加载所选引擎"
: "点每个表格区域的「渲染表格」按钮就地渲染(先过白名单净化)";
}
$("fxRenderBar").addEventListener("click", (e) => {
const btn = e.target.closest("[data-fx-mode]");
if (!btn) return;
const mode = btn.dataset.fxMode;
if (mode === state.fx.mode) return;
state.fx.mode = mode;
try {
localStorage.setItem(FX_MODE_KEY, mode);
} catch {}
updateFxBarUI();
applyFormulaRender();
});
(function initFxMode() {
try {
const m = localStorage.getItem(FX_MODE_KEY);
if (m === "katex" || m === "mathjax" || m === "off")
state.fx.mode = m;
} catch {}
})();
function renderRegions() {
const wrap = $("regionList");
const bar = $("fxRenderBar");
if (!state.result) {
if (bar) bar.hidden = true;
wrap.innerHTML = `<div class="empty-inline">${ic("scan")}<p>识别完成后,区域将按阅读顺序在此列出;<br>点击列表项可在图像上定位高亮,点击图中区域可反向定位。</p></div>`;
return;
}
const regions = state.result.regions;
const hasFormula = regions.some((r) => r.kind === "formula");
const hasTable = regions.some((r) => r.kind === "table");
if (bar) {
bar.hidden = !(hasFormula || hasTable);
if (hasFormula || hasTable) updateFxBarUI();
}
const consumedBy = new Map();
regions.forEach((r, i) => {
if (r.kind === "table" && r.table)
r.table.sourceLineIndices.forEach((n) => {
if (!consumedBy.has(n)) consumedBy.set(n, i + 1);
});
});
wrap.innerHTML = regions
.map((r, i) => {
const conf =
r.conf != null
? (r.conf > 1 ? r.conf : r.conf * 100).toFixed(1) + "%"
: "—";
const poly = r.polygon
? r.polygon
.map((p) => Math.round(p[0]) + "," + Math.round(p[1]))
.join(" ")
: "";
const bb = r.polygon
? (() => {
const xs = r.polygon.map((p) => p[0]),
ys = r.polygon.map((p) => p[1]);
return `${Math.round(Math.min(...xs))},${Math.round(Math.min(...ys))} → ${Math.round(Math.max(...xs))},${Math.round(Math.max(...ys))}`;
})()
: "—";
if (r.kind === "table") return tableRegionHtml(r, i, poly, bb);
const isFx = r.kind === "formula";
const textCell = isFx
? `<span class="ri-text fx-slot" data-latex="${esc(r.text)}">${esc(r.text) || "(空)"}</span>`
: `<span class="ri-text">${esc(r.text) || "(空)"}</span>`;
const owner = consumedBy.get(i);
const row = `<button class="region-item${owner ? " ri-folded" : ""}" data-i="${i}" aria-pressed="false" ${poly ? `title="polygon: ${esc(poly)}"` : ""}>
<span class="ri-idx">${String(i + 1).padStart(2, "0")}</span>
<span class="ri-body">
<span class="ri-line">
<i class="tag ${isFx ? "tag-fx" : "tag-txt"}">${isFx ? "公式" : "文本"}</i>
${textCell}
${owner ? `<i class="tag tag-tbl-in">已归入表格 ${owner}</i>` : ""}
</span>
<span class="ri-sub">conf ${conf} · ${bb}</span>
</span>
</button>`;
if (!owner) return row;
return `<div class="ri-fold-wrap">${row}<details class="ri-fold"><summary>展开原文</summary><div class="ri-fold-body">${esc(r.text) || "(空)"}</div></details></div>`;
})
.join("");
wrap.querySelectorAll(".region-table [data-table-html]").forEach((btn) => {
const body = btn.closest(".ri-body");
const code = body && body.querySelector(".rt-raw > code");
if (code) code.textContent = btn.getAttribute("data-table-html") || "";
});
if (hasFormula) applyFormulaRender();
}
function tableRegionHtml(r, i, poly, bb) {
const t = r.table || {};
const cells = Array.isArray(t.cells) ? t.cells : [];
const low = t.confidence === "low";
const badge = low
? "低置信(网格空洞或截断)"
: t.confidence === "high"
? "高置信"
: "置信未知";
const grid =
t.rows != null && t.cols != null
? `${t.rows} × ${t.cols}`
: "网格未知";
const matched = cells.filter(
(c) => c && (c.match_kind === "single" || c.match_kind === "multiple"),
).length;
const degen =
Array.isArray(t.degradations) && t.degradations.length
? t.degradations.map(degradationText).join(";")
: "无降级";
const consumed = Array.isArray(t.sourceLineIndices)
? t.sourceLineIndices.length
: 0;
const html = typeof t.html === "string" ? t.html : "";
return `<div class="region-item region-table${low ? " rt-low" : ""}" data-i="${i}" aria-pressed="false" ${poly ? `title="polygon: ${esc(poly)}"` : ""}>
<span class="ri-idx">${String(i + 1).padStart(2, "0")}</span>
<span class="ri-body">
<span class="ri-line">
<i class="tag tag-tbl">表格</i>
<span class="ri-text">${esc(grid)} · ${cells.length} 单元格</span>
<i class="rt-badge${low ? " rt-badge-low" : ""}">${esc(badge)}</i>
</span>
<span class="ri-sub">空洞 ${t.holes != null ? t.holes : "—"} · 匹配到文本 ${matched} 格 · 归入本表 ${consumed} 行 · ${esc(degen)} · ${bb}</span>
<div class="rt-actions">
<button class="rt-btn" type="button" data-table-render="1" data-rendered="0" aria-expanded="false" data-table-html="${esc(html)}">渲染表格</button>
<span class="rt-hint">渲染前先过白名单净化</span>
</div>
<div class="rt-wrap" hidden></div>
<pre class="rt-raw" aria-label="表格 HTML 原文"><code></code></pre>
</span>
</div>`;
}
function toggleTableRender(btn) {
const card = btn.closest(".region-table");
if (!card) return;
const wrap = card.querySelector(".rt-wrap");
if (!wrap) return;
const raw = card.querySelector(".rt-raw");
const rendered = btn.getAttribute("data-rendered") === "1";
if (rendered) {
wrap.innerHTML = "";
wrap.hidden = true;
if (raw) raw.hidden = false;
btn.setAttribute("data-rendered", "0");
btn.setAttribute("aria-expanded", "false");
btn.textContent = "渲染表格";
return;
}
wrap.innerHTML = sanitizeTableHtml(btn.getAttribute("data-table-html") || "");
wrap.hidden = false;
if (raw) raw.hidden = true;
btn.setAttribute("data-rendered", "1");
btn.setAttribute("aria-expanded", "true");
btn.textContent = "收起表格";
}
async function copyTextToClipboard(text) {
if (!text) return false;
const syncCopy = () => {
let ta;
try {
ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, ta.value.length);
return document.execCommand("copy");
} catch (_) {
return false;
} finally {
if (ta) ta.remove();
}
};
if (syncCopy()) return true;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (_) {}
return false;
}
function fullTextForCopy() {
const res = state.result;
if (!res) return "";
if (Array.isArray(res.regions) && res.regions.length) {
const regions = res.regions;
const consumed = new Set();
regions.forEach((r) => {
if (r && r.kind === "table" && r.table)
r.table.sourceLineIndices.forEach((n) => consumed.add(n));
});
const parts = [];
regions.forEach((r, i) => {
if (!r) return;
if (r.kind === "table") {
const md = (r.table && r.table.markdown) || "";
if (md.trim() !== "") parts.push(md.trim());
return;
}
if (consumed.has(i)) return;
if (r.text) parts.push(r.text);
});
if (parts.length) return parts.join("\n");
}
return res.plain || "";
}
$("copyAllBtn").addEventListener("click", async () => {
if (!state.result) return;
const ok = await copyTextToClipboard(fullTextForCopy());
toast(
ok ? "全文已复制到剪贴板" : "复制失败,请手动选择文本",
ok ? "ok" : "err",
ok ? "copy" : "alert",
);
});
$("regionList").addEventListener("click", (e) => {
const tbtn = e.target.closest("[data-table-render]");
if (tbtn) {
toggleTableRender(tbtn);
return;
}
const item = e.target.closest(".region-item");
if (!item) return;
if (e.target.closest(".rt-wrap")) return;
const i = Number(item.getAttribute("data-i"));
if (!Number.isInteger(i) || i < 0) return;
selectRegion(state.selected === i ? -1 : i);
});
$("regionList").addEventListener("dblclick", async (e) => {
const item = e.target.closest(".region-item");
if (!item) return;
const slot = item.querySelector(".fx-slot");
if (!slot) return;
const latex =
slot.getAttribute("data-latex") || slot.textContent || "";
if (!latex.trim()) return;
const ok = await copyTextToClipboard(latex);
toast(
ok ? "公式 LaTeX 已复制" : "复制失败,请手动选择文本",
ok ? "ok" : "err",
ok ? "copy" : "alert",
);
});
$("annotatedBtn").addEventListener("click", async () => {
const j = state.job;
if (!state.result || !j) return;
if (DEMO) {
demoAnnotated();
return;
}
try {
const b = await apiBlob("/api/jobs/" + j.id + "/annotated.png");
saveBlob(b, "ocr-" + j.id + "-annotated.png");
toast("标注图已保存", "ok", "download");
} catch (e) {
toastErr(e);
}
});
const EXPORTS = { expJson: "json", expMd: "md", expHtml: "html" };
Object.keys(EXPORTS).forEach((id) => {
$(id).addEventListener("click", () => exportAs(EXPORTS[id]));
});
async function exportAs(fmt) {
const j = state.job;
if (!state.result || !j) return;
if (DEMO) {
demoExport(fmt);
return;
}
try {
const b = await apiBlob(`/api/jobs/${j.id}/export?format=${fmt}`);
saveBlob(b, `ocr-${j.id}.${fmt}`);
toast(fmt.toUpperCase() + " 导出已保存", "ok", "download");
} catch (e) {
toastErr(e);
}
}
function fmtPct(v) {
return typeof v === "number" ? (v * 100).toFixed(1) + "%" : "—";
}
function fmtNum(v, d) {
return typeof v === "number" ? v.toFixed(d == null ? 4 : d) : "—";
}
function renderEvaluation(rep) {
const out = $("evalOut");
out.hidden = false;
const cases = Array.isArray(rep.cases) ? rep.cases : [];
out.innerHTML =
`<p class="eval-sum"><b>均值 CER</b> ${fmtNum(rep.mean_cer)} · ` +
`<b>精确匹配</b> ${fmtPct(rep.exact_match_rate)} · ` +
`<b>用例</b> ${cases.length} · <b>IoU 阈值</b> ${fmtNum(rep.iou_threshold, 2)} · ` +
`清单 <code>${esc(rep.manifest_file || "—")}</code></p>` +
`<table class="diag-table">
<tr><td>#</td><td>图像</td><td>CER</td><td>精确</td></tr>` +
cases
.map(
(c, i) =>
`<tr><td>${i + 1}</td><td title="${esc(c.image || "")}">${esc((c.image || "").split(/[\\/]/).pop())}</td>` +
`<td>${fmtNum(c.cer)}</td><td>${c.exact_text === true ? "是" : "否"}</td></tr>`,
)
.join("") +
`</table>
<p class="diag-note">指标由服务端复用库的 <code>evaluation</code> 计算(字符错误率 = 编辑距离 ÷ 参考长度;
精确匹配按空白归一化后比较),与 <code>rapidstruct evaluate --manifest</code> 的报告同源同字段。
一次请求最多评估 <code>--max-eval-cases</code> 张图(默认 32,见 /api/status 的 limits)。</p>`;
}
async function runEvaluation() {
if (DEMO) {
toast(
"离线预览模式不执行评估:请用 rapidstruct serve 打开本页,并在输入框填入清单路径",
"err",
"info",
);
return;
}
const path = ($("evalPath").value || "").trim();
const btn = $("evalBtn");
const out = $("evalOut");
if (!path) {
toast(
"请先填入已标注清单的绝对路径(rapidstruct evaluate --manifest 的格式)",
"err",
"info",
);
return;
}
btn.disabled = true;
out.hidden = false;
out.innerHTML = `<p class="eval-sum">${ic("loader", "spin")} 评估中…(每张图一次完整推理,最多 --max-eval-cases 张)</p>`;
try {
const rep = await apiSendJson("POST", "/api/evaluate", {
manifest: path,
});
renderEvaluation(rep);
toast(`评估完成 · 均值 CER ${fmtNum(rep.mean_cer)}`, "ok");
} catch (e) {
out.hidden = true;
out.innerHTML = "";
const reason = e && e.body && e.body.detail && e.body.detail.reason;
if (reason) toast(reason, "err", "alert");
else toastErr(e);
} finally {
btn.disabled = false;
}
}
$("evalBtn").addEventListener("click", runEvaluation);
$("evalPath").addEventListener("keydown", (e) => {
if (e.key === "Enter") runEvaluation();
});
$("diagBody").addEventListener("change", (e) => {
if (e.target && e.target.id === "flowToggle")
setFlowVerbose(!!e.target.checked, true);
});
$("diagBody").addEventListener("click", (e) => {
if (e.target && e.target.id === "flowToggle")
e.stopPropagation();
});
function diagContractHtml() {
if (!state.diag.length) return "";
return `<div class="diag-sec">契约错误 · 响应字段</div>
<p class="diag-line">${state.diag.map((x) => esc(x)).join("<br>")}</p>
<p class="diag-note">前端按 src/api.rs 的冻结契约解析响应,不做别名兼容:字段被改名或缺失时
一律在此点名路径并拒绝渲染,避免把后端改名悄悄吞掉。</p>`;
}
function renderDiag() {
const d = $("diagBody");
const head = diagContractHtml();
const flow = flowHtml();
if (!state.result) {
d.innerHTML =
head +
flow +
'<p class="diag-line">识别完成后,此处展示逐阶段耗时(时间账本)与运行时指纹。</p>';
return;
}
const t = state.result.timings;
const rows = Object.entries(t).filter(
([k, v]) =>
typeof v === "number" && k !== "total_ms" && k !== "total",
);
const total =
t.total_ms != null
? t.total_ms
: t.total != null
? t.total
: rows.reduce((a, [, v]) => a + v, 0);
const s = state.status || emptyStatus();
const fp = s.fingerprint;
d.innerHTML =
head +
flow +
`<div class="diag-sec">逐阶段耗时(MS)</div>
<table class="diag-table">${rows
.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v.toFixed(1)}</td></tr>`)
.join("")}
<tr class="total"><td>total</td><td>${total.toFixed(1)}</td></tr>
</table>` +
ledgerHtml() +
`<div class="diag-sec">PROVIDER 实测</div>
<p class="diag-line">requested ${esc(s.requested || "未知")} · selected_ep ${esc(s.selected_ep || "未知")}` +
(s.fallback === true
? " · 已回退 CPU"
: s.fallback === false
? " · 未回退"
: " · 回退状态未知") +
` — 本页任务的实测耗时见上表。</p>` +
(fp
? `<div class="diag-sec">ORT 指纹</div>
<p class="diag-line">${esc(fp.file || "")}${fp.size_bytes ? " · " + fmtBytes(fp.size_bytes) : ""}` +
(fp.sha256
? " · sha256 " + esc(String(fp.sha256).slice(0, 16)) + "…"
: "") +
"</p>"
: "") +
`<div class="diag-sec">运行时与内存</div>
<p class="diag-line">ORT ${esc(s.ort || "—")} · 峰值工作集 ${s.memory != null ? fmtBytes(s.memory) : "—"}` +
(s.memorySource ? `(来源:${esc(s.memorySource)})` : "") +
"</p>" +
`<p class="diag-note">口径说明:以上数据来自引擎内建 timings / stages、库的时间账本与 ORT 指纹,前端不重新测量;
provider 名称不构成性能结论,请以实测耗时为准。账本的占比只在<b>口径残差</b>的量级内成立,
残差的含义以下方库原文为准。</p>`;
}
function ledgerHtml() {
const l = state.result && state.result.ledger;
if (!l) return "";
const c = l.conservation || {};
const named = [
"total_ms",
"attributed_ms",
"input_ms",
"inference_ms",
"rust_ms",
"unattributed_ms",
]
.filter((k) => typeof l[k] === "number")
.map(
(k) => `<tr><td>${esc(k)}</td><td>${l[k].toFixed(1)}</td></tr>`,
)
.join("");
const residual =
typeof c.residual_ms === "number" ? c.residual_ms.toFixed(6) : "—";
const excess =
typeof c.excess_ms === "number" ? c.excess_ms.toFixed(6) : "—";
const tol =
typeof c.tolerance_ms === "number"
? c.tolerance_ms.toFixed(6)
: "—";
const verdict =
c.conserved === true
? "守恒(在容差内)"
: "不守恒(口径差异,不是总量算错)";
return `<div class="diag-sec">时间账本 · 口径残差(MS,库数据)</div>
<table class="diag-table">${named}
<tr><td>residual_ms = attributed − total</td><td>${residual}</td></tr>
<tr><td>excess_ms = max(0, −residual)</td><td>${excess}</td></tr>
<tr><td>tolerance_ms</td><td>${tol}</td></tr>
</table>
<p class="diag-line">${esc(verdict)}</p>
<p class="diag-note">${esc(typeof c.interpretation === "string" ? c.interpretation : "(服务端未提供账本解释文案)")}</p>`;
}
function statusOnce() {
return apiJson("GET", "/api/status")
.then((s) => {
state.netFail = false;
state.status = normalizeStatus(s);
renderEngine();
renderPill();
})
.catch((e) => {
state.netFail = true;
state.status = emptyStatus("failed");
renderEngine();
renderPill();
});
}
function statusLoop() {
statusOnce()
.then(() => {
setTimeout(
statusLoop,
state.status.state === "ready" ? 8000 : 2000,
);
})
.catch(() => setTimeout(statusLoop, 5000));
}
function refreshModels() {
if (DEMO) {
renderBanner();
renderButton();
renderModeRow();
renderEngine();
return Promise.resolve();
}
return apiJson("GET", "/api/models")
.then((m) => {
state.models = normalizeModels(m);
renderBanner();
renderButton();
renderModeRow();
renderEngine();
})
.catch((e) => {
state.models = { error: e };
renderBanner();
renderButton();
renderModeRow();
renderEngine();
});
}
function demoStatusData() {
const ready = demo.phase === "ready";
const ph = demoPhaseFromModels();
return {
engine: {
state:
ph === "waitmodels"
? "waiting_models"
: ph === "building"
? "loading"
: ph === "ready"
? "ready"
: "loading",
},
provider: {
requested: "directml",
selected_ep: "CPUExecutionProvider",
fallback_to_cpu: true,
},
ort: { version: "1.20.1", fingerprint: null },
memory: { peak_working_set_bytes: ready ? 537395200 : 81234567 },
queues: {
text: {
used: state.job && state.job.state === "queued" ? 1 : 0,
capacity: 4,
},
formula: { used: 0, capacity: 2 },
},
};
}
function demoSetStatus() {
state.status = normalizeStatus(demoStatusData());
renderEngine();
renderPill();
}
function planRow(name, role, state, pipeline) {
return {
name: name,
role: role,
state: state,
pipeline: pipeline,
declared_sha256: null,
has_declared_digest: false,
};
}
function demoInitialModels() {
return normalizeModels({
model_dir: "<redacted>",
source: "default_table",
downloads_allowed: true,
complete: true,
missing: [],
corrupt: [],
blocked: [],
formula: {
complete: false,
missing: ["pp_formulanet_plus_m.onnx"],
corrupt: [],
blocked: [],
routing: true,
disabled_reason: null,
required_roles: ["formula_recognizer"],
detector: { configured: true, file: "pix2text-mfd-1.5.onnx" },
},
table: {
complete: true,
missing: [],
corrupt: [],
blocked: [],
routing: true,
disabled_reason: null,
in_plan: true,
required_roles: ["table_structure"],
structure: {
configured: true,
file: "slanet-plus.onnx",
sha256: null,
state: "present",
},
},
pipelines: {
text: {
pipeline: "text",
complete: true,
in_plan: true,
routing: true,
plan: [
planRow("PP-OCRv5_server_det.onnx", "detector", "present", "text"),
planRow("PP-OCRv5_server_rec.onnx", "recognizer", "present", "text"),
planRow("ppocrv5_dict.txt", "dictionary", "present", "text"),
],
},
formula: {
pipeline: "formula",
complete: false,
in_plan: true,
routing: true,
plan: [
planRow(
"pp_formulanet_plus_m.onnx",
"formula_recognizer",
"missing",
"formula",
),
planRow(
"pix2text-mfd-1.5.onnx",
"formula_detector",
"present",
"formula",
),
],
},
table: {
pipeline: "table",
complete: true,
in_plan: true,
routing: true,
plan: [
planRow(
"slanet-plus.onnx",
"table_structure",
"present",
"table",
),
],
},
},
sets: [
{
id: "PP-OCRv5(演示)",
complete: true,
download_bytes_total: 0,
files: [
{
name: "PP-OCRv5_server_det.onnx",
role: "detector",
state: "present",
size_bytes: 4940000,
sha256: "8f3ac21d90e…",
source_url:
"https://paddleocr.bj.bcebos.com/PP-OCRv5/server/det.onnx",
},
{
name: "PP-OCRv5_server_rec.onnx",
role: "recognizer",
state: "present",
size_bytes: 8010000,
sha256: "c71b0f4a22e…",
source_url:
"https://paddleocr.bj.bcebos.com/PP-OCRv5/server/rec.onnx",
},
{
name: "ppocrv5_dict.txt",
role: "dictionary",
state: "present",
size_bytes: 126469,
sha256: "a2d9c8e710b…",
source_url:
"https://paddleocr.bj.bcebos.com/PP-OCRv5/server/dict.txt",
},
],
},
{
id: "PP-FormulaNet(演示)",
complete: false,
download_bytes_total: 593916167,
files: [
{
name: "pp_formulanet_plus_m.onnx",
role: "formula_recognizer",
state: "missing",
size_bytes: 593916167,
sha256: "6e509ec4b7cda604dc9bf80a876ae6494693deea4c7f4157a4dc0eba0ebda745",
source_url:
"https://www.modelscope.cn/seeyuer/pp-formulanet-plus-m-optimized/resolve/0bb8154e3583d24613eaa74448de43ecf4ae73ec/pp_formulanet_plus_m.onnx",
},
],
},
],
});
}
function demoPhaseFromModels() {
if (demo.phase === "boot") return "boot";
if (demo.phase === "building") return "building";
const t = modelsReadyFor("text");
if (t.fatal || !t.ready) return "waitmodels";
return demo.phase === "ready" ? "ready" : "waitmodels";
}
async function enterDemo() {
$("demoStrip").hidden = false;
demo.phase = "boot";
demoSetStatus();
await sleep(1100);
demo.phase = "waitmodels";
state.models = demoInitialModels();
demo.textReady = modelsReadyFor("text").ready;
demoSetStatus();
renderBanner();
renderButton();
renderModeRow();
renderVerify();
}
async function demoDownload(setId) {
const set = state.models.sets.find((s) => s.id === setId);
if (!set) return toast(`模型集 ${setId} 不存在`, "err", "cloud-off");
demo.dlActive = true;
demo.dlSetId = setId;
const jobId = "demo-dl-" + Math.random().toString(36).slice(2, 8);
state.dlSetId = setId;
state.dlJob = {
id: jobId,
set_id: setId,
state: "queued",
elapsed_ms: 0,
};
renderBanner();
await sleep(300);
state.dlJob.state = "running";
const t0 = performance.now();
for (const f of set.files) {
if (!state.dlJob) return;
if (f.state === "present") continue;
demo.dlFile = f.name;
let p = 0;
while (p < 100) {
if (!state.dlJob) return;
p = Math.min(100, p + 2 + Math.random() * 7);
demo.dlPct = p;
state.dlJob.elapsed_ms = performance.now() - t0;
renderBanner();
await sleep(65);
}
f.state = "present";
}
demo.dlActive = false;
demo.dlSetId = null;
set.complete = set.files.every((f) => f.state === "present");
set.bytes = set.files
.filter((f) => f.state !== "present")
.reduce((a, f) => a + (f.size || 0), 0);
const finishedId = state.dlJob.id;
state.dlJob = null;
state.dlSetId = null;
demo.textReady = modelsReadyFor("text").ready;
renderBanner();
renderButton();
renderModeRow();
toast(
`模型集「${setId}」下载完成(job ${finishedId}),引擎将在下次识别时惰性创建`,
"ok",
"package",
);
if (modelsReadyFor("text").ready) {
demo.phase = "building";
demoSetStatus();
await sleep(demo.buildMs);
demo.phase = "ready";
demoSetStatus();
renderButton();
toast(
`引擎创建完成 · 耗时 ${(demo.buildMs / 1000).toFixed(1)} s`,
"ok",
);
}
}
async function demoUpload() {
state.uploading = true;
showUp(true);
setUpProgress(0);
renderButton();
drop.classList.add("no-click");
demo.upAbort = false;
let p = 0;
while (p < 100) {
if (demo.upAbort) break;
p = Math.min(100, p + 4 + Math.random() * 11);
setUpProgress(p / 100);
await sleep(70 + Math.random() * 70);
}
state.uploading = false;
showUp(false);
drop.classList.remove("no-click");
renderButton();
if (demo.upAbort) {
toast("已取消上传", "err", "info");
return;
}
await sleep(240);
clearResult(false);
toast("任务已提交(202)· 文本队列", "ok");
beginJob(
{
job_id: "demo-" + Math.random().toString(36).slice(2, 8),
kind: "text",
queue: "text",
state: "queued",
position: 1,
},
"text",
false,
);
await sleep(850);
if (!state.job || state.job.state !== "queued")
return;
state.job.state = "running";
renderJob();
const t0 = performance.now();
while (performance.now() - t0 < 2500) {
if (!state.job || state.job.state !== "running") return;
state.job.elapsed_ms = performance.now() - t0;
renderJob();
await sleep(180);
}
state.job.state = "succeeded";
state.result = demoResult(state.image.w, state.image.h);
scanOff();
finishJob();
}
function regionKindLabel(kind) {
return kind === "formula" ? "公式" : kind === "table" ? "表格" : "文本";
}
function demoResult(w, h) {
const tableLines = [
["项目", 0.955],
["服务费 1,200.00", 0.948],
["税费 80.00", 0.941],
];
const rows = [
["发票代码:144032257110", 0.982],
["开票日期:2024-06-18", 0.976],
["购方名称:本地评估科技有限公司", 0.964],
["合计金额(大写)壹仟贰佰捌拾元整", 0.971],
["E = mc^{2}", 0.913, "formula"],
["备注:本行由离线预览数据填充", 0.887],
...tableLines,
];
const box = (i) => {
const x0 = Math.round(w * 0.1);
const x1 = Math.round(w * (0.58 + 0.05 * (i % 3)));
const y0 = Math.round(h * (0.11 + 0.075 * i));
const y1 = y0 + Math.max(14, Math.round(h * 0.05));
return [
[x0, y0],
[x1, y0],
[x1, y1],
[x0, y1],
];
};
const wordsOf = (text, quad, score) => {
const xs = quad.map((p) => p[0]);
const ys = quad.map((p) => p[1]);
const x0 = Math.min(...xs),
x1 = Math.max(...xs);
const y0 = Math.min(...ys),
y1 = Math.max(...ys);
const chars = [...text];
const col = (x1 - x0) / Math.max(1, chars.length);
const out = [];
chars.forEach((ch, k) => {
if (ch.trim() === "") return;
const left = x0 + k * col;
out.push({
text: ch,
score,
kind: "char",
polygon: [
[left, y0],
[left + col, y0],
[left + col, y1],
[left, y1],
],
});
});
return out;
};
const regions = rows.map((r, i) => {
const quad = box(i);
const kind = r[2] || "text";
return {
text: r[0],
conf: r[1],
words: kind === "text" ? wordsOf(r[0], quad, r[1]) : null,
kind,
order: i + 1,
polygon: quad,
};
});
const firstTableLine = rows.length - tableLines.length;
const lastTableLine = rows.length - 1;
const tableHtml =
'<table><thead><tr><td colspan="2">离线预览表格</td></tr></thead>' +
"<tbody><tr><td>项目</td><td>服务费 1,200.00</td></tr>" +
"<tr><td>税费</td><td>80.00</td></tr></tbody></table>";
regions.push({
text: "",
conf: null,
kind: "table",
order: regions.length + 1,
polygon: [
[Math.round(w * 0.08), Math.round(h * (0.11 + 0.075 * firstTableLine) - h * 0.012)],
[Math.round(w * 0.66), Math.round(h * (0.11 + 0.075 * firstTableLine) - h * 0.012)],
[Math.round(w * 0.66), Math.round(h * (0.11 + 0.075 * lastTableLine) + h * 0.05)],
[Math.round(w * 0.08), Math.round(h * (0.11 + 0.075 * lastTableLine) + h * 0.05)],
],
table: {
html: tableHtml,
table_html: tableHtml,
markdown: "| 项目 | 服务费 1,200.00 |\n| --- | --- |\n| 税费 | 80.00 |",
rows: 3,
cols: 2,
confidence: "low",
degradations: [{ kind: "grid_holes", holes: 1 }],
holes: 1,
cells: tableLines.map((r, i) => ({
row: i,
col: 0,
rowspan: 1,
colspan: 1,
text: r[0],
score: r[1],
match_kind: "single",
polygon: box(firstTableLine + i),
})),
source_line_indices: tableLines.map((_, i) => firstTableLine + i),
model_id: "demo/slanet-plus.onnx",
},
});
return {
regions,
plain: rows.map((r) => r[0]).join("\n"),
timings: {
preprocess_ms: 14.2,
det_ms: 412.8,
cls_ms: 8.4,
rec_ms: 236.5,
postprocess_ms: 3.1,
total_ms: 675.0,
},
};
}
function demoAnnotated() {
const i = state.image;
if (!i || !state.result) return;
const c = document.createElement("canvas");
c.width = i.w;
c.height = i.h;
const g = c.getContext("2d");
g.drawImage(imgEl, 0, 0);
state.result.regions.forEach((r) => {
if (!r.polygon) return;
g.beginPath();
g.moveTo(r.polygon[0][0], r.polygon[0][1]);
for (let k = 1; k < r.polygon.length; k++)
g.lineTo(r.polygon[k][0], r.polygon[k][1]);
g.closePath();
g.setLineDash(r.kind === "formula" ? [8, 6] : []);
g.lineWidth = Math.max(2, i.w / 500);
g.strokeStyle = "#111";
g.stroke();
});
c.toBlob((b) => {
saveBlob(b, "ocr-" + state.job.id + "-annotated.png");
toast("标注图已保存", "ok", "download");
});
}
function demoExport(fmt) {
const r = state.result,
id = state.job.id;
let blob, name;
if (fmt === "json") {
blob = new Blob(
[
JSON.stringify(
r.raw && Object.keys(r.raw).length
? r.raw
: {
job_id: id,
plain_text: r.plain,
regions: r.regions,
timings: r.timings,
},
null,
2,
),
],
{ type: "application/json" },
);
} else if (fmt === "md") {
const md = [
"# OCR 识别结果",
"",
"- 任务:`" + id + "`",
"- 区域数:" + r.regions.length,
"",
"| # | 类型 | 置信度 | 文本 |",
"|---|---|---|---|",
...r.regions.map(
(g, i) =>
`| ${i + 1} | ${regionKindLabel(g.kind)} | ` +
`${g.conf != null ? (g.conf > 1 ? g.conf : g.conf * 100).toFixed(1) + "%" : "—"} | ${g.text.replace(/\|/g, "\\|")} |`,
),
].join("\n");
blob = new Blob([md], { type: "text/markdown" });
} else {
const html =
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>OCR ' +
esc(id) +
"</title>" +
'<style>body{font-family:"Noto Sans SC","Microsoft YaHei",sans-serif;max-width:760px;margin:40px auto;' +
"padding:0 20px;color:#111;background:#fafafa}h1{font-size:20px}table{border-collapse:collapse;width:100%;" +
"font-size:14px}td,th{border-bottom:1px solid #ddd;padding:8px 6px;text-align:left}th{font-size:12px;" +
"color:#666;letter-spacing:.1em}small{color:#888}</style></head><body><h1>OCR 识别结果</h1><p><small>" +
esc(id) +
" · " +
r.regions.length +
" 个区域 · 由 RapidStructure 本地评估台导出</small></p><table><tr><th>#</th><th>类型</th><th>置信度</th><th>文本</th></tr>" +
r.regions
.map(
(g, i) =>
"<tr><td>" +
(i + 1) +
"</td><td>" +
regionKindLabel(g.kind) +
"</td><td>" +
(g.conf != null
? (g.conf > 1 ? g.conf : g.conf * 100).toFixed(1) + "%"
: "—") +
"</td><td>" +
esc(g.text) +
"</td></tr>",
)
.join("") +
"</table></body></html>";
blob = new Blob([html], { type: "text/html" });
}
saveBlob(blob, "ocr-" + id + "." + fmt);
toast(fmt.toUpperCase() + " 导出已保存", "ok", "download");
}
renderPill();
renderEngine();
renderRegions();
renderDiag();
renderButton();
renderModeRow();
renderVerify();
if (DEMO) {
enterDemo();
} else {
statusLoop();
refreshModels();
}
})();
</script>
</body>
</html>