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 + + + +
+
+ + + + + +
+ + + +
+ + 100% + + + + +
+ +
+ + +
+
选择 OFD 文件
+ + +
+ + +
+ + +
+ + + + + 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 +}