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="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 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="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> </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> <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> </div>
+6 -5
View File
@@ -803,7 +803,7 @@ button:not(:disabled):hover {
.convert-options { .convert-options {
flex: none; flex: none;
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 1; grid-row: 1;
width: 100%; width: 100%;
@@ -812,11 +812,11 @@ button:not(:disabled):hover {
} }
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) { .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])) { .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 { .convert-options .form-row {
@@ -1059,12 +1059,13 @@ button:not(:disabled):hover {
margin: 0; margin: 0;
} }
.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) { .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])) { .convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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; grid-column: 1 / -1;
} }
.convert-options .form-row { .convert-options .form-row {
+118 -66
View File
@@ -20,8 +20,8 @@ const STATUS = {
}; };
const wasmRequests = new Map(); const wasmRequests = new Map();
const metaContents = new WeakMap(); 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 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", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)])); 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 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 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}`)])); 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() { 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.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.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
batchElements.Clear.disabled ||= !batch.items.length; batchElements.Clear.disabled ||= !batch.items.length;
@@ -8159,16 +8159,31 @@ function batchStatus(item, status, text) {
function batchProgress(item, position, count, progress) { function batchProgress(item, position, count, progress) {
if (batch.canceled) return; if (batch.canceled) return;
const now = performance.now(); 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; const sameStage = previous?.item === item && previous?.progress.phase === progress.phase;
if (sameStage && now - batch.progressTime < 80 && progress.completed !== progress.total) return; if (sameStage && now - previous.updated < 80 && progress.completed !== progress.total) return;
batch.progressTime = now; const current = { item, position, count, progress, started: sameStage ? previous.started : now, updated: now };
batch.progress = { item, position, count, progress, started: sameStage ? previous.started : now }; if (item) item.progress = current;
else batch.progress = current;
updateBatchProgress(); updateBatchProgress();
} }
function 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 { item, position, count, progress, started } = batch.progress;
const compressing = progress.phase === "compress" || progress.phase === "write.compress"; const compressing = progress.phase === "compress" || progress.phase === "write.compress";
const fontMode = (item?.format || batchElements.Format.value) === "ofd" ? batchElements.FontMode.value : "0"; const fontMode = (item?.format || batchElements.Format.value) === "ofd" ? batchElements.FontMode.value : "0";
@@ -8182,29 +8197,29 @@ function updateBatchProgress() {
else batchElements.Progress.removeAttribute("value"); else batchElements.Progress.removeAttribute("value");
} }
function batchCall(name, args, onProgress) { function batchCall(slot, name, args, onProgress) {
return new Promise((resolve, reject) => { 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; const id = ++batch.sequence;
batch.activeID = id; batch.requests.set(id, { slot, resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null });
batch.requests.set(id, { resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null }); try { slot.worker.postMessage({ id, name, args }); }
try { batch.worker.postMessage({ id, name, args }); } catch (err) { batch.requests.delete(id); reject(err); }
catch (err) { batch.requests.delete(id); batch.activeID = 0; reject(err); }
}); });
} }
function startBatchWorker() { function startBatchWorker() {
batch.workerError = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = createEngineWorker(); const worker = createEngineWorker();
batch.worker = worker; const slot = { worker, error: null };
batch.workers.add(slot);
const fail = async error => { const fail = async error => {
batch.workerError = error; slot.error = error;
worker.terminate(); worker.terminate();
reject(error); reject(error);
const requests = [...batch.requests.values()]; for (const [id, request] of batch.requests) {
batch.requests.clear(); if (request.slot !== slot) continue;
for (const request of requests) { batch.requests.delete(id);
if (request.destination && request.outputName) { if (request.destination && request.outputName) {
try { await request.destination.removeEntry(request.outputName, { recursive: true }); } try { await request.destination.removeEntry(request.outputName, { recursive: true }); }
catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); } catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); }
@@ -8215,7 +8230,7 @@ function startBatchWorker() {
worker.onerror = event => fail(new Error(event.message || "转换引擎异常")); worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
worker.onmessageerror = () => fail(new Error("转换数据传递失败")); worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
worker.onmessage = async ({ data }) => { 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 === "exit") { fail(new Error(data.error)); return; }
if (data.type === "progress") { if (data.type === "progress") {
if (!batch.canceled) batchElements.Status.textContent = { download: "正在下载", compile: "正在编译", start: "正在启动" }[data.phase] || "正在准备"; 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 === "output") { request.outputName = data.name; return; }
if (data.type === "conversion") { request.onProgress?.(data); return; } if (data.type === "conversion") { request.onProgress?.(data); return; }
if (data.memory) slot.memory = data.memory;
batch.requests.delete(data.id); batch.requests.delete(data.id);
if (batch.activeID === data.id) batch.activeID = 0;
if (data.ok) request.resolve(data.data); if (data.ok) request.resolve(data.data);
else request.reject(Object.assign(new Error(data.error), { code: data.code, name: data.canceled ? "AbortError" : "Error" })); 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; batchElements.Cancel.disabled = true;
batch.progress = null; batch.progress = null;
batchElements.Status.textContent = "正在取消"; 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) { function warnBatch(event) {
@@ -8286,6 +8302,8 @@ async function runBatch() {
batch.running = true; batch.running = true;
batch.canceled = false; batch.canceled = false;
batch.progress = null; batch.progress = null;
batch.jobs = jobs;
batch.credentials = Promise.resolve();
const progressTimer = window.setInterval(updateBatchProgress, 1000); const progressTimer = window.setInterval(updateBatchProgress, 1000);
renderBatchList(); renderBatchList();
window.addEventListener("beforeunload", warnBatch); window.addEventListener("beforeunload", warnBatch);
@@ -8294,7 +8312,7 @@ async function runBatch() {
let storage = null; let storage = null;
let stagingName = ""; let stagingName = "";
let packed = false; let packed = false;
const entries = []; const results = new Array(jobs.length);
try { try {
if (archive) { if (archive) {
zipFile = await saveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] }); zipFile = await saveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
@@ -8313,59 +8331,93 @@ async function runBatch() {
batch.localFonts = fontManager.localEntries(); batch.localFonts = fontManager.localEntries();
batch.fontData = new Map(); batch.fontData = new Map();
batchElements.Status.textContent = "正在准备"; batchElements.Status.textContent = "正在准备";
await startBatchWorker(); const first = await startBatchWorker();
const fonts = await fontManager.files(fontManager.records()); const fonts = await fontManager.files(fontManager.records());
const names = new Set(); const names = new Set();
for (const [position, item] of jobs.entries()) { const plans = jobs.map(item => {
if (batch.canceled) break;
item.details = []; item.details = [];
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value)); 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"; const base = item.file.name.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
let unique = base; let unique = base;
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase(); 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()); names.add(outputName());
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null, 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 }; fontNames: [...batch.localFonts.keys()], fontMode: format.value === "ofd" ? Number(batchElements.FontMode.value) : 0 };
try { return { item, format, options };
for (;;) { });
try { const capacity = nativeHost ? (await nativeHost.call("batch", {})).parallelism : Math.max(1, (navigator.hardwareConcurrency || 2) - 1);
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress)); const automatic = nativeHost ? capacity : Math.min(capacity, Math.max(1, Math.floor((navigator.deviceMemory || 4) / 2)));
for (const file of result.files) entries.push(file); const parallel = Math.min(jobs.length, nativeHost ? capacity : jobs.length, Number(batchElements.Parallel.value) || automatic);
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`); const workerMemory = (navigator.deviceMemory || 4) * 1024 ** 3 / 4 / parallel;
for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`); let next = 0;
if (result.unchanged && !result.bytes) { const run = async initial => {
batchStatus(item, "unchanged", "原样"); let slot = initial;
item.details = ["无需转换,源文件未改动"]; while (!batch.canceled && next < plans.length) {
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成"); const position = next++;
break; const { item, format, options } = plans[position];
} catch (err) { batchStatus(item, "running", "准备");
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err; updateBatchProgress();
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code; try {
options.credentials?.password?.fill(0); if (!slot || slot.error) slot = await startBatchWorker();
options.credentials?.key?.fill(0); for (;;) {
options.credentials?.keyPassword?.fill(0); try {
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword"); const result = await batchCall(slot, "ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError"); results[position] = result.files;
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages; item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError"); 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) { updateBatchProgress();
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);
} }
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) { 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"); if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
packed = true; packed = true;
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成"); for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
@@ -8381,10 +8433,10 @@ async function runBatch() {
batch.progress = null; batch.progress = null;
if (!batchElements.Progress.hasAttribute("value")) batchElements.Progress.value = 0; 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 = "待转"; } if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; }
batch.worker?.terminate(); for (const slot of batch.workers) slot.worker.terminate();
batch.worker = null; batch.workers.clear();
batch.requests.clear(); batch.requests.clear();
batch.activeID = 0; if (nativeHost) await nativeHost.release(results);
batch.fontData?.clear(); batch.fontData?.clear();
batch.localFonts?.clear(); batch.localFonts?.clear();
if (storage && stagingName) { if (storage && stagingName) {
+36 -5
View File
@@ -1,5 +1,15 @@
let engineModule = null;
export function createEngineWorker() { 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) { export async function documentBytes(file) {
@@ -124,7 +134,11 @@ class NativeHost {
let unsubscribe; let unsubscribe;
let pending = Promise.resolve(); let pending = Promise.resolve();
const requests = new Map(); 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}) => { const ready = host.call("engine.open", {}).then(({engine}) => {
if (closed) return engine; if (closed) return engine;
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit); unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
@@ -147,7 +161,21 @@ class NativeHost {
const engine = await ready; const engine = await ready;
if (closed) return; if (closed) return;
controller.signal.throwIfAborted(); 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; } if (closed) { await host.release(result); return; }
let decoded; let decoded;
try { try {
@@ -163,9 +191,12 @@ class NativeHost {
terminate() { terminate() {
if (closed) return; if (closed) return;
closed = true; closed = true;
unsubscribe?.();
for (const controller of requests.values()) controller.abort(); 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(() => {}); ready.catch(() => {});
+19 -12
View File
@@ -4,6 +4,7 @@ let pending = Promise.resolve();
const operations = new Map(); const operations = new Map();
const fontRequests = new Map(); const fontRequests = new Map();
let fontSequence = 0; let fontSequence = 0;
let engineMemory = null;
function requestFont(id, signal, name, done) { function requestFont(id, signal, name, done) {
if (signal.aborted) { done(null, "", true); return; } 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.postMessage({ id, type: "font", request, name });
} }
self.onmessage = ({ data }) => { 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") { if (data.type === "font") {
fontRequests.get(data.request)?.(data); fontRequests.get(data.request)?.(data);
fontRequests.delete(data.request); fontRequests.delete(data.request);
@@ -84,12 +90,15 @@ async function handleBatchMessage({ id, name, args }) {
if (!format) throw new Error("不支持此格式"); if (!format) throw new Error("不支持此格式");
root = destination; root = destination;
const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document"; 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}` }; const target = root ? await navigator.locks.request("ofdgo-convert-output", { signal }, async () => {
if (root && format.paged) { signal.throwIfAborted();
directory = await root.getDirectoryHandle(target.name, { create: true }); 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; created = target.name;
self.postMessage({ id, type: "output", name: created }); 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) => { const boundary = async (action, pageName) => {
signal.throwIfAborted(); signal.throwIfAborted();
if (action === "open") { if (action === "open") {
@@ -144,7 +153,7 @@ async function handleBatchMessage({ id, name, args }) {
} }
result.data.size = size; result.data.size = size;
operations.delete(id); operations.delete(id);
self.postMessage({ id, ...result }); self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
} catch (err) { } catch (err) {
await output?.abort().catch(() => {}); await output?.abort().catch(() => {});
let message = signal.aborted ? "转换已取消" : err.message; let message = signal.aborted ? "转换已取消" : err.message;
@@ -152,7 +161,7 @@ async function handleBatchMessage({ id, name, args }) {
try { await root.removeEntry(created, { recursive: true }); } try { await root.removeEntry(created, { recursive: true }); }
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; } 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 { } finally {
credentials?.password?.fill(0); credentials?.password?.fill(0);
credentials?.key?.fill(0); credentials?.key?.fill(0);
@@ -301,12 +310,10 @@ function exitWASM(err) {
self.close(); self.close();
} }
async function loadWASM() { async function loadWASM(module) {
const go = new Go(); const go = new Go();
self.postMessage({ type: "progress", phase: "download", text: "正在下载引擎", percent: 16 }); const instance = await WebAssembly.instantiate(module, go.importObject);
const response = await fetch("./ofdgo.wasm"); engineMemory = instance.exports.mem;
self.postMessage({ type: "progress", phase: "compile", text: "正在编译引擎", percent: 35 });
const { instance } = await WebAssembly.instantiateStreaming(response, go.importObject);
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 }); self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
go.run(instance).then(exitWASM, exitWASM); go.run(instance).then(exitWASM, exitWASM);
if (!go.exited) { 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 copiedStyle *ofdgo.GraphicObject
ctx context.Context ctx context.Context
resolveSource func(context.Context, any) (io.ReaderAt, int64, error) resolveSource func(context.Context, any) (io.ReaderAt, int64, error)
conversionPool *ofdgo.ConversionPool
} }
// Open 打开浏览器内存中的OFD文档 // Open 打开浏览器内存中的OFD文档
@@ -1391,6 +1392,7 @@ func (e *businessEngine) convertFile(args []platformValue) (any, error) {
} }
options := ofdgo.ConvertOptions{ options := ofdgo.ConvertOptions{
Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(),
Pool: e.conversionPool,
SkipUnchanged: true, SkipUnchanged: true,
Backends: &backends, Backends: &backends,
RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())}, RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())},
+7 -1
View File
@@ -67,6 +67,8 @@ type NativeOutputTarget struct {
// hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭 // hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭
type NativeEngine struct { type NativeEngine struct {
businessEngine businessEngine
// ConversionPool 协调不同会话的批量转换资源
ConversionPool *ofdgo.ConversionPool
// Resolve 按授权标识提供资源读取源 // Resolve 按授权标识提供资源读取源
Resolve NativeResolver Resolve NativeResolver
// LoadFont 按名称读取系统字体数据 // LoadFont 按名称读取系统字体数据
@@ -134,7 +136,11 @@ func NewNativeEngine(resolve NativeResolver) *NativeEngine { return &NativeEngin
// 批量输出到非nil目标时必须同时提供ReserveOutput和CleanupOutput,避免误清理既有文件 // 批量输出到非nil目标时必须同时提供ReserveOutput和CleanupOutput,避免误清理既有文件
// 取消为协作式检查,不能抢占宿主阻塞的ReadAt、字体读取或加密库内部计算 // 取消为协作式检查,不能抢占宿主阻塞的ReadAt、字体读取或加密库内部计算
func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit func(any)) (result map[string]any) { 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 { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
+92 -13
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
//go:build windows && amd64 //go:build !js || !wasm
package webview package webview
@@ -44,17 +44,19 @@ const applicationID = "me.aite.ofdgo"
// Host 连接桌面系统能力和共用文档业务 // Host 连接桌面系统能力和共用文档业务
type Host struct { type Host struct {
mu sync.Mutex mu sync.Mutex
window *guigo.Window window *guigo.Window
files *fileStore files *fileStore
engines map[string]*webui.NativeEngine engines map[string]*webui.NativeEngine
engineStops map[string]func() bool engineStops map[string]func() bool
cancels map[string]context.CancelFunc conversionPool *ofdgo.ConversionPool
fontOnce sync.Once cancels map[string]context.CancelFunc
fonts []map[string]any batchResults map[string]any
fontFiles map[string]string fontOnce sync.Once
fontError error fonts []map[string]any
pendingPaths []string fontFiles map[string]string
fontError error
pendingPaths []string
} }
// resourceReader 保存资源读取位置及关闭操作 // resourceReader 保存资源读取位置及关闭操作
@@ -76,9 +78,14 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
return err 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 := &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() defer h.close()
bindings := make(map[string]guigo.HandlerFunc) 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) } bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
} }
static := http.FileServerFS(webuiassets.NativeFS) static := http.FileServerFS(webuiassets.NativeFS)
@@ -260,6 +267,7 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
switch name { switch name {
case "engine.open": case "engine.open":
engine := webui.NewNativeEngine(h.files.resolve) engine := webui.NewNativeEngine(h.files.resolve)
engine.ConversionPool = h.conversionPool
engine.OpenWriter = h.openWriter engine.OpenWriter = h.openWriter
engine.ReserveOutput = h.reserveOutput engine.ReserveOutput = h.reserveOutput
engine.CleanupOutput = h.cleanupOutput 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.engineStops[id] = context.AfterFunc(guigo.PageContext(ctx), func() { _ = h.closeEngine(id) })
h.mu.Unlock() h.mu.Unlock()
return guigo.Result{Value: map[string]any{"engine": id}, Discard: func() { _ = h.closeEngine(id) }}, nil 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": case "engine.close":
return nil, h.closeEngine(text("engine")) 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": case "engine.call":
id := text("engine") id := text("engine")
key := id + "/" + fmt.Sprint(args["id"]) key := id + "/" + fmt.Sprint(args["id"])
@@ -670,6 +739,7 @@ func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) {
// 入参: id 会话标识 // 入参: id 会话标识
// 返回: error 关闭错误 // 返回: error 关闭错误
func (h *Host) closeEngine(id string) error { func (h *Host) closeEngine(id string) error {
var results []any
h.mu.Lock() h.mu.Lock()
engine := h.engines[id] engine := h.engines[id]
delete(h.engines, id) delete(h.engines, id)
@@ -682,7 +752,16 @@ func (h *Host) closeEngine(id string) error {
cancel() cancel()
} }
} }
for key, result := range h.batchResults {
if strings.HasPrefix(key, id+"/") {
results = append(results, result)
delete(h.batchResults, key)
}
}
h.mu.Unlock() h.mu.Unlock()
for _, result := range results {
h.discard(result)
}
if engine != nil { if engine != nil {
return engine.Close() 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" "crypto/sha256"
"image" "image"
"io" "io"
"runtime"
"strconv" "strconv"
"sync" "sync"
@@ -83,11 +82,17 @@ func (e *Editor) outputImageOptions(key string) (CompressionOptions, image.Point
// 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空 // 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空
// 返回: error 读取、进度或取消错误 // 返回: error 读取、进度或取消错误
func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) 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 { if workers < 2 || len(keys) < 2 {
return nil 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 { if strconv.IntSize == 32 {
limit = 64 << 20 limit = 64 << 20
} }
@@ -149,14 +154,14 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
if pending[cacheKey] { if pending[cacheKey] {
continue continue
} }
cost := outputImageWorkingSet(e.output.ctx, data) cost := imageWorkingSet(e.output.ctx, data, limit)
for running != 0 && (len(data) > outputImageBatchLimit-size || cost > limit-workingSet) { for running != 0 && (len(data) > encodedLimit-size || cost > limit-workingSet) {
if err := finish(); err != nil { if err := finish(); err != nil {
return err return err
} }
} }
job := outputImageJob{key: cacheKey, data: data, workingSet: cost} 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 { if err := e.runOutputImages([]outputImageJob{job}, results); err != nil {
return err return err
} }
@@ -167,7 +172,7 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
size += len(data) size += len(data)
workingSet += cost workingSet += cost
group.Go(func() { 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 completed <- job
}) })
} }
@@ -184,24 +189,31 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
// 入参: ctx 取消上下文, data 原始图像编码 // 入参: ctx 取消上下文, data 原始图像编码
// 返回: int 编码及像素缓冲的估算字节数,上限为outputImageWorkingSetLimit // 返回: int 编码及像素缓冲的估算字节数,上限为outputImageWorkingSetLimit
func outputImageWorkingSet(ctx context.Context, data []byte) int { func outputImageWorkingSet(ctx context.Context, data []byte) int {
if len(data) >= outputImageWorkingSetLimit { return imageWorkingSet(ctx, data, outputImageWorkingSetLimit)
return 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) input := bytes.NewReader(data)
available := outputImageWorkingSetLimit - len(data) available := limit - len(data)
if baseimage.IsTIFF(data) { if baseimage.IsTIFF(data) {
layout, err := baseimage.ReadTIFFLayoutContext(ctx, input) layout, err := baseimage.ReadTIFFLayoutContext(ctx, input)
if err != nil { if err != nil {
return outputImageWorkingSetLimit return limit
} }
return len(data) + tiffImageWorkingSet(layout, available) return len(data) + tiffImageWorkingSet(layout, available)
} }
config, _, err := baseimage.DecodeConfig(imageInput{ReadCloser: io.NopCloser(input), context: ctx}) config, _, err := baseimage.DecodeConfig(imageInput{ReadCloser: io.NopCloser(input), context: ctx})
if err != nil || config.Width <= 0 || config.Height <= 0 { if err != nil || config.Width <= 0 || config.Height <= 0 {
return outputImageWorkingSetLimit return limit
} }
if config.Width > available/16/config.Height { if config.Width > available/16/config.Height {
return outputImageWorkingSetLimit return limit
} }
return len(data) + config.Width*config.Height*16 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 { func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageKey][]byte) error {
ctx := e.output.ctx ctx := e.output.ctx
optimize := func(job *outputImageJob) { 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 { if len(jobs) == 1 {
optimize(&jobs[0]) optimize(&jobs[0])
@@ -261,3 +273,23 @@ func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageK
} }
return nil 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] candidate, cached := imageResults[cacheKey]
var err error var err error
if !cached { 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 { if err := e.output.ctx.Err(); err != nil {
return err return err
} }
+9
View File
@@ -45,6 +45,7 @@ type ConvertProgress struct {
// Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面 // Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 // PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
// SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据 // SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
// Pool可由多个独立转换共享,nil时不限制文件间并发
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出 // 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
@@ -58,6 +59,7 @@ type ConvertOptions struct {
Pages []int Pages []int
PageRange string PageRange string
SkipUnchanged bool SkipUnchanged bool
Pool *ConversionPool
Compression CompressionOptions Compression CompressionOptions
Fonts FontMode Fonts FontMode
Backends *RenderBackends Backends *RenderBackends
@@ -114,6 +116,13 @@ func OutputFormats() []OutputFormat {
// 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项 // 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项
// 返回: ConvertReport 转换报告, error 错误信息 // 返回: ConvertReport 转换报告, error 错误信息
func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options ConvertOptions) (report ConvertReport, err 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 { if err = options.Compression.Validate(); err != nil {
return report, err 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 ( import (
"fmt" "fmt"
"math" "math"
"runtime"
"slices" "slices"
"sync" "sync"
@@ -438,7 +437,7 @@ func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace,
} }
return c.importer.ctx.Err() 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 workers == 1 {
if err := sampleRows(0, c.height); err != nil { if err := sampleRows(0, c.height); err != nil {
return nil, err return nil, err