feat(转换能力): 支持原生与网页批量并行
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-10-18 01:48:36 +08:00
1 parent 0eae7c72d0
commit a0899fa150
15 files changed
+549 -119

No files matched your search

+1
View File
@@ -199,6 +199,7 @@
<div id="convertFontRow" class="form-row" hidden><label for="convertFontMode">字体</label><select id="convertFontMode"><option value="0">保持</option><option value="1">内嵌</option><option value="2">外部</option></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 class="form-row"><label for="convertParallel">并行</label><input id="convertParallel" type="number" min="1" step="1" placeholder="自动"></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>
+6 -5
View File
@@ -803,7 +803,7 @@ button:not(:disabled):hover {
.convert-options {
flex: none;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-column: 1 / -1;
grid-row: 1;
width: 100%;
@@ -812,11 +812,11 @@ button:not(:disabled):hover {
}
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.convert-options .form-row {
@@ -1059,12 +1059,13 @@ button:not(:disabled):hover {
margin: 0;
}
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) .form-row:last-child {
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow:not([hidden])) .form-row:last-child,
.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow[hidden]) .form-row:last-child {
grid-column: 1 / -1;
}
.convert-options .form-row {
+118 -66
View File
@@ -20,8 +20,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, progress: null };
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
const batch = { items: [], formats: [], running: false, canceled: false, workers: new Set(), requests: new Map(), sequence: 0, progress: null, jobs: [], credentials: Promise.resolve() };
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "Parallel", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
const fontBinding = { font: null, choices: [], sequence: 0 };
const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)]));
const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Kind", "List", "Empty", "Reference", "ReferenceGroup", "Actions", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "BindingActions", "Page", "Object", "Pick", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)]));
@@ -8128,7 +8128,7 @@ function renderBatchList() {
}
function updateBatchControls() {
for (const name of ["Add", "Clear", "Format", "Compression", "FontMode", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
for (const name of ["Add", "Clear", "Format", "Compression", "FontMode", "Destination", "DPI", "Parallel"]) batchElements[name].disabled = batch.running;
batchElements.FontRow.hidden = !(batch.items.length ? batch.items : [null]).some(item => (item?.format || batchElements.Format.value) === "ofd");
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
batchElements.Clear.disabled ||= !batch.items.length;
@@ -8159,16 +8159,31 @@ function batchStatus(item, status, text) {
function batchProgress(item, position, count, progress) {
if (batch.canceled) return;
const now = performance.now();
const previous = batch.progress;
const previous = item ? item.progress : batch.progress;
const sameStage = previous?.item === item && previous?.progress.phase === progress.phase;
if (sameStage && now - batch.progressTime < 80 && progress.completed !== progress.total) return;
batch.progressTime = now;
batch.progress = { item, position, count, progress, started: sameStage ? previous.started : now };
if (sameStage && now - previous.updated < 80 && progress.completed !== progress.total) return;
const current = { item, position, count, progress, started: sameStage ? previous.started : now, updated: now };
if (item) item.progress = current;
else batch.progress = current;
updateBatchProgress();
}
function updateBatchProgress() {
if (!batch.progress) return;
if (batch.canceled) return;
for (const item of batch.jobs) {
if (item.status !== "running" || !item.progress) continue;
const progress = item.progress.progress;
const stage = progress.phase?.includes("compress") ? "压缩" : progress.phase?.startsWith("write") ? "写入" : { open: "读取", pages: "解析", convert: "转换", prepare: "准备", fonts: "字体", export: "导出" }[progress.phase] || "处理";
batchStatus(item, "running", `${stage}${progress.total > 0 ? ` ${progress.completed}/${progress.total}` : ""}`);
}
if (!batch.progress) {
const completed = batch.jobs.filter(item => ["done", "ready", "unchanged", "failed", "canceled"].includes(item.status)).length;
const running = batch.jobs.filter(item => item.status === "running").length;
batchElements.Status.textContent = `完成 ${completed}/${batch.jobs.length} · 运行 ${running}`;
batchElements.Progress.max = batch.jobs.length || 1;
batchElements.Progress.value = completed;
return;
}
const { item, position, count, progress, started } = batch.progress;
const compressing = progress.phase === "compress" || progress.phase === "write.compress";
const fontMode = (item?.format || batchElements.Format.value) === "ofd" ? batchElements.FontMode.value : "0";
@@ -8182,29 +8197,29 @@ function updateBatchProgress() {
else batchElements.Progress.removeAttribute("value");
}
function batchCall(name, args, onProgress) {
function batchCall(slot, name, args, onProgress) {
return new Promise((resolve, reject) => {
if (batch.workerError) { reject(batch.workerError); return; }
if (slot.error) { reject(slot.error); return; }
if (batch.canceled) { reject(new DOMException("已取消转换", "AbortError")); 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); }
batch.requests.set(id, { slot, resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null });
try { slot.worker.postMessage({ id, name, args }); }
catch (err) { batch.requests.delete(id); reject(err); }
});
}
function startBatchWorker() {
batch.workerError = null;
return new Promise((resolve, reject) => {
const worker = createEngineWorker();
batch.worker = worker;
const slot = { worker, error: null };
batch.workers.add(slot);
const fail = async error => {
batch.workerError = error;
slot.error = error;
worker.terminate();
reject(error);
const requests = [...batch.requests.values()];
batch.requests.clear();
for (const request of requests) {
for (const [id, request] of batch.requests) {
if (request.slot !== slot) continue;
batch.requests.delete(id);
if (request.destination && request.outputName) {
try { await request.destination.removeEntry(request.outputName, { recursive: true }); }
catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); }
@@ -8215,7 +8230,7 @@ function startBatchWorker() {
worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
worker.onmessage = async ({ data }) => {
if (data.type === "ready") { resolve(); return; }
if (data.type === "ready") { resolve(slot); return; }
if (data.type === "exit") { fail(new Error(data.error)); return; }
if (data.type === "progress") {
if (!batch.canceled) batchElements.Status.textContent = { download: "正在下载", compile: "正在编译", start: "正在启动" }[data.phase] || "正在准备";
@@ -8237,8 +8252,8 @@ function startBatchWorker() {
}
if (data.type === "output") { request.outputName = data.name; return; }
if (data.type === "conversion") { request.onProgress?.(data); return; }
if (data.memory) slot.memory = data.memory;
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" }));
};
@@ -8250,7 +8265,8 @@ function cancelBatch() {
batchElements.Cancel.disabled = true;
batch.progress = null;
batchElements.Status.textContent = "正在取消";
if (batch.activeID) batch.worker.postMessage({ type: "cancel", id: batch.activeID });
if (batch.unlocking) finishCredentials(null);
for (const [id, request] of batch.requests) request.slot.worker.postMessage({ type: "cancel", id });
}
function warnBatch(event) {
@@ -8286,6 +8302,8 @@ async function runBatch() {
batch.running = true;
batch.canceled = false;
batch.progress = null;
batch.jobs = jobs;
batch.credentials = Promise.resolve();
const progressTimer = window.setInterval(updateBatchProgress, 1000);
renderBatchList();
window.addEventListener("beforeunload", warnBatch);
@@ -8294,7 +8312,7 @@ async function runBatch() {
let storage = null;
let stagingName = "";
let packed = false;
const entries = [];
const results = new Array(jobs.length);
try {
if (archive) {
zipFile = await saveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
@@ -8313,59 +8331,93 @@ async function runBatch() {
batch.localFonts = fontManager.localEntries();
batch.fontData = new Map();
batchElements.Status.textContent = "正在准备";
await startBatchWorker();
const first = await startBatchWorker();
const fonts = await fontManager.files(fontManager.records());
const names = new Set();
for (const [position, item] of jobs.entries()) {
if (batch.canceled) break;
const plans = jobs.map(item => {
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})`;
for (let suffix = 2; names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
names.add(outputName());
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
fontNames: [...batch.localFonts.keys()], fontMode: format.value === "ofd" ? Number(batchElements.FontMode.value) : 0 };
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}`);
for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`);
if (result.unchanged && !result.bytes) {
batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break;
} catch (err) {
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword");
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
return { item, format, options };
});
const capacity = nativeHost ? (await nativeHost.call("batch", {})).parallelism : Math.max(1, (navigator.hardwareConcurrency || 2) - 1);
const automatic = nativeHost ? capacity : Math.min(capacity, Math.max(1, Math.floor((navigator.deviceMemory || 4) / 2)));
const parallel = Math.min(jobs.length, nativeHost ? capacity : jobs.length, Number(batchElements.Parallel.value) || automatic);
const workerMemory = (navigator.deviceMemory || 4) * 1024 ** 3 / 4 / parallel;
let next = 0;
const run = async initial => {
let slot = initial;
while (!batch.canceled && next < plans.length) {
const position = next++;
const { item, format, options } = plans[position];
batchStatus(item, "running", "准备");
updateBatchProgress();
try {
if (!slot || slot.error) slot = await startBatchWorker();
for (;;) {
try {
const result = await batchCall(slot, "ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
results[position] = result.files;
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`);
if (result.unchanged && !result.bytes) {
batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break;
} catch (err) {
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
const unlock = batch.credentials.then(async () => {
if (batch.canceled) throw new DOMException("已取消转换", "AbortError");
batch.unlocking = true;
try { return await requestCredentials(code, state.openSeq, err.code === "pdfPassword"); }
finally { batch.unlocking = false; }
});
batch.credentials = unlock.then(() => {}, () => {});
options.credentials = await unlock;
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
}
}
} catch (err) {
batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败");
item.details = [err.message];
} finally {
item.progress = null;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
if (!nativeHost && slot && (next >= plans.length || slot.memory >= workerMemory)) {
slot.worker.terminate();
batch.workers.delete(slot);
slot = null;
}
}
} catch (err) {
batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败");
item.details = [err.message];
if (batch.workerError) throw err;
} finally {
batch.progress = null;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
updateBatchProgress();
}
batchElements.Progress.max = jobs.length;
batchElements.Progress.value = position + 1;
}
};
const running = Array.from({ length: parallel }, (_, index) => (async () => {
if (index) await new Promise(resolve => window.setTimeout(resolve, index * 200));
await run(index === 0 ? first : null);
})());
const settled = await Promise.allSettled(running);
const failure = settled.find(result => result.status === "rejected");
if (failure) throw failure.reason;
const entries = results.flat().filter(Boolean);
if (archive && entries.length && !batch.canceled) {
const result = await batchCall("ofdgoPackFiles", [entries, zipFile, compression], progress => batchProgress(null, 0, 1, progress));
const slot = [...batch.workers].find(slot => !slot.error) || await startBatchWorker();
const result = await batchCall(slot, "ofdgoPackFiles", [entries, zipFile, compression], 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", "完成");
@@ -8381,10 +8433,10 @@ async function runBatch() {
batch.progress = null;
if (!batchElements.Progress.hasAttribute("value")) batchElements.Progress.value = 0;
if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; }
batch.worker?.terminate();
batch.worker = null;
for (const slot of batch.workers) slot.worker.terminate();
batch.workers.clear();
batch.requests.clear();
batch.activeID = 0;
if (nativeHost) await nativeHost.release(results);
batch.fontData?.clear();
batch.localFonts?.clear();
if (storage && stagingName) {
+36 -5
View File
@@ -1,5 +1,15 @@
let engineModule = null;
export function createEngineWorker() {
return nativeHost ? nativeHost.worker() : new Worker("./ofdgo_wasm.js");
if (nativeHost) return nativeHost.worker();
const worker = new Worker("./ofdgo_wasm.js");
worker.addEventListener("message", event => {
if (event.data.type !== "module") return;
event.stopImmediatePropagation();
engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; });
engineModule.then(module => worker.postMessage({ type: "module", module }), error => worker.postMessage({ type: "module", error: error.message }));
});
return worker;
}
export async function documentBytes(file) {
@@ -124,7 +134,11 @@ class NativeHost {
let unsubscribe;
let pending = Promise.resolve();
const requests = new Map();
const emit = data => { if (!closed) worker.onmessage?.({data}); };
const completions = new Map();
const emit = data => {
if (data.type === "result") { completions.get(data.id)?.(data.result); return; }
if (!closed) worker.onmessage?.({data});
};
const ready = host.call("engine.open", {}).then(({engine}) => {
if (closed) return engine;
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
@@ -147,7 +161,21 @@ class NativeHost {
const engine = await ready;
if (closed) return;
controller.signal.throwIfAborted();
const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args:data.args}, controller.signal);
const args = {engine, id:data.id, name:data.name, args:data.args};
let result;
if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles") {
const completed = new Promise(resolve => completions.set(data.id, resolve));
const cancel = () => host.call("engine.cancel", {engine, id:data.id}).catch(() => {});
controller.signal.addEventListener("abort", cancel, {once:true});
try {
await host.call("engine.start", args, controller.signal);
result = await completed;
await host.call("engine.finish", {engine, id:data.id});
} finally {
controller.signal.removeEventListener("abort", cancel);
completions.delete(data.id);
}
} else result = await host.call("engine.call", args, controller.signal);
if (closed) { await host.release(result); return; }
let decoded;
try {
@@ -163,9 +191,12 @@ class NativeHost {
terminate() {
if (closed) return;
closed = true;
unsubscribe?.();
for (const controller of requests.values()) controller.abort();
ready.then(engine => host.call("engine.close", {engine})).catch(() => {});
ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(() => {
unsubscribe?.();
for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"});
completions.clear();
});
},
};
ready.catch(() => {});
+19 -12
View File
@@ -4,6 +4,7 @@ let pending = Promise.resolve();
const operations = new Map();
const fontRequests = new Map();
let fontSequence = 0;
let engineMemory = null;
function requestFont(id, signal, name, done) {
if (signal.aborted) { done(null, "", true); return; }
@@ -20,6 +21,11 @@ function requestFont(id, signal, name, done) {
self.postMessage({ id, type: "font", request, name });
}
self.onmessage = ({ data }) => {
if (data.type === "module") {
if (data.error) exitWASM(new Error(data.error));
else loadWASM(data.module).catch(exitWASM);
return;
}
if (data.type === "font") {
fontRequests.get(data.request)?.(data);
fontRequests.delete(data.request);
@@ -84,12 +90,15 @@ async function handleBatchMessage({ id, name, args }) {
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 });
const target = root ? await navigator.locks.request("ofdgo-convert-output", { signal }, async () => {
signal.throwIfAborted();
const target = await availableOutput(root, base, format);
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
else { await root.getFileHandle(target.name, { create: true }); directory = root; }
created = target.name;
self.postMessage({ id, type: "output", name: created });
} else directory = root;
return target;
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
const boundary = async (action, pageName) => {
signal.throwIfAborted();
if (action === "open") {
@@ -144,7 +153,7 @@ async function handleBatchMessage({ id, name, args }) {
}
result.data.size = size;
operations.delete(id);
self.postMessage({ id, ...result });
self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
} catch (err) {
await output?.abort().catch(() => {});
let message = signal.aborted ? "转换已取消" : err.message;
@@ -152,7 +161,7 @@ async function handleBatchMessage({ id, name, args }) {
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 });
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
} finally {
credentials?.password?.fill(0);
credentials?.key?.fill(0);
@@ -301,12 +310,10 @@ function exitWASM(err) {
self.close();
}
async function loadWASM() {
async function loadWASM(module) {
const go = new Go();
self.postMessage({ type: "progress", phase: "download", text: "正在下载引擎", percent: 16 });
const response = await fetch("./ofdgo.wasm");
self.postMessage({ type: "progress", phase: "compile", text: "正在编译引擎", percent: 35 });
const { instance } = await WebAssembly.instantiateStreaming(response, go.importObject);
const instance = await WebAssembly.instantiate(module, go.importObject);
engineMemory = instance.exports.mem;
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
go.run(instance).then(exitWASM, exitWASM);
if (!go.exited) {
@@ -316,4 +323,4 @@ async function loadWASM() {
}
}
loadWASM().catch(exitWASM);
self.postMessage({ type: "module" });
+2
View File
@@ -424,6 +424,7 @@ type businessEngine struct {
copiedStyle *ofdgo.GraphicObject
ctx context.Context
resolveSource func(context.Context, any) (io.ReaderAt, int64, error)
conversionPool *ofdgo.ConversionPool
}
// Open 打开浏览器内存中的OFD文档
@@ -1391,6 +1392,7 @@ func (e *businessEngine) convertFile(args []platformValue) (any, error) {
}
options := ofdgo.ConvertOptions{
Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(),
Pool: e.conversionPool,
SkipUnchanged: true,
Backends: &backends,
RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())},
+7 -1
View File
@@ -67,6 +67,8 @@ type NativeOutputTarget struct {
// hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭
type NativeEngine struct {
businessEngine
// ConversionPool 协调不同会话的批量转换资源
ConversionPool *ofdgo.ConversionPool
// Resolve 按授权标识提供资源读取源
Resolve NativeResolver
// LoadFont 按名称读取系统字体数据
@@ -134,7 +136,11 @@ func NewNativeEngine(resolve NativeResolver) *NativeEngine { return &NativeEngin
// 批量输出到非nil目标时必须同时提供ReserveOutput和CleanupOutput,避免误清理既有文件
// 取消为协作式检查,不能抢占宿主阻塞的ReadAt、字体读取或加密库内部计算
func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit func(any)) (result map[string]any) {
e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveSource = e.source })
e.once.Do(func() {
e.gate = make(chan struct{}, 1)
e.resolveSource = e.source
e.conversionPool = e.ConversionPool
})
if ctx == nil {
ctx = context.Background()
}
+92 -13
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows && amd64
//go:build !js || !wasm
package webview
@@ -44,17 +44,19 @@ const applicationID = "me.aite.ofdgo"
// Host 连接桌面系统能力和共用文档业务
type Host struct {
mu sync.Mutex
window *guigo.Window
files *fileStore
engines map[string]*webui.NativeEngine
engineStops map[string]func() bool
cancels map[string]context.CancelFunc
fontOnce sync.Once
fonts []map[string]any
fontFiles map[string]string
fontError error
pendingPaths []string
mu sync.Mutex
window *guigo.Window
files *fileStore
engines map[string]*webui.NativeEngine
engineStops map[string]func() bool
conversionPool *ofdgo.ConversionPool
cancels map[string]context.CancelFunc
batchResults map[string]any
fontOnce sync.Once
fonts []map[string]any
fontFiles map[string]string
fontError error
pendingPaths []string
}
// resourceReader 保存资源读取位置及关闭操作
@@ -76,9 +78,14 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
return err
}
h := &Host{files: files, engines: make(map[string]*webui.NativeEngine), cancels: make(map[string]context.CancelFunc), fontFiles: make(map[string]string), pendingPaths: append([]string(nil), paths...)}
h.conversionPool, err = ofdgo.NewConversionPool(ofdgo.ConversionPoolOptions{AvailableMemory: conversionMemory})
if err != nil {
files.close()
return err
}
defer h.close()
bindings := make(map[string]guigo.HandlerFunc)
for _, name := range []string{"engine.open", "engine.close", "engine.call", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} {
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} {
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
}
static := http.FileServerFS(webuiassets.NativeFS)
@@ -260,6 +267,7 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
switch name {
case "engine.open":
engine := webui.NewNativeEngine(h.files.resolve)
engine.ConversionPool = h.conversionPool
engine.OpenWriter = h.openWriter
engine.ReserveOutput = h.reserveOutput
engine.CleanupOutput = h.cleanupOutput
@@ -273,8 +281,69 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
h.engineStops[id] = context.AfterFunc(guigo.PageContext(ctx), func() { _ = h.closeEngine(id) })
h.mu.Unlock()
return guigo.Result{Value: map[string]any{"engine": id}, Discard: func() { _ = h.closeEngine(id) }}, nil
case "batch":
return map[string]any{"parallelism": h.conversionPool.Parallelism()}, nil
case "engine.close":
return nil, h.closeEngine(text("engine"))
case "engine.finish":
key := text("engine") + "/" + fmt.Sprint(args["id"])
h.mu.Lock()
delete(h.batchResults, key)
h.mu.Unlock()
return nil, nil
case "engine.cancel":
key := text("engine") + "/" + fmt.Sprint(args["id"])
h.mu.Lock()
cancel := h.cancels[key]
h.mu.Unlock()
if cancel != nil {
cancel()
}
return nil, nil
case "engine.start":
operationName := text("name")
if operationName != "ofdgoConvertFile" && operationName != "ofdgoPackFiles" {
return nil, fmt.Errorf("unsupported batch operation")
}
id := text("engine")
key := id + "/" + fmt.Sprint(args["id"])
page := guigo.PageContext(ctx)
operation, cancel := context.WithCancel(page)
h.mu.Lock()
engine := h.engines[id]
_, duplicate := h.cancels[key]
if engine == nil || duplicate {
h.mu.Unlock()
cancel()
return nil, fmt.Errorf("invalid batch operation")
}
h.cancels[key] = cancel
h.mu.Unlock()
values, _ := args["args"].([]any)
go func() {
defer cancel()
defer func() { h.mu.Lock(); delete(h.cancels, key); h.mu.Unlock() }()
result := engine.Call(operation, operationName, values, func(event any) {
if value, ok := event.(map[string]any); ok {
value["id"] = args["id"]
h.emit("ofdgo.engine."+id, value)
}
})
encoded := h.encode(page, result)
h.mu.Lock()
if page.Err() != nil || h.engines[id] != engine {
h.mu.Unlock()
h.discard(encoded)
return
}
if h.batchResults == nil {
h.batchResults = make(map[string]any)
}
h.batchResults[key] = encoded
h.mu.Unlock()
h.emit("ofdgo.engine."+id, map[string]any{"id": args["id"], "type": "result", "result": encoded})
}()
return guigo.Result{Value: true, Discard: cancel}, nil
case "engine.call":
id := text("engine")
key := id + "/" + fmt.Sprint(args["id"])
@@ -670,6 +739,7 @@ func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) {
// 入参: id 会话标识
// 返回: error 关闭错误
func (h *Host) closeEngine(id string) error {
var results []any
h.mu.Lock()
engine := h.engines[id]
delete(h.engines, id)
@@ -682,7 +752,16 @@ func (h *Host) closeEngine(id string) error {
cancel()
}
}
for key, result := range h.batchResults {
if strings.HasPrefix(key, id+"/") {
results = append(results, result)
delete(h.batchResults, key)
}
}
h.mu.Unlock()
for _, result := range results {
h.discard(result)
}
if engine != nil {
return engine.Close()
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package webview
// conversionMemory 未提供系统内存探测时仅按并行数调度
var conversionMemory func() int64
@@ -0,0 +1,45 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package webview
import (
"syscall"
"unsafe"
)
// memoryStatusProc 查询系统物理内存和提交额度
var memoryStatusProc = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
// conversionMemory 预留系统内存后返回可用于新增转换的额度
// 返回: int64 可用字节数,查询失败时暂停扩容
func conversionMemory() int64 {
status := struct {
length uint32
load uint32
total uint64
available uint64
totalPage uint64
availablePage uint64
totalVirtual uint64
availableVirtual uint64
extended uint64
}{}
status.length = uint32(unsafe.Sizeof(status))
ok, _, _ := memoryStatusProc.Call(uintptr(unsafe.Pointer(&status)))
if ok == 0 {
return 0
}
return max(0, int64(min(status.available, status.availablePage))-int64(status.total/8))
}
+46 -14
View File
@@ -20,7 +20,6 @@ import (
"crypto/sha256"
"image"
"io"
"runtime"
"strconv"
"sync"
@@ -83,11 +82,17 @@ func (e *Editor) outputImageOptions(key string) (CompressionOptions, image.Point
// 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空
// 返回: error 读取、进度或取消错误
func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) error {
workers := min(4, runtime.GOMAXPROCS(0))
workers := conversionWorkers(e.output.ctx, 4)
if workers < 2 || len(keys) < 2 {
return nil
}
limit := outputImageWorkingSetLimit
limit, encodedLimit := outputImageWorkingSetLimit, outputImageBatchLimit
if pool, _ := e.output.ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil {
if available := pool.memoryLimit(); available > 0 {
limit = int(min(available, int64(int(^uint(0)>>1))))
encodedLimit = limit
}
}
if strconv.IntSize == 32 {
limit = 64 << 20
}
@@ -149,14 +154,14 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
if pending[cacheKey] {
continue
}
cost := outputImageWorkingSet(e.output.ctx, data)
for running != 0 && (len(data) > outputImageBatchLimit-size || cost > limit-workingSet) {
cost := imageWorkingSet(e.output.ctx, data, limit)
for running != 0 && (len(data) > encodedLimit-size || cost > limit-workingSet) {
if err := finish(); err != nil {
return err
}
}
job := outputImageJob{key: cacheKey, data: data, workingSet: cost}
if len(data) >= outputImageBatchLimit || cost >= limit {
if len(data) >= encodedLimit || cost >= limit {
if err := e.runOutputImages([]outputImageJob{job}, results); err != nil {
return err
}
@@ -167,7 +172,7 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
size += len(data)
workingSet += cost
group.Go(func() {
job.candidate, job.err = optimizeImageResource(ctx, job.data, job.key.options, job.key.size)
job.candidate, job.err = optimizeOutputImage(ctx, job)
completed <- job
})
}
@@ -184,24 +189,31 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
// 入参: ctx 取消上下文, data 原始图像编码
// 返回: int 编码及像素缓冲的估算字节数,上限为outputImageWorkingSetLimit
func outputImageWorkingSet(ctx context.Context, data []byte) int {
if len(data) >= outputImageWorkingSetLimit {
return outputImageWorkingSetLimit
return imageWorkingSet(ctx, data, outputImageWorkingSetLimit)
}
// imageWorkingSet 按当前预算估算图像解码工作量
// 入参: ctx 取消上下文, data 图像编码, limit 估算上限
// 返回: int 工作量字节数
func imageWorkingSet(ctx context.Context, data []byte, limit int) int {
if len(data) >= limit {
return limit
}
input := bytes.NewReader(data)
available := outputImageWorkingSetLimit - len(data)
available := limit - len(data)
if baseimage.IsTIFF(data) {
layout, err := baseimage.ReadTIFFLayoutContext(ctx, input)
if err != nil {
return outputImageWorkingSetLimit
return limit
}
return len(data) + tiffImageWorkingSet(layout, available)
}
config, _, err := baseimage.DecodeConfig(imageInput{ReadCloser: io.NopCloser(input), context: ctx})
if err != nil || config.Width <= 0 || config.Height <= 0 {
return outputImageWorkingSetLimit
return limit
}
if config.Width > available/16/config.Height {
return outputImageWorkingSetLimit
return limit
}
return len(data) + config.Width*config.Height*16
}
@@ -239,7 +251,7 @@ func tiffImageWorkingSet(layout baseimage.TIFFLayout, limit int) int {
func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageKey][]byte) error {
ctx := e.output.ctx
optimize := func(job *outputImageJob) {
job.candidate, job.err = optimizeImageResource(ctx, job.data, job.key.options, job.key.size)
job.candidate, job.err = optimizeOutputImage(ctx, *job)
}
if len(jobs) == 1 {
optimize(&jobs[0])
@@ -261,3 +273,23 @@ func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageK
}
return nil
}
// optimizeOutputImage 在共享预算内优化独立图像
// 入参: ctx 转换上下文, job 图像任务
// 返回: []byte 候选编码, error 转换错误
func optimizeOutputImage(ctx context.Context, job outputImageJob) ([]byte, error) {
if pool, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil {
if job.workingSet == 0 {
limit := outputImageWorkingSetLimit
if available := pool.memoryLimit(); available > 0 {
limit = int(min(available, int64(int(^uint(0)>>1))))
}
job.workingSet = imageWorkingSet(ctx, job.data, limit)
}
if err := pool.acquire(ctx, true, int64(job.workingSet)); err != nil {
return nil, err
}
defer pool.release(true, int64(job.workingSet))
}
return optimizeImageResource(ctx, job.data, job.key.options, job.key.size)
}
+1 -1
View File
@@ -294,7 +294,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
candidate, cached := imageResults[cacheKey]
var err error
if !cached {
candidate, err = optimizeImageResource(e.output.ctx, data, options, demand)
candidate, err = optimizeOutputImage(e.output.ctx, outputImageJob{data: data, key: outputImageKey{options: options, size: demand}})
if err := e.output.ctx.Err(); err != nil {
return err
}
+9
View File
@@ -45,6 +45,7 @@ type ConvertProgress struct {
// Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
// SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
// Pool可由多个独立转换共享,nil时不限制文件间并发
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
@@ -58,6 +59,7 @@ type ConvertOptions struct {
Pages []int
PageRange string
SkipUnchanged bool
Pool *ConversionPool
Compression CompressionOptions
Fonts FontMode
Backends *RenderBackends
@@ -114,6 +116,13 @@ func OutputFormats() []OutputFormat {
// 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项
// 返回: ConvertReport 转换报告, error 错误信息
func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options ConvertOptions) (report ConvertReport, err error) {
if options.Pool != nil {
if err = options.Pool.acquire(ctx, false, 0); err != nil {
return report, err
}
defer options.Pool.release(false, 0)
ctx = context.WithValue(ctx, conversionPoolKey{}, options.Pool)
}
if err = options.Compression.Validate(); err != nil {
return report, err
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"context"
"fmt"
"runtime"
"sync"
"time"
)
// ConversionPoolOptions 设置共享转换并行度及图像工作集预算
// Parallelism为0时使用GOMAXPROCS,MemoryLimit为0时不指定固定预算
// AvailableMemory返回可用于新增任务的字节数,nil表示不探测,回调须支持并发调用
// 内存预算用于调度,不限制解析器及运行时的实际分配
type ConversionPoolOptions struct {
Parallelism int
MemoryLimit int64
AvailableMemory func() int64
}
// ConversionPool 协调独立转换和图像任务,配置在创建后不变,可跨会话复用
type ConversionPool struct {
options ConversionPoolOptions
mu sync.Mutex
changed chan struct{}
active int
images int
memory int64
}
// conversionPoolKey 标识单次转换使用的共享预算
type conversionPoolKey struct{}
// NewConversionPool 创建共享转换调度器,不启动后台任务
// 入参: options 并行及内存选项
// 返回: *ConversionPool 调度器, error 配置错误
func NewConversionPool(options ConversionPoolOptions) (*ConversionPool, error) {
if options.Parallelism < 0 || options.MemoryLimit < 0 {
return nil, fmt.Errorf("invalid conversion pool options")
}
if options.Parallelism == 0 {
options.Parallelism = runtime.GOMAXPROCS(0)
}
return &ConversionPool{options: options, changed: make(chan struct{})}, nil
}
// Parallelism 返回同时运行的转换数量上限
// 返回: int 并行上限
func (p *ConversionPool) Parallelism() int { return p.options.Parallelism }
// memoryLimit 读取当前可分配的图像工作量预算
// 返回: int64 可用字节数,负值表示未设置预算
func (p *ConversionPool) memoryLimit() int64 {
limit := int64(-1)
if p.options.MemoryLimit > 0 {
limit = p.options.MemoryLimit
}
if p.options.AvailableMemory != nil {
available := max(0, p.options.AvailableMemory())
if limit < 0 || available < limit {
limit = available
}
}
return limit
}
// acquire 等待转换或图像预算,超预算图像可独占运行
// 入参: ctx 取消上下文, image 是否为图像任务, size 图像工作量
// 返回: error 取消错误
func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) error {
for {
if err := ctx.Err(); err != nil {
return err
}
limit := p.memoryLimit()
p.mu.Lock()
allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0)
if image {
allowed = p.images < runtime.GOMAXPROCS(0) && (p.images == 0 || limit < 0 || size <= limit-p.memory)
}
if allowed {
if image {
p.images++
p.memory += size
} else {
p.active++
}
p.mu.Unlock()
return nil
}
changed := p.changed
p.mu.Unlock()
timer := time.NewTimer(250 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-changed:
case <-timer.C:
}
timer.Stop()
}
}
// release 归还任务预算并唤醒等待者
// 入参: image 是否为图像任务, size 图像工作量
func (p *ConversionPool) release(image bool, size int64) {
p.mu.Lock()
defer p.mu.Unlock()
if image {
p.images--
p.memory -= size
} else {
p.active--
}
close(p.changed)
p.changed = make(chan struct{})
}
// conversionWorkers 按当前文件数量分配内部计算任务
// 入参: ctx 转换上下文, fallback 非共享模式并行上限
// 返回: int 内部并行数
func conversionWorkers(ctx context.Context, fallback int) int {
p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool)
if p == nil {
return min(fallback, runtime.GOMAXPROCS(0))
}
p.mu.Lock()
active := p.active
p.mu.Unlock()
return max(1, runtime.GOMAXPROCS(0)/max(1, active))
}
+1 -2
View File
@@ -17,7 +17,6 @@ package ofdgo
import (
"fmt"
"math"
"runtime"
"slices"
"sync"
@@ -438,7 +437,7 @@ func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace,
}
return c.importer.ctx.Err()
}
workers := min(runtime.GOMAXPROCS(0), 4, max(1, c.height/32))
workers := min(conversionWorkers(c.importer.ctx, 4), max(1, c.height/32))
if workers == 1 {
if err := sampleRows(0, c.height); err != nil {
return nil, err