mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:25:49 +08:00
1 parent
d9046af3b0
commit
ecf221c1c0
12 files changed
+1222
-30
No files matched your search
@@ -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">⇄</span></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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user