Files
ofdgo/assets/webui/ofdgo.js
T
2026-07-07 09:52:23 +08:00

1650 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
el.viewerPanel.addEventListener("dblclick", openOFDFromViewer);
updateSidebarState();
boot();
function openOFDFromViewer() {
if (state.doc || document.body.hasAttribute("aria-busy")) {
return;
}
el.ofdInput.click();
}
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 autoLoadDocumentLocalFonts(openSeq) {
if (!externalDocumentFontNames().length || !canReadLocalFonts()) {
return false;
}
setProgress("正在匹配字体", 62, STATUS.fonts);
try {
const available = state.systemFontCatalog.length ? state.systemFontCatalog : await queryLocalFonts();
if (openSeq !== state.openSeq) {
return false;
}
return await loadDocumentLocalFonts(available, openSeq);
} catch (err) {
if (err && err.name === "NotAllowedError") {
setStatus("未授权读取系统字体");
return false;
}
setStatus(String(err.message || err));
return false;
}
}
async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
const docFonts = state.doc?.fonts || [];
const docNames = externalDocumentFontNames();
if (!docFonts.length) {
state.localFonts = [];
setStatus("暂无字体");
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 resetLocalFonts = !options.skipAutoFonts;
if (resetLocalFonts) {
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, resetLocalFonts ? 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 && await autoLoadDocumentLocalFonts(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();
applyFit(false);
await nextFrame();
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, "正在渲染页面");
}
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;
el.exportButton.disabled = true;
setBusy(true, "正在准备 PDF", 18, STATUS.exporting);
try {
await waitForPaint();
if (openSeq !== state.openSeq) {
return;
}
setProgress("正在生成 PDF", 45);
await waitForPaint();
if (openSeq !== state.openSeq) {
return;
}
const result = callWASM("ofdgoExportPDF");
if (openSeq !== state.openSeq) {
return;
}
setProgress("正在保存 PDF", 86);
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);
updateControls();
}
}
}
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));
}
function waitForPaint() {
return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}