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

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

No files matched your search

+18
View File
@@ -20,6 +20,7 @@
<button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建"><span class="new-icon" aria-hidden="true"></span></button> <button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建"><span class="new-icon" aria-hidden="true"></span></button>
<button id="saveButton" class="icon-button" type="button" title="保存" aria-label="保存" aria-keyshortcuts="Control+S Meta+S" disabled><span class="save-icon" aria-hidden="true"></span></button> <button id="saveButton" class="icon-button" type="button" title="保存" aria-label="保存" aria-keyshortcuts="Control+S Meta+S" disabled><span class="save-icon" aria-hidden="true"></span></button>
<button id="editButton" class="icon-button" type="button" title="编辑" aria-label="编辑" aria-pressed="false" disabled><span class="edit-icon" aria-hidden="true"></span></button> <button id="editButton" class="icon-button" type="button" title="编辑" aria-label="编辑" aria-pressed="false" disabled><span class="edit-icon" aria-hidden="true"></span></button>
<button id="convertButton" class="icon-button" type="button" title="转换" aria-label="转换" aria-haspopup="dialog" aria-controls="convertPanel"><span aria-hidden="true">&#8644;</span></button>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button> <button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button> <button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
</div> </div>
@@ -171,6 +172,23 @@
<button id="batchPagesButton" class="editor-group-action" type="button" title="批量页面" aria-label="批量页面" aria-haspopup="dialog" aria-controls="batchPagesPanel">页面</button> <button id="batchPagesButton" class="editor-group-action" type="button" title="批量页面" aria-label="批量页面" aria-haspopup="dialog" aria-controls="batchPagesPanel">页面</button>
</div> </div>
</div> </div>
<dialog id="convertPanel" class="form-dialog convert-dialog" aria-labelledby="convertTitle">
<form id="convertForm">
<h2 id="convertTitle" class="form-title">批量转换<span id="convertCount"></span></h2>
<input id="convertInput" class="file-input" type="file" accept=".pdf,.ofd" multiple>
<div class="convert-table"><table><thead><tr><th scope="col">文件</th><th scope="col">页码</th><th scope="col">格式</th><th scope="col">状态</th><th scope="col">操作</th></tr></thead><tbody id="convertList"></tbody></table><div id="convertEmpty" class="convert-empty">暂无文件</div></div>
<div class="convert-progress"><progress id="convertProgress" max="1" value="0" aria-label="转换进度"></progress><div id="convertStatus" role="status" aria-live="polite"></div></div>
<div class="form-actions">
<button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button>
<div class="convert-options">
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
</div>
<button id="convertClose" class="button" type="button">关闭</button><button id="convertCancel" class="button" type="button" hidden>取消</button><button id="convertStart" class="button" type="submit" disabled>转换</button>
</div>
</form>
</dialog>
<dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面"> <dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面">
<form id="batchPagesForm"> <form id="batchPagesForm">
<div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</option><option value="delete">删除</option><option value="save">另存</option></select></div> <div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</option><option value="delete">删除</option><option value="save">另存</option></select></div>
+231
View File
@@ -690,6 +690,237 @@ button:not(:disabled):hover {
line-height: 1.5; 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 { #sourceTextPanel {
width: 440px; width: 440px;
} }
+319 -1
View File
@@ -19,6 +19,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 };
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 wasmPromise = null;
let wasmWorker = null; let wasmWorker = null;
@@ -1086,6 +1088,18 @@ el.pageForm.addEventListener("submit", async (event) => {
} }
}); });
el.ofdButton.addEventListener("click", openOFDFile); 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.newButton.addEventListener("click", openCreatePanel);
el.editButton.addEventListener("click", toggleEditor); el.editButton.addEventListener("click", toggleEditor);
el.editButton.addEventListener("pointerdown", (event) => { el.editButton.addEventListener("pointerdown", (event) => {
@@ -1443,7 +1457,7 @@ function handleKeyDown(event) {
} }
function formDialogOpen() { 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; || 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() { 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") || []; const formats = await callWASM("ofdgoExportFormats") || [];
state.exportFormats = formats; state.exportFormats = formats;
el.exportFormat.replaceChildren(); 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) { async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) {
if (!state.doc || document.body.hasAttribute("aria-busy")) { if (!state.doc || document.body.hasAttribute("aria-busy")) {
return; return;
+129 -2
View File
@@ -7,11 +7,138 @@ self.onmessage = ({ data }) => {
operations.get(data.id)?.abort(); operations.get(data.id)?.abort();
return; 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()); 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 }) { async function handleMessage({ id, name, args }) {
let output; let output;
let channel; let channel;
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1 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/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/text v0.42.0 golang.org/x/text v0.42.0
+2 -2
View File
@@ -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/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 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= 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-20260926052151-12ff0e0004de h1:Aiyem3mCMV7+EAAGLYaI2XQGwEK1bZ1Bz+xTF5f0xiY=
github.com/xiaoqidun/pdfgo v0.0.0-20260926024032-ab5bec77f380/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= 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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+179
View File
@@ -17,6 +17,7 @@
package webui package webui
import ( import (
"archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
@@ -28,6 +29,7 @@ import (
"image" "image"
"image/color" "image/color"
"io" "io"
"io/fs"
"math" "math"
"path" "path"
"reflect" "reflect"
@@ -45,6 +47,16 @@ type exportWriter struct {
write js.Value write js.Value
} }
// browserReaderAt 分块读取浏览器文件,只缓存最近的一个数据块
type browserReaderAt struct {
read js.Value
checkpoint js.Value
size int64
offset int64
data []byte
checked time.Time
}
// apiResult 浏览器接口返回结果 // apiResult 浏览器接口返回结果
type apiResult struct { type apiResult struct {
OK bool `json:"ok"` OK bool `json:"ok"`
@@ -133,6 +145,45 @@ func (w exportWriter) Write(data []byte) (int, error) {
return size, nil 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 等待浏览器处理导出检查点 // awaitExport 等待浏览器处理导出检查点
// 入参: fn 浏览器回调, args 回调参数 // 入参: fn 浏览器回调, args 回调参数
// 返回: error 写入错误或取消原因 // 返回: error 写入错误或取消原因
@@ -157,6 +208,9 @@ func awaitExport(fn js.Value, args ...any) error {
func RunWASM() { func RunWASM() {
registerCallback("ofdgoOpen", openDocument) registerCallback("ofdgoOpen", openDocument)
registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument)
registerAsyncCallback("ofdgoConvertFile", convertFile)
registerAsyncCallback("ofdgoPackFiles", packFiles)
registerCallback("ofdgoOutputFormats", func([]js.Value) (any, error) { return ofdgo.OutputFormats(), nil })
registerCallback("ofdgoConfigure", configureDocument) registerCallback("ofdgoConfigure", configureDocument)
registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoDocumentInfo", documentInfo)
registerCallback("ofdgoVerifySignatures", verifySignatures) 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 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文档 // openDocument 打开OFD文档
// 入参: args 浏览器参数 // 入参: args 浏览器参数
// 返回: any 文档信息, error 错误信息 // 返回: any 文档信息, error 错误信息
+3 -20
View File
@@ -137,12 +137,7 @@ type PageSVG struct {
} }
// ExportFormat 导出格式 // ExportFormat 导出格式
type ExportFormat struct { type ExportFormat = ofdgo.OutputFormat
Value string `json:"value"`
Label string `json:"label"`
Extension string `json:"extension"`
MIME string `json:"mime"`
}
// SignatureInfo 签名验证信息 // SignatureInfo 签名验证信息
type SignatureInfo struct { type SignatureInfo struct {
@@ -257,16 +252,6 @@ type EncryptionInfo struct {
Layers int `json:"layers,omitempty"` 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文档 // Open 打开浏览器内存中的OFD文档
// 入参: data OFD文件数据, opts 打开选项 // 入参: data OFD文件数据, opts 打开选项
// 返回: *Session 文档会话, error 错误信息 // 返回: *Session 文档会话, error 错误信息
@@ -676,9 +661,7 @@ func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error)
// ExportFormats 获取导出格式 // ExportFormats 获取导出格式
// 返回: []ExportFormat 导出格式列表 // 返回: []ExportFormat 导出格式列表
func ExportFormats() []ExportFormat { func ExportFormats() []ExportFormat {
formats := make([]ExportFormat, len(supportedExportFormats)) return ofdgo.OutputFormats()[1:]
copy(formats, supportedExportFormats)
return formats
} }
// exportFormat 获取导出格式 // exportFormat 获取导出格式
@@ -689,7 +672,7 @@ func exportFormat(value string) (ExportFormat, bool) {
if value == "jpeg" { if value == "jpeg" {
value = "jpg" value = "jpg"
} }
for _, format := range supportedExportFormats { for _, format := range ExportFormats() {
if format.Value == value { if format.Value == value {
return format, true return format, true
} }
+316
View File
@@ -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
}
+8 -1
View File
@@ -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 { if err := options.OnProgress("write", 0, 0); err != nil {
return PDFImportReport{}, err 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 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 return report, err
} }
+12 -1
View File
@@ -14,7 +14,11 @@
package ofdgo package ofdgo
import "github.com/tdewolff/canvas" import (
"fmt"
"github.com/tdewolff/canvas"
)
// loadFont 加载字体 // loadFont 加载字体
// 入参: fontID 字体ID // 入参: fontID 字体ID
@@ -30,6 +34,13 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily {
} }
of := r.Reader.fontCache[fontID] of := r.Reader.fontCache[fontID]
if len(resolved.Data) == 0 { 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 return nil
} }
if of == nil { if of == nil {
+2
View File
@@ -501,7 +501,9 @@ func (x *ofdXML) resources(fonts, images []editorResource, spaces []ColorSpace)
attrs.add("FixedWidth", "true") attrs.add("FixedWidth", "true")
} }
x.start("Font", attrs) x.start("Font", attrs)
if font.FontFile != "" {
x.text("FontFile", font.FontFile) x.text("FontFile", font.FontFile)
}
x.end("Font") x.end("Font")
} }
x.end("Fonts") x.end("Fonts")