feat(前端界面): OFDGo WebUI支持单页输出
build.yaml / build (push) Successful in 33s

This commit is contained in:
2026-07-07 10:45:17 +08:00
parent 2fc360e048
commit db860a22f1
5 changed files with 343 additions and 54 deletions
+26 -17
View File
@@ -10,25 +10,34 @@
<div class="app">
<header class="toolbar app-bar">
<input id="ofdInput" class="file-input" type="file" accept=".ofd,application/ofd,application/zip">
<button id="ofdButton" class="button primary" type="button" data-short="打开">打开</button>
<input id="fontInput" class="file-input" type="file" accept=".ttf,.otf,.ttc,font/*" multiple>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
<div class="tool-group">
<button id="ofdButton" class="button primary" type="button" data-short="打开">打开</button>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
</div>
<div class="toolbar-space"></div>
<button id="prevButton" class="icon-button" type="button" title="上一页" disabled>&lsaquo;</button>
<label class="page-control">
<input id="pageInput" type="number" min="1" value="1" disabled>
<span>/</span>
<span id="pageTotal">0</span>
</label>
<button id="nextButton" class="icon-button" type="button" title="下一页" disabled>&rsaquo;</button>
<div class="divider"></div>
<button id="zoomOutButton" class="icon-button" type="button" title="缩小" disabled>-</button>
<span id="zoomLabel" class="zoom-label">100%</span>
<button id="zoomInButton" class="icon-button" type="button" title="放大" disabled>+</button>
<button id="fitButton" class="button" type="button" data-short="宽" disabled>适宽</button>
<button id="fitHeightButton" class="button" type="button" data-short="" disabled>适高</button>
<button id="exportButton" class="button" type="button" data-short="PDF" title="导出 PDF" aria-label="导出 PDF" disabled>PDF</button>
<div class="tool-group">
<button id="prevButton" class="icon-button" type="button" title="上一页" disabled>&lsaquo;</button>
<label class="page-control">
<input id="pageInput" type="number" min="1" value="1" disabled>
<span>/</span>
<span id="pageTotal">0</span>
</label>
<button id="nextButton" class="icon-button" type="button" title="下一页" disabled>&rsaquo;</button>
</div>
<div class="tool-group">
<button id="zoomOutButton" class="icon-button" type="button" title="缩小" disabled>-</button>
<span id="zoomLabel" class="zoom-label">100%</span>
<button id="zoomInButton" class="icon-button" type="button" title="放大" disabled>+</button>
<button id="fitButton" class="button" type="button" data-short="" disabled>适宽</button>
<button id="fitHeightButton" class="button" type="button" data-short="高" disabled>适高</button>
</div>
<div class="tool-group">
<select id="pageExportFormat" class="format-select" title="单页格式" aria-label="单页格式" disabled></select>
<button id="exportPageButton" class="button" type="button" data-short="页" title="导出单页" aria-label="导出单页" disabled>单页</button>
<button id="exportButton" class="button" type="button" data-short="文档" title="导出文档 PDF" aria-label="导出文档 PDF" disabled>文档</button>
</div>
</header>
<main class="workspace">
+51 -5
View File
@@ -32,7 +32,8 @@ body {
}
button,
input {
input,
select {
font: inherit;
}
@@ -45,13 +46,15 @@ button {
}
button:disabled,
input:disabled {
input:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.48;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
@@ -149,10 +152,29 @@ button:not(:disabled):hover {
flex: 1 1 auto;
}
.divider {
.tool-group {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
min-width: 0;
}
.tool-group + .tool-group {
margin-left: 2px;
padding-left: 18px;
}
.tool-group + .tool-group::before {
position: absolute;
top: 50%;
left: 5px;
width: 1px;
height: 22px;
background: var(--line);
content: "";
transform: translateY(-50%);
}
.page-control {
@@ -176,6 +198,16 @@ button:not(:disabled):hover {
background: transparent;
}
.format-select {
height: var(--control-height);
min-width: 64px;
padding: 0 7px;
border: 1px solid var(--line);
border-radius: 0;
background: #ffffff;
color: #1f2428;
}
.zoom-label {
min-width: 42px;
text-align: center;
@@ -842,11 +874,20 @@ body[aria-busy="true"] .viewer-panel {
row-gap: 0;
}
.divider {
.toolbar-space {
display: none;
}
.toolbar-space {
.tool-group {
gap: 6px;
}
.tool-group + .tool-group {
margin-left: 0;
padding-left: 0;
}
.tool-group + .tool-group::before {
display: none;
}
@@ -894,6 +935,11 @@ body[aria-busy="true"] .viewer-panel {
padding-inline: 8px;
}
.format-select {
min-width: 56px;
padding: 0 5px;
}
.footer-links {
justify-content: flex-end;
}
+111 -21
View File
@@ -26,11 +26,14 @@ const STATUS = {
engine: "正在准备引擎",
recovering: "正在恢复引擎",
fonts: "正在匹配字体",
exporting: "正在导出 PDF",
exporting: "正在导出文档 PDF",
pageExporting: "正在导出单页",
};
const WASM_CALLBACKS = [
"ofdgoOpen",
"ofdgoRenderPage",
"ofdgoExportFormats",
"ofdgoExportPage",
"ofdgoExportPDF",
"ofdgoFontCandidates",
];
@@ -63,6 +66,7 @@ const state = {
thumbnailCache: new Map(),
thumbnailInFlight: new Set(),
thumbnailObserver: null,
exportFormats: [],
showPages: !COMPACT_LAYOUT.matches,
showMeta: !COMPACT_LAYOUT.matches,
};
@@ -84,6 +88,8 @@ const el = {
zoomLabel: document.querySelector("#zoomLabel"),
fitButton: document.querySelector("#fitButton"),
fitHeightButton: document.querySelector("#fitHeightButton"),
pageExportFormat: document.querySelector("#pageExportFormat"),
exportPageButton: document.querySelector("#exportPageButton"),
exportButton: document.querySelector("#exportButton"),
emptyState: document.querySelector("#emptyState"),
progressPanel: document.querySelector("#progressPanel"),
@@ -119,6 +125,7 @@ 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.exportPageButton.addEventListener("click", exportCurrentPage);
el.exportButton.addEventListener("click", exportPDF);
el.pageInput.addEventListener("change", () => {
const page = Number.parseInt(el.pageInput.value, 10);
@@ -180,6 +187,7 @@ async function boot() {
try {
await ensureWASM();
setProgress("引擎准备完成", 70);
loadExportFormats();
setEmpty("选择 OFD 文件");
updateFontSummary();
renderFontList();
@@ -195,6 +203,21 @@ async function boot() {
}
}
function loadExportFormats() {
const formats = callWASM("ofdgoExportFormats") || [];
state.exportFormats = formats;
el.pageExportFormat.replaceChildren();
for (const format of formats) {
const option = document.createElement("option");
option.value = format.value;
option.textContent = format.label;
el.pageExportFormat.append(option);
}
if (formats.length) {
el.pageExportFormat.value = formats[0].value;
}
}
async function ensureWASM() {
if (state.ready && !state.wasmExited) {
return;
@@ -858,14 +881,14 @@ async function exportPDF() {
return;
}
const openSeq = state.openSeq;
el.exportButton.disabled = true;
setBusy(true, "正在准备 PDF", 18, STATUS.exporting);
setExportControlsDisabled(true);
setBusy(true, "正在准备文档 PDF", 18, STATUS.exporting);
try {
await waitForPaint();
if (openSeq !== state.openSeq) {
return;
}
setProgress("正在生成 PDF", 45);
setProgress("正在生成文档 PDF", 45);
await waitForPaint();
if (openSeq !== state.openSeq) {
return;
@@ -874,18 +897,53 @@ async function exportPDF() {
if (openSeq !== state.openSeq) {
return;
}
setProgress("正在保存 PDF", 86);
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)}`);
downloadBytes(bytes, "application/pdf", pdfFileName());
setStatus(`文档 PDF 已导出 ${formatBytes(result.size || bytes.length)}`);
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, false);
}
} finally {
if (openSeq === state.openSeq) {
setBusy(false);
updateControls();
}
}
}
async function exportCurrentPage() {
if (!state.doc) {
return;
}
const format = el.pageExportFormat.value;
if (!format) {
return;
}
const info = exportFormatInfo(format);
const label = info?.label || String(format || "").toUpperCase();
const openSeq = state.openSeq;
setExportControlsDisabled(true);
setBusy(true, `正在准备 ${label}`, 18, STATUS.pageExporting);
try {
await waitForPaint();
if (openSeq !== state.openSeq) {
return;
}
setProgress(`正在生成 ${label}`, 45);
await waitForPaint();
if (openSeq !== state.openSeq) {
return;
}
const result = callWASM("ofdgoExportPage", state.pageIndex, format);
if (openSeq !== state.openSeq) {
return;
}
setProgress(`正在保存 ${result.label || label}`, 86);
const bytes = base64ToBytes(result.base64);
downloadBytes(bytes, result.mime || info?.mime || "application/octet-stream", pageFileName(result.extension || info?.extension || format));
setStatus(`${result.label || label} 已导出 ${formatBytes(result.size || bytes.length, result.label || label)}`);
} catch (err) {
if (openSeq === state.openSeq) {
showError(err, false);
@@ -1536,9 +1594,17 @@ function updateControls() {
el.fitHeightButton.disabled = !hasDoc;
el.fitButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "width");
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
el.pageExportFormat.disabled = !hasDoc || !state.exportFormats.length;
el.exportPageButton.disabled = !hasDoc || !state.exportFormats.length;
el.exportButton.disabled = !hasDoc;
}
function setExportControlsDisabled(disabled) {
el.exportButton.disabled = disabled;
el.exportPageButton.disabled = disabled;
el.pageExportFormat.disabled = disabled;
}
function callWASM(name, ...args) {
const fn = globalThis[name];
if (typeof fn !== "function") {
@@ -1624,15 +1690,39 @@ function base64ToBytes(base64) {
return bytes;
}
function pdfFileName() {
const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, "");
const safe = base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo";
return `${safe}.pdf`;
function downloadBytes(bytes, mime, name) {
const blob = new Blob([bytes], { type: mime });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.href = url;
link.download = name;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function formatBytes(size) {
function baseFileName() {
const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, "");
return base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo";
}
function pdfFileName() {
return `${baseFileName()}.pdf`;
}
function pageFileName(extension) {
const page = String(state.pageIndex + 1).padStart(String(state.doc?.pageCount || 1).length, "0");
return `${baseFileName()}_p${page}.${extension || "bin"}`;
}
function exportFormatInfo(value) {
return state.exportFormats.find((format) => format.value === value) || null;
}
function formatBytes(size, fallback = "PDF") {
if (!Number.isFinite(size) || size <= 0) {
return "PDF";
return fallback;
}
if (size < 1024 * 1024) {
return `${Math.round(size / 1024)} KB`;