diff --git a/assets/webui/assets.go b/assets/webui/assets.go
new file mode 100644
index 0000000..053c1d2
--- /dev/null
+++ b/assets/webui/assets.go
@@ -0,0 +1,22 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package webuiassets
+
+import "embed"
+
+// FS WebUI嵌入静态文件
+//
+//go:embed index.html ofdgo.js ofdgo.css wasm_exec.js ofdgo.wasm
+var FS embed.FS
diff --git a/assets/webui/index.html b/assets/webui/index.html
new file mode 100644
index 0000000..9a2ed16
--- /dev/null
+++ b/assets/webui/index.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+ OFDGo WebUI
+
+
+
+
+
+
+
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
new file mode 100644
index 0000000..7c1108b
--- /dev/null
+++ b/assets/webui/ofdgo.css
@@ -0,0 +1,900 @@
+:root {
+ color-scheme: light;
+ font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
+ background: #eef1f4;
+ color: #1f2428;
+ --line: #cfd7dd;
+ --line-strong: #aab7c1;
+ --panel: #f8fafb;
+ --page: #ffffff;
+ --text-muted: #66737f;
+ --accent: #0f7b68;
+ --accent-strong: #095c4e;
+ --focus: #c9892b;
+ --sidebar-width: clamp(220px, 20vw, 292px);
+ --bar-bg: #fbfcfd;
+ --bar-height: 40px;
+ --bar-pad: 5px 10px;
+ --control-height: 28px;
+ --shadow-soft: 0 10px 28px rgba(31, 36, 40, 0.12);
+ --shadow-page: 0 14px 30px rgba(31, 36, 40, 0.16);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background: #eef1f4;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ border: 1px solid var(--line-strong);
+ background: #ffffff;
+ color: #1f2428;
+ cursor: pointer;
+ transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
+}
+
+button:disabled,
+input:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+button:focus-visible,
+input:focus-visible,
+a:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+}
+
+.app {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.app-bar {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+ height: var(--bar-height);
+ min-height: var(--bar-height);
+ flex: 0 0 var(--bar-height);
+ padding: var(--bar-pad);
+ background: var(--bar-bg);
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.68) inset;
+}
+
+.toolbar {
+ position: relative;
+ z-index: 12;
+ border-bottom: 1px solid var(--line);
+ font-size: 14px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.toolbar::-webkit-scrollbar {
+ display: none;
+}
+
+.file-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+}
+
+.button,
+.icon-button {
+ height: var(--control-height);
+ border-radius: 0;
+}
+
+.button {
+ padding: 0 10px;
+ white-space: nowrap;
+}
+
+.button.primary {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: #ffffff;
+}
+
+.button.primary:hover {
+ background: var(--accent-strong);
+}
+
+button:not(:disabled):hover {
+ border-color: var(--accent);
+ background: #f2faf7;
+}
+
+.button[aria-pressed="true"],
+.icon-button[aria-pressed="true"] {
+ border-color: var(--accent);
+ background: #e4f3ef;
+ color: var(--accent-strong);
+}
+
+.icon-button {
+ display: inline-grid;
+ place-items: center;
+ width: var(--control-height);
+ padding: 0;
+ font-size: 14px;
+ line-height: 1;
+}
+
+.toolbar-space {
+ flex: 1 1 auto;
+}
+
+.divider {
+ width: 1px;
+ height: 22px;
+ background: var(--line);
+}
+
+.page-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: var(--control-height);
+ padding: 0 7px;
+ border: 1px solid var(--line);
+ border-radius: 0;
+ background: #ffffff;
+ color: var(--text-muted);
+}
+
+.page-control input {
+ width: 48px;
+ border: 0;
+ outline: 0;
+ text-align: right;
+ color: #1f2428;
+ background: transparent;
+}
+
+.zoom-label {
+ min-width: 42px;
+ text-align: center;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--sidebar-width);
+ min-height: 0;
+}
+
+body[data-hide-pages] .workspace {
+ grid-template-columns: minmax(0, 1fr) var(--sidebar-width);
+}
+
+body[data-hide-meta] .workspace {
+ grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
+}
+
+body[data-hide-pages][data-hide-meta] .workspace {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+body[data-hide-pages] .page-list-panel,
+body[data-hide-meta] .meta-panel {
+ display: none;
+}
+
+.page-list-panel,
+.meta-panel {
+ min-height: 0;
+ padding: 12px;
+ border-right: 1px solid var(--line);
+ background: var(--panel);
+ overflow: auto;
+}
+
+.meta-panel {
+ border-right: 0;
+ border-left: 1px solid var(--line);
+}
+
+.panel-title {
+ margin-bottom: 10px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0;
+}
+
+.panel-title.compact {
+ margin-bottom: 0;
+}
+
+.panel-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+}
+
+.panel-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.small-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 26px;
+ padding: 0 8px;
+ border-radius: 0;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.page-list {
+ display: grid;
+ gap: 8px;
+}
+
+.page-list-item {
+ display: grid;
+ gap: 6px;
+ width: 100%;
+ height: auto;
+ padding: 8px;
+ font-variant-numeric: tabular-nums;
+ text-align: left;
+ transition: border-color 0.16s ease, background-color 0.16s ease;
+}
+
+.page-list-item[aria-current] {
+ border-color: var(--accent);
+ background: #e4f3ef;
+ color: var(--accent-strong);
+ font-weight: 700;
+}
+
+.thumb-paper {
+ display: grid;
+ place-items: center;
+ width: 100%;
+ aspect-ratio: var(--thumb-ratio, 0.707 / 1);
+ overflow: hidden;
+ border: 1px solid var(--line);
+ background: #ffffff;
+ box-shadow: 0 2px 7px rgba(31, 36, 40, 0.1);
+ color: #9aa6b1;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.thumb-paper.pending,
+.thumb-paper.error {
+ background:
+ linear-gradient(90deg, rgba(31, 36, 40, 0.04) 1px, transparent 1px),
+ linear-gradient(0deg, rgba(31, 36, 40, 0.04) 1px, transparent 1px),
+ #ffffff;
+ background-size: 12px 12px;
+}
+
+.thumb-svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.thumb-label,
+.thumb-size {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.thumb-label {
+ font-size: 13px;
+ color: #1f2428;
+}
+
+.thumb-size {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-weight: 400;
+}
+
+.viewer-panel {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ background:
+ linear-gradient(90deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
+ linear-gradient(0deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
+ #e5e9ed;
+ background-size: 24px 24px;
+}
+
+.empty-state {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ color: var(--text-muted);
+ font-size: 14px;
+ text-align: center;
+}
+
+.empty-state[hidden],
+.page-frame[hidden],
+.progress-panel[hidden] {
+ display: none;
+}
+
+.progress-panel {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--bar-height) + 14px);
+ z-index: 20;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 8px 10px;
+ width: min(420px, calc(100vw - 32px));
+ padding: 10px 12px;
+ border: 1px solid rgba(170, 183, 193, 0.82);
+ background: rgba(251, 252, 253, 0.96);
+ box-shadow: var(--shadow-soft);
+ transform: translateX(-50%);
+ backdrop-filter: blur(8px);
+}
+
+.progress-spinner {
+ width: 18px;
+ height: 18px;
+ border: 2px solid #d5dde3;
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.progress-label {
+ min-width: 0;
+ overflow: hidden;
+ color: #1f2428;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-track {
+ grid-column: 1 / -1;
+ width: 100%;
+ height: 4px;
+ overflow: hidden;
+ background: #dbe2e7;
+}
+
+.progress-bar {
+ width: 0;
+ height: 100%;
+ background: var(--accent);
+ transition: width 0.18s ease;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.page-frame {
+ min-width: 100%;
+ min-height: 100%;
+ padding: 34px 28px;
+}
+
+.svg-host {
+ display: grid;
+ justify-items: center;
+ gap: 28px;
+ width: max-content;
+ margin: 0 auto;
+}
+
+.page-shell {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid rgba(170, 183, 193, 0.55);
+ box-shadow: var(--shadow-page);
+ background: var(--page);
+}
+
+.page-shell.error {
+ background: #fff8f7;
+}
+
+.page-surface {
+ transform-origin: 0 0;
+}
+
+.page-placeholder {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: var(--text-muted);
+ font-size: 13px;
+ background:
+ linear-gradient(90deg, rgba(31, 36, 40, 0.04) 1px, transparent 1px),
+ linear-gradient(0deg, rgba(31, 36, 40, 0.04) 1px, transparent 1px),
+ #ffffff;
+ background-size: 18px 18px;
+}
+
+.page-shell.rendered .page-placeholder {
+ display: none;
+}
+
+.ofd-svg {
+ display: block;
+ max-width: none;
+ transform-origin: 0 0;
+}
+
+.meta-list {
+ display: grid;
+ gap: 12px;
+ margin: 0;
+}
+
+.meta-list div {
+ display: grid;
+ gap: 3px;
+}
+
+.meta-list dt {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.meta-list dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ line-height: 1.35;
+}
+
+.font-panel,
+.doc-font-panel {
+ margin-top: 18px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+}
+
+.count-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ height: 26px;
+ padding: 0 7px;
+ border: 1px solid var(--line);
+ background: #ffffff;
+ color: var(--text-muted);
+ font-size: 11px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.doc-font-list {
+ display: grid;
+ gap: 8px;
+}
+
+.doc-font-row {
+ display: grid;
+ gap: 6px;
+ padding: 9px;
+ border: 1px solid var(--line);
+ background: #ffffff;
+ transition: border-color 0.16s ease, background-color 0.16s ease;
+}
+
+.page-list-item:not(:disabled):hover,
+.doc-font-row:hover,
+.font-row:hover {
+ border-color: var(--line-strong);
+ background: #ffffff;
+}
+
+.doc-font-row.missing {
+ border-color: #e2c7c4;
+ background: #fffdfc;
+}
+
+.doc-font-row.fallback {
+ border-color: #ded0a6;
+ background: #fffdf7;
+}
+
+.doc-font-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 8px;
+}
+
+.doc-font-name {
+ overflow: hidden;
+ color: #1f2428;
+ font-size: 13px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.doc-font-badges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+.font-badge {
+ padding: 3px 6px;
+ background: #eef1f4;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.35;
+ white-space: nowrap;
+}
+
+.font-badge.embedded,
+.font-badge.matched {
+ background: #e4f3ef;
+ color: var(--accent-strong);
+}
+
+.font-badge.fallback {
+ background: #fbefd3;
+ color: #705719;
+}
+
+.font-badge.missing {
+ background: #f9e8e5;
+ color: #803f38;
+}
+
+.doc-font-detail {
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+
+.font-list {
+ display: grid;
+ gap: 8px;
+}
+
+.font-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 6px;
+ padding: 6px;
+ border: 1px solid var(--line);
+ background: #ffffff;
+}
+
+.font-main {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+}
+
+.font-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 5px;
+ min-width: max-content;
+}
+
+.font-row input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.font-name-input {
+ width: 100%;
+ min-width: 0;
+ height: 26px;
+ padding: 0 6px;
+ border: 1px solid var(--line);
+ background: #ffffff;
+ color: #1f2428;
+ font-size: 12px;
+}
+
+.font-source {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 20px;
+ padding: 0 6px;
+ border: 1px solid var(--line);
+ background: #f8fafb;
+ color: var(--text-muted);
+ font-size: 11px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.font-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ height: 26px;
+ color: var(--text-muted);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.font-delete {
+ display: inline-grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ color: #7c3333;
+ font-size: 18px;
+ line-height: 1;
+}
+
+.font-empty {
+ padding: 10px;
+ border: 1px dashed var(--line-strong);
+ color: var(--text-muted);
+ font-size: 13px;
+ text-align: center;
+}
+
+body[aria-busy="true"] .viewer-panel {
+ cursor: progress;
+}
+
+.app-footer {
+ justify-content: space-between;
+ border-top: 1px solid var(--line);
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.footer-brand {
+ flex: 0 0 var(--sidebar-width);
+ font-weight: 400;
+ color: var(--accent-strong);
+ text-decoration: none;
+}
+
+.status-text {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text-muted);
+ line-height: 1.35;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.footer-links {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ flex: 0 0 var(--sidebar-width);
+ justify-content: flex-end;
+ min-width: 0;
+}
+
+.app-footer a {
+ color: var(--accent-strong);
+ font-weight: 400;
+ text-decoration: none;
+}
+
+.app-footer a:hover {
+ text-decoration: underline;
+}
+
+@media (max-width: 900px) {
+ :root {
+ --sidebar-width: min(78vw, 292px);
+ }
+
+ .toolbar {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ gap: 6px;
+ }
+
+ .toolbar > * {
+ flex: 0 0 auto;
+ }
+
+ .toolbar-space {
+ display: none;
+ }
+
+ .app-footer {
+ flex-wrap: nowrap;
+ }
+
+ .footer-brand,
+ .footer-links {
+ flex: 0 1 auto;
+ }
+
+ .status-text {
+ text-align: center;
+ }
+
+ .workspace {
+ position: relative;
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ body[data-hide-pages] .workspace,
+ body[data-hide-meta] .workspace,
+ body[data-hide-pages][data-hide-meta] .workspace {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .page-list-panel,
+ .meta-panel {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 8;
+ width: var(--sidebar-width);
+ box-shadow: 0 16px 32px rgba(31, 36, 40, 0.18);
+ transition: transform 0.18s ease;
+ }
+
+ body[data-hide-pages] .page-list-panel,
+ body[data-hide-meta] .meta-panel {
+ display: block;
+ }
+
+ .page-list-panel {
+ left: 0;
+ }
+
+ .meta-panel {
+ right: 0;
+ }
+
+ body[data-hide-pages] .page-list-panel {
+ transform: translateX(-100%);
+ pointer-events: none;
+ }
+
+ body[data-hide-meta] .meta-panel {
+ transform: translateX(100%);
+ pointer-events: none;
+ }
+}
+
+@media (max-width: 640px) {
+ :root {
+ --bar-height: 42px;
+ --control-height: 28px;
+ --bar-pad: 5px 8px;
+ }
+
+ .workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .toolbar {
+ height: var(--bar-height);
+ min-height: var(--bar-height);
+ flex-wrap: nowrap;
+ align-content: center;
+ overflow-x: auto;
+ overflow-y: hidden;
+ row-gap: 0;
+ }
+
+ .divider {
+ display: none;
+ }
+
+ .toolbar-space {
+ display: none;
+ }
+
+ .button {
+ padding: 0 7px;
+ }
+
+ .button[data-short] {
+ min-width: var(--control-height);
+ font-size: 0;
+ }
+
+ .button[data-short]::after {
+ content: attr(data-short);
+ font-size: 12px;
+ font-weight: 500;
+ }
+
+ .button.primary[data-short] {
+ min-width: 42px;
+ }
+
+ .icon-button {
+ font-size: 13px;
+ }
+
+ .page-control {
+ gap: 4px;
+ padding: 0 6px;
+ }
+
+ .page-control input {
+ width: 38px;
+ }
+
+ .page-frame {
+ padding: 22px 14px;
+ }
+
+ .zoom-label {
+ min-width: 34px;
+ }
+
+ #exportButton {
+ padding-inline: 8px;
+ }
+
+ .footer-links {
+ justify-content: flex-end;
+ }
+}
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
new file mode 100644
index 0000000..d691f81
--- /dev/null
+++ b/assets/webui/ofdgo.js
@@ -0,0 +1,1612 @@
+const MM_TO_PX = 96 / 25.4;
+const COMMON_FONT_NAMES = [
+ "SimSun",
+ "NSimSun",
+ "SimHei",
+ "KaiTi",
+ "FangSong",
+ "Microsoft YaHei",
+ "PingFang SC",
+ "Noto Sans CJK SC",
+ "Source Han Sans SC",
+ "FZXiaoBiaoSong-B05",
+ "Arial",
+ "Arial Black",
+ "Helvetica",
+ "Courier New",
+ "Times New Roman",
+];
+const LOCAL_FONT_LOAD_LIMIT = 16;
+const FIT_WIDTH_MARGIN = 72;
+const FIT_HEIGHT_MARGIN = 86;
+const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
+const STATUS = {
+ ready: "选择 OFD 文件开始阅读",
+ opening: "正在打开 OFD",
+ engine: "正在准备渲染引擎",
+ recovering: "正在恢复渲染引擎",
+ fonts: "正在匹配字体",
+ exporting: "正在导出 PDF",
+};
+const WASM_CALLBACKS = [
+ "ofdgoOpen",
+ "ofdgoRenderPage",
+ "ofdgoExportPDF",
+ "ofdgoFontCandidates",
+];
+
+let wasmPromise = null;
+let wasmModule = null;
+let wasmRecoveryTimer = 0;
+
+const state = {
+ ready: false,
+ wasmExited: false,
+ wasmSeq: 0,
+ wasmRecovering: false,
+ wasmRecoveries: 0,
+ ofdBytes: null,
+ fileName: "ofdgo.ofd",
+ openSeq: 0,
+ fontSeq: 0,
+ localFonts: [],
+ userFonts: [],
+ systemFontCatalog: [],
+ doc: null,
+ pageIndex: 0,
+ scale: 1,
+ fitMode: "width",
+ pageCache: new Map(),
+ pageInFlight: new Set(),
+ pageObserver: null,
+ scrollFrame: 0,
+ thumbnailCache: new Map(),
+ thumbnailInFlight: new Set(),
+ thumbnailObserver: null,
+ showPages: !COMPACT_LAYOUT.matches,
+ showMeta: !COMPACT_LAYOUT.matches,
+};
+
+const el = {
+ ofdInput: document.querySelector("#ofdInput"),
+ ofdButton: document.querySelector("#ofdButton"),
+ fontInput: document.querySelector("#fontInput"),
+ togglePagesButton: document.querySelector("#togglePagesButton"),
+ toggleMetaButton: document.querySelector("#toggleMetaButton"),
+ fontAddButton: document.querySelector("#fontAddButton"),
+ localFontButton: document.querySelector("#localFontButton"),
+ prevButton: document.querySelector("#prevButton"),
+ nextButton: document.querySelector("#nextButton"),
+ pageInput: document.querySelector("#pageInput"),
+ pageTotal: document.querySelector("#pageTotal"),
+ zoomOutButton: document.querySelector("#zoomOutButton"),
+ zoomInButton: document.querySelector("#zoomInButton"),
+ zoomLabel: document.querySelector("#zoomLabel"),
+ fitButton: document.querySelector("#fitButton"),
+ fitHeightButton: document.querySelector("#fitHeightButton"),
+ exportButton: document.querySelector("#exportButton"),
+ emptyState: document.querySelector("#emptyState"),
+ progressPanel: document.querySelector("#progressPanel"),
+ progressLabel: document.querySelector("#progressLabel"),
+ progressBar: document.querySelector("#progressBar"),
+ pageFrame: document.querySelector("#pageFrame"),
+ viewerPanel: document.querySelector(".viewer-panel"),
+ svgHost: document.querySelector("#svgHost"),
+ pageListPanel: document.querySelector(".page-list-panel"),
+ pageList: document.querySelector("#pageList"),
+ metaTitle: document.querySelector("#metaTitle"),
+ metaAuthor: document.querySelector("#metaAuthor"),
+ metaVersion: document.querySelector("#metaVersion"),
+ metaType: document.querySelector("#metaType"),
+ metaFonts: document.querySelector("#metaFonts"),
+ docFontList: document.querySelector("#docFontList"),
+ docFontSummary: document.querySelector("#docFontSummary"),
+ availableFontSummary: document.querySelector("#availableFontSummary"),
+ fontList: document.querySelector("#fontList"),
+ statusText: document.querySelector("#statusText"),
+};
+
+el.ofdButton.addEventListener("click", () => el.ofdInput.click());
+el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
+el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
+el.fontAddButton.addEventListener("click", () => el.fontInput.click());
+el.localFontButton.addEventListener("click", loadLocalFonts);
+el.ofdInput.addEventListener("change", openSelectedOFD);
+el.fontInput.addEventListener("change", openSelectedFonts);
+el.prevButton.addEventListener("click", () => renderPage(state.pageIndex - 1));
+el.nextButton.addEventListener("click", () => renderPage(state.pageIndex + 1));
+el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1));
+el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1));
+el.fitButton.addEventListener("click", fitWidth);
+el.fitHeightButton.addEventListener("click", fitHeight);
+el.exportButton.addEventListener("click", exportPDF);
+el.pageInput.addEventListener("change", () => {
+ const page = Number.parseInt(el.pageInput.value, 10);
+ if (Number.isFinite(page)) {
+ renderPage(page - 1);
+ }
+});
+window.addEventListener("resize", () => {
+ if (state.doc) {
+ applyFit(false);
+ }
+});
+COMPACT_LAYOUT.addEventListener("change", syncLayoutMode);
+el.viewerPanel.addEventListener("scroll", () => {
+ schedulePageSync();
+});
+
+updateSidebarState();
+boot();
+
+function toggleSidebar(side) {
+ if (side === "pages") {
+ state.showPages = !state.showPages;
+ } else if (side === "meta") {
+ state.showMeta = !state.showMeta;
+ }
+ updateSidebarState();
+ if (state.doc) {
+ applyFit(false);
+ }
+}
+
+function updateSidebarState() {
+ document.body.toggleAttribute("data-hide-pages", !state.showPages);
+ document.body.toggleAttribute("data-hide-meta", !state.showMeta);
+ el.togglePagesButton.setAttribute("aria-pressed", String(state.showPages));
+ el.toggleMetaButton.setAttribute("aria-pressed", String(state.showMeta));
+}
+
+function syncLayoutMode(event) {
+ state.showPages = !event.matches;
+ state.showMeta = !event.matches;
+ updateSidebarState();
+ if (state.doc) {
+ applyFit(false);
+ }
+}
+
+async function boot() {
+ setBusy(true, "准备渲染引擎", 8, STATUS.engine);
+ try {
+ await ensureWASM();
+ setProgress("准备就绪", 70);
+ setEmpty("选择 OFD 文件");
+ updateFontSummary();
+ renderFontList();
+ updateControls();
+ updateLocalFontButton();
+ setStatus(STATUS.ready);
+ setBusy(false);
+ } catch (err) {
+ setStatus("渲染引擎加载失败");
+ setEmpty(String(err.message || err));
+ setBusy(false);
+ return;
+ }
+}
+
+async function ensureWASM() {
+ if (state.ready && !state.wasmExited) {
+ return;
+ }
+ if (!wasmPromise) {
+ wasmPromise = loadWASM().finally(() => {
+ wasmPromise = null;
+ });
+ }
+ await wasmPromise;
+}
+
+async function loadWASM() {
+ if (!globalThis.Go) {
+ throw new Error("渲染引擎脚本缺失");
+ }
+ const wasmSeq = state.wasmSeq + 1;
+ state.wasmSeq = wasmSeq;
+ state.ready = false;
+ state.wasmExited = false;
+ clearWASMCallbacks();
+ const go = new Go();
+ if (!wasmModule) {
+ setProgress("下载渲染引擎", 16);
+ const response = await fetch("./ofdgo.wasm");
+ try {
+ setProgress("编译渲染引擎", 35);
+ wasmModule = await WebAssembly.compileStreaming(response.clone());
+ } catch {
+ const bytes = await response.arrayBuffer();
+ setProgress("编译渲染引擎", 45);
+ wasmModule = await WebAssembly.compile(bytes);
+ }
+ }
+ setProgress("启动渲染引擎", 58);
+ const instance = await WebAssembly.instantiate(wasmModule, go.importObject);
+ go.run(instance).then(() => {
+ markWASMExited(wasmSeq);
+ }).catch((err) => {
+ markWASMExited(wasmSeq, err);
+ });
+ await waitFor(() => WASM_CALLBACKS.every((name) => typeof globalThis[name] === "function"));
+ if (wasmSeq !== state.wasmSeq) {
+ return;
+ }
+ state.ready = true;
+ state.wasmExited = false;
+}
+
+function clearWASMCallbacks() {
+ for (const name of WASM_CALLBACKS) {
+ globalThis[name] = undefined;
+ }
+}
+
+function markWASMExited(wasmSeq = state.wasmSeq, err) {
+ if (wasmSeq !== state.wasmSeq) {
+ return;
+ }
+ state.ready = false;
+ state.wasmExited = true;
+ if (err) {
+ setStatus("渲染引擎异常,正在恢复");
+ }
+ scheduleWASMRecovery();
+}
+
+function scheduleWASMRecovery() {
+ if (!state.ofdBytes || state.wasmRecovering || state.wasmRecoveries >= 2) {
+ return;
+ }
+ window.clearTimeout(wasmRecoveryTimer);
+ wasmRecoveryTimer = window.setTimeout(recoverWASM, 0);
+}
+
+async function recoverWASM() {
+ if (state.wasmRecovering || !state.ofdBytes) {
+ return;
+ }
+ state.wasmRecovering = true;
+ state.wasmRecoveries += 1;
+ const pageIndex = state.pageIndex;
+ const fitMode = state.fitMode || "width";
+ setBusy(true, "恢复渲染引擎", 18, STATUS.recovering);
+ try {
+ await ensureWASM();
+ await openDocument({
+ pageIndex,
+ fitMode,
+ skipAutoFonts: true,
+ });
+ } catch (err) {
+ showError(err, !state.doc);
+ } finally {
+ state.wasmRecovering = false;
+ if (!state.doc) {
+ setBusy(false);
+ }
+ }
+}
+
+function waitFor(predicate) {
+ return new Promise((resolve, reject) => {
+ const started = performance.now();
+ const timer = window.setInterval(() => {
+ if (predicate()) {
+ window.clearInterval(timer);
+ resolve();
+ return;
+ }
+ if (performance.now() - started > 5000) {
+ window.clearInterval(timer);
+ reject(new Error("渲染引擎初始化超时"));
+ }
+ }, 20);
+ });
+}
+
+async function openSelectedOFD() {
+ const file = el.ofdInput.files[0];
+ if (!file) {
+ return;
+ }
+ state.wasmRecoveries = 0;
+ setBusy(true, "读取 OFD 文件", 10, STATUS.opening);
+ try {
+ state.fileName = file.name || "ofdgo.ofd";
+ state.ofdBytes = new Uint8Array(await file.arrayBuffer());
+ await openDocument();
+ } catch (err) {
+ showError(err, true);
+ setBusy(false);
+ }
+}
+
+async function openSelectedFonts() {
+ const files = Array.from(el.fontInput.files || []);
+ if (!files.length) {
+ return;
+ }
+ const fonts = await Promise.all(files.map(async (file) => (
+ createFontRecord(file.name, new Uint8Array(await file.arrayBuffer()), "upload")
+ )));
+ state.userFonts.push(...fonts);
+ el.fontInput.value = "";
+ await applyFontChange();
+}
+
+async function loadLocalFonts() {
+ if (!canReadLocalFonts()) {
+ setStatus("当前浏览器不支持读取系统字体");
+ return;
+ }
+ setBusy(true, state.doc ? "匹配系统字体" : "请求系统字体授权", 12, state.doc ? STATUS.fonts : "正在请求系统字体授权");
+ try {
+ await nextFrame();
+ const available = await queryLocalFonts();
+ if (!state.doc) {
+ setStatus(available.length ? `已授权 ${available.length} 个系统字体` : "未读取到系统字体");
+ return;
+ }
+ if (await loadDocumentLocalFonts(available)) {
+ await applyFontChange({ skipAutoFonts: true });
+ }
+ } catch (err) {
+ if (err && err.name === "NotAllowedError") {
+ setStatus("未授权读取系统字体");
+ } else {
+ setStatus(String(err.message || err));
+ }
+ } finally {
+ setBusy(false);
+ }
+}
+
+async function queryLocalFonts() {
+ const available = await window.queryLocalFonts();
+ state.systemFontCatalog = available;
+ return available;
+}
+
+async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
+ const docFonts = state.doc?.fonts || [];
+ const docNames = externalDocumentFontNames();
+ if (!docFonts.length) {
+ state.localFonts = [];
+ setStatus("当前 OFD 未声明字体");
+ updateFontSummary();
+ renderFontList();
+ return false;
+ }
+ if (docFonts.length && !docNames.length) {
+ state.localFonts = [];
+ setStatus("OFD 字体均为内嵌");
+ updateFontSummary();
+ renderFontList();
+ return false;
+ }
+ const docWanted = fontNameKeys(docNames);
+ const docLoadLimit = docNames.length ? Math.min(LOCAL_FONT_LOAD_LIMIT, Math.max(4, docNames.length * 3)) : 6;
+ let selected = selectLocalFonts(available, docWanted, docLoadLimit);
+ if (!selected.length) {
+ const fallbackWanted = fontNameKeys(COMMON_FONT_NAMES);
+ selected = selectLocalFonts(available, fallbackWanted, 6);
+ }
+ const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到文档所需字体";
+ const fonts = [];
+ for (let i = 0; i < selected.length; i += 1) {
+ setProgress(`读取系统字体 ${i + 1}/${selected.length}`, 20 + Math.round(i / Math.max(1, selected.length) * 60));
+ const item = selected[i];
+ const blob = await item.blob();
+ if (openSeq !== state.openSeq) {
+ return false;
+ }
+ fonts.push(createFontRecord(localFontName(item), new Uint8Array(await blob.arrayBuffer()), "browser"));
+ if (openSeq !== state.openSeq) {
+ return false;
+ }
+ }
+ if (openSeq !== state.openSeq) {
+ return false;
+ }
+ state.localFonts = mergeFontRecords([], fonts);
+ setStatus(fonts.length ? `已加载 ${fonts.length} 个系统字体` : emptyStatus);
+ updateFontSummary();
+ renderFontList();
+ return fonts.length > 0;
+}
+
+function uniqueLocalFonts(fonts) {
+ const seen = new Set();
+ const selected = [];
+ for (const font of fonts) {
+ const key = normalizeFontName(localFontName(font));
+ if (!key || seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ selected.push(font);
+ }
+ return selected;
+}
+
+function selectLocalFonts(fonts, wanted, limit) {
+ const ranked = [];
+ for (const font of fonts) {
+ const rank = localFontMatchRank(font, wanted);
+ if (!rank) {
+ continue;
+ }
+ ranked.push({
+ font,
+ rank,
+ styleRank: localFontStyleRank(font),
+ name: normalizeFontName(localFontName(font)),
+ });
+ }
+ ranked.sort((a, b) => (
+ a.rank - b.rank ||
+ a.styleRank - b.styleRank ||
+ a.name.localeCompare(b.name)
+ ));
+ return uniqueLocalFonts(ranked.map((item) => item.font)).slice(0, limit);
+}
+
+function allFonts() {
+ return fontData(fontRecords());
+}
+
+function uploadedFonts() {
+ return fontData(state.userFonts);
+}
+
+function fontData(fonts) {
+ return fonts
+ .filter((font) => font.enabled)
+ .map((font) => ({
+ name: font.name,
+ data: font.data,
+ }));
+}
+
+function updateFontSummary() {
+ const total = fontRecords().length;
+ const enabled = fontRecords().filter((font) => font.enabled).length;
+ if (el.availableFontSummary) {
+ el.availableFontSummary.textContent = total ? `${enabled}/${total}` : "0";
+ }
+}
+
+function createFontRecord(name, data, source) {
+ state.fontSeq += 1;
+ return {
+ id: `${source}-${state.fontSeq}`,
+ name: name || "font.ttf",
+ data,
+ source,
+ enabled: true,
+ };
+}
+
+function mergeFontRecords(oldFonts, newFonts) {
+ const seen = new Set(oldFonts.map((font) => normalizeFontName(font.name)));
+ const merged = [...oldFonts];
+ for (const font of newFonts) {
+ const key = normalizeFontName(font.name);
+ if (!key || seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ merged.push(font);
+ }
+ return merged;
+}
+
+function fontRecords() {
+ return [...state.localFonts, ...state.userFonts];
+}
+
+function externalDocumentFontNames() {
+ const names = [];
+ for (const font of state.doc?.fonts || []) {
+ if (font.embedded || font.status === "embedded") {
+ continue;
+ }
+ names.push(font.fontName, font.familyName);
+ }
+ return names;
+}
+
+function fontNameKeys(names) {
+ const keys = new Set();
+ for (const name of fontCandidateNames(names)) {
+ const key = normalizeFontName(name);
+ if (key) {
+ keys.add(key);
+ }
+ }
+ return keys;
+}
+
+function fontCandidateNames(names) {
+ const source = (Array.isArray(names) ? names : [])
+ .map((name) => String(name || "").trim())
+ .filter((name) => name !== "");
+ if (!source.length) {
+ return [];
+ }
+ if (state.ready && !state.wasmExited && typeof globalThis.ofdgoFontCandidates === "function") {
+ try {
+ const candidates = callWASM("ofdgoFontCandidates", source);
+ if (Array.isArray(candidates)) {
+ return candidates;
+ }
+ } catch {
+ return source;
+ }
+ }
+ return source;
+}
+
+function localFontMatchRank(font, wanted) {
+ let best = 0;
+ for (const name of [font.family, font.fullName, font.postscriptName, font.style]) {
+ const key = normalizeFontName(name);
+ if (!key) {
+ continue;
+ }
+ for (const wantedKey of wanted) {
+ let rank = 0;
+ if (key === wantedKey) {
+ rank = 1;
+ } else if (key.startsWith(wantedKey) || wantedKey.startsWith(key)) {
+ rank = 2;
+ } else if (key.includes(wantedKey) || wantedKey.includes(key)) {
+ rank = 3;
+ }
+ if (rank && (!best || rank < best)) {
+ best = rank;
+ }
+ }
+ }
+ return best;
+}
+
+function localFontStyleRank(font) {
+ const key = normalizeFontName(font.style);
+ if (!key || key === "regular" || key === "normal") {
+ return 0;
+ }
+ if (key.includes("bold") || key.includes("italic")) {
+ return 2;
+ }
+ return 1;
+}
+
+function localFontName(font) {
+ const name = font.fullName || font.family || font.postscriptName || "local-font";
+ return `${name}.ttf`;
+}
+
+function normalizeFontName(name = "") {
+ return String(name)
+ .toLowerCase()
+ .replace(/\.[^.]+$/, "")
+ .replace(/[\s_\-()()]/g, "");
+}
+
+async function applyFontChange(options = {}) {
+ updateFontSummary();
+ renderFontList();
+ if (!state.ofdBytes || !state.doc) {
+ return;
+ }
+ await openDocument({
+ pageIndex: state.pageIndex,
+ fitMode: state.fitMode,
+ skipAutoFonts: options.skipAutoFonts !== false,
+ });
+}
+
+function removeFont(id) {
+ state.localFonts = state.localFonts.filter((font) => font.id !== id);
+ state.userFonts = state.userFonts.filter((font) => font.id !== id);
+}
+
+function renderFontList() {
+ if (!el.fontList) {
+ return;
+ }
+ el.fontList.replaceChildren();
+ const fonts = fontRecords();
+ if (!fonts.length) {
+ const empty = document.createElement("div");
+ empty.className = "font-empty";
+ empty.textContent = "暂无字体";
+ el.fontList.append(empty);
+ return;
+ }
+ const fragment = document.createDocumentFragment();
+ for (const font of fonts) {
+ const row = document.createElement("div");
+ row.className = "font-row";
+ row.dataset.fontId = font.id;
+
+ const source = document.createElement("span");
+ source.className = "font-source";
+ source.textContent = fontSourceText(font.source);
+
+ const enabled = document.createElement("input");
+ enabled.type = "checkbox";
+ enabled.checked = font.enabled;
+ enabled.title = "启用字体";
+
+ const enabledText = document.createElement("span");
+ enabledText.textContent = font.enabled ? "启用" : "停用";
+ enabled.addEventListener("change", async () => {
+ font.enabled = enabled.checked;
+ enabledText.textContent = font.enabled ? "启用" : "停用";
+ await applyFontChange();
+ });
+
+ const name = document.createElement("input");
+ name.className = "font-name-input";
+ name.value = font.name;
+ name.title = "字体文件名或匹配名";
+ name.addEventListener("change", async () => {
+ const next = name.value.trim();
+ if (!next) {
+ name.value = font.name;
+ return;
+ }
+ font.name = next;
+ await applyFontChange();
+ });
+
+ const main = document.createElement("div");
+ main.className = "font-main";
+ main.append(source, name);
+
+ const toggle = document.createElement("label");
+ toggle.className = "font-toggle";
+ toggle.append(enabled, enabledText);
+
+ const remove = document.createElement("button");
+ remove.className = "font-delete";
+ remove.type = "button";
+ remove.title = "删除字体";
+ remove.textContent = "×";
+ remove.addEventListener("click", async () => {
+ removeFont(font.id);
+ await applyFontChange();
+ });
+
+ const actions = document.createElement("div");
+ actions.className = "font-actions";
+ actions.append(toggle, remove);
+
+ row.append(main, actions);
+ fragment.append(row);
+ }
+ el.fontList.append(fragment);
+}
+
+function fontSourceText(source) {
+ switch (source) {
+ case "browser":
+ return "系统";
+ default:
+ return "上传";
+ }
+}
+
+function updateLocalFontButton() {
+ if (!el.localFontButton) {
+ return;
+ }
+ const supported = canReadLocalFonts();
+ el.localFontButton.disabled = !supported;
+ el.localFontButton.title = supported ? "授权读取浏览器可访问的系统字体" : "当前浏览器不支持读取系统字体";
+}
+
+function canReadLocalFonts() {
+ return typeof window.queryLocalFonts === "function";
+}
+
+async function openDocument(options = {}) {
+ if (!state.ofdBytes) {
+ return;
+ }
+ if (!state.ready || state.wasmExited) {
+ await ensureWASM();
+ }
+ const openSeq = options.openSeq || (state.openSeq += 1);
+ const autoFonts = !options.skipAutoFonts && state.systemFontCatalog.length > 0;
+ if (autoFonts) {
+ state.localFonts = [];
+ updateFontSummary();
+ renderFontList();
+ }
+ setBusy(true, "打开 OFD", 20, STATUS.opening);
+ try {
+ setProgress("解析 OFD", 52);
+ await nextFrame();
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ const doc = callWASM("ofdgoOpen", state.ofdBytes, autoFonts ? uploadedFonts() : allFonts());
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ const pageCount = doc.pageCount || 0;
+ const pageIndex = Math.min(Math.max(options.pageIndex || 0, 0), Math.max(pageCount - 1, 0));
+ state.doc = doc;
+ state.pageIndex = pageIndex;
+ state.scale = 1;
+ state.fitMode = options.fitMode || "width";
+ if (!options.skipAutoFonts && state.systemFontCatalog.length > 0) {
+ setProgress("匹配系统字体", 62, STATUS.fonts);
+ if (await loadDocumentLocalFonts(state.systemFontCatalog, openSeq)) {
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ await openDocument({
+ pageIndex,
+ fitMode: state.fitMode,
+ skipAutoFonts: true,
+ openSeq,
+ });
+ return;
+ }
+ }
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ resetPageFlow();
+ renderPageList();
+ renderMeta();
+ renderPageFlow();
+ await nextFrame();
+ applyFit(false);
+ await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
+ queueNearbyPages(pageIndex, openSeq);
+ } catch (err) {
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ showError(err, true);
+ } finally {
+ if (openSeq === state.openSeq) {
+ setBusy(false);
+ }
+ }
+}
+
+async function renderPage(index, options = {}) {
+ if (!state.doc) {
+ return;
+ }
+ const openSeq = options.openSeq || state.openSeq;
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ const pageCount = state.doc.pageCount || 0;
+ if (index < 0 || index >= pageCount) {
+ return;
+ }
+ if (options.keepBusy) {
+ setProgress("渲染页面", 76);
+ } else {
+ setBusy(true, "渲染页面", 35, `正在渲染第 ${index + 1} 页`);
+ }
+ try {
+ setCurrentPage(index);
+ applyFit(false);
+ if (options.scroll !== false) {
+ scrollToPage(index);
+ }
+ await renderFlowPage(index, { throwError: true, openSeq });
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ if (options.scroll !== false) {
+ scrollToPage(index);
+ }
+ queueNearbyPages(index, openSeq);
+ updatePageListCurrent();
+ updateControls();
+ setStatus(pageStatus(index, pageCount));
+ } catch (err) {
+ if (openSeq === state.openSeq) {
+ showError(err, false);
+ }
+ } finally {
+ if (!options.keepBusy && openSeq === state.openSeq) {
+ setBusy(false);
+ }
+ }
+}
+
+async function exportPDF() {
+ if (!state.doc) {
+ return;
+ }
+ const openSeq = state.openSeq;
+ setBusy(true, "生成 PDF", 18, STATUS.exporting);
+ try {
+ await nextFrame();
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ setProgress("导出 PDF", 45);
+ const result = callWASM("ofdgoExportPDF");
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ const bytes = base64ToBytes(result.base64);
+ const blob = new Blob([bytes], { type: "application/pdf" });
+ const link = document.createElement("a");
+ const url = URL.createObjectURL(blob);
+ link.href = url;
+ link.download = pdfFileName();
+ document.body.append(link);
+ link.click();
+ link.remove();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
+ setStatus(`PDF 已导出 ${formatBytes(result.size || bytes.length)}`);
+ } catch (err) {
+ if (openSeq === state.openSeq) {
+ showError(err, false);
+ }
+ } finally {
+ if (openSeq === state.openSeq) {
+ setBusy(false);
+ }
+ }
+}
+
+function renderPageFlow() {
+ el.svgHost.replaceChildren();
+ if (!state.doc) {
+ return;
+ }
+ if (state.pageObserver) {
+ state.pageObserver.disconnect();
+ }
+ const fragment = document.createDocumentFragment();
+ const targets = [];
+ for (const page of state.doc.pages || []) {
+ const shell = document.createElement("div");
+ shell.className = "page-shell";
+ shell.dataset.pageIndex = String(page.index);
+
+ const surface = document.createElement("div");
+ surface.className = "page-surface";
+
+ const placeholder = document.createElement("div");
+ placeholder.className = "page-placeholder";
+ placeholder.textContent = `第 ${page.index + 1} 页`;
+
+ shell.append(surface, placeholder);
+ fragment.append(shell);
+ targets.push([shell, page.index]);
+ }
+ el.svgHost.append(fragment);
+ layoutPages();
+ for (const [shell, index] of targets) {
+ observeFlowPage(shell, index);
+ }
+ el.emptyState.hidden = true;
+ el.pageFrame.hidden = false;
+}
+
+function resetPageFlow() {
+ state.pageCache.clear();
+ state.pageInFlight.clear();
+ resetThumbnails();
+ if (state.pageObserver) {
+ state.pageObserver.disconnect();
+ state.pageObserver = null;
+ }
+}
+
+function observeFlowPage(shell, index) {
+ const openSeq = state.openSeq;
+ if (index < 4 && index !== state.pageIndex) {
+ renderFlowPage(index, { openSeq });
+ return;
+ }
+ const observer = flowPageObserver();
+ if (observer) {
+ observer.observe(shell);
+ }
+}
+
+function flowPageObserver() {
+ if (!("IntersectionObserver" in window)) {
+ return null;
+ }
+ if (!state.pageObserver) {
+ const openSeq = state.openSeq;
+ state.pageObserver = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) {
+ continue;
+ }
+ const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
+ state.pageObserver.unobserve(entry.target);
+ renderFlowPage(index, { openSeq });
+ }
+ }, {
+ root: el.viewerPanel,
+ rootMargin: "600px 0px",
+ });
+ }
+ return state.pageObserver;
+}
+
+async function renderFlowPage(index, options = {}) {
+ const openSeq = options.openSeq || state.openSeq;
+ if (openSeq !== state.openSeq) {
+ return null;
+ }
+ if (state.pageCache.has(index)) {
+ return state.pageCache.get(index);
+ }
+ const key = `${openSeq}:${index}`;
+ if (state.pageInFlight.has(key)) {
+ return null;
+ }
+ state.pageInFlight.add(key);
+ try {
+ await nextFrame();
+ if (openSeq !== state.openSeq) {
+ return null;
+ }
+ const page = callWASM("ofdgoRenderPage", index);
+ if (openSeq !== state.openSeq) {
+ return null;
+ }
+ state.pageCache.set(index, page);
+ cacheThumbnail(index, page.svg);
+ mountPageSVG(index, page, openSeq);
+ updateThumbnail(index, openSeq);
+ return page;
+ } catch (err) {
+ if (openSeq === state.openSeq) {
+ markFlowPageError(index, err);
+ }
+ if (options.throwError && openSeq === state.openSeq) {
+ throw err;
+ }
+ return null;
+ } finally {
+ state.pageInFlight.delete(key);
+ }
+}
+
+function mountPageSVG(index, page, openSeq = state.openSeq) {
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ const shell = pageShell(index);
+ if (!shell) {
+ return;
+ }
+ const surface = shell.querySelector(".page-surface");
+ const svg = parseSVG(page.svg, `p${openSeq}-${index}`);
+ svg.classList.add("ofd-svg");
+ surface.replaceChildren(svg);
+ shell.classList.add("rendered");
+ if (state.doc?.pages?.[index]) {
+ layoutPageShell(shell, state.doc.pages[index]);
+ }
+}
+
+function markFlowPageError(index, err) {
+ const shell = pageShell(index);
+ if (!shell) {
+ return;
+ }
+ shell.classList.add("error");
+ const placeholder = shell.querySelector(".page-placeholder");
+ if (placeholder) {
+ placeholder.textContent = String(err.message || err);
+ }
+}
+
+function queueNearbyPages(index, openSeq = state.openSeq) {
+ for (let i = Math.max(0, index - 1); i <= Math.min((state.doc?.pageCount || 1) - 1, index + 2); i += 1) {
+ renderFlowPage(i, { openSeq });
+ }
+}
+
+function pageShell(index) {
+ return el.svgHost.querySelector(`.page-shell[data-page-index="${index}"]`);
+}
+
+function scrollToPage(index) {
+ const shell = pageShell(index);
+ if (shell) {
+ shell.scrollIntoView({ block: "start", inline: "nearest" });
+ }
+}
+
+function schedulePageSync() {
+ if (state.scrollFrame || !state.doc) {
+ return;
+ }
+ state.scrollFrame = requestAnimationFrame(() => {
+ state.scrollFrame = 0;
+ syncCurrentPageFromScroll();
+ });
+}
+
+function syncCurrentPageFromScroll() {
+ const shells = Array.from(el.svgHost.querySelectorAll(".page-shell"));
+ if (!shells.length) {
+ return;
+ }
+ const viewerRect = el.viewerPanel.getBoundingClientRect();
+ const targetY = viewerRect.top + Math.min(viewerRect.height * 0.45, 280);
+ let nextIndex = state.pageIndex;
+ let best = Number.POSITIVE_INFINITY;
+ for (const shell of shells) {
+ const rect = shell.getBoundingClientRect();
+ const distance = Math.abs(rect.top - targetY);
+ if (distance < best) {
+ best = distance;
+ nextIndex = Number.parseInt(shell.dataset.pageIndex, 10);
+ }
+ }
+ if (Number.isFinite(nextIndex) && nextIndex !== state.pageIndex) {
+ setCurrentPage(nextIndex);
+ queueNearbyPages(nextIndex);
+ }
+}
+
+function setCurrentPage(index) {
+ state.pageIndex = index;
+ updatePageListCurrent();
+ updateControls();
+ if (state.doc) {
+ setStatus(pageStatus(index, state.doc.pageCount));
+ }
+}
+
+function updatePageListCurrent() {
+ for (const item of el.pageList.querySelectorAll(".page-list-item")) {
+ setPageItemCurrent(item, Number.parseInt(item.dataset.pageIndex, 10) === state.pageIndex);
+ }
+}
+
+function setPageItemCurrent(item, current) {
+ if (current) {
+ item.setAttribute("aria-current", "page");
+ return;
+ }
+ item.removeAttribute("aria-current");
+}
+
+function layoutPages() {
+ if (!state.doc) {
+ return;
+ }
+ for (const page of state.doc.pages || []) {
+ const shell = pageShell(page.index);
+ if (shell) {
+ layoutPageShell(shell, page);
+ }
+ }
+}
+
+function layoutPageShell(shell, page) {
+ const width = Math.max(1, page.width * MM_TO_PX);
+ const height = Math.max(1, page.height * MM_TO_PX);
+ shell.style.width = `${width * state.scale}px`;
+ shell.style.height = `${height * state.scale}px`;
+ const surface = shell.querySelector(".page-surface");
+ if (surface) {
+ surface.style.width = `${width}px`;
+ surface.style.height = `${height}px`;
+ surface.style.transform = `scale(${state.scale})`;
+ }
+}
+
+function parseSVG(svgText, prefix = "") {
+ const parsed = new DOMParser().parseFromString(svgText, "image/svg+xml");
+ const error = parsed.querySelector("parsererror");
+ if (error) {
+ throw new Error(error.textContent.trim());
+ }
+ const svg = document.importNode(parsed.documentElement, true);
+ prefixSVGIds(svg, prefix);
+ return svg;
+}
+
+function prefixSVGIds(svg, prefix) {
+ if (!prefix) {
+ return;
+ }
+ const idMap = new Map();
+ for (const node of svg.querySelectorAll("[id]")) {
+ const id = node.getAttribute("id");
+ if (!id) {
+ continue;
+ }
+ const next = `${prefix}-${id}`;
+ idMap.set(id, next);
+ node.setAttribute("id", next);
+ }
+ if (idMap.size === 0) {
+ return;
+ }
+ const replaceRef = (value) => {
+ if (!value) {
+ return value;
+ }
+ let next = value.replace(/url\(#([^)]+)\)/g, (match, id) => {
+ const mapped = idMap.get(id);
+ return mapped ? `url(#${mapped})` : match;
+ });
+ if (next.startsWith("#")) {
+ const mapped = idMap.get(next.slice(1));
+ if (mapped) {
+ next = `#${mapped}`;
+ }
+ }
+ return next;
+ };
+ const attrs = ["clip-path", "fill", "filter", "href", "marker-end", "marker-mid", "marker-start", "mask", "stroke", "style", "xlink:href"];
+ for (const node of svg.querySelectorAll("*")) {
+ for (const attr of attrs) {
+ if (node.hasAttribute(attr)) {
+ node.setAttribute(attr, replaceRef(node.getAttribute(attr)));
+ }
+ }
+ }
+}
+
+function renderPageList() {
+ el.pageList.replaceChildren();
+ if (!state.doc) {
+ return;
+ }
+ if (state.thumbnailObserver) {
+ state.thumbnailObserver.disconnect();
+ }
+ const fragment = document.createDocumentFragment();
+ const thumbnailTargets = [];
+ const openSeq = state.openSeq;
+ for (const page of state.doc.pages || []) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "page-list-item";
+ button.dataset.pageIndex = String(page.index);
+ button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
+ setPageItemCurrent(button, page.index === state.pageIndex);
+ if (page.width > 0 && page.height > 0) {
+ button.style.setProperty("--thumb-ratio", `${page.width} / ${page.height}`);
+ }
+
+ const thumb = document.createElement("span");
+ thumb.className = "thumb-paper";
+ thumb.setAttribute("aria-hidden", "true");
+ setThumbnailContent(thumb, state.thumbnailCache.get(page.index), page.index, openSeq);
+
+ const label = document.createElement("span");
+ label.className = "thumb-label";
+ label.textContent = `第 ${page.index + 1} 页`;
+
+ const size = document.createElement("span");
+ size.className = "thumb-size";
+ size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
+
+ button.append(thumb, label, size);
+ button.addEventListener("click", () => renderPage(page.index));
+ fragment.append(button);
+ thumbnailTargets.push([button, page.index]);
+ }
+ el.pageList.append(fragment);
+ for (const [button, index] of thumbnailTargets) {
+ observeThumbnail(button, index, openSeq);
+ }
+}
+
+function resetThumbnails() {
+ state.thumbnailCache.clear();
+ state.thumbnailInFlight.clear();
+ if (state.thumbnailObserver) {
+ state.thumbnailObserver.disconnect();
+ state.thumbnailObserver = null;
+ }
+}
+
+function cacheThumbnail(index, svgText) {
+ if (typeof svgText === "string" && svgText) {
+ state.thumbnailCache.set(index, svgText);
+ }
+}
+
+function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) {
+ container.replaceChildren();
+ if (!svgText) {
+ container.classList.add("pending");
+ container.textContent = String(index + 1);
+ return;
+ }
+ try {
+ const svg = parseSVG(svgText, `t${openSeq}-${index}`);
+ svg.classList.add("thumb-svg");
+ svg.setAttribute("aria-hidden", "true");
+ container.classList.remove("pending", "error");
+ container.append(svg);
+ } catch {
+ container.classList.add("error");
+ container.textContent = String(index + 1);
+ }
+}
+
+function observeThumbnail(button, index, openSeq = state.openSeq) {
+ if (state.thumbnailCache.has(index)) {
+ return;
+ }
+ if (index === state.pageIndex || index < 6) {
+ renderThumbnail(index, openSeq);
+ return;
+ }
+ const observer = thumbnailObserver();
+ if (observer) {
+ observer.observe(button);
+ return;
+ }
+ if (index < 8) {
+ renderThumbnail(index, openSeq);
+ }
+}
+
+function thumbnailObserver() {
+ if (!("IntersectionObserver" in window)) {
+ return null;
+ }
+ if (!state.thumbnailObserver) {
+ const openSeq = state.openSeq;
+ state.thumbnailObserver = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) {
+ continue;
+ }
+ state.thumbnailObserver.unobserve(entry.target);
+ renderThumbnail(Number.parseInt(entry.target.dataset.pageIndex, 10), openSeq);
+ }
+ }, {
+ root: el.pageListPanel,
+ rootMargin: "180px 0px",
+ });
+ }
+ return state.thumbnailObserver;
+}
+
+async function renderThumbnail(index, openSeq = state.openSeq) {
+ if (openSeq !== state.openSeq || !state.doc || state.thumbnailCache.has(index)) {
+ return;
+ }
+ const key = `${openSeq}:${index}`;
+ if (state.thumbnailInFlight.has(key)) {
+ return;
+ }
+ state.thumbnailInFlight.add(key);
+ try {
+ await nextFrame();
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ const page = callWASM("ofdgoRenderPage", index);
+ if (openSeq !== state.openSeq) {
+ return;
+ }
+ cacheThumbnail(index, page.svg);
+ updateThumbnail(index, openSeq);
+ } catch {
+ if (openSeq === state.openSeq) {
+ markThumbnailError(index);
+ }
+ } finally {
+ state.thumbnailInFlight.delete(key);
+ }
+}
+
+function updateThumbnail(index, openSeq = state.openSeq) {
+ const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
+ if (thumb) {
+ setThumbnailContent(thumb, state.thumbnailCache.get(index), index, openSeq);
+ }
+}
+
+function markThumbnailError(index) {
+ const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
+ if (thumb) {
+ thumb.classList.add("error");
+ thumb.textContent = String(index + 1);
+ }
+}
+
+function renderMeta() {
+ const doc = state.doc || {};
+ el.metaTitle.textContent = doc.title || "-";
+ el.metaAuthor.textContent = doc.author || "-";
+ el.metaVersion.textContent = doc.version || "-";
+ el.metaType.textContent = doc.docType || "-";
+ el.metaFonts.textContent = String(doc.fontCount || 0);
+ el.pageTotal.textContent = String(doc.pageCount || 0);
+ renderDocumentFonts();
+ renderFontList();
+ updateLocalFontButton();
+}
+
+function renderDocumentFonts() {
+ if (!el.docFontList) {
+ return;
+ }
+ const fonts = state.doc?.fonts || [];
+ el.docFontList.replaceChildren();
+ if (el.docFontSummary) {
+ el.docFontSummary.textContent = fontSummary(fonts);
+ }
+ if (!fonts.length) {
+ const empty = document.createElement("div");
+ empty.className = "font-empty";
+ empty.textContent = "未声明字体";
+ el.docFontList.append(empty);
+ return;
+ }
+ const fragment = document.createDocumentFragment();
+ for (const font of fonts) {
+ const row = document.createElement("div");
+ row.className = `doc-font-row ${font.status || "missing"}`;
+
+ const head = document.createElement("div");
+ head.className = "doc-font-head";
+
+ const name = document.createElement("div");
+ name.className = "doc-font-name";
+ name.textContent = font.fontName || font.familyName || font.id || "-";
+
+ const badges = document.createElement("div");
+ badges.className = "doc-font-badges";
+ badges.append(fontBadge(statusText(font.status), font.status || "missing"));
+ if (font.embedded && font.status !== "embedded") {
+ badges.append(fontBadge("内嵌", "embedded"));
+ }
+ const detail = document.createElement("div");
+ detail.className = "doc-font-detail";
+ detail.textContent = fontDetail(font);
+
+ head.append(name, badges);
+ row.append(head, detail);
+ fragment.append(row);
+ }
+ el.docFontList.append(fragment);
+}
+
+function fontBadge(text, status) {
+ const badge = document.createElement("span");
+ badge.className = `font-badge ${status}`;
+ badge.textContent = text;
+ return badge;
+}
+
+function fontSummary(fonts) {
+ if (!fonts.length) {
+ return "0";
+ }
+ const missing = fonts.filter((font) => font.status === "missing").length;
+ const fallback = fonts.filter((font) => font.status === "fallback").length;
+ if (missing || fallback) {
+ return `${fonts.length} · 缺失 ${missing} · 回退 ${fallback}`;
+ }
+ return `共 ${fonts.length}`;
+}
+
+function pageStatus(index, pageCount) {
+ return `第 ${index + 1} / ${pageCount} 页`;
+}
+
+function statusText(status) {
+ switch (status) {
+ case "embedded":
+ return "内嵌";
+ case "matched":
+ return "已匹配";
+ case "fallback":
+ return "回退";
+ default:
+ return "缺失";
+ }
+}
+
+function fontDetail(font) {
+ const parts = [];
+ if (font.familyName && font.familyName !== font.fontName) {
+ parts.push(`族 ${font.familyName}`);
+ }
+ if (font.charset) {
+ parts.push(`字符集 ${font.charset}`);
+ }
+ if (font.matched) {
+ parts.push(font.embedded || font.status === "embedded" ? `内嵌 ${font.matched}` : `匹配 ${font.matched}`);
+ }
+ if (font.detail) {
+ parts.push(font.detail);
+ }
+ return parts.join(" · ") || "-";
+}
+
+function fitWidth(updateStatus = true) {
+ const page = currentPageInfo();
+ if (!page) {
+ return;
+ }
+ state.fitMode = "width";
+ const available = Math.max(280, el.pageFrame.clientWidth - FIT_WIDTH_MARGIN);
+ const width = Math.max(1, page.width * MM_TO_PX);
+ setScale(available / width, updateStatus, "width");
+}
+
+function fitHeight(updateStatus = true) {
+ const page = currentPageInfo();
+ if (!page) {
+ return;
+ }
+ state.fitMode = "height";
+ const available = Math.max(220, el.viewerPanel.clientHeight - FIT_HEIGHT_MARGIN);
+ const height = Math.max(1, page.height * MM_TO_PX);
+ setScale(available / height, updateStatus, "height");
+}
+
+function applyFit(updateStatus = true) {
+ if (state.fitMode === "height") {
+ fitHeight(updateStatus);
+ return;
+ }
+ if (state.fitMode === "width") {
+ fitWidth(updateStatus);
+ }
+}
+
+function setScale(nextScale, updateStatus = true, fitMode = "free") {
+ state.fitMode = fitMode;
+ state.scale = Math.min(4, Math.max(0.2, nextScale));
+ layoutPages();
+ el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`;
+ if (updateStatus && state.doc) {
+ setStatus(`第 ${state.pageIndex + 1} / ${state.doc.pageCount} 页`);
+ }
+ updateControls();
+}
+
+function currentPageInfo() {
+ return state.doc?.pages?.[state.pageIndex] || null;
+}
+
+function updateControls() {
+ const hasDoc = Boolean(state.doc);
+ const pageCount = state.doc ? state.doc.pageCount : 0;
+ el.prevButton.disabled = !hasDoc || state.pageIndex <= 0;
+ el.nextButton.disabled = !hasDoc || state.pageIndex >= pageCount - 1;
+ el.pageInput.disabled = !hasDoc;
+ el.pageInput.max = String(pageCount || 1);
+ el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 1);
+ el.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2;
+ el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
+ el.fitButton.disabled = !hasDoc;
+ el.fitHeightButton.disabled = !hasDoc;
+ el.fitButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "width");
+ el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
+ el.exportButton.disabled = !hasDoc;
+}
+
+function callWASM(name, ...args) {
+ const fn = globalThis[name];
+ if (typeof fn !== "function") {
+ throw new Error("渲染引擎未初始化");
+ }
+ if (state.wasmExited) {
+ scheduleWASMRecovery();
+ throw new Error(state.wasmRecovering ? "渲染引擎正在恢复" : "渲染引擎已退出,正在恢复");
+ }
+ let payload;
+ try {
+ payload = fn(...args);
+ } catch (err) {
+ const message = String(err.message || err);
+ if (message.includes("Go program has already exited")) {
+ markWASMExited(state.wasmSeq);
+ throw new Error("渲染引擎已退出,正在恢复");
+ }
+ throw err;
+ }
+ const result = JSON.parse(payload);
+ if (!result.ok) {
+ throw new Error(result.error || "WASM 调用失败");
+ }
+ return result.data;
+}
+
+function showError(err, empty = !state.doc) {
+ const message = String(err.message || err);
+ setStatus(message);
+ if (empty) {
+ setEmpty(message);
+ }
+}
+
+function setBusy(busy, text = "", percent = 0, status = "") {
+ document.body.toggleAttribute("aria-busy", busy);
+ if (!busy) {
+ el.progressPanel.hidden = true;
+ return;
+ }
+ el.progressPanel.hidden = false;
+ if (status) {
+ setStatus(status);
+ }
+ setProgress(text, percent);
+}
+
+function setProgress(text = "", percent = 0, status = "") {
+ if (text) {
+ el.progressLabel.textContent = text;
+ }
+ if (status) {
+ setStatus(status);
+ }
+ const value = Math.max(0, Math.min(100, percent));
+ el.progressBar.style.width = `${value}%`;
+}
+
+function setStatus(text) {
+ el.statusText.textContent = text;
+}
+
+function setEmpty(text) {
+ el.emptyState.hidden = false;
+ el.pageFrame.hidden = true;
+ el.emptyState.textContent = text;
+}
+
+function formatSize(value) {
+ if (!Number.isFinite(value) || value <= 0) {
+ return "-";
+ }
+ return value.toFixed(1);
+}
+
+function base64ToBytes(base64) {
+ const binary = atob(base64 || "");
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+function pdfFileName() {
+ const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, "");
+ const safe = base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo";
+ return `${safe}.pdf`;
+}
+
+function formatBytes(size) {
+ if (!Number.isFinite(size) || size <= 0) {
+ return "PDF";
+ }
+ if (size < 1024 * 1024) {
+ return `${Math.round(size / 1024)} KB`;
+ }
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function nextFrame() {
+ return new Promise((resolve) => requestAnimationFrame(resolve));
+}
diff --git a/assets/webui/wasm_exec.js b/assets/webui/wasm_exec.js
new file mode 100644
index 0000000..d71af9e
--- /dev/null
+++ b/assets/webui/wasm_exec.js
@@ -0,0 +1,575 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+"use strict";
+
+(() => {
+ const enosys = () => {
+ const err = new Error("not implemented");
+ err.code = "ENOSYS";
+ return err;
+ };
+
+ if (!globalThis.fs) {
+ let outputBuf = "";
+ globalThis.fs = {
+ constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
+ writeSync(fd, buf) {
+ outputBuf += decoder.decode(buf);
+ const nl = outputBuf.lastIndexOf("\n");
+ if (nl != -1) {
+ console.log(outputBuf.substring(0, nl));
+ outputBuf = outputBuf.substring(nl + 1);
+ }
+ return buf.length;
+ },
+ write(fd, buf, offset, length, position, callback) {
+ if (offset !== 0 || length !== buf.length || position !== null) {
+ callback(enosys());
+ return;
+ }
+ const n = this.writeSync(fd, buf);
+ callback(null, n);
+ },
+ chmod(path, mode, callback) { callback(enosys()); },
+ chown(path, uid, gid, callback) { callback(enosys()); },
+ close(fd, callback) { callback(enosys()); },
+ fchmod(fd, mode, callback) { callback(enosys()); },
+ fchown(fd, uid, gid, callback) { callback(enosys()); },
+ fstat(fd, callback) { callback(enosys()); },
+ fsync(fd, callback) { callback(null); },
+ ftruncate(fd, length, callback) { callback(enosys()); },
+ lchown(path, uid, gid, callback) { callback(enosys()); },
+ link(path, link, callback) { callback(enosys()); },
+ lstat(path, callback) { callback(enosys()); },
+ mkdir(path, perm, callback) { callback(enosys()); },
+ open(path, flags, mode, callback) { callback(enosys()); },
+ read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
+ readdir(path, callback) { callback(enosys()); },
+ readlink(path, callback) { callback(enosys()); },
+ rename(from, to, callback) { callback(enosys()); },
+ rmdir(path, callback) { callback(enosys()); },
+ stat(path, callback) { callback(enosys()); },
+ symlink(path, link, callback) { callback(enosys()); },
+ truncate(path, length, callback) { callback(enosys()); },
+ unlink(path, callback) { callback(enosys()); },
+ utimes(path, atime, mtime, callback) { callback(enosys()); },
+ };
+ }
+
+ if (!globalThis.process) {
+ globalThis.process = {
+ getuid() { return -1; },
+ getgid() { return -1; },
+ geteuid() { return -1; },
+ getegid() { return -1; },
+ getgroups() { throw enosys(); },
+ pid: -1,
+ ppid: -1,
+ umask() { throw enosys(); },
+ cwd() { throw enosys(); },
+ chdir() { throw enosys(); },
+ }
+ }
+
+ if (!globalThis.path) {
+ globalThis.path = {
+ resolve(...pathSegments) {
+ return pathSegments.join("/");
+ }
+ }
+ }
+
+ if (!globalThis.crypto) {
+ throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
+ }
+
+ if (!globalThis.performance) {
+ throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
+ }
+
+ if (!globalThis.TextEncoder) {
+ throw new Error("globalThis.TextEncoder is not available, polyfill required");
+ }
+
+ if (!globalThis.TextDecoder) {
+ throw new Error("globalThis.TextDecoder is not available, polyfill required");
+ }
+
+ const encoder = new TextEncoder("utf-8");
+ const decoder = new TextDecoder("utf-8");
+
+ globalThis.Go = class {
+ constructor() {
+ this.argv = ["js"];
+ this.env = {};
+ this.exit = (code) => {
+ if (code !== 0) {
+ console.warn("exit code:", code);
+ }
+ };
+ this._exitPromise = new Promise((resolve) => {
+ this._resolveExitPromise = resolve;
+ });
+ this._pendingEvent = null;
+ this._scheduledTimeouts = new Map();
+ this._nextCallbackTimeoutID = 1;
+
+ const setInt64 = (addr, v) => {
+ this.mem.setUint32(addr + 0, v, true);
+ this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
+ }
+
+ const setInt32 = (addr, v) => {
+ this.mem.setUint32(addr + 0, v, true);
+ }
+
+ const getInt64 = (addr) => {
+ const low = this.mem.getUint32(addr + 0, true);
+ const high = this.mem.getInt32(addr + 4, true);
+ return low + high * 4294967296;
+ }
+
+ const loadValue = (addr) => {
+ const f = this.mem.getFloat64(addr, true);
+ if (f === 0) {
+ return undefined;
+ }
+ if (!isNaN(f)) {
+ return f;
+ }
+
+ const id = this.mem.getUint32(addr, true);
+ return this._values[id];
+ }
+
+ const storeValue = (addr, v) => {
+ const nanHead = 0x7FF80000;
+
+ if (typeof v === "number" && v !== 0) {
+ if (isNaN(v)) {
+ this.mem.setUint32(addr + 4, nanHead, true);
+ this.mem.setUint32(addr, 0, true);
+ return;
+ }
+ this.mem.setFloat64(addr, v, true);
+ return;
+ }
+
+ if (v === undefined) {
+ this.mem.setFloat64(addr, 0, true);
+ return;
+ }
+
+ let id = this._ids.get(v);
+ if (id === undefined) {
+ id = this._idPool.pop();
+ if (id === undefined) {
+ id = this._values.length;
+ }
+ this._values[id] = v;
+ this._goRefCounts[id] = 0;
+ this._ids.set(v, id);
+ }
+ this._goRefCounts[id]++;
+ let typeFlag = 0;
+ switch (typeof v) {
+ case "object":
+ if (v !== null) {
+ typeFlag = 1;
+ }
+ break;
+ case "string":
+ typeFlag = 2;
+ break;
+ case "symbol":
+ typeFlag = 3;
+ break;
+ case "function":
+ typeFlag = 4;
+ break;
+ }
+ this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
+ this.mem.setUint32(addr, id, true);
+ }
+
+ const loadSlice = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return new Uint8Array(this._inst.exports.mem.buffer, array, len);
+ }
+
+ const loadSliceOfValues = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ const a = new Array(len);
+ for (let i = 0; i < len; i++) {
+ a[i] = loadValue(array + i * 8);
+ }
+ return a;
+ }
+
+ const loadString = (addr) => {
+ const saddr = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
+ }
+
+ const testCallExport = (a, b) => {
+ this._inst.exports.testExport0();
+ return this._inst.exports.testExport(a, b);
+ }
+
+ const timeOrigin = Date.now() - performance.now();
+ this.importObject = {
+ _gotest: {
+ add: (a, b) => a + b,
+ callExport: testCallExport,
+ },
+ gojs: {
+ // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
+ // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
+ // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
+ // This changes the SP, thus we have to update the SP used by the imported function.
+
+ // func wasmExit(code int32)
+ "runtime.wasmExit": (sp) => {
+ sp >>>= 0;
+ const code = this.mem.getInt32(sp + 8, true);
+ this.exited = true;
+ delete this._inst;
+ delete this._values;
+ delete this._goRefCounts;
+ delete this._ids;
+ delete this._idPool;
+ this.exit(code);
+ },
+
+ // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
+ "runtime.wasmWrite": (sp) => {
+ sp >>>= 0;
+ const fd = getInt64(sp + 8);
+ const p = getInt64(sp + 16);
+ const n = this.mem.getInt32(sp + 24, true);
+ fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
+ },
+
+ // func resetMemoryDataView()
+ "runtime.resetMemoryDataView": (sp) => {
+ sp >>>= 0;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ },
+
+ // func nanotime1() int64
+ "runtime.nanotime1": (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
+ },
+
+ // func walltime() (sec int64, nsec int32)
+ "runtime.walltime": (sp) => {
+ sp >>>= 0;
+ const msec = (new Date).getTime();
+ setInt64(sp + 8, msec / 1000);
+ this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
+ },
+
+ // func scheduleTimeoutEvent(delay int64) int32
+ "runtime.scheduleTimeoutEvent": (sp) => {
+ sp >>>= 0;
+ const id = this._nextCallbackTimeoutID;
+ this._nextCallbackTimeoutID++;
+ this._scheduledTimeouts.set(id, setTimeout(
+ () => {
+ this._resume();
+ while (this._scheduledTimeouts.has(id)) {
+ // for some reason Go failed to register the timeout event, log and try again
+ // (temporary workaround for https://github.com/golang/go/issues/28975)
+ console.warn("scheduleTimeoutEvent: missed timeout event");
+ this._resume();
+ }
+ },
+ getInt64(sp + 8),
+ ));
+ this.mem.setInt32(sp + 16, id, true);
+ },
+
+ // func clearTimeoutEvent(id int32)
+ "runtime.clearTimeoutEvent": (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getInt32(sp + 8, true);
+ clearTimeout(this._scheduledTimeouts.get(id));
+ this._scheduledTimeouts.delete(id);
+ },
+
+ // func getRandomData(r []byte)
+ "runtime.getRandomData": (sp) => {
+ sp >>>= 0;
+ crypto.getRandomValues(loadSlice(sp + 8));
+ },
+
+ // func finalizeRef(v ref)
+ "syscall/js.finalizeRef": (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getUint32(sp + 8, true);
+ this._goRefCounts[id]--;
+ if (this._goRefCounts[id] === 0) {
+ const v = this._values[id];
+ this._values[id] = null;
+ this._ids.delete(v);
+ this._idPool.push(id);
+ }
+ },
+
+ // func stringVal(value string) ref
+ "syscall/js.stringVal": (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, loadString(sp + 8));
+ },
+
+ // func valueGet(v ref, p string) ref
+ "syscall/js.valueGet": (sp) => {
+ sp >>>= 0;
+ const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 32, result);
+ },
+
+ // func valueSet(v ref, p string, x ref)
+ "syscall/js.valueSet": (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
+ },
+
+ // func valueDelete(v ref, p string)
+ "syscall/js.valueDelete": (sp) => {
+ sp >>>= 0;
+ Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
+ },
+
+ // func valueIndex(v ref, i int) ref
+ "syscall/js.valueIndex": (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
+ },
+
+ // valueSetIndex(v ref, i int, x ref)
+ "syscall/js.valueSetIndex": (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
+ },
+
+ // func valueCall(v ref, m string, args []ref) (ref, bool)
+ "syscall/js.valueCall": (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const m = Reflect.get(v, loadString(sp + 16));
+ const args = loadSliceOfValues(sp + 32);
+ const result = Reflect.apply(m, v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, result);
+ this.mem.setUint8(sp + 64, 1);
+ } catch (err) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, err);
+ this.mem.setUint8(sp + 64, 0);
+ }
+ },
+
+ // func valueInvoke(v ref, args []ref) (ref, bool)
+ "syscall/js.valueInvoke": (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.apply(v, undefined, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (err) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, err);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueNew(v ref, args []ref) (ref, bool)
+ "syscall/js.valueNew": (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.construct(v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (err) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, err);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueLength(v ref) int
+ "syscall/js.valueLength": (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
+ },
+
+ // valuePrepareString(v ref) (ref, int)
+ "syscall/js.valuePrepareString": (sp) => {
+ sp >>>= 0;
+ const str = encoder.encode(String(loadValue(sp + 8)));
+ storeValue(sp + 16, str);
+ setInt64(sp + 24, str.length);
+ },
+
+ // valueLoadString(v ref, b []byte)
+ "syscall/js.valueLoadString": (sp) => {
+ sp >>>= 0;
+ const str = loadValue(sp + 8);
+ loadSlice(sp + 16).set(str);
+ },
+
+ // func valueInstanceOf(v ref, t ref) bool
+ "syscall/js.valueInstanceOf": (sp) => {
+ sp >>>= 0;
+ this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
+ },
+
+ // func copyBytesToGo(dst []byte, src ref) (int, bool)
+ "syscall/js.copyBytesToGo": (sp) => {
+ sp >>>= 0;
+ const dst = loadSlice(sp + 8);
+ const src = loadValue(sp + 32);
+ if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ // func copyBytesToJS(dst ref, src []byte) (int, bool)
+ "syscall/js.copyBytesToJS": (sp) => {
+ sp >>>= 0;
+ const dst = loadValue(sp + 8);
+ const src = loadSlice(sp + 16);
+ if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ "debug": (value) => {
+ console.log(value);
+ },
+ }
+ };
+ }
+
+ async run(instance) {
+ if (!(instance instanceof WebAssembly.Instance)) {
+ throw new Error("Go.run: WebAssembly.Instance expected");
+ }
+ this._inst = instance;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ this._values = [ // JS values that Go currently has references to, indexed by reference id
+ NaN,
+ 0,
+ null,
+ true,
+ false,
+ globalThis,
+ this,
+ ];
+ this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
+ this._ids = new Map([ // mapping from JS values to reference ids
+ [0, 1],
+ [null, 2],
+ [true, 3],
+ [false, 4],
+ [globalThis, 5],
+ [this, 6],
+ ]);
+ this._idPool = []; // unused ids that have been garbage collected
+ this.exited = false; // whether the Go program has exited
+
+ // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
+ let offset = 4096;
+
+ const strPtr = (str) => {
+ const ptr = offset;
+ const bytes = encoder.encode(str + "\0");
+ new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
+ offset += bytes.length;
+ if (offset % 8 !== 0) {
+ offset += 8 - (offset % 8);
+ }
+ return ptr;
+ };
+
+ const argc = this.argv.length;
+
+ const argvPtrs = [];
+ this.argv.forEach((arg) => {
+ argvPtrs.push(strPtr(arg));
+ });
+ argvPtrs.push(0);
+
+ const keys = Object.keys(this.env).sort();
+ keys.forEach((key) => {
+ argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
+ });
+ argvPtrs.push(0);
+
+ const argv = offset;
+ argvPtrs.forEach((ptr) => {
+ this.mem.setUint32(offset, ptr, true);
+ this.mem.setUint32(offset + 4, 0, true);
+ offset += 8;
+ });
+
+ // The linker guarantees global data starts from at least wasmMinDataAddr.
+ // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
+ const wasmMinDataAddr = 4096 + 8192;
+ if (offset >= wasmMinDataAddr) {
+ throw new Error("total length of command line and environment variables exceeds limit");
+ }
+
+ this._inst.exports.run(argc, argv);
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ await this._exitPromise;
+ }
+
+ _resume() {
+ if (this.exited) {
+ throw new Error("Go program has already exited");
+ }
+ this._inst.exports.resume();
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ }
+
+ _makeFuncWrapper(id) {
+ const go = this;
+ return function () {
+ const event = { id: id, this: this, args: arguments };
+ go._pendingEvent = event;
+ go._resume();
+ return event.result;
+ };
+ }
+ }
+})();
diff --git a/cmd/webui/wasm.go b/cmd/webui/wasm.go
new file mode 100644
index 0000000..663a568
--- /dev/null
+++ b/cmd/webui/wasm.go
@@ -0,0 +1,23 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build js && wasm
+
+package main
+
+import "github.com/xiaoqidun/ofdgo/internal/webui"
+
+func main() {
+ webui.RunWASM()
+}
diff --git a/cmd/webui/webui.go b/cmd/webui/webui.go
new file mode 100644
index 0000000..9d2b543
--- /dev/null
+++ b/cmd/webui/webui.go
@@ -0,0 +1,46 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !js || !wasm
+
+package main
+
+import (
+ "flag"
+ "net/http"
+ "os"
+ "strings"
+
+ webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
+)
+
+func main() {
+ listen := flag.String("listen", "127.0.0.1:8080", "listen address")
+ flag.Parse()
+ if err := http.ListenAndServe(*listen, serveWebUI()); err != nil {
+ os.Exit(1)
+ }
+}
+
+// serveWebUI 创建WebUI静态文件处理器
+// 返回: http.Handler HTTP处理器
+func serveWebUI() http.Handler {
+ files := http.FileServer(http.FS(webuiassets.FS))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, ".wasm") {
+ w.Header().Set("Content-Type", "application/wasm")
+ }
+ files.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
new file mode 100644
index 0000000..8dd060c
--- /dev/null
+++ b/internal/webui/wasm.go
@@ -0,0 +1,221 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build js && wasm
+
+package webui
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "syscall/js"
+
+ "github.com/xiaoqidun/ofdgo"
+)
+
+// currentSession 当前WebUI文档会话
+var currentSession *Session
+
+// callbacks 浏览器回调函数引用
+var callbacks []js.Func
+
+// apiResult 浏览器接口返回结果
+type apiResult struct {
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+ Data any `json:"data,omitempty"`
+}
+
+// RunWASM 注册浏览器WASM接口并阻塞运行
+func RunWASM() {
+ registerCallback("ofdgoOpen", openDocument)
+ registerCallback("ofdgoRenderPage", renderPage)
+ registerCallback("ofdgoExportPDF", exportPDF)
+ registerCallback("ofdgoFontCandidates", fontCandidates)
+ select {}
+}
+
+// registerCallback 注册浏览器回调函数
+// 入参: name 回调名称, fn 回调函数
+func registerCallback(name string, fn func([]js.Value) (any, error)) {
+ cb := js.FuncOf(func(this js.Value, args []js.Value) any {
+ data, err := safeCall(fn, args)
+ if err != nil {
+ return encodeResult(apiResult{OK: false, Error: err.Error()})
+ }
+ return encodeResult(apiResult{OK: true, Data: data})
+ })
+ js.Global().Set(name, cb)
+ callbacks = append(callbacks, cb)
+}
+
+// safeCall 调用浏览器回调并转换异常
+// 入参: fn 回调函数, args 回调参数
+// 返回: any 回调结果, error 错误信息
+func safeCall(fn func([]js.Value) (any, error), args []js.Value) (data any, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ err = fmt.Errorf("%v", r)
+ }
+ }()
+ return fn(args)
+}
+
+// openDocument 打开OFD文档
+// 入参: args 浏览器参数
+// 返回: any 文档信息, error 错误信息
+func openDocument(args []js.Value) (any, error) {
+ if len(args) == 0 {
+ return nil, fmt.Errorf("missing ofd data")
+ }
+ data, err := bytesFromJS(args[0])
+ if err != nil {
+ return nil, err
+ }
+ fonts, err := fontsFromJS(jsArg(args, 1))
+ if err != nil {
+ return nil, err
+ }
+ if currentSession != nil {
+ _ = currentSession.Close()
+ currentSession = nil
+ }
+ session, err := Open(data, OpenOptions{Fonts: fonts})
+ if err != nil {
+ return nil, err
+ }
+ currentSession = session
+ return currentSession.Info(), nil
+}
+
+// renderPage 渲染OFD页面
+// 入参: args 浏览器参数
+// 返回: any 页面SVG结果, error 错误信息
+func renderPage(args []js.Value) (any, error) {
+ if currentSession == nil {
+ return nil, fmt.Errorf("ofd document is not opened")
+ }
+ if len(args) == 0 {
+ return nil, fmt.Errorf("missing page index")
+ }
+ return currentSession.RenderPageSVG(args[0].Int())
+}
+
+// exportPDF 导出OFD为PDF
+// 入参: args 浏览器参数
+// 返回: any PDF结果, error 错误信息
+func exportPDF(args []js.Value) (any, error) {
+ if currentSession == nil {
+ return nil, fmt.Errorf("ofd document is not opened")
+ }
+ data, err := currentSession.ExportPDF()
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"base64": base64.StdEncoding.EncodeToString(data), "size": len(data)}, nil
+}
+
+// fontCandidates 获取字体候选名称
+// 入参: args 浏览器参数
+// 返回: any 字体候选名称, error 错误信息
+func fontCandidates(args []js.Value) (any, error) {
+ names := stringsFromJS(jsArg(args, 0))
+ return ofdgo.FontCandidateNames(names...), nil
+}
+
+// bytesFromJS 从浏览器值读取二进制数据
+// 入参: value 浏览器值
+// 返回: []byte 二进制数据, error 错误信息
+func bytesFromJS(value js.Value) ([]byte, error) {
+ if value.IsUndefined() || value.IsNull() {
+ return nil, fmt.Errorf("missing binary data")
+ }
+ uint8Array := js.Global().Get("Uint8Array")
+ arrayBuffer := js.Global().Get("ArrayBuffer")
+ if value.InstanceOf(arrayBuffer) {
+ value = uint8Array.New(value)
+ }
+ if !value.InstanceOf(uint8Array) {
+ return nil, fmt.Errorf("binary data must be Uint8Array or ArrayBuffer")
+ }
+ data := make([]byte, value.Get("byteLength").Int())
+ if n := js.CopyBytesToGo(data, value); n != len(data) {
+ return nil, fmt.Errorf("copied %d of %d bytes", n, len(data))
+ }
+ return data, nil
+}
+
+// fontsFromJS 从浏览器值读取字体文件
+// 入参: value 浏览器值
+// 返回: []FontFile 字体文件, error 错误信息
+func fontsFromJS(value js.Value) ([]FontFile, error) {
+ if value.IsUndefined() || value.IsNull() {
+ return nil, nil
+ }
+ length := value.Get("length").Int()
+ fonts := make([]FontFile, 0, length)
+ for i := 0; i < length; i++ {
+ item := value.Index(i)
+ data, err := bytesFromJS(item.Get("data"))
+ if err != nil {
+ return nil, err
+ }
+ fonts = append(fonts, FontFile{Name: item.Get("name").String(), Data: data})
+ }
+ return fonts, nil
+}
+
+// stringsFromJS 从浏览器值读取字符串列表
+// 入参: value 浏览器值
+// 返回: []string 字符串列表
+func stringsFromJS(value js.Value) []string {
+ if value.IsUndefined() || value.IsNull() {
+ return nil
+ }
+ if js.Global().Get("Array").Call("isArray", value).Bool() {
+ length := value.Get("length").Int()
+ items := make([]string, 0, length)
+ for i := 0; i < length; i++ {
+ item := value.Index(i)
+ if !item.IsUndefined() && !item.IsNull() {
+ items = append(items, item.String())
+ }
+ }
+ return items
+ }
+ return []string{value.String()}
+}
+
+// jsArg 获取浏览器参数
+// 入参: args 浏览器参数列表, index 参数索引
+// 返回: js.Value 浏览器值
+func jsArg(args []js.Value, index int) js.Value {
+ if index < 0 || index >= len(args) {
+ return js.Undefined()
+ }
+ return args[index]
+}
+
+// encodeResult 编码浏览器接口返回结果
+// 入参: result 返回结果
+// 返回: string JSON字符串
+func encodeResult(result apiResult) string {
+ data, err := json.Marshal(result)
+ if err != nil {
+ fallback, _ := json.Marshal(apiResult{OK: false, Error: err.Error()})
+ return string(fallback)
+ }
+ return string(data)
+}
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
new file mode 100644
index 0000000..6977458
--- /dev/null
+++ b/internal/webui/webui.go
@@ -0,0 +1,182 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package webui
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/xiaoqidun/ofdgo"
+)
+
+// OpenOptions 打开OFD文档选项
+type OpenOptions struct {
+ Fonts []FontFile
+}
+
+// FontFile 字体文件
+type FontFile = ofdgo.FontFile
+
+// FontInfo OFD字体信息
+type FontInfo = ofdgo.FontInfo
+
+// Session WebUI文档会话
+type Session struct {
+ Reader *ofdgo.Reader
+ Renderer *ofdgo.Renderer
+ doc *ofdgo.Document
+}
+
+// DocumentInfo 文档信息
+type DocumentInfo struct {
+ Version string `json:"version"`
+ DocType string `json:"docType"`
+ Title string `json:"title"`
+ Author string `json:"author"`
+ Subject string `json:"subject"`
+ CreationDate string `json:"creationDate"`
+ ModDate string `json:"modDate"`
+ PageCount int `json:"pageCount"`
+ FontCount int `json:"fontCount"`
+ Fonts []FontInfo `json:"fonts"`
+ Pages []PageInfo `json:"pages"`
+}
+
+// PageInfo 页面信息
+type PageInfo struct {
+ Index int `json:"index"`
+ ID string `json:"id"`
+ Width float64 `json:"width"`
+ Height float64 `json:"height"`
+}
+
+// PageSVG 页面SVG结果
+type PageSVG struct {
+ Index int `json:"index"`
+ Number int `json:"number"`
+ ID string `json:"id"`
+ Width float64 `json:"width"`
+ Height float64 `json:"height"`
+ SVG string `json:"svg"`
+}
+
+// Open 打开浏览器内存中的OFD文档
+// 入参: data OFD文件数据, opts 打开选项
+// 返回: *Session 文档会话, error 错误信息
+func Open(data []byte, opts OpenOptions) (*Session, error) {
+ if len(data) == 0 {
+ return nil, fmt.Errorf("empty ofd data")
+ }
+ reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data)))
+ if err != nil {
+ return nil, err
+ }
+ doc, err := reader.Doc()
+ if err != nil {
+ reader.Close()
+ return nil, err
+ }
+ var rendererOptions []ofdgo.RendererOption
+ if len(opts.Fonts) > 0 {
+ fontFS := ofdgo.NewFontFS(opts.Fonts)
+ if fontFS.Len() == 0 {
+ reader.Close()
+ return nil, fmt.Errorf("invalid font file")
+ }
+ rendererOptions = append(rendererOptions, ofdgo.WithFontFS(fontFS))
+ }
+ return &Session{Reader: reader, Renderer: ofdgo.NewRenderer(reader, rendererOptions...), doc: doc}, nil
+}
+
+// Close 关闭文档会话
+// 返回: error 错误信息
+func (s *Session) Close() error {
+ if s == nil || s.Reader == nil {
+ return nil
+ }
+ return s.Reader.Close()
+}
+
+// Info 获取文档信息
+// 返回: DocumentInfo 文档信息
+func (s *Session) Info() DocumentInfo {
+ info := DocumentInfo{
+ Version: s.Reader.Version(),
+ DocType: s.Reader.DocType(),
+ PageCount: len(s.doc.Pages.Page),
+ Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)),
+ }
+ if fonts, err := s.Renderer.FontInfos(); err == nil {
+ info.Fonts = fonts
+ }
+ info.FontCount = len(info.Fonts)
+ if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil {
+ info.Title = docInfo.Title
+ info.Author = docInfo.Author
+ info.Subject = docInfo.Subject
+ info.CreationDate = docInfo.CreationDate
+ info.ModDate = docInfo.ModDate
+ }
+ for index, pageRef := range s.doc.Pages.Page {
+ pageInfo := PageInfo{Index: index, ID: pageRef.ID}
+ if page, err := s.Reader.PageContent(pageRef); err == nil {
+ if box, err := s.Renderer.GetPageBox(page); err == nil {
+ pageInfo.Width = box.W
+ pageInfo.Height = box.H
+ }
+ }
+ info.Pages = append(info.Pages, pageInfo)
+ }
+ return info
+}
+
+// RenderPageSVG 渲染页面为SVG
+// 入参: index 页面索引
+// 返回: PageSVG 页面SVG结果, error 错误信息
+func (s *Session) RenderPageSVG(index int) (PageSVG, error) {
+ if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
+ return PageSVG{}, fmt.Errorf("ofd document is not opened")
+ }
+ if index < 0 || index >= len(s.doc.Pages.Page) {
+ return PageSVG{}, fmt.Errorf("page index %d out of range", index)
+ }
+ pageRef := s.doc.Pages.Page[index]
+ page, err := s.Reader.PageContent(pageRef)
+ if err != nil {
+ return PageSVG{}, err
+ }
+ box, err := s.Renderer.GetPageBox(page)
+ if err != nil {
+ return PageSVG{}, err
+ }
+ var buf bytes.Buffer
+ if err := s.Renderer.RenderToSVG(page, &buf); err != nil {
+ return PageSVG{}, err
+ }
+ return PageSVG{Index: index, Number: index + 1, ID: pageRef.ID, Width: box.W, Height: box.H, SVG: buf.String()}, nil
+}
+
+// ExportPDF 导出整个文档为PDF
+// 返回: []byte PDF文件数据, error 错误信息
+func (s *Session) ExportPDF() ([]byte, error) {
+ if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
+ return nil, fmt.Errorf("ofd document is not opened")
+ }
+ var buf bytes.Buffer
+ if err := s.Renderer.RenderToMultiPagePDF(&buf); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}