diff --git a/assets/webui/index.html b/assets/webui/index.html
index 4fc7b07..3865178 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -13,7 +13,7 @@
-
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index b1bf28f..c8d0a1a 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -149,6 +149,30 @@ button:not(:disabled):hover {
font-size: 14px;
}
+.open-icon {
+ position: relative;
+ width: 14px;
+ height: 12px;
+}
+
+.open-icon::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 6px;
+ height: 4px;
+ border: 1px solid currentColor;
+ border-bottom: 0;
+ content: "";
+}
+
+.open-icon::after {
+ position: absolute;
+ inset: 3px 0 0;
+ border: 1px solid currentColor;
+ content: "";
+}
+
.toolbar-space {
flex: 1 1 auto;
}
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index bb125af..299ab4b 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -17,6 +17,8 @@ const COMMON_FONT_NAMES = [
"Times New Roman",
];
const LOCAL_FONT_LOAD_LIMIT = 16;
+const FONT_DATABASE = "ofdgo-fonts";
+const fontChannel = typeof BroadcastChannel === "function" ? new BroadcastChannel(FONT_DATABASE) : null;
const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
const DEFAULT_IMAGE_DPI = 300;
const STATUS = {
@@ -42,6 +44,7 @@ const WASM_CALLBACKS = [
let wasmPromise = null;
let wasmModule = null;
let wasmRecoveryTimer = 0;
+let fontDatabase = null;
const state = {
ready: false,
@@ -55,6 +58,8 @@ const state = {
fontSeq: 0,
localFonts: [],
userFonts: [],
+ fontSyncPending: false,
+ fontSyncing: false,
systemFontCatalog: [],
systemFontCatalogLoaded: false,
systemFontPermission: "prompt",
@@ -168,6 +173,19 @@ el.viewerPanel.addEventListener("scroll", () => {
schedulePageSync();
});
el.viewerPanel.addEventListener("dblclick", openOFDFromViewer);
+if (fontChannel) {
+ fontChannel.onmessage = scheduleFontSync;
+}
+el.fontList.addEventListener("focusout", () => {
+ if (state.fontSyncPending) {
+ window.setTimeout(syncUserFonts, 0);
+ }
+});
+document.addEventListener("visibilitychange", () => {
+ if (!document.hidden) {
+ scheduleFontSync();
+ }
+});
el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput);
updateSidebarState();
@@ -190,6 +208,9 @@ async function openOFDFromViewer() {
}
function openFontFile(input) {
+ if (document.body.hasAttribute("aria-busy")) {
+ return;
+ }
input.value = "";
input.click();
}
@@ -237,6 +258,12 @@ async function boot() {
}
setProgress("正在准备引擎", 8, STATUS.engine);
await ensureWASM();
+ let fontsRestored = true;
+ try {
+ await restoreUserFonts();
+ } catch {
+ fontsRestored = false;
+ }
setProgress("引擎准备完成", 70);
loadExportFormats();
setEmpty("选择 OFD 文件");
@@ -245,7 +272,7 @@ async function boot() {
updateControls();
updateLocalFontButton();
await refreshLocalFontPermission();
- setStatus(STATUS.ready);
+ setStatus(fontsRestored ? STATUS.ready : "字体读取失败");
setBusy(false);
} catch (err) {
setStatus("渲染引擎加载失败");
@@ -334,7 +361,9 @@ async function refreshApplication() {
if (document.body.hasAttribute("aria-busy")) {
return;
}
- if ((state.ofdBytes || state.userFonts.length) && !window.confirm("刷新后需重新打开文件和添加字体,是否继续?")) {
+ const temporaryFonts = state.userFonts.some((font) => font.source === "upload");
+ const message = temporaryFonts ? "刷新后需重新打开文件和添加未保存字体,是否继续?" : "刷新后需重新打开文件,是否继续?";
+ if ((state.ofdBytes || temporaryFonts) && !window.confirm(message)) {
return;
}
setBusy(true, "正在刷新应用", null, "正在刷新应用");
@@ -530,14 +559,103 @@ function isOFDFile(file) {
return /\.ofd$/i.test(file.name || "");
}
+function openFontDatabase() {
+ if (!fontDatabase) {
+ fontDatabase = new Promise((resolve, reject) => {
+ const request = indexedDB.open(FONT_DATABASE, 1);
+ request.onupgradeneeded = () => {
+ const store = request.result.createObjectStore("fonts", { keyPath: "id", autoIncrement: true });
+ store.createIndex("checksum", "checksum", { unique: true });
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ db.onversionchange = () => {
+ db.close();
+ fontDatabase = null;
+ };
+ resolve(db);
+ };
+ request.onerror = () => reject(request.error);
+ }).catch((err) => {
+ fontDatabase = null;
+ throw err;
+ });
+ }
+ return fontDatabase;
+}
+
+async function fontTransaction(mode, action) {
+ const db = await openFontDatabase();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("fonts", mode);
+ let request;
+ tx.oncomplete = () => {
+ if (mode === "readwrite") {
+ fontChannel?.postMessage(null);
+ }
+ resolve(request?.result);
+ };
+ tx.onabort = () => reject(tx.error || new Error("字体保存失败"));
+ try {
+ request = action(tx.objectStore("fonts"));
+ } catch (err) {
+ tx.abort();
+ reject(err);
+ }
+ });
+}
+
+function listStoredFonts() {
+ return fontTransaction("readonly", (store) => store.getAll());
+}
+
+async function addStoredFonts(fonts) {
+ const records = new Map();
+ for (const font of fonts) {
+ const digest = await crypto.subtle.digest("SHA-256", font.data);
+ const checksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
+ if (!records.has(checksum)) {
+ records.set(checksum, { checksum, name: font.name, enabled: font.enabled, data: new Blob([font.data]) });
+ }
+ }
+ await fontTransaction("readwrite", (store) => {
+ for (const font of records.values()) {
+ const request = store.index("checksum").getKey(font.checksum);
+ request.onsuccess = () => {
+ if (request.result === undefined) {
+ store.add(font);
+ }
+ };
+ }
+ });
+}
+
+function updateStoredFont(id, changes) {
+ return fontTransaction("readwrite", (store) => {
+ const request = store.get(id);
+ request.onsuccess = () => {
+ if (request.result) {
+ store.put({ ...request.result, ...changes });
+ }
+ };
+ });
+}
+
+function deleteStoredFont(id) {
+ return fontTransaction("readwrite", (store) => store.delete(id));
+}
+
async function openSelectedFonts(event) {
+ if (document.body.hasAttribute("aria-busy")) {
+ return;
+ }
const input = event.currentTarget;
const files = Array.from(input.files || []).filter((file) => (
!input.webkitdirectory || /\.(ttf|otf|ttc)$/i.test(file.name)
));
input.value = "";
if (!files.length) {
- setStatus("未发现字体文件");
+ setStatus("暂无字体文件");
return;
}
setBusy(true, "正在读取字体", 10, STATUS.fonts);
@@ -548,11 +666,21 @@ async function openSelectedFonts(event) {
const file = files[i];
fonts.push(createFontRecord(file.name, new Uint8Array(await file.arrayBuffer()), "upload"));
}
- state.userFonts.push(...fonts);
- await applyFontChange();
- if (!state.doc) {
- setStatus(`已添加 ${fonts.length} 个字体`);
+ let saved = true;
+ try {
+ await addStoredFonts(fonts);
+ } catch {
+ saved = false;
+ state.userFonts.push(...fonts);
}
+ const changed = saved ? await restoreUserFonts() : true;
+ if (saved && navigator.storage?.persist) {
+ navigator.storage.persist().catch(() => false);
+ }
+ if (changed) {
+ await applyFontChange();
+ }
+ setStatus(saved ? "字体保存完成" : "字体仅限本次");
} catch (err) {
showError(err, false);
} finally {
@@ -561,6 +689,9 @@ async function openSelectedFonts(event) {
}
async function loadLocalFonts() {
+ if (document.body.hasAttribute("aria-busy")) {
+ return;
+ }
if (!canReadLocalFonts()) {
setStatus("当前浏览器不支持读取系统字体");
return;
@@ -728,21 +859,64 @@ function selectLocalFonts(fonts, wanted, limit) {
return uniqueLocalFonts(ranked.map((item) => item.font)).slice(0, limit);
}
-function allFonts() {
- return fontData(fontRecords());
+async function fontData(fonts) {
+ const result = [];
+ for (const font of fonts) {
+ if (!font.enabled) {
+ continue;
+ }
+ if (font.data instanceof Blob) {
+ font.data = new Uint8Array(await font.data.arrayBuffer());
+ }
+ result.push({ name: font.name, data: font.data });
+ }
+ return result;
}
-function uploadedFonts() {
- return fontData(state.userFonts);
+async function restoreUserFonts() {
+ const stored = await listStoredFonts();
+ const previous = new Map(state.userFonts.map((font) => [font.id, font]));
+ const fonts = stored.map((font) => ({
+ ...font,
+ data: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).data : font.data,
+ source: "stored",
+ }));
+ fonts.push(...state.userFonts.filter((font) => font.source === "upload"));
+ if (fonts.length === state.userFonts.length && fonts.every((font, index) => {
+ const old = state.userFonts[index];
+ return font.id === old.id && font.checksum === old.checksum && font.name === old.name && font.enabled === old.enabled;
+ })) {
+ return false;
+ }
+ state.userFonts = fonts;
+ return true;
}
-function fontData(fonts) {
- return fonts
- .filter((font) => font.enabled)
- .map((font) => ({
- name: font.name,
- data: font.data,
- }));
+function scheduleFontSync() {
+ state.fontSyncPending = true;
+ syncUserFonts();
+}
+
+async function syncUserFonts() {
+ if (!state.ready || !state.fontSyncPending || state.fontSyncing || document.hidden || document.body.hasAttribute("aria-busy") || document.activeElement?.classList.contains("font-name-input")) {
+ return;
+ }
+ state.fontSyncPending = false;
+ state.fontSyncing = true;
+ setBusy(true, "正在同步字体", null);
+ try {
+ if (await restoreUserFonts()) {
+ await applyFontChange();
+ }
+ if (!state.doc) {
+ setStatus(STATUS.ready);
+ }
+ } catch {
+ setStatus("字体读取失败");
+ } finally {
+ state.fontSyncing = false;
+ setBusy(false);
+ }
}
function updateFontSummary() {
@@ -865,6 +1039,7 @@ async function applyFontChange(options = {}) {
await openDocument({
pageIndex: state.pageIndex,
fitMode: state.fitMode,
+ scale: state.scale,
skipAutoFonts: options.skipAutoFonts !== false,
reuseSession: true,
fontsChanged: true,
@@ -890,6 +1065,36 @@ function removeFont(id) {
state.userFonts = state.userFonts.filter((font) => font.id !== id);
}
+async function changeFont(font, changes) {
+ if (document.body.hasAttribute("aria-busy")) {
+ return;
+ }
+ setBusy(true, "正在更新字体", null, STATUS.fonts);
+ try {
+ if (font.source === "stored") {
+ if (changes) {
+ await updateStoredFont(font.id, changes);
+ } else {
+ await deleteStoredFont(font.id);
+ }
+ await restoreUserFonts();
+ } else if (changes) {
+ Object.assign(font, changes);
+ } else {
+ removeFont(font.id);
+ }
+ await applyFontChange();
+ if (!state.doc) {
+ setStatus(STATUS.ready);
+ }
+ } catch {
+ renderFontList();
+ setStatus("字体更新失败");
+ } finally {
+ setBusy(false);
+ }
+}
+
function renderFontList() {
el.fontList.replaceChildren();
const fonts = fontRecords();
@@ -918,9 +1123,7 @@ function renderFontList() {
const enabledText = document.createElement("span");
enabledText.textContent = font.enabled ? "启用" : "停用";
enabled.addEventListener("change", async () => {
- font.enabled = enabled.checked;
- enabledText.textContent = font.enabled ? "启用" : "停用";
- await applyFontChange();
+ await changeFont(font, { enabled: enabled.checked });
});
const name = document.createElement("input");
@@ -933,8 +1136,7 @@ function renderFontList() {
name.value = font.name;
return;
}
- font.name = next;
- await applyFontChange();
+ await changeFont(font, { name: next });
});
const main = document.createElement("div");
@@ -951,8 +1153,7 @@ function renderFontList() {
remove.title = "删除字体";
remove.textContent = "×";
remove.addEventListener("click", async () => {
- removeFont(font.id);
- await applyFontChange();
+ await changeFont(font);
});
const actions = document.createElement("div");
@@ -1028,9 +1229,13 @@ async function openDocument(options = {}) {
if (openSeq !== state.openSeq) {
return;
}
- const fonts = resetLocalFonts ? uploadedFonts() : allFonts();
+ const fonts = !options.reuseSession || options.fontsChanged
+ ? await fontData(resetLocalFonts ? state.userFonts : fontRecords()) : null;
+ if (openSeq !== state.openSeq) {
+ return;
+ }
const doc = options.reuseSession
- ? callWASM("ofdgoConfigure", options.fontsChanged ? fonts : null, state.renderAnnotations)
+ ? callWASM("ofdgoConfigure", fonts, state.renderAnnotations)
: callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations);
if (openSeq !== state.openSeq) {
return;
@@ -2451,8 +2656,12 @@ function showError(err, empty = !state.doc) {
function setBusy(busy, text = "", percent = 0, status = "") {
document.body.toggleAttribute("aria-busy", busy);
+ el.fontList.inert = busy;
if (!busy) {
el.progressPanel.hidden = true;
+ if (state.fontSyncPending) {
+ window.setTimeout(syncUserFonts, 0);
+ }
return;
}
el.progressPanel.hidden = false;