mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-08-30 12:12:40 +08:00
This commit is contained in:
+26
-17
@@ -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">☰</button>
|
||||
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">ⓘ</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">☰</button>
|
||||
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">ⓘ</button>
|
||||
</div>
|
||||
<div class="toolbar-space"></div>
|
||||
<button id="prevButton" class="icon-button" type="button" title="上一页" disabled>‹</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>›</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>‹</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>›</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
@@ -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
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user