diff --git a/assets/webui/index.html b/assets/webui/index.html index 38ca3fb..d094e99 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -20,6 +20,7 @@ + @@ -171,6 +172,23 @@ + +
+

批量转换

+ +
文件页码格式状态操作
暂无文件
+
+
+ +
+
+ +
+
+ +
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 83fc8c8..54927d7 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -690,6 +690,237 @@ button:not(:disabled):hover { line-height: 1.5; } +.convert-dialog { + --convert-format-width: 64px; + width: 720px; +} + +.convert-dialog .form-title { + display: flex; + align-items: center; + gap: 12px; +} + +.convert-options { + flex: none; + display: grid; + grid-template-columns: calc(2em + 8px + var(--convert-format-width)) 132px calc(2em + 8px + var(--convert-format-width)); + gap: 12px; + margin: 0 auto; +} + +.convert-options:has(#convertDPIRow[hidden]) { + grid-template-columns: repeat(2, calc(2em + 8px + var(--convert-format-width))); +} + +.convert-options .form-row { + gap: 8px; + margin-bottom: 0; +} + +#convertCount { + margin-left: auto; + color: var(--text-muted); + font-size: 12px; + font-weight: 400; +} + +.convert-table { + max-height: 40dvh; + min-height: 120px; + overflow: auto; + border: 1px solid var(--line); + background: #fff; +} + +.convert-table table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.convert-table th, +.convert-table td { + padding: 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; +} + +.convert-table th { + position: sticky; + top: 0; + z-index: 1; + background: #eef1f4; + font-weight: 500; +} + +.convert-table th:nth-child(2) { + width: 112px; +} + +.convert-table th:nth-child(3), +.convert-table th:nth-child(4) { + width: calc(var(--convert-format-width) + 16px); +} + +.convert-table select, +.convert-table input { + width: 100%; + min-width: 0; + height: var(--control-height); + padding: 0 6px; + border: 1px solid var(--line); + border-radius: 0; + background: #fff; + color: inherit; +} + +.convert-table input { + font-variant-numeric: tabular-nums; +} + +.convert-table th:last-child, +.convert-table td:last-child { + width: 48px; + text-align: center; +} + +.convert-table tbody tr:last-child td { + border-bottom: 0; +} + +.convert-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.convert-table tr:has(+ .convert-details) > td { + border-bottom: 0; +} + +.convert-table .convert-details > td { + width: auto; + padding-top: 0; + text-align: left; +} + +.convert-table .convert-details details { + margin: 0; + color: var(--text-muted); + overflow-wrap: anywhere; + font-size: 12px; +} + +.convert-table .convert-details details[open] > summary { + margin-bottom: 8px; +} + +.convert-messages { + max-height: min(180px, 24dvh); + margin: 0; + padding: 0; + overflow: auto; + list-style: none; + line-height: 1.5; + white-space: pre-wrap; +} + +.convert-messages > li + li { + margin-top: 6px; +} + +.convert-table td[data-status="failed"] { + color: #b42318; +} + +.convert-table td[data-status="done"] { + color: var(--accent-strong); +} + +.convert-empty { + padding: 28px 12px; + text-align: center; + color: var(--text-muted); +} + +.convert-progress { + margin-top: 12px; +} + +.convert-dialog .form-actions { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 12px; +} + +.convert-dialog .form-actions .button { + min-width: 3.5em; +} + +.convert-progress progress { + display: block; + width: 100%; + height: 6px; + accent-color: var(--accent); +} + +#convertStatus { + min-height: 1.5em; + margin-top: 12px; + line-height: 1.5; + overflow-wrap: anywhere; + color: var(--text-muted); +} + +#convertButton[data-running] { + color: var(--accent-strong); + background: var(--accent-soft); +} + +@media (max-width: 700px) { + .convert-dialog .form-actions { flex-wrap: wrap; } + .convert-options { order: -1; flex-basis: 100%; justify-content: center; margin: 0 0 4px; } + #convertClose { margin-left: auto; } +} + +@media (max-width: 620px) { + .convert-options { + grid-template-columns: calc(2em + 8px + var(--convert-format-width)) 132px; + } + .convert-table table, + .convert-table tbody { display: block; } + .convert-table thead { display: none; } + .convert-table tr { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px; + border-bottom: 1px solid var(--line); + } + .convert-table tr:last-child { border-bottom: 0; } + .convert-table tr:has(+ .convert-details) { padding-bottom: 0; border-bottom: 0; } + .convert-table td { min-width: 0; padding: 0; border: 0; } + .convert-table td:first-child { flex: 0 0 calc(100% - var(--convert-format-width) - var(--control-height) - 16px); } + .convert-table td:nth-child(4) { order: 1; flex: 0 0 var(--convert-format-width); text-align: right; } + .convert-table td:last-child { order: 2; flex: 0 0 var(--control-height); } + .convert-table td[data-label] { + order: 3; + display: grid; + grid-template-columns: 2em minmax(0, 1fr); + align-items: center; + gap: 8px; + } + .convert-table td:nth-child(2) { flex: 1 1 0; } + .convert-table td:nth-child(3) { flex: 0 0 calc(2em + 8px + var(--convert-format-width)); } + .convert-table td[data-label]::before { content: attr(data-label); color: var(--text-muted); } + .convert-table .convert-details > td { flex: 0 0 100%; } +} + #sourceTextPanel { width: 440px; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 4308303..055c695 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -19,6 +19,8 @@ const STATUS = { }; const wasmRequests = new Map(); const metaContents = new WeakMap(); +const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 }; +const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)])); let wasmPromise = null; let wasmWorker = null; @@ -1086,6 +1088,18 @@ el.pageForm.addEventListener("submit", async (event) => { } }); el.ofdButton.addEventListener("click", openOFDFile); +batchElements.Button.addEventListener("click", () => { updateBatchControls(); batchElements.Panel.showModal(); }); +batchElements.Add.addEventListener("click", () => batchElements.Input.click()); +batchElements.Input.addEventListener("change", () => { addBatchFiles(batchElements.Input.files); batchElements.Input.value = ""; }); +batchElements.Clear.addEventListener("click", () => { batch.items = []; renderBatchList(); batchElements.Status.textContent = ""; batchElements.Progress.value = 0; }); +batchElements.Format.addEventListener("change", updateBatchControls); +batchElements.Close.addEventListener("click", () => batchElements.Panel.close()); +batchElements.Cancel.addEventListener("click", cancelBatch); +batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); }); +batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; }); +batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); }); +batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker; +batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive"; el.newButton.addEventListener("click", openCreatePanel); el.editButton.addEventListener("click", toggleEditor); el.editButton.addEventListener("pointerdown", (event) => { @@ -1443,7 +1457,7 @@ function handleKeyDown(event) { } function formDialogOpen() { - return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open + return batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; } @@ -3173,6 +3187,9 @@ async function refreshApplication() { } async function loadExportFormats() { + batch.formats = await callWASM("ofdgoOutputFormats"); + batchElements.Format.replaceChildren(...batch.formats.map(format => new Option(format.label, format.value))); + updateBatchControls(); const formats = await callWASM("ofdgoExportFormats") || []; state.exportFormats = formats; el.exportFormat.replaceChildren(); @@ -4382,6 +4399,307 @@ async function updateExportRange() { } } +function addBatchFiles(files) { + let rejected = false; + for (const file of files) { + if (!/\.(ofd|pdf)$/i.test(file.name)) { rejected = true; continue; } + batch.items.push({ file, format: "", pages: "", status: "pending", text: "待转", details: [] }); + } + if (rejected) batchElements.Status.textContent = "仅支持PDF、OFD"; + renderBatchList(); +} + +function renderBatchList() { + const fragment = document.createDocumentFragment(); + for (const item of batch.items) { + const row = document.createElement("tr"); + const name = row.insertCell(); + const label = document.createElement("span"); + label.className = "convert-name"; + label.textContent = item.file.name; + label.title = item.file.name; + name.append(label); + let detailRow; + if (item.details.length) { + detailRow = document.createElement("tr"); + detailRow.className = "convert-details"; + const cell = detailRow.insertCell(); + cell.colSpan = 5; + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "提示"; + const messages = document.createElement("ul"); + messages.className = "convert-messages"; + messages.tabIndex = 0; + messages.setAttribute("aria-label", `${item.file.name}的转换提示`); + for (const message of item.details) { + const entry = document.createElement("li"); + entry.textContent = message; + messages.append(entry); + } + details.append(summary, messages); + cell.append(details); + } + const reset = () => { + item.details = []; + detailRow?.remove(); + batchStatus(item, "pending", "待转"); + batchElements.Status.textContent = ""; + batchElements.Progress.value = 0; + updateBatchControls(); + }; + const pagesCell = row.insertCell(); + pagesCell.dataset.label = "页码"; + const pages = document.createElement("input"); + pages.type = "text"; + pages.placeholder = "全部"; + pages.title = "如1-3,5;留空为全部"; + pages.setAttribute("aria-label", `${item.file.name}的输出页码`); + pages.value = item.pages; + pages.disabled = batch.running; + pages.addEventListener("change", () => { item.pages = pages.value; reset(); }); + pagesCell.append(pages); + const formatCell = row.insertCell(); + formatCell.dataset.label = "格式"; + const format = document.createElement("select"); + format.setAttribute("aria-label", `${item.file.name}的输出格式`); + format.append(new Option("默认", ""), ...batch.formats.map(value => new Option(value.label, value.value))); + format.value = item.format; + format.disabled = batch.running; + format.addEventListener("change", () => { item.format = format.value; reset(); }); + formatCell.append(format); + item.cell = row.insertCell(); + item.cell.textContent = item.text; + item.cell.dataset.status = item.status; + const action = row.insertCell(); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "icon-button"; + remove.textContent = "×"; + remove.title = "移除"; + remove.setAttribute("aria-label", `移除${item.file.name}`); + remove.disabled = batch.running; + remove.addEventListener("click", () => { batch.items.splice(batch.items.indexOf(item), 1); renderBatchList(); }); + action.append(remove); + fragment.append(row); + if (detailRow) fragment.append(detailRow); + } + batchElements.List.replaceChildren(fragment); + batchElements.Empty.hidden = batch.items.length > 0; + updateBatchControls(); +} + +function updateBatchControls() { + for (const name of ["Add", "Clear", "Format", "Destination", "DPI"]) batchElements[name].disabled = batch.running; + batchElements.Clear.disabled ||= !batch.items.length; + batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length; + batchElements.Start.hidden = batch.running; + batchElements.Cancel.hidden = !batch.running; + batchElements.Cancel.disabled = batch.canceled; + batchElements.Close.textContent = batch.running ? "收起" : "关闭"; + batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : ""; + const formats = batch.items.length ? batch.items.map(item => item.format || batchElements.Format.value) : [batchElements.Format.value]; + batchElements.DPIRow.hidden = !formats.some(value => batch.formats.find(format => format.value === value)?.raster); + batchElements.Button.toggleAttribute("data-running", batch.running); +} + +function batchStatus(item, status, text) { + item.status = status; + item.text = text; + item.cell.textContent = text; + item.cell.dataset.status = status; +} + +function batchProgress(item, position, count, progress) { + const now = performance.now(); + if (now - batch.progressTime < 80 && progress.completed !== progress.total) return; + batch.progressTime = now; + const stage = progress.phase?.startsWith("write.") ? "写入" : { open: "读取", pages: "解析", convert: "转换", prepare: "准备", fonts: "字体", resources: "资源", references: "检查", write: "写入", export: "导出", pack: "打包", commit: "保存" }[progress.phase] || "处理"; + const detail = progress.total > 0 ? `${progress.completed}/${progress.total}` : ""; + if (item) batchStatus(item, "running", stage); + batchElements.Status.textContent = progress.phase === "pack" ? `打包 ${detail}` : `文件 ${position + 1}/${count} · ${stage}${detail ? ` ${detail}` : ""}`; + batchElements.Progress.max = count; + batchElements.Progress.value = progress.phase === "pack" && progress.total > 0 ? progress.completed / progress.total : position; +} + +function batchCall(name, args, onProgress) { + return new Promise((resolve, reject) => { + if (batch.workerError) { reject(batch.workerError); return; } + const id = ++batch.sequence; + batch.activeID = id; + batch.requests.set(id, { resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null }); + try { batch.worker.postMessage({ id, name, args }); } + catch (err) { batch.requests.delete(id); batch.activeID = 0; reject(err); } + }); +} + +function startBatchWorker() { + batch.workerError = null; + return new Promise((resolve, reject) => { + const worker = new Worker("./ofdgo_wasm.js"); + batch.worker = worker; + const fail = async error => { + batch.workerError = error; + worker.terminate(); + reject(error); + const requests = [...batch.requests.values()]; + batch.requests.clear(); + for (const request of requests) { + if (request.destination && request.outputName) { + try { await request.destination.removeEntry(request.outputName, { recursive: true }); } + catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); } + } + request.reject(error); + } + }; + worker.onerror = event => fail(new Error(event.message || "转换引擎异常")); + worker.onmessageerror = () => fail(new Error("转换数据传递失败")); + worker.onmessage = ({ data }) => { + if (data.type === "ready") { resolve(); return; } + if (data.type === "exit") { fail(new Error(data.error)); return; } + if (data.type === "progress") { batchElements.Status.textContent = data.text; return; } + const request = batch.requests.get(data.id); + if (!request) return; + if (data.type === "output") { request.outputName = data.name; return; } + if (data.type === "conversion") { request.onProgress?.(data); return; } + batch.requests.delete(data.id); + if (batch.activeID === data.id) batch.activeID = 0; + if (data.ok) request.resolve(data.data); + else request.reject(Object.assign(new Error(data.error), { code: data.code, name: data.canceled ? "AbortError" : "Error" })); + }; + }); +} + +function cancelBatch() { + batch.canceled = true; + batchElements.Cancel.disabled = true; + batchElements.Status.textContent = "正在取消"; + if (batch.activeID) batch.worker.postMessage({ type: "cancel", id: batch.activeID }); +} + +function warnBatch(event) { + if (batch.running) { event.preventDefault(); event.returnValue = ""; } +} + +async function runBatch() { + if (batch.running || !batch.items.length) return; + const defaultFormat = batch.formats.find(format => format.value === batchElements.Format.value); + if (!defaultFormat) return; + const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim(); + const dpi = Number(batchElements.DPI.value); + const archive = batchElements.Destination.value === "archive"; + const backend = state.renderBackend; + for (const item of batch.items) { + const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value)); + const key = JSON.stringify([format.value, pageRange(item), format.raster ? dpi : 0, archive, backend]); + if (item.settingsKey !== key) { + item.status = "pending"; + item.text = "待转"; + item.details = []; + item.settingsKey = key; + } + } + renderBatchList(); + let jobs = batch.items.filter(item => item.status !== "done" && item.status !== "unchanged"); + if (!jobs.length) { + jobs = [...batch.items]; + for (const item of jobs) { item.status = "pending"; item.text = "待转"; item.details = []; } + } + batch.running = true; + batch.canceled = false; + renderBatchList(); + window.addEventListener("beforeunload", warnBatch); + let destination = null; + let zipFile = null; + let storage = null; + let stagingName = ""; + let packed = false; + const entries = []; + try { + if (archive) { + if (window.showSaveFilePicker) zipFile = await window.showSaveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] }); + if (navigator.storage?.getDirectory) { + storage = await navigator.storage.getDirectory(); + stagingName = `ofdgo-convert-${crypto.randomUUID()}`; + destination = await storage.getDirectoryHandle(stagingName, { create: true }); + } + } else destination = await window.showDirectoryPicker({ mode: "readwrite" }); + if (batch.canceled) return; + batchElements.Status.textContent = "正在准备引擎"; + await startBatchWorker(); + const fonts = await fontManager.files(fontManager.records()); + const names = new Set(); + for (const [position, item] of jobs.entries()) { + if (batch.canceled) break; + item.details = []; + const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value)); + const base = item.file.name.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document"; + let unique = base; + const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase(); + for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`; + names.add(outputName()); + const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null }; + try { + for (;;) { + try { + const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress)); + for (const file of result.files) entries.push(file); + item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`); + if (result.unchanged) { + batchStatus(item, "unchanged", "原样"); + item.details = ["无需转换,源文件未改动"]; + } else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成"); + break; + } catch (err) { + if (batch.canceled || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err; + options.credentials?.password?.fill(0); + options.credentials?.key?.fill(0); + options.credentials?.keyPassword?.fill(0); + options.credentials = await requestCredentials(err.code, state.openSeq); + if (!options.credentials) throw new DOMException("已取消解锁", "AbortError"); + if (!window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError"); + } + } + } catch (err) { + batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败"); + item.details = [err.message]; + if (batch.workerError) throw err; + } finally { + options.credentials?.password?.fill(0); + options.credentials?.key?.fill(0); + options.credentials?.keyPassword?.fill(0); + } + batchElements.Progress.max = jobs.length; + batchElements.Progress.value = position + 1; + } + if (archive && entries.length && !batch.canceled) { + const result = await batchCall("ofdgoPackFiles", [entries, zipFile], progress => batchProgress(null, 0, 1, progress)); + if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip"); + packed = true; + for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成"); + } + const done = jobs.filter(item => item.status === "done").length; + const failed = jobs.filter(item => item.status === "failed").length; + const unchanged = batch.items.filter(item => item.status === "unchanged").length; + batchElements.Status.textContent = `${batch.canceled ? "已取消 · " : ""}完成 ${done}${failed ? ` · 失败 ${failed}` : ""}${unchanged ? ` · 原样 ${unchanged}` : ""}`; + } catch (err) { + batchElements.Status.textContent = err.name === "AbortError" ? "转换已取消" : err.message; + } finally { + if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; } + batch.worker?.terminate(); + batch.worker = null; + batch.requests.clear(); + batch.activeID = 0; + if (storage && stagingName) { + try { await storage.removeEntry(stagingName, { recursive: true }); } + catch { batchElements.Status.textContent += " · 临时文件清理失败"; } + } + batch.running = false; + window.removeEventListener("beforeunload", warnBatch); + renderBatchList(); + } +} + async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 4f23bf4..033d6ca 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -7,11 +7,138 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { + if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { operations.set(data.id, new AbortController()); } - pending = pending.then(() => handleMessage(data)); + pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); }; + +async function availableOutput(directory, base, format) { + for (let suffix = 0; ; suffix++) { + const stem = suffix ? `${base} (${suffix + 1})` : base; + const name = format.paged ? stem : `${stem}.${format.extension}`; + try { + await directory.getFileHandle(name); + } catch (err) { + if (err.name === "NotFoundError") return { stem, name }; + if (err.name !== "TypeMismatchError") throw err; + } + } +} + +async function handleBatchMessage({ id, name, args }) { + const signal = operations.get(id).signal; + const channel = new MessageChannel(); + const finish = (done, err) => { + channel.port1.onmessage = () => done(err?.message || "", signal.aborted); + channel.port2.postMessage(null); + }; + const reader = new FileReaderSync(); + const read = file => (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size))); + let output = null; + let chunks = []; + let files = []; + let directory = null; + let created = null; + let root = null; + let current = null; + let size = 0; + let credentials = null; + const write = (bytes, done) => { + if (signal.aborted) return finish(done); + size += bytes.byteLength; + if (output) output.write(bytes).then(() => finish(done), err => finish(done, err)); + else { chunks.push(new Blob([bytes])); finish(done); } + }; + try { + signal.throwIfAborted(); + let payload; + if (name === "ofdgoConvertFile") { + const [file, options, fonts, destination] = args; + credentials = options.credentials; + const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data; + const format = formats.find(item => item.value === options.format); + if (!format) throw new Error("不支持此格式"); + root = destination; + const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document"; + const target = root ? await availableOutput(root, base, format) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` }; + if (root && format.paged) { + directory = await root.getDirectoryHandle(target.name, { create: true }); + created = target.name; + self.postMessage({ id, type: "output", name: created }); + } else directory = root; + const boundary = async (action, pageName) => { + signal.throwIfAborted(); + if (action === "open") { + const fileName = format.paged ? pageName : target.name; + current = { name: format.paged ? `${target.stem}/${pageName}` : fileName, mime: format.mime }; + chunks = []; + if (directory) { + const handle = await directory.getFileHandle(fileName, { create: true }); + if (!format.paged) { + created = fileName; + self.postMessage({ id, type: "output", name: created }); + } + current.handle = handle; + output = await handle.createWritable(); + } + } else { + if (output) { await output.close(); output = null; } + if (options.archive) current.file = current.handle ? await current.handle.getFile() : new File(chunks, pageName, { type: format.mime }); + delete current.handle; + files.push(current); + chunks = []; + } + }; + payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write, + (phase, completed, total, done) => { + self.postMessage({ id, type: "conversion", phase, completed, total }); + finish(done); + }, + (action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)), + done => finish(done), fonts); + } else { + const [entries, handle] = args; + if (handle) output = await handle.createWritable(); + payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })), + index => read(entries[index].file), write, + (completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); }, + done => finish(done)); + } + signal.throwIfAborted(); + const result = typeof payload === "string" ? JSON.parse(payload) : payload; + if (!result.ok) throw Object.assign(new Error(result.error), { code: result.code }); + if (name === "ofdgoPackFiles") { + if (output) { await output.close(); output = null; } + else result.data.blob = new Blob(chunks, { type: "application/zip" }); + } else { + if (result.data.unchanged) { + if (output) { await output.abort(); output = null; } + if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; } + files = []; + } + result.data.files = files; + } + result.data.size = size; + operations.delete(id); + self.postMessage({ id, ...result }); + } catch (err) { + await output?.abort().catch(() => {}); + let message = signal.aborted ? "转换已取消" : err.message; + if (root && created) { + try { await root.removeEntry(created, { recursive: true }); } + catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; } + } + self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted }); + } finally { + credentials?.password?.fill(0); + credentials?.key?.fill(0); + credentials?.keyPassword?.fill(0); + channel.port1.close(); + channel.port2.close(); + operations.delete(id); + } +} async function handleMessage({ id, name, args }) { let output; let channel; diff --git a/go.mod b/go.mod index 0d0ed3e..3d4b568 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/xiaoqidun/jbig2 v1.1.1 - github.com/xiaoqidun/pdfgo v0.0.0-20260926024032-ab5bec77f380 + github.com/xiaoqidun/pdfgo v0.0.0-20260926052151-12ff0e0004de golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 golang.org/x/text v0.42.0 diff --git a/go.sum b/go.sum index fb57bff..a65b2d1 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI= github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= -github.com/xiaoqidun/pdfgo v0.0.0-20260926024032-ab5bec77f380 h1:NIKpfWSCzRi0ytSS43KFwfV0lxItrepU+QEeab1Yt1w= -github.com/xiaoqidun/pdfgo v0.0.0-20260926024032-ab5bec77f380/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260926052151-12ff0e0004de h1:Aiyem3mCMV7+EAAGLYaI2XQGwEK1bZ1Bz+xTF5f0xiY= +github.com/xiaoqidun/pdfgo v0.0.0-20260926052151-12ff0e0004de/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index b52b86c..c87caac 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -17,6 +17,7 @@ package webui import ( + "archive/zip" "bufio" "bytes" "context" @@ -28,6 +29,7 @@ import ( "image" "image/color" "io" + "io/fs" "math" "path" "reflect" @@ -45,6 +47,16 @@ type exportWriter struct { write js.Value } +// browserReaderAt 分块读取浏览器文件,只缓存最近的一个数据块 +type browserReaderAt struct { + read js.Value + checkpoint js.Value + size int64 + offset int64 + data []byte + checked time.Time +} + // apiResult 浏览器接口返回结果 type apiResult struct { OK bool `json:"ok"` @@ -133,6 +145,45 @@ func (w exportWriter) Write(data []byte) (int, error) { return size, nil } +// ReadAt 按需读取文件片段并定期交付取消检查点 +// 入参: data 目标缓冲, offset 文件偏移 +// 返回: int 读取字节数, error 读取错误 +func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { + if offset < 0 { + return 0, fmt.Errorf("negative file offset") + } + n := 0 + for len(data) > 0 { + if offset >= r.size { + return n, io.EOF + } + if time.Since(r.checked) >= 50*time.Millisecond { + if err := awaitExport(r.checkpoint); err != nil { + return n, err + } + r.checked = time.Now() + } + if len(r.data) == 0 || offset < r.offset || offset-r.offset >= int64(len(r.data)) { + r.offset = offset / (256 << 10) * (256 << 10) + size := int(min(int64(256<<10), r.size-r.offset)) + if cap(r.data) < size { + r.data = make([]byte, size) + } else { + r.data = r.data[:size] + } + value := r.read.Invoke(float64(r.offset), size) + if js.CopyBytesToGo(r.data, value) != size { + return n, io.ErrUnexpectedEOF + } + } + copied := copy(data, r.data[offset-r.offset:]) + n += copied + offset += int64(copied) + data = data[copied:] + } + return n, nil +} + // awaitExport 等待浏览器处理导出检查点 // 入参: fn 浏览器回调, args 回调参数 // 返回: error 写入错误或取消原因 @@ -157,6 +208,9 @@ func awaitExport(fn js.Value, args ...any) error { func RunWASM() { registerCallback("ofdgoOpen", openDocument) registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) + registerAsyncCallback("ofdgoConvertFile", convertFile) + registerAsyncCallback("ofdgoPackFiles", packFiles) + registerCallback("ofdgoOutputFormats", func([]js.Value) (any, error) { return ofdgo.OutputFormats(), nil }) registerCallback("ofdgoConfigure", configureDocument) registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoVerifySignatures", verifySignatures) @@ -336,6 +390,131 @@ func convertPDFDocument(args []js.Value) (any, error) { return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "warnings": string(warnings)}), nil } +// convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 +// 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查及字体 +// 返回: any 转换报告, error 转换错误 +func convertFile(args []js.Value) (any, error) { + if len(args) != 8 { + return nil, fmt.Errorf("missing conversion arguments") + } + settings := args[2] + backends, err := ofdgo.NewRenderBackends(settings.Get("backend").String()) + if err != nil { + return nil, err + } + fonts, err := fontsFromJS(args[7]) + if err != nil { + return nil, err + } + options := ofdgo.ConvertOptions{ + Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), + SkipUnchanged: true, + Backends: &backends, + RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())}, + OnProgress: func(progress ofdgo.ConvertProgress) error { + return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) + }, + } + if len(fonts) > 0 { + options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(ofdgo.NewFontFS(fonts))) + } + if value := settings.Get("credentials"); !value.IsNull() && !value.IsUndefined() { + credentials, err := credentialsFromJS(value) + if err != nil { + return nil, err + } + defer clear(credentials.Password) + options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) + } + var format ofdgo.OutputFormat + for _, item := range ofdgo.OutputFormats() { + if item.Value == options.Format { + format = item + } + } + writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) + if format.Paged { + options.PageOutput = func(index int, export func(io.Writer) error) error { + name := fmt.Sprintf("%06d.%s", index+1, format.Extension) + if err := awaitExport(args[5], "open", name); err != nil { + return err + } + if err := export(writer); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } + return awaitExport(args[5], "close", name) + } + } else if err := awaitExport(args[5], "open", "document."+format.Extension); err != nil { + return nil, err + } + source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())} + report, err := ofdgo.Convert(context.Background(), source, source.size, writer, options) + if err != nil { + return nil, err + } + if report.Unchanged { + return report, nil + } + if err := writer.Flush(); err != nil { + return nil, err + } + if !format.Paged { + if err := awaitExport(args[5], "close", "document."+format.Extension); err != nil { + return nil, err + } + } + return report, nil +} + +// packFiles 将已经成功转换的文件逐个打包,不重复转换或读取整个文件 +// 入参: args 文件列表、分块读取、分块写出、进度及取消检查 +// 返回: any 输出格式, error 打包错误 +func packFiles(args []js.Value) (any, error) { + if len(args) != 5 { + return nil, fmt.Errorf("missing archive arguments") + } + writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20) + archive := zip.NewWriter(writer) + files := args[0] + for i := 0; i < files.Length(); i++ { + if err := awaitExport(args[3], i, files.Length()); err != nil { + return nil, err + } + file := files.Index(i) + name := file.Get("name").String() + if !fs.ValidPath(name) || strings.Contains(name, "\\") { + return nil, fmt.Errorf("invalid archive path %q", name) + } + method := uint16(zip.Deflate) + switch strings.ToLower(path.Ext(name)) { + case ".ofd", ".pdf", ".png", ".jpg", ".jpeg": + method = zip.Store + } + entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method}) + if err != nil { + return nil, err + } + read := args[1].Invoke(i) + source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())} + if _, err := io.Copy(entry, io.NewSectionReader(source, 0, source.size)); err != nil { + return nil, err + } + } + if err := archive.Close(); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + if err := awaitExport(args[3], files.Length(), files.Length()); err != nil { + return nil, err + } + return map[string]any{"mime": "application/zip"}, nil +} + // openDocument 打开OFD文档 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 567f465..bbb19ea 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -137,12 +137,7 @@ type PageSVG struct { } // ExportFormat 导出格式 -type ExportFormat struct { - Value string `json:"value"` - Label string `json:"label"` - Extension string `json:"extension"` - MIME string `json:"mime"` -} +type ExportFormat = ofdgo.OutputFormat // SignatureInfo 签名验证信息 type SignatureInfo struct { @@ -257,16 +252,6 @@ type EncryptionInfo struct { Layers int `json:"layers,omitempty"` } -// supportedExportFormats 导出格式列表 -var supportedExportFormats = []ExportFormat{ - {Value: "pdf", Label: "PDF", Extension: "pdf", MIME: "application/pdf"}, - {Value: "svg", Label: "SVG", Extension: "svg", MIME: "image/svg+xml"}, - {Value: "eps", Label: "EPS", Extension: "eps", MIME: "application/postscript"}, - {Value: "png", Label: "PNG", Extension: "png", MIME: "image/png"}, - {Value: "jpg", Label: "JPG", Extension: "jpg", MIME: "image/jpeg"}, - {Value: "txt", Label: "TXT", Extension: "txt", MIME: "text/plain"}, -} - // Open 打开浏览器内存中的OFD文档 // 入参: data OFD文件数据, opts 打开选项 // 返回: *Session 文档会话, error 错误信息 @@ -676,9 +661,7 @@ func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) // ExportFormats 获取导出格式 // 返回: []ExportFormat 导出格式列表 func ExportFormats() []ExportFormat { - formats := make([]ExportFormat, len(supportedExportFormats)) - copy(formats, supportedExportFormats) - return formats + return ofdgo.OutputFormats()[1:] } // exportFormat 获取导出格式 @@ -689,7 +672,7 @@ func exportFormat(value string) (ExportFormat, bool) { if value == "jpeg" { value = "jpg" } - for _, format := range supportedExportFormats { + for _, format := range ExportFormats() { if format.Value == value { return format, true } diff --git a/ofdgo_convert.go b/ofdgo_convert.go new file mode 100644 index 0000000..78809cd --- /dev/null +++ b/ofdgo_convert.go @@ -0,0 +1,316 @@ +// 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 ( + "bytes" + "context" + "fmt" + "io" + "strings" +) + +// OutputFormat 描述文档输出格式,Paged表示逐页文件,Raster表示受DPI控制的位图 +type OutputFormat struct { + Value string `json:"value"` + Label string `json:"label"` + Extension string `json:"extension"` + MIME string `json:"mime"` + Paged bool `json:"paged,omitempty"` + Raster bool `json:"raster,omitempty"` +} + +// ConvertProgress 回报open、pages、convert、prepare、write、write.*及export阶段,Total为0表示总量未知 +type ConvertProgress struct { + Stage string `json:"stage"` + Completed int `json:"completed"` + Total int `json:"total"` +} + +// ConvertOptions 设置文档转换格式、页码、阅读和渲染选项 +// Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面 +// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 +// SkipUnchanged在源格式与目标相同且未选页时不写出数据,不校验或重新生成源文档 +// PDF先转换为OFD对象再输出,PDF选项中的进度回调仍会调用 +// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 +// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil +// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换 +type ConvertOptions struct { + Input string + Format string + Pages []int + PageRange string + SkipUnchanged bool + Backends *RenderBackends + ReaderOptions []ReaderOption + RendererOptions []RendererOption + PDF PDFImportOptions + OnProgress func(ConvertProgress) error + PageOutput func(index int, export func(io.Writer) error) error +} + +// ConvertReport 汇总源格式、输出格式、页数、字节数及PDF转换诊断 +// Unchanged表示无需转换或完整OFD原样复制,Bytes仅统计实际输出,不包含调用方额外封装 +type ConvertReport struct { + Input string `json:"input"` + Format OutputFormat `json:"format"` + Pages int `json:"pages"` + Bytes int64 `json:"bytes"` + Unchanged bool `json:"unchanged,omitempty"` + PDF PDFImportReport `json:"pdf"` +} + +// convertReader 将取消上下文传递到随机读取 +type convertReader struct { + context context.Context + source io.ReaderAt +} + +// convertWriter 将取消上下文传递到写入并统计实际字节数 +type convertWriter struct { + context context.Context + writer io.Writer + count *int64 +} + +// OutputFormats 返回独立的输出格式列表,不依赖WebUI或具体渲染后端 +// 返回: []OutputFormat 输出格式列表 +func OutputFormats() []OutputFormat { + return []OutputFormat{ + {Value: "ofd", Label: "OFD", Extension: "ofd", MIME: "application/ofd"}, + {Value: "pdf", Label: "PDF", Extension: "pdf", MIME: "application/pdf"}, + {Value: "svg", Label: "SVG", Extension: "svg", MIME: "image/svg+xml", Paged: true}, + {Value: "eps", Label: "EPS", Extension: "eps", MIME: "application/postscript", Paged: true}, + {Value: "png", Label: "PNG", Extension: "png", MIME: "image/png", Paged: true, Raster: true}, + {Value: "jpg", Label: "JPG", Extension: "jpg", MIME: "image/jpeg", Paged: true, Raster: true}, + {Value: "txt", Label: "TXT", Extension: "txt", MIME: "text/plain"}, + } +} + +// Convert 将PDF或OFD转换为指定格式,不关闭调用方的输入和输出 +// 完整OFD另存保持源字节,包括制作软件、签名及加密;选页和跨格式输出重新生成文档 +// 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存 +// 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功 +// 入参: 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 err = ctx.Err(); err != nil { + return report, err + } + format := strings.ToLower(strings.TrimSpace(options.Format)) + if format == "jpeg" { + format = "jpg" + } + for _, candidate := range OutputFormats() { + if candidate.Value == format { + report.Format = candidate + break + } + } + if report.Format.Value == "" { + return report, fmt.Errorf("unsupported output format %s", options.Format) + } + if options.PageOutput != nil && !report.Format.Paged { + return report, fmt.Errorf("page output requires a paged format") + } + if options.Pages != nil && len(options.Pages) == 0 { + return report, fmt.Errorf("no pages selected") + } + if options.Pages != nil && strings.TrimSpace(options.PageRange) != "" { + return report, fmt.Errorf("conflicting page selections") + } + progress := func(stage string, completed, total int) error { + if err := ctx.Err(); err != nil { + return err + } + if options.OnProgress != nil { + return options.OnProgress(ConvertProgress{Stage: stage, Completed: completed, Total: total}) + } + return nil + } + writeProgress := func(stage string, completed, total int) error { + if stage == "write" { + return progress(stage, completed, total) + } + return progress("write."+stage, completed, total) + } + if err = progress("open", 0, 0); err != nil { + return report, err + } + source = convertReader{context: ctx, source: source} + report.Input = strings.ToLower(strings.TrimSpace(options.Input)) + if report.Input == "" { + var header [1024]byte + n, readErr := source.ReadAt(header[:], 0) + if readErr != nil && readErr != io.EOF { + return report, readErr + } + switch { + case bytes.HasPrefix(header[:n], []byte("PK\x03\x04")): + report.Input = "ofd" + case bytes.Contains(header[:n], []byte("%PDF-")): + report.Input = "pdf" + } + } + var reader *Reader + var editor *Editor + var count int + if report.Input != "pdf" && report.Input != "ofd" { + return report, fmt.Errorf("unsupported input format") + } + if options.SkipUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { + report.Unchanged = true + return report, nil + } + if output == nil && options.PageOutput == nil { + return report, fmt.Errorf("missing output writer") + } + switch report.Input { + case "pdf": + pdfOptions := options.PDF + if options.Backends != nil { + pdfOptions.Backends = options.Backends + } + pdfOptions.OnProgress = func(stage string, completed, total int) error { + if err := progress(stage, completed, total); err != nil { + return err + } + if options.PDF.OnProgress != nil { + return options.PDF.OnProgress(stage, completed, total) + } + return nil + } + editor, report.PDF, err = ImportPDF(ctx, source, size, pdfOptions) + if err != nil { + return report, err + } + count = editor.PageCount() + case "ofd": + reader, err = NewReader(source, size, options.ReaderOptions...) + if err != nil { + return report, err + } + defer reader.Close() + count, err = reader.PageCount() + if err != nil { + return report, err + } + } + if strings.TrimSpace(options.PageRange) != "" { + options.Pages, err = ParsePageRange(options.PageRange, count) + if err != nil { + return report, err + } + } + indices, err := exportPageIndices(count, options.Pages) + if err != nil { + return report, err + } + report.Pages = len(indices) + writer := convertWriter{context: ctx, writer: output, count: &report.Bytes} + if format == "ofd" { + if options.Pages == nil && editor == nil { + if err = progress("write", 0, 0); err != nil { + return report, err + } + _, err = io.Copy(writer, io.NewSectionReader(source, 0, size)) + report.Unchanged = err == nil + return report, err + } + if editor == nil { + editor, err = reader.Editor() + if err != nil { + return report, err + } + } + editor.OnWriteProgress = writeProgress + if options.Pages == nil { + _, err = editor.WriteTo(writer) + } else { + _, err = editor.WritePagesTo(writer, indices) + } + return report, err + } + if reader == nil { + if err = progress("prepare", 0, count); err != nil { + return report, err + } + reader, err = editor.reader(progress) + if err != nil { + return report, err + } + defer reader.Close() + } + renderer := NewRenderer(reader, options.RendererOptions...) + if options.Backends != nil { + WithRenderBackends(*options.Backends)(renderer) + } + renderer.OnExportProgress = func(completed, total int) error { + return progress("export", completed, total) + } + switch format { + case "pdf": + err = renderer.RenderToMultiPagePDF(writer, indices...) + case "txt": + err = renderer.RenderToMultiPageText(writer, indices...) + default: + if options.PageOutput == nil { + err = renderer.RenderToZIP(writer, format, indices...) + report.Format = OutputFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"} + break + } + for i, index := range indices { + if err = progress("export", i, len(indices)); err != nil { + return report, err + } + page, readErr := reader.PageContentByIndex(index) + if readErr != nil { + return report, readErr + } + if err = options.PageOutput(index, func(output io.Writer) error { + return renderer.RenderTo(page, convertWriter{context: ctx, writer: output, count: &report.Bytes}, format) + }); err != nil { + return report, fmt.Errorf("export page %d: %w", index+1, err) + } + } + err = progress("export", len(indices), len(indices)) + } + return report, err +} + +// ReadAt 读取指定位置的数据并响应取消 +// 入参: data 目标缓冲, offset 源偏移 +// 返回: int 读取字节数, error 读取错误 +func (r convertReader) ReadAt(data []byte, offset int64) (int, error) { + if err := r.context.Err(); err != nil { + return 0, err + } + return r.source.ReadAt(data, offset) +} + +// Write 写出数据并响应取消及短写 +// 入参: data 待写数据 +// 返回: int 写入字节数, error 写入错误 +func (w convertWriter) Write(data []byte) (int, error) { + if err := w.context.Err(); err != nil { + return 0, err + } + n, err := w.writer.Write(data) + *w.count += int64(n) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return n, err +} diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index a6f227f..286f5c3 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -197,11 +197,18 @@ func ConvertPDF(ctx context.Context, source io.ReaderAt, size int64, output io.W if err := options.OnProgress("write", 0, 0); err != nil { return PDFImportReport{}, err } - editor.OnWriteProgress = func(stage string, completed, total int) error { + } + editor.OnWriteProgress = func(stage string, completed, total int) error { + if err := ctx.Err(); err != nil { + return err + } + if options.OnProgress != nil { return options.OnProgress("write."+stage, completed, total) } + return nil } - _, err = editor.WriteTo(output) + var written int64 + _, err = editor.WriteTo(convertWriter{context: ctx, writer: output, count: &written}) return report, err } diff --git a/ofdgo_renderer_font_loader_canvas.go b/ofdgo_renderer_font_loader_canvas.go index b493738..9280b3e 100644 --- a/ofdgo_renderer_font_loader_canvas.go +++ b/ofdgo_renderer_font_loader_canvas.go @@ -14,7 +14,11 @@ package ofdgo -import "github.com/tdewolff/canvas" +import ( + "fmt" + + "github.com/tdewolff/canvas" +) // loadFont 加载字体 // 入参: fontID 字体ID @@ -30,6 +34,13 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily { } of := r.Reader.fontCache[fontID] if len(resolved.Data) == 0 { + if !r.textOnly { + name := fontID + if of != nil && of.FontName != "" { + name = of.FontName + } + r.renderError = fmt.Errorf("font %q is unavailable", name) + } return nil } if of == nil { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 48b2323..0eb9c40 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -501,7 +501,9 @@ func (x *ofdXML) resources(fonts, images []editorResource, spaces []ColorSpace) attrs.add("FixedWidth", "true") } x.start("Font", attrs) - x.text("FontFile", font.FontFile) + if font.FontFile != "" { + x.text("FontFile", font.FontFile) + } x.end("Font") } x.end("Fonts")