mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 14:53:44 +08:00
1 parent
d9046af3b0
commit
ecf221c1c0
12 files changed
+1222
-30
No files matched your search
@@ -20,6 +20,7 @@
|
||||
<button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建"><span class="new-icon" aria-hidden="true"></span></button>
|
||||
<button id="saveButton" class="icon-button" type="button" title="保存" aria-label="保存" aria-keyshortcuts="Control+S Meta+S" disabled><span class="save-icon" aria-hidden="true"></span></button>
|
||||
<button id="editButton" class="icon-button" type="button" title="编辑" aria-label="编辑" aria-pressed="false" disabled><span class="edit-icon" aria-hidden="true"></span></button>
|
||||
<button id="convertButton" class="icon-button" type="button" title="转换" aria-label="转换" aria-haspopup="dialog" aria-controls="convertPanel"><span aria-hidden="true">⇄</span></button>
|
||||
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">☰</button>
|
||||
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">ⓘ</button>
|
||||
</div>
|
||||
@@ -171,6 +172,23 @@
|
||||
<button id="batchPagesButton" class="editor-group-action" type="button" title="批量页面" aria-label="批量页面" aria-haspopup="dialog" aria-controls="batchPagesPanel">页面</button>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="convertPanel" class="form-dialog convert-dialog" aria-labelledby="convertTitle">
|
||||
<form id="convertForm">
|
||||
<h2 id="convertTitle" class="form-title">批量转换<span id="convertCount"></span></h2>
|
||||
<input id="convertInput" class="file-input" type="file" accept=".pdf,.ofd" multiple>
|
||||
<div class="convert-table"><table><thead><tr><th scope="col">文件</th><th scope="col">页码</th><th scope="col">格式</th><th scope="col">状态</th><th scope="col">操作</th></tr></thead><tbody id="convertList"></tbody></table><div id="convertEmpty" class="convert-empty">暂无文件</div></div>
|
||||
<div class="convert-progress"><progress id="convertProgress" max="1" value="0" aria-label="转换进度"></progress><div id="convertStatus" role="status" aria-live="polite"></div></div>
|
||||
<div class="form-actions">
|
||||
<button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button>
|
||||
<div class="convert-options">
|
||||
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
|
||||
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
|
||||
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
|
||||
</div>
|
||||
<button id="convertClose" class="button" type="button">关闭</button><button id="convertCancel" class="button" type="button" hidden>取消</button><button id="convertStart" class="button" type="submit" disabled>转换</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="batchPagesPanel" class="form-dialog" aria-label="批量页面">
|
||||
<form id="batchPagesForm">
|
||||
<div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</option><option value="delete">删除</option><option value="save">另存</option></select></div>
|
||||
|
||||
@@ -690,6 +690,237 @@ button:not(:disabled):hover {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.convert-dialog {
|
||||
--convert-format-width: 64px;
|
||||
width: 720px;
|
||||
}
|
||||
|
||||
.convert-dialog .form-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.convert-options {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: calc(2em + 8px + var(--convert-format-width)) 132px calc(2em + 8px + var(--convert-format-width));
|
||||
gap: 12px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.convert-options:has(#convertDPIRow[hidden]) {
|
||||
grid-template-columns: repeat(2, calc(2em + 8px + var(--convert-format-width)));
|
||||
}
|
||||
|
||||
.convert-options .form-row {
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#convertCount {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.convert-table {
|
||||
max-height: 40dvh;
|
||||
min-height: 120px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.convert-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.convert-table th,
|
||||
.convert-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.convert-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #eef1f4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.convert-table th:nth-child(2) {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.convert-table th:nth-child(3),
|
||||
.convert-table th:nth-child(4) {
|
||||
width: calc(var(--convert-format-width) + 16px);
|
||||
}
|
||||
|
||||
.convert-table select,
|
||||
.convert-table input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--control-height);
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.convert-table input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.convert-table th:last-child,
|
||||
.convert-table td:last-child {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.convert-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.convert-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.convert-table tr:has(+ .convert-details) > td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.convert-table .convert-details > td {
|
||||
width: auto;
|
||||
padding-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.convert-table .convert-details details {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.convert-table .convert-details details[open] > summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.convert-messages {
|
||||
max-height: min(180px, 24dvh);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.convert-messages > li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.convert-table td[data-status="failed"] {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.convert-table td[data-status="done"] {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.convert-empty {
|
||||
padding: 28px 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.convert-progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.convert-dialog .form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.convert-dialog .form-actions .button {
|
||||
min-width: 3.5em;
|
||||
}
|
||||
|
||||
.convert-progress progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
#convertStatus {
|
||||
min-height: 1.5em;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#convertButton[data-running] {
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.convert-dialog .form-actions { flex-wrap: wrap; }
|
||||
.convert-options { order: -1; flex-basis: 100%; justify-content: center; margin: 0 0 4px; }
|
||||
#convertClose { margin-left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.convert-options {
|
||||
grid-template-columns: calc(2em + 8px + var(--convert-format-width)) 132px;
|
||||
}
|
||||
.convert-table table,
|
||||
.convert-table tbody { display: block; }
|
||||
.convert-table thead { display: none; }
|
||||
.convert-table tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.convert-table tr:last-child { border-bottom: 0; }
|
||||
.convert-table tr:has(+ .convert-details) { padding-bottom: 0; border-bottom: 0; }
|
||||
.convert-table td { min-width: 0; padding: 0; border: 0; }
|
||||
.convert-table td:first-child { flex: 0 0 calc(100% - var(--convert-format-width) - var(--control-height) - 16px); }
|
||||
.convert-table td:nth-child(4) { order: 1; flex: 0 0 var(--convert-format-width); text-align: right; }
|
||||
.convert-table td:last-child { order: 2; flex: 0 0 var(--control-height); }
|
||||
.convert-table td[data-label] {
|
||||
order: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 2em minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.convert-table td:nth-child(2) { flex: 1 1 0; }
|
||||
.convert-table td:nth-child(3) { flex: 0 0 calc(2em + 8px + var(--convert-format-width)); }
|
||||
.convert-table td[data-label]::before { content: attr(data-label); color: var(--text-muted); }
|
||||
.convert-table .convert-details > td { flex: 0 0 100%; }
|
||||
}
|
||||
|
||||
#sourceTextPanel {
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
+319
-1
@@ -19,6 +19,8 @@ const STATUS = {
|
||||
};
|
||||
const wasmRequests = new Map();
|
||||
const metaContents = new WeakMap();
|
||||
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 };
|
||||
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
|
||||
|
||||
let wasmPromise = null;
|
||||
let wasmWorker = null;
|
||||
@@ -1086,6 +1088,18 @@ el.pageForm.addEventListener("submit", async (event) => {
|
||||
}
|
||||
});
|
||||
el.ofdButton.addEventListener("click", openOFDFile);
|
||||
batchElements.Button.addEventListener("click", () => { updateBatchControls(); batchElements.Panel.showModal(); });
|
||||
batchElements.Add.addEventListener("click", () => batchElements.Input.click());
|
||||
batchElements.Input.addEventListener("change", () => { addBatchFiles(batchElements.Input.files); batchElements.Input.value = ""; });
|
||||
batchElements.Clear.addEventListener("click", () => { batch.items = []; renderBatchList(); batchElements.Status.textContent = ""; batchElements.Progress.value = 0; });
|
||||
batchElements.Format.addEventListener("change", updateBatchControls);
|
||||
batchElements.Close.addEventListener("click", () => batchElements.Panel.close());
|
||||
batchElements.Cancel.addEventListener("click", cancelBatch);
|
||||
batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); });
|
||||
batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; });
|
||||
batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); });
|
||||
batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker;
|
||||
batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive";
|
||||
el.newButton.addEventListener("click", openCreatePanel);
|
||||
el.editButton.addEventListener("click", toggleEditor);
|
||||
el.editButton.addEventListener("pointerdown", (event) => {
|
||||
@@ -1443,7 +1457,7 @@ function handleKeyDown(event) {
|
||||
}
|
||||
|
||||
function formDialogOpen() {
|
||||
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
|
||||
return batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
|
||||
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|
||||
}
|
||||
|
||||
@@ -3173,6 +3187,9 @@ async function refreshApplication() {
|
||||
}
|
||||
|
||||
async function loadExportFormats() {
|
||||
batch.formats = await callWASM("ofdgoOutputFormats");
|
||||
batchElements.Format.replaceChildren(...batch.formats.map(format => new Option(format.label, format.value)));
|
||||
updateBatchControls();
|
||||
const formats = await callWASM("ofdgoExportFormats") || [];
|
||||
state.exportFormats = formats;
|
||||
el.exportFormat.replaceChildren();
|
||||
@@ -4382,6 +4399,307 @@ async function updateExportRange() {
|
||||
}
|
||||
}
|
||||
|
||||
function addBatchFiles(files) {
|
||||
let rejected = false;
|
||||
for (const file of files) {
|
||||
if (!/\.(ofd|pdf)$/i.test(file.name)) { rejected = true; continue; }
|
||||
batch.items.push({ file, format: "", pages: "", status: "pending", text: "待转", details: [] });
|
||||
}
|
||||
if (rejected) batchElements.Status.textContent = "仅支持PDF、OFD";
|
||||
renderBatchList();
|
||||
}
|
||||
|
||||
function renderBatchList() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const item of batch.items) {
|
||||
const row = document.createElement("tr");
|
||||
const name = row.insertCell();
|
||||
const label = document.createElement("span");
|
||||
label.className = "convert-name";
|
||||
label.textContent = item.file.name;
|
||||
label.title = item.file.name;
|
||||
name.append(label);
|
||||
let detailRow;
|
||||
if (item.details.length) {
|
||||
detailRow = document.createElement("tr");
|
||||
detailRow.className = "convert-details";
|
||||
const cell = detailRow.insertCell();
|
||||
cell.colSpan = 5;
|
||||
const details = document.createElement("details");
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "提示";
|
||||
const messages = document.createElement("ul");
|
||||
messages.className = "convert-messages";
|
||||
messages.tabIndex = 0;
|
||||
messages.setAttribute("aria-label", `${item.file.name}的转换提示`);
|
||||
for (const message of item.details) {
|
||||
const entry = document.createElement("li");
|
||||
entry.textContent = message;
|
||||
messages.append(entry);
|
||||
}
|
||||
details.append(summary, messages);
|
||||
cell.append(details);
|
||||
}
|
||||
const reset = () => {
|
||||
item.details = [];
|
||||
detailRow?.remove();
|
||||
batchStatus(item, "pending", "待转");
|
||||
batchElements.Status.textContent = "";
|
||||
batchElements.Progress.value = 0;
|
||||
updateBatchControls();
|
||||
};
|
||||
const pagesCell = row.insertCell();
|
||||
pagesCell.dataset.label = "页码";
|
||||
const pages = document.createElement("input");
|
||||
pages.type = "text";
|
||||
pages.placeholder = "全部";
|
||||
pages.title = "如1-3,5;留空为全部";
|
||||
pages.setAttribute("aria-label", `${item.file.name}的输出页码`);
|
||||
pages.value = item.pages;
|
||||
pages.disabled = batch.running;
|
||||
pages.addEventListener("change", () => { item.pages = pages.value; reset(); });
|
||||
pagesCell.append(pages);
|
||||
const formatCell = row.insertCell();
|
||||
formatCell.dataset.label = "格式";
|
||||
const format = document.createElement("select");
|
||||
format.setAttribute("aria-label", `${item.file.name}的输出格式`);
|
||||
format.append(new Option("默认", ""), ...batch.formats.map(value => new Option(value.label, value.value)));
|
||||
format.value = item.format;
|
||||
format.disabled = batch.running;
|
||||
format.addEventListener("change", () => { item.format = format.value; reset(); });
|
||||
formatCell.append(format);
|
||||
item.cell = row.insertCell();
|
||||
item.cell.textContent = item.text;
|
||||
item.cell.dataset.status = item.status;
|
||||
const action = row.insertCell();
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "icon-button";
|
||||
remove.textContent = "×";
|
||||
remove.title = "移除";
|
||||
remove.setAttribute("aria-label", `移除${item.file.name}`);
|
||||
remove.disabled = batch.running;
|
||||
remove.addEventListener("click", () => { batch.items.splice(batch.items.indexOf(item), 1); renderBatchList(); });
|
||||
action.append(remove);
|
||||
fragment.append(row);
|
||||
if (detailRow) fragment.append(detailRow);
|
||||
}
|
||||
batchElements.List.replaceChildren(fragment);
|
||||
batchElements.Empty.hidden = batch.items.length > 0;
|
||||
updateBatchControls();
|
||||
}
|
||||
|
||||
function updateBatchControls() {
|
||||
for (const name of ["Add", "Clear", "Format", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
|
||||
batchElements.Clear.disabled ||= !batch.items.length;
|
||||
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
|
||||
batchElements.Start.hidden = batch.running;
|
||||
batchElements.Cancel.hidden = !batch.running;
|
||||
batchElements.Cancel.disabled = batch.canceled;
|
||||
batchElements.Close.textContent = batch.running ? "收起" : "关闭";
|
||||
batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : "";
|
||||
const formats = batch.items.length ? batch.items.map(item => item.format || batchElements.Format.value) : [batchElements.Format.value];
|
||||
batchElements.DPIRow.hidden = !formats.some(value => batch.formats.find(format => format.value === value)?.raster);
|
||||
batchElements.Button.toggleAttribute("data-running", batch.running);
|
||||
}
|
||||
|
||||
function batchStatus(item, status, text) {
|
||||
item.status = status;
|
||||
item.text = text;
|
||||
item.cell.textContent = text;
|
||||
item.cell.dataset.status = status;
|
||||
}
|
||||
|
||||
function batchProgress(item, position, count, progress) {
|
||||
const now = performance.now();
|
||||
if (now - batch.progressTime < 80 && progress.completed !== progress.total) return;
|
||||
batch.progressTime = now;
|
||||
const stage = progress.phase?.startsWith("write.") ? "写入" : { open: "读取", pages: "解析", convert: "转换", prepare: "准备", fonts: "字体", resources: "资源", references: "检查", write: "写入", export: "导出", pack: "打包", commit: "保存" }[progress.phase] || "处理";
|
||||
const detail = progress.total > 0 ? `${progress.completed}/${progress.total}` : "";
|
||||
if (item) batchStatus(item, "running", stage);
|
||||
batchElements.Status.textContent = progress.phase === "pack" ? `打包 ${detail}` : `文件 ${position + 1}/${count} · ${stage}${detail ? ` ${detail}` : ""}`;
|
||||
batchElements.Progress.max = count;
|
||||
batchElements.Progress.value = progress.phase === "pack" && progress.total > 0 ? progress.completed / progress.total : position;
|
||||
}
|
||||
|
||||
function batchCall(name, args, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (batch.workerError) { reject(batch.workerError); return; }
|
||||
const id = ++batch.sequence;
|
||||
batch.activeID = id;
|
||||
batch.requests.set(id, { resolve, reject, onProgress, destination: name === "ofdgoConvertFile" ? args[3] : null });
|
||||
try { batch.worker.postMessage({ id, name, args }); }
|
||||
catch (err) { batch.requests.delete(id); batch.activeID = 0; reject(err); }
|
||||
});
|
||||
}
|
||||
|
||||
function startBatchWorker() {
|
||||
batch.workerError = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker("./ofdgo_wasm.js");
|
||||
batch.worker = worker;
|
||||
const fail = async error => {
|
||||
batch.workerError = error;
|
||||
worker.terminate();
|
||||
reject(error);
|
||||
const requests = [...batch.requests.values()];
|
||||
batch.requests.clear();
|
||||
for (const request of requests) {
|
||||
if (request.destination && request.outputName) {
|
||||
try { await request.destination.removeEntry(request.outputName, { recursive: true }); }
|
||||
catch (err) { error = new Error(`${error.message};清理失败:${err.message}`); }
|
||||
}
|
||||
request.reject(error);
|
||||
}
|
||||
};
|
||||
worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
|
||||
worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
|
||||
worker.onmessage = ({ data }) => {
|
||||
if (data.type === "ready") { resolve(); return; }
|
||||
if (data.type === "exit") { fail(new Error(data.error)); return; }
|
||||
if (data.type === "progress") { batchElements.Status.textContent = data.text; return; }
|
||||
const request = batch.requests.get(data.id);
|
||||
if (!request) return;
|
||||
if (data.type === "output") { request.outputName = data.name; return; }
|
||||
if (data.type === "conversion") { request.onProgress?.(data); return; }
|
||||
batch.requests.delete(data.id);
|
||||
if (batch.activeID === data.id) batch.activeID = 0;
|
||||
if (data.ok) request.resolve(data.data);
|
||||
else request.reject(Object.assign(new Error(data.error), { code: data.code, name: data.canceled ? "AbortError" : "Error" }));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function cancelBatch() {
|
||||
batch.canceled = true;
|
||||
batchElements.Cancel.disabled = true;
|
||||
batchElements.Status.textContent = "正在取消";
|
||||
if (batch.activeID) batch.worker.postMessage({ type: "cancel", id: batch.activeID });
|
||||
}
|
||||
|
||||
function warnBatch(event) {
|
||||
if (batch.running) { event.preventDefault(); event.returnValue = ""; }
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
if (batch.running || !batch.items.length) return;
|
||||
const defaultFormat = batch.formats.find(format => format.value === batchElements.Format.value);
|
||||
if (!defaultFormat) return;
|
||||
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
|
||||
const dpi = Number(batchElements.DPI.value);
|
||||
const archive = batchElements.Destination.value === "archive";
|
||||
const backend = state.renderBackend;
|
||||
for (const item of batch.items) {
|
||||
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
||||
const key = JSON.stringify([format.value, pageRange(item), format.raster ? dpi : 0, archive, backend]);
|
||||
if (item.settingsKey !== key) {
|
||||
item.status = "pending";
|
||||
item.text = "待转";
|
||||
item.details = [];
|
||||
item.settingsKey = key;
|
||||
}
|
||||
}
|
||||
renderBatchList();
|
||||
let jobs = batch.items.filter(item => item.status !== "done" && item.status !== "unchanged");
|
||||
if (!jobs.length) {
|
||||
jobs = [...batch.items];
|
||||
for (const item of jobs) { item.status = "pending"; item.text = "待转"; item.details = []; }
|
||||
}
|
||||
batch.running = true;
|
||||
batch.canceled = false;
|
||||
renderBatchList();
|
||||
window.addEventListener("beforeunload", warnBatch);
|
||||
let destination = null;
|
||||
let zipFile = null;
|
||||
let storage = null;
|
||||
let stagingName = "";
|
||||
let packed = false;
|
||||
const entries = [];
|
||||
try {
|
||||
if (archive) {
|
||||
if (window.showSaveFilePicker) zipFile = await window.showSaveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] });
|
||||
if (navigator.storage?.getDirectory) {
|
||||
storage = await navigator.storage.getDirectory();
|
||||
stagingName = `ofdgo-convert-${crypto.randomUUID()}`;
|
||||
destination = await storage.getDirectoryHandle(stagingName, { create: true });
|
||||
}
|
||||
} else destination = await window.showDirectoryPicker({ mode: "readwrite" });
|
||||
if (batch.canceled) return;
|
||||
batchElements.Status.textContent = "正在准备引擎";
|
||||
await startBatchWorker();
|
||||
const fonts = await fontManager.files(fontManager.records());
|
||||
const names = new Set();
|
||||
for (const [position, item] of jobs.entries()) {
|
||||
if (batch.canceled) break;
|
||||
item.details = [];
|
||||
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
||||
const base = item.file.name.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
|
||||
let unique = base;
|
||||
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
|
||||
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
|
||||
names.add(outputName());
|
||||
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null };
|
||||
try {
|
||||
for (;;) {
|
||||
try {
|
||||
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
|
||||
for (const file of result.files) entries.push(file);
|
||||
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
|
||||
if (result.unchanged) {
|
||||
batchStatus(item, "unchanged", "原样");
|
||||
item.details = ["无需转换,源文件未改动"];
|
||||
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
|
||||
break;
|
||||
} catch (err) {
|
||||
if (batch.canceled || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err;
|
||||
options.credentials?.password?.fill(0);
|
||||
options.credentials?.key?.fill(0);
|
||||
options.credentials?.keyPassword?.fill(0);
|
||||
options.credentials = await requestCredentials(err.code, state.openSeq);
|
||||
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
|
||||
if (!window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
batchStatus(item, err.name === "AbortError" ? "canceled" : "failed", err.name === "AbortError" ? "取消" : "失败");
|
||||
item.details = [err.message];
|
||||
if (batch.workerError) throw err;
|
||||
} finally {
|
||||
options.credentials?.password?.fill(0);
|
||||
options.credentials?.key?.fill(0);
|
||||
options.credentials?.keyPassword?.fill(0);
|
||||
}
|
||||
batchElements.Progress.max = jobs.length;
|
||||
batchElements.Progress.value = position + 1;
|
||||
}
|
||||
if (archive && entries.length && !batch.canceled) {
|
||||
const result = await batchCall("ofdgoPackFiles", [entries, zipFile], progress => batchProgress(null, 0, 1, progress));
|
||||
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
|
||||
packed = true;
|
||||
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
|
||||
}
|
||||
const done = jobs.filter(item => item.status === "done").length;
|
||||
const failed = jobs.filter(item => item.status === "failed").length;
|
||||
const unchanged = batch.items.filter(item => item.status === "unchanged").length;
|
||||
batchElements.Status.textContent = `${batch.canceled ? "已取消 · " : ""}完成 ${done}${failed ? ` · 失败 ${failed}` : ""}${unchanged ? ` · 原样 ${unchanged}` : ""}`;
|
||||
} catch (err) {
|
||||
batchElements.Status.textContent = err.name === "AbortError" ? "转换已取消" : err.message;
|
||||
} finally {
|
||||
if (archive && !packed) for (const item of jobs) if (item.status === "ready") { item.status = "pending"; item.text = "待转"; }
|
||||
batch.worker?.terminate();
|
||||
batch.worker = null;
|
||||
batch.requests.clear();
|
||||
batch.activeID = 0;
|
||||
if (storage && stagingName) {
|
||||
try { await storage.removeEntry(stagingName, { recursive: true }); }
|
||||
catch { batchElements.Status.textContent += " · 临时文件清理失败"; }
|
||||
}
|
||||
batch.running = false;
|
||||
window.removeEventListener("beforeunload", warnBatch);
|
||||
renderBatchList();
|
||||
}
|
||||
}
|
||||
|
||||
async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) {
|
||||
if (!state.doc || document.body.hasAttribute("aria-busy")) {
|
||||
return;
|
||||
|
||||
+129
-2
@@ -7,11 +7,138 @@ self.onmessage = ({ data }) => {
|
||||
operations.get(data.id)?.abort();
|
||||
return;
|
||||
}
|
||||
if (data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
|
||||
if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
|
||||
operations.set(data.id, new AbortController());
|
||||
}
|
||||
pending = pending.then(() => handleMessage(data));
|
||||
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data));
|
||||
};
|
||||
|
||||
async function availableOutput(directory, base, format) {
|
||||
for (let suffix = 0; ; suffix++) {
|
||||
const stem = suffix ? `${base} (${suffix + 1})` : base;
|
||||
const name = format.paged ? stem : `${stem}.${format.extension}`;
|
||||
try {
|
||||
await directory.getFileHandle(name);
|
||||
} catch (err) {
|
||||
if (err.name === "NotFoundError") return { stem, name };
|
||||
if (err.name !== "TypeMismatchError") throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchMessage({ id, name, args }) {
|
||||
const signal = operations.get(id).signal;
|
||||
const channel = new MessageChannel();
|
||||
const finish = (done, err) => {
|
||||
channel.port1.onmessage = () => done(err?.message || "", signal.aborted);
|
||||
channel.port2.postMessage(null);
|
||||
};
|
||||
const reader = new FileReaderSync();
|
||||
const read = file => (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)));
|
||||
let output = null;
|
||||
let chunks = [];
|
||||
let files = [];
|
||||
let directory = null;
|
||||
let created = null;
|
||||
let root = null;
|
||||
let current = null;
|
||||
let size = 0;
|
||||
let credentials = null;
|
||||
const write = (bytes, done) => {
|
||||
if (signal.aborted) return finish(done);
|
||||
size += bytes.byteLength;
|
||||
if (output) output.write(bytes).then(() => finish(done), err => finish(done, err));
|
||||
else { chunks.push(new Blob([bytes])); finish(done); }
|
||||
};
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
let payload;
|
||||
if (name === "ofdgoConvertFile") {
|
||||
const [file, options, fonts, destination] = args;
|
||||
credentials = options.credentials;
|
||||
const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data;
|
||||
const format = formats.find(item => item.value === options.format);
|
||||
if (!format) throw new Error("不支持此格式");
|
||||
root = destination;
|
||||
const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
|
||||
const target = root ? await availableOutput(root, base, format) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
|
||||
if (root && format.paged) {
|
||||
directory = await root.getDirectoryHandle(target.name, { create: true });
|
||||
created = target.name;
|
||||
self.postMessage({ id, type: "output", name: created });
|
||||
} else directory = root;
|
||||
const boundary = async (action, pageName) => {
|
||||
signal.throwIfAborted();
|
||||
if (action === "open") {
|
||||
const fileName = format.paged ? pageName : target.name;
|
||||
current = { name: format.paged ? `${target.stem}/${pageName}` : fileName, mime: format.mime };
|
||||
chunks = [];
|
||||
if (directory) {
|
||||
const handle = await directory.getFileHandle(fileName, { create: true });
|
||||
if (!format.paged) {
|
||||
created = fileName;
|
||||
self.postMessage({ id, type: "output", name: created });
|
||||
}
|
||||
current.handle = handle;
|
||||
output = await handle.createWritable();
|
||||
}
|
||||
} else {
|
||||
if (output) { await output.close(); output = null; }
|
||||
if (options.archive) current.file = current.handle ? await current.handle.getFile() : new File(chunks, pageName, { type: format.mime });
|
||||
delete current.handle;
|
||||
files.push(current);
|
||||
chunks = [];
|
||||
}
|
||||
};
|
||||
payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write,
|
||||
(phase, completed, total, done) => {
|
||||
self.postMessage({ id, type: "conversion", phase, completed, total });
|
||||
finish(done);
|
||||
},
|
||||
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
|
||||
done => finish(done), fonts);
|
||||
} else {
|
||||
const [entries, handle] = args;
|
||||
if (handle) output = await handle.createWritable();
|
||||
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
|
||||
index => read(entries[index].file), write,
|
||||
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
|
||||
done => finish(done));
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
if (!result.ok) throw Object.assign(new Error(result.error), { code: result.code });
|
||||
if (name === "ofdgoPackFiles") {
|
||||
if (output) { await output.close(); output = null; }
|
||||
else result.data.blob = new Blob(chunks, { type: "application/zip" });
|
||||
} else {
|
||||
if (result.data.unchanged) {
|
||||
if (output) { await output.abort(); output = null; }
|
||||
if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; }
|
||||
files = [];
|
||||
}
|
||||
result.data.files = files;
|
||||
}
|
||||
result.data.size = size;
|
||||
operations.delete(id);
|
||||
self.postMessage({ id, ...result });
|
||||
} catch (err) {
|
||||
await output?.abort().catch(() => {});
|
||||
let message = signal.aborted ? "转换已取消" : err.message;
|
||||
if (root && created) {
|
||||
try { await root.removeEntry(created, { recursive: true }); }
|
||||
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
|
||||
}
|
||||
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted });
|
||||
} finally {
|
||||
credentials?.password?.fill(0);
|
||||
credentials?.key?.fill(0);
|
||||
credentials?.keyPassword?.fill(0);
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
operations.delete(id);
|
||||
}
|
||||
}
|
||||
async function handleMessage({ id, name, args }) {
|
||||
let output;
|
||||
let channel;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 错误信息
|
||||
|
||||
+3
-20
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+9
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user