feat(转换能力): 新增批量转换功能
build.yaml / build (push) Successful in 41s

This commit is contained in:
xiaoqidun committed 2026-09-26 13:39:15 +08:00
1 parent d9046af3b0
commit ecf221c1c0
12 files changed
+1222 -30

No files matched your search

+18
View File
@@ -20,6 +20,7 @@
<button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建"><span class="new-icon" aria-hidden="true"></span></button>
<button id="saveButton" class="icon-button" type="button" title="保存" aria-label="保存" aria-keyshortcuts="Control+S Meta+S" disabled><span class="save-icon" aria-hidden="true"></span></button>
<button id="editButton" class="icon-button" type="button" title="编辑" aria-label="编辑" aria-pressed="false" disabled><span class="edit-icon" aria-hidden="true"></span></button>
<button id="convertButton" class="icon-button" type="button" title="转换" aria-label="转换" aria-haspopup="dialog" aria-controls="convertPanel"><span aria-hidden="true">&#8644;</span></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>
@@ -171,6 +172,23 @@
<button id="batchPagesButton" class="editor-group-action" type="button" title="批量页面" aria-label="批量页面" aria-haspopup="dialog" aria-controls="batchPagesPanel">页面</button>
</div>
</div>
<dialog id="convertPanel" class="form-dialog convert-dialog" aria-labelledby="convertTitle">
<form id="convertForm">
<h2 id="convertTitle" class="form-title">批量转换<span id="convertCount"></span></h2>
<input id="convertInput" class="file-input" type="file" accept=".pdf,.ofd" multiple>
<div class="convert-table"><table><thead><tr><th scope="col">文件</th><th scope="col">页码</th><th scope="col">格式</th><th scope="col">状态</th><th scope="col">操作</th></tr></thead><tbody id="convertList"></tbody></table><div id="convertEmpty" class="convert-empty">暂无文件</div></div>
<div class="convert-progress"><progress id="convertProgress" max="1" value="0" aria-label="转换进度"></progress><div id="convertStatus" role="status" aria-live="polite"></div></div>
<div class="form-actions">
<button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button>
<div class="convert-options">
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
</div>
<button id="convertClose" class="button" type="button">关闭</button><button id="convertCancel" class="button" type="button" hidden>取消</button><button id="convertStart" class="button" type="submit" disabled>转换</button>
</div>
</form>
</dialog>
<dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面">
<form id="batchPagesForm">
<div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</option><option value="delete">删除</option><option value="save">另存</option></select></div>
+231
View File
@@ -690,6 +690,237 @@ button:not(:disabled):hover {
line-height: 1.5;
}
.convert-dialog {
--convert-format-width: 64px;
width: 720px;
}
.convert-dialog .form-title {
display: flex;
align-items: center;
gap: 12px;
}
.convert-options {
flex: none;
display: grid;
grid-template-columns: calc(2em + 8px + var(--convert-format-width)) 132px calc(2em + 8px + var(--convert-format-width));
gap: 12px;
margin: 0 auto;
}
.convert-options:has(#convertDPIRow[hidden]) {
grid-template-columns: repeat(2, calc(2em + 8px + var(--convert-format-width)));
}
.convert-options .form-row {
gap: 8px;
margin-bottom: 0;
}
#convertCount {
margin-left: auto;
color: var(--text-muted);
font-size: 12px;
font-weight: 400;
}
.convert-table {
max-height: 40dvh;
min-height: 120px;
overflow: auto;
border: 1px solid var(--line);
background: #fff;
}
.convert-table table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.convert-table th,
.convert-table td {
padding: 8px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
.convert-table th {
position: sticky;
top: 0;
z-index: 1;
background: #eef1f4;
font-weight: 500;
}
.convert-table th:nth-child(2) {
width: 112px;
}
.convert-table th:nth-child(3),
.convert-table th:nth-child(4) {
width: calc(var(--convert-format-width) + 16px);
}
.convert-table select,
.convert-table input {
width: 100%;
min-width: 0;
height: var(--control-height);
padding: 0 6px;
border: 1px solid var(--line);
border-radius: 0;
background: #fff;
color: inherit;
}
.convert-table input {
font-variant-numeric: tabular-nums;
}
.convert-table th:last-child,
.convert-table td:last-child {
width: 48px;
text-align: center;
}
.convert-table tbody tr:last-child td {
border-bottom: 0;
}
.convert-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.convert-table tr:has(+ .convert-details) > td {
border-bottom: 0;
}
.convert-table .convert-details > td {
width: auto;
padding-top: 0;
text-align: left;
}
.convert-table .convert-details details {
margin: 0;
color: var(--text-muted);
overflow-wrap: anywhere;
font-size: 12px;
}
.convert-table .convert-details details[open] > summary {
margin-bottom: 8px;
}
.convert-messages {
max-height: min(180px, 24dvh);
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
line-height: 1.5;
white-space: pre-wrap;
}
.convert-messages > li + li {
margin-top: 6px;
}
.convert-table td[data-status="failed"] {
color: #b42318;
}
.convert-table td[data-status="done"] {
color: var(--accent-strong);
}
.convert-empty {
padding: 28px 12px;
text-align: center;
color: var(--text-muted);
}
.convert-progress {
margin-top: 12px;
}
.convert-dialog .form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 12px;
}
.convert-dialog .form-actions .button {
min-width: 3.5em;
}
.convert-progress progress {
display: block;
width: 100%;
height: 6px;
accent-color: var(--accent);
}
#convertStatus {
min-height: 1.5em;
margin-top: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
color: var(--text-muted);
}
#convertButton[data-running] {
color: var(--accent-strong);
background: var(--accent-soft);
}
@media (max-width: 700px) {
.convert-dialog .form-actions { flex-wrap: wrap; }
.convert-options { order: -1; flex-basis: 100%; justify-content: center; margin: 0 0 4px; }
#convertClose { margin-left: auto; }
}
@media (max-width: 620px) {
.convert-options {
grid-template-columns: calc(2em + 8px + var(--convert-format-width)) 132px;
}
.convert-table table,
.convert-table tbody { display: block; }
.convert-table thead { display: none; }
.convert-table tr {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--line);
}
.convert-table tr:last-child { border-bottom: 0; }
.convert-table tr:has(+ .convert-details) { padding-bottom: 0; border-bottom: 0; }
.convert-table td { min-width: 0; padding: 0; border: 0; }
.convert-table td:first-child { flex: 0 0 calc(100% - var(--convert-format-width) - var(--control-height) - 16px); }
.convert-table td:nth-child(4) { order: 1; flex: 0 0 var(--convert-format-width); text-align: right; }
.convert-table td:last-child { order: 2; flex: 0 0 var(--control-height); }
.convert-table td[data-label] {
order: 3;
display: grid;
grid-template-columns: 2em minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.convert-table td:nth-child(2) { flex: 1 1 0; }
.convert-table td:nth-child(3) { flex: 0 0 calc(2em + 8px + var(--convert-format-width)); }
.convert-table td[data-label]::before { content: attr(data-label); color: var(--text-muted); }
.convert-table .convert-details > td { flex: 0 0 100%; }
}
#sourceTextPanel {
width: 440px;
}
+319 -1
View File
@@ -19,6 +19,8 @@ const STATUS = {
};
const wasmRequests = new Map();
const metaContents = new WeakMap();
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 };
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
let wasmPromise = null;
let wasmWorker = null;
@@ -1086,6 +1088,18 @@ el.pageForm.addEventListener("submit", async (event) => {
}
});
el.ofdButton.addEventListener("click", openOFDFile);
batchElements.Button.addEventListener("click", () => { updateBatchControls(); batchElements.Panel.showModal(); });
batchElements.Add.addEventListener("click", () => batchElements.Input.click());
batchElements.Input.addEventListener("change", () => { addBatchFiles(batchElements.Input.files); batchElements.Input.value = ""; });
batchElements.Clear.addEventListener("click", () => { batch.items = []; renderBatchList(); batchElements.Status.textContent = ""; batchElements.Progress.value = 0; });
batchElements.Format.addEventListener("change", updateBatchControls);
batchElements.Close.addEventListener("click", () => batchElements.Panel.close());
batchElements.Cancel.addEventListener("click", cancelBatch);
batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); });
batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; });
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker;
batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive";
el.newButton.addEventListener("click", openCreatePanel);
el.editButton.addEventListener("click", toggleEditor);
el.editButton.addEventListener("pointerdown", (event) => {
@@ -1443,7 +1457,7 @@ function handleKeyDown(event) {
}
function formDialogOpen() {
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
return batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
}
@@ -3173,6 +3187,9 @@ async function refreshApplication() {
}
async function loadExportFormats() {
batch.formats = await callWASM("ofdgoOutputFormats");
batchElements.Format.replaceChildren(...batch.formats.map(format => new Option(format.label, format.value)));
updateBatchControls();
const formats = await callWASM("ofdgoExportFormats") || [];
state.exportFormats = formats;
el.exportFormat.replaceChildren();
@@ -4382,6 +4399,307 @@ async function updateExportRange() {
}
}
function addBatchFiles(files) {
let rejected = false;
for (const file of files) {
if (!/\.(ofd|pdf)$/i.test(file.name)) { rejected = true; continue; }
batch.items.push({ file, format: "", pages: "", status: "pending", text: "待转", details: [] });
}
if (rejected) batchElements.Status.textContent = "仅支持PDF、OFD";
renderBatchList();
}
function renderBatchList() {
const fragment = document.createDocumentFragment();
for (const item of batch.items) {
const row = document.createElement("tr");
const name = row.insertCell();
const label = document.createElement("span");
label.className = "convert-name";
label.textContent = item.file.name;
label.title = item.file.name;
name.append(label);
let detailRow;
if (item.details.length) {
detailRow = document.createElement("tr");
detailRow.className = "convert-details";
const cell = detailRow.insertCell();
cell.colSpan = 5;
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "提示";
const messages = document.createElement("ul");
messages.className = "convert-messages";
messages.tabIndex = 0;
messages.setAttribute("aria-label", `${item.file.name}的转换提示`);
for (const message of item.details) {
const entry = document.createElement("li");
entry.textContent = message;
messages.append(entry);
}
details.append(summary, messages);
cell.append(details);
}
const reset = () => {
item.details = [];
detailRow?.remove();
batchStatus(item, "pending", "待转");
batchElements.Status.textContent = "";
batchElements.Progress.value = 0;
updateBatchControls();
};
const pagesCell = row.insertCell();
pagesCell.dataset.label = "页码";
const pages = document.createElement("input");
pages.type = "text";
pages.placeholder = "全部";
pages.title = "如1-3,5;留空为全部";
pages.setAttribute("aria-label", `${item.file.name}的输出页码`);
pages.value = item.pages;
pages.disabled = batch.running;
pages.addEventListener("change", () => { item.pages = pages.value; reset(); });
pagesCell.append(pages);
const formatCell = row.insertCell();
formatCell.dataset.label = "格式";
const format = document.createElement("select");
format.setAttribute("aria-label", `${item.file.name}的输出格式`);
format.append(new Option("默认", ""), ...batch.formats.map(value => new Option(value.label, value.value)));
format.value = item.format;
format.disabled = batch.running;
format.addEventListener("change", () => { item.format = format.value; reset(); });
formatCell.append(format);
item.cell = row.insertCell();
item.cell.textContent = item.text;
item.cell.dataset.status = item.status;
const action = row.insertCell();
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.textContent = "×";
remove.title = "移除";
remove.setAttribute("aria-label", `移除${item.file.name}`);
remove.disabled = batch.running;
remove.addEventListener("click", () => { batch.items.splice(batch.items.indexOf(item), 1); renderBatchList(); });
action.append(remove);
fragment.append(row);
if (detailRow) fragment.append(detailRow);
}
batchElements.List.replaceChildren(fragment);
batchElements.Empty.hidden = batch.items.length > 0;
updateBatchControls();
}
function updateBatchControls() {
for (const name of ["Add", "Clear", "Format", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
batchElements.Clear.disabled ||= !batch.items.length;
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
batchElements.Start.hidden = batch.running;
batchElements.Cancel.hidden = !batch.running;
batchElements.Cancel.disabled = batch.canceled;
batchElements.Close.textContent = batch.running ? "收起" : "关闭";
batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : "";
const formats = batch.items.length ? batch.items.map(item => item.format || batchElements.Format.value) : [batchElements.Format.value];
batchElements.DPIRow.hidden = !formats.some(value => batch.formats.find(format => format.value === value)?.raster);
batchElements.Button.toggleAttribute("data-running", batch.running);
}
function batchStatus(item, status, text) {
item.status = status;
item.text = text;
item.cell.textContent = text;
item.cell.dataset.status = status;
}
function batchProgress(item, position, count, progress) {
const now = performance.now();
if (now - batch.progressTime < 80 && progress.completed !== progress.total) return;
batch.progressTime = now;
const stage = progress.phase?.startsWith("write.") ? "写入" : { open: "读取", pages: "解析", convert: "转换", prepare: "准备", fonts: "字体", resources: "资源", references: "检查", write: "写入", export: "导出", pack: "打包", commit: "保存" }[progress.phase] || "处理";
const detail = progress.total > 0 ? `${progress.completed}/${progress.total}` : "";
if (item) batchStatus(item, "running", stage);
batchElements.Status.textContent = progress.phase === "pack" ? `打包 ${detail}` : `文件 ${position + 1}/${count} · ${stage}${detail ? ` ${detail}` : ""}`;
batchElements.Progress.max = count;
batchElements.Progress.value = progress.phase === "pack" && progress.total > 0 ? progress.completed / progress.total : position;
}
function batchCall(name, args, onProgress) {
return new Promise((resolve, reject) => {
if (batch.workerError) { reject(batch.workerError); return; }
const id = ++batch.sequence;
batch.activeID = id;
batch.requests.set(id, { resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null });
try { batch.worker.postMessage({ id, name, args }); }
catch (err) { batch.requests.delete(id); batch.activeID = 0; reject(err); }
});
}
function startBatchWorker() {
batch.workerError = null;
return new Promise((resolve, reject) => {
const worker = new Worker("./ofdgo_wasm.js");
batch.worker = worker;
const fail = async error => {
batch.workerError = error;
worker.terminate();
reject(error);
const requests = [...batch.requests.values()];
batch.requests.clear();
for (const request of requests) {
if (request.destination && request.outputName) {
try { await request.destination.removeEntry(request.outputName, { recursive: true }); }
catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); }
}
request.reject(error);
}
};
worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
worker.onmessage = ({ data }) => {
if (data.type === "ready") { resolve(); return; }
if (data.type === "exit") { fail(new Error(data.error)); return; }
if (data.type === "progress") { batchElements.Status.textContent = data.text; return; }
const request = batch.requests.get(data.id);
if (!request) return;
if (data.type === "output") { request.outputName = data.name; return; }
if (data.type === "conversion") { request.onProgress?.(data); return; }
batch.requests.delete(data.id);
if (batch.activeID === data.id) batch.activeID = 0;
if (data.ok) request.resolve(data.data);
else request.reject(Object.assign(new Error(data.error), { code: data.code, name: data.canceled ? "AbortError" : "Error" }));
};
});
}
function cancelBatch() {
batch.canceled = true;
batchElements.Cancel.disabled = true;
batchElements.Status.textContent = "正在取消";
if (batch.activeID) batch.worker.postMessage({ type: "cancel", id: batch.activeID });
}
function warnBatch(event) {
if (batch.running) { event.preventDefault(); event.returnValue = ""; }
}
async function runBatch() {
if (batch.running || !batch.items.length) return;
const defaultFormat = batch.formats.find(format => format.value === batchElements.Format.value);
if (!defaultFormat) return;
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
const dpi = Number(batchElements.DPI.value);
const archive = batchElements.Destination.value === "archive";
const backend = state.renderBackend;
for (const item of batch.items) {
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const key = JSON.stringify([format.value, pageRange(item), format.raster ? dpi : 0, archive, backend]);
if (item.settingsKey !== key) {
item.status = "pending";
item.text = "待转";
item.details = [];
item.settingsKey = key;
}
}
renderBatchList();
let jobs = batch.items.filter(item => item.status !== "done" && item.status !== "unchanged");
if (!jobs.length) {
jobs = [...batch.items];
for (const item of jobs) { item.status = "pending"; item.text = "待转"; item.details = []; }
}
batch.running = true;
batch.canceled = false;
renderBatchList();
window.addEventListener("beforeunload", warnBatch);
let destination = null;
let zipFile = null;
let storage = null;
let stagingName = "";
let packed = false;
const entries = [];
try {
if (archive) {
if (window.showSaveFilePicker) zipFile = await window.showSaveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
if (navigator.storage?.getDirectory) {
storage = await navigator.storage.getDirectory();
stagingName = `ofdgo-convert-${crypto.randomUUID()}`;
destination = await storage.getDirectoryHandle(stagingName, { create: true });
}
} else destination = await window.showDirectoryPicker({ mode: "readwrite" });
if (batch.canceled) return;
batchElements.Status.textContent = "正在准备引擎";
await startBatchWorker();
const fonts = await fontManager.files(fontManager.records());
const names = new Set();
for (const [position, item] of jobs.entries()) {
if (batch.canceled) break;
item.details = [];
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const base = item.file.name.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
let unique = base;
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
names.add(outputName());
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null };
try {
for (;;) {
try {
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
for (const file of result.files) entries.push(file);
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
if (result.unchanged) {
batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break;
} catch (err) {
if (batch.canceled || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
options.credentials = await requestCredentials(err.code, state.openSeq);
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
if (!window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
}
}
} catch (err) {
batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败");
item.details = [err.message];
if (batch.workerError) throw err;
} finally {
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
}
batchElements.Progress.max = jobs.length;
batchElements.Progress.value = position + 1;
}
if (archive && entries.length && !batch.canceled) {
const result = await batchCall("ofdgoPackFiles", [entries, zipFile], progress => batchProgress(null, 0, 1, progress));
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
packed = true;
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
}
const done = jobs.filter(item => item.status === "done").length;
const failed = jobs.filter(item => item.status === "failed").length;
const unchanged = batch.items.filter(item => item.status === "unchanged").length;
batchElements.Status.textContent = `${batch.canceled ? "已取消 · " : ""}完成 ${done}${failed ? ` · 失败 ${failed}` : ""}${unchanged ? ` · 原样 ${unchanged}` : ""}`;
} catch (err) {
batchElements.Status.textContent = err.name === "AbortError" ? "转换已取消" : err.message;
} finally {
if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; }
batch.worker?.terminate();
batch.worker = null;
batch.requests.clear();
batch.activeID = 0;
if (storage && stagingName) {
try { await storage.removeEntry(stagingName, { recursive: true }); }
catch { batchElements.Status.textContent += " · 临时文件清理失败"; }
}
batch.running = false;
window.removeEventListener("beforeunload", warnBatch);
renderBatchList();
}
}
async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) {
if (!state.doc || document.body.hasAttribute("aria-busy")) {
return;
+129 -2
View File
@@ -7,11 +7,138 @@ self.onmessage = ({ data }) => {
operations.get(data.id)?.abort();
return;
}
if (data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
operations.set(data.id, new AbortController());
}
pending = pending.then(() => handleMessage(data));
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data));
};
async function availableOutput(directory, base, format) {
for (let suffix = 0; ; suffix++) {
const stem = suffix ? `${base} (${suffix + 1})` : base;
const name = format.paged ? stem : `${stem}.${format.extension}`;
try {
await directory.getFileHandle(name);
} catch (err) {
if (err.name === "NotFoundError") return { stem, name };
if (err.name !== "TypeMismatchError") throw err;
}
}
}
async function handleBatchMessage({ id, name, args }) {
const signal = operations.get(id).signal;
const channel = new MessageChannel();
const finish = (done, err) => {
channel.port1.onmessage = () => done(err?.message || "", signal.aborted);
channel.port2.postMessage(null);
};
const reader = new FileReaderSync();
const read = file => (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)));
let output = null;
let chunks = [];
let files = [];
let directory = null;
let created = null;
let root = null;
let current = null;
let size = 0;
let credentials = null;
const write = (bytes, done) => {
if (signal.aborted) return finish(done);
size += bytes.byteLength;
if (output) output.write(bytes).then(() => finish(done), err => finish(done, err));
else { chunks.push(new Blob([bytes])); finish(done); }
};
try {
signal.throwIfAborted();
let payload;
if (name === "ofdgoConvertFile") {
const [file, options, fonts, destination] = args;
credentials = options.credentials;
const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data;
const format = formats.find(item => item.value === options.format);
if (!format) throw new Error("不支持此格式");
root = destination;
const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
const target = root ? await availableOutput(root, base, format) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
if (root && format.paged) {
directory = await root.getDirectoryHandle(target.name, { create: true });
created = target.name;
self.postMessage({ id, type: "output", name: created });
} else directory = root;
const boundary = async (action, pageName) => {
signal.throwIfAborted();
if (action === "open") {
const fileName = format.paged ? pageName : target.name;
current = { name: format.paged ? `${target.stem}/${pageName}` : fileName, mime: format.mime };
chunks = [];
if (directory) {
const handle = await directory.getFileHandle(fileName, { create: true });
if (!format.paged) {
created = fileName;
self.postMessage({ id, type: "output", name: created });
}
current.handle = handle;
output = await handle.createWritable();
}
} else {
if (output) { await output.close(); output = null; }
if (options.archive) current.file = current.handle ? await current.handle.getFile() : new File(chunks, pageName, { type: format.mime });
delete current.handle;
files.push(current);
chunks = [];
}
};
payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write,
(phase, completed, total, done) => {
self.postMessage({ id, type: "conversion", phase, completed, total });
finish(done);
},
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts);
} else {
const [entries, handle] = args;
if (handle) output = await handle.createWritable();
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
index => read(entries[index].file), write,
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
done => finish(done));
}
signal.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
if (!result.ok) throw Object.assign(new Error(result.error), { code: result.code });
if (name === "ofdgoPackFiles") {
if (output) { await output.close(); output = null; }
else result.data.blob = new Blob(chunks, { type: "application/zip" });
} else {
if (result.data.unchanged) {
if (output) { await output.abort(); output = null; }
if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; }
files = [];
}
result.data.files = files;
}
result.data.size = size;
operations.delete(id);
self.postMessage({ id, ...result });
} catch (err) {
await output?.abort().catch(() => {});
let message = signal.aborted ? "转换已取消" : err.message;
if (root && created) {
try { await root.removeEntry(created, { recursive: true }); }
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
}
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted });
} finally {
credentials?.password?.fill(0);
credentials?.key?.fill(0);
credentials?.keyPassword?.fill(0);
channel.port1.close();
channel.port2.close();
operations.delete(id);
}
}
async function handleMessage({ id, name, args }) {
let output;
let channel;