feat(文档压缩): 支持输出压缩并优化布局
build.yaml / build (push) Successful in 43s

This commit is contained in:
xiaoqidun committed 2026-09-29 19:47:17 +08:00
1 parent 870c29a83f
commit 9fd9ba7a28
20 files changed
+862 -97

No files matched your search

+2
View File
@@ -57,6 +57,7 @@
<option value="300" selected>300 dpi</option>
<option value="600">600 dpi</option>
</select><span id="dpiValue" aria-hidden="true">300</span></label>
<label class="toolbar-select"><select id="outputCompression" class="format-select" title="压缩" aria-label="压缩" disabled><option value="0">不变</option><option value="1">无损</option><option value="2">有损</option></select><span id="compressionValue" data-short="原" aria-hidden="true">不变</span></label>
<button id="exportPageButton" class="button" type="button" data-short="单" title="导出单页" aria-label="导出单页" disabled>单页</button>
<button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
</div>
@@ -183,6 +184,7 @@
<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 class="form-row"><label for="convertCompression">压缩</label><select id="convertCompression"><option value="0">不变</option><option value="1">无损</option><option value="2">有损</option></select></div>
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
</div>
+19 -12
View File
@@ -723,13 +723,16 @@ button:not(:disabled):hover {
.convert-options {
flex: none;
display: grid;
grid-template-columns: calc(2em + 8px + var(--convert-control-width)) 132px calc(2em + 8px + var(--convert-control-width));
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-column: 1 / -1;
grid-row: 1;
width: 100%;
gap: 12px;
margin: 0 auto;
}
.convert-options:has(#convertDPIRow[hidden]) {
grid-template-columns: repeat(2, calc(2em + 8px + var(--convert-control-width)));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.convert-options .form-row {
@@ -906,7 +909,8 @@ button:not(:disabled):hover {
}
.convert-dialog .form-actions {
display: flex;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
justify-content: flex-end;
margin-top: 12px;
@@ -914,8 +918,14 @@ button:not(:disabled):hover {
.convert-dialog .form-actions .button {
min-width: 3.5em;
grid-row: 2;
}
#convertAdd { grid-column: 1; }
#convertClear { grid-column: 2; }
#convertClose { grid-column: 4; justify-self: end; }
#convertCancel, #convertStart { grid-column: 5; justify-self: end; }
.convert-progress progress {
display: block;
width: 100%;
@@ -951,19 +961,18 @@ button:not(:disabled):hover {
}
.convert-dialog .form-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 12px;
}
.convert-dialog .form-actions > .button { grid-row: 2; }
.convert-options {
grid-column: 1 / -1;
grid-row: 1;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
margin: 0;
}
.convert-options:has(#convertDPIRow[hidden]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.convert-options .form-row {
grid-template-columns: minmax(0, 1fr);
@@ -971,15 +980,10 @@ button:not(:disabled):hover {
text-align: left;
}
.convert-options:has(#convertDPIRow[hidden]) .form-row {
grid-template-columns: 2em minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.convert-options label { width: auto; }
#convertAdd { grid-column: 1; }
#convertClear { grid-column: 2; }
#convertClose { grid-column: 3; }
#convertCancel,
#convertStart { grid-column: 4; }
}
@media (max-width: 620px) {
@@ -3089,6 +3093,9 @@ button.outline-link:hover {
.toolbar-select > span {
padding: 0;
justify-content: center;
font-size: 11px;
}
#compressionValue { font-size: 0; }
#compressionValue::after { content: attr(data-short); font-size: 12px; }
}
+27 -13
View File
@@ -20,7 +20,7 @@ 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}`)]));
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
let wasmPromise = null;
let remoteDownload = null;
@@ -403,6 +403,8 @@ const el = {
continuousButton: document.querySelector("#continuousButton"),
annotationButton: document.querySelector("#annotationButton"),
imageDPI: document.querySelector("#imageDPI"),
outputCompression: document.querySelector("#outputCompression"),
compressionValue: document.querySelector("#compressionValue"),
dpiValue: document.querySelector("#dpiValue"),
formatValue: document.querySelector("#formatValue"),
exportFormat: document.querySelector("#exportFormat"),
@@ -1250,6 +1252,10 @@ document.addEventListener("cut", copySelection);
el.viewerPanel.addEventListener("paste", pasteEditorContent);
document.addEventListener("selectionchange", syncSelection);
el.exportFormat.addEventListener("change", () => updateDPIControl());
el.outputCompression.addEventListener("change", () => {
el.compressionValue.textContent = el.outputCompression.selectedOptions[0].textContent;
el.compressionValue.dataset.short = ["原", "无", "有"][Number(el.outputCompression.value)];
});
el.exportPageButton.addEventListener("click", () => exportFile(false));
el.exportButton.addEventListener("click", () => openExportPanel());
el.exportCancel.addEventListener("click", () => el.exportPanel.close());
@@ -4640,6 +4646,7 @@ async function signDocument(event) {
pages:el.signPages.value.trim(), placement:el.signPlacement.value,
x:Number(el.signX.value), y:Number(el.signY.value), width:Number(el.signWidth.value), height:Number(el.signHeight.value),
mode:el.signMode.value, lock:el.signLock.checked,
compression: { mode: Number(el.outputCompression.value) || 0 },
};
el.signKey.value = "";
if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return;
@@ -4842,7 +4849,8 @@ function renderBatchList() {
}
function updateBatchControls() {
for (const name of ["Add", "Clear", "Format", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
for (const name of ["Add", "Clear", "Format", "Compression", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
batchElements.Clear.disabled ||= !batch.items.length;
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
batchElements.Start.hidden = batch.running;
@@ -4957,12 +4965,13 @@ async function runBatch() {
if (!defaultFormat) return;
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
const dpi = Number(batchElements.DPI.value);
const compression = { mode: Number(batchElements.Compression.value) || 0 };
const archive = batchElements.Destination.value === "archive";
const backend = state.renderBackend;
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
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), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey]);
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression]);
if (item.settingsKey !== key) {
item.status = "pending";
item.text = "待转";
@@ -5015,7 +5024,7 @@ async function runBatch() {
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,
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
fontNames: [...batch.localFonts.keys()] };
try {
for (;;) {
@@ -5023,19 +5032,21 @@ async function runBatch() {
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) {
if (result.unchanged && !result.bytes) {
batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break;
} catch (err) {
if (batch.canceled || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err;
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
options.credentials = await requestCredentials(err.code, state.openSeq);
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword");
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
if (!window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
}
}
} catch (err) {
@@ -5051,7 +5062,7 @@ async function runBatch() {
batchElements.Progress.value = position + 1;
}
if (archive && entries.length && !batch.canceled) {
const result = await batchCall("ofdgoPackFiles", [entries, zipFile], progress => batchProgress(null, 0, 1, progress));
const result = await batchCall("ofdgoPackFiles", [entries, zipFile, compression], progress => batchProgress(null, 0, 1, progress));
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
packed = true;
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
@@ -5085,6 +5096,8 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
return;
}
const saving = value === "ofd";
const compression = { mode: Number(el.outputCompression.value) || 0 };
const outputOptions = compression.mode ? [compression] : [];
if (saving && !state.editorInfo && !state.ofdBytes && !encryption) {
return;
}
@@ -5126,12 +5139,12 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
delete encryption.recipientFiles;
if (openSeq !== state.openSeq) return;
}
const readingSave = saving && !state.editorInfo && !encryption && indices === null;
const readingSave = saving && !state.editorInfo && !encryption && indices === null && compression.mode === 0;
const result = readingSave
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, file) : saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, file);
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, ...outputOptions, file) : saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
if (openSeq !== state.openSeq) {
return;
}
@@ -7925,6 +7938,7 @@ function updateControls() {
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
updateAnnotationButton();
el.exportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.outputCompression.disabled = !hasDoc || state.exporting;
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
updateDPIControl();
+7 -4
View File
@@ -120,12 +120,12 @@ async function handleBatchMessage({ id, name, args }) {
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done));
} else {
const [entries, handle] = args;
const [entries, handle, compression] = 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));
done => finish(done), compression);
}
signal.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
@@ -134,7 +134,7 @@ async function handleBatchMessage({ id, name, args }) {
if (output) { await output.close(); output = null; }
else result.data.blob = new Blob(chunks, { type: "application/zip" });
} else {
if (result.data.unchanged) {
if (result.data.unchanged && !result.data.bytes) {
if (output) { await output.abort(); output = null; }
if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; }
files = [];
@@ -200,6 +200,7 @@ async function handleMessage({ id, name, args }) {
if (converting) args.push((name, done) => requestFont(id, signal, name, done));
} else {
const file = args.pop();
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
const indices = saving ? args.shift() : null;
if (file) output = await file.createWritable();
signal.throwIfAborted();
@@ -226,8 +227,10 @@ async function handleMessage({ id, name, args }) {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done);
});
if (indices != null) args.push(indices);
if (compression) args.push(indices, compression, done => finish(done));
else if (indices != null) args.push(indices);
}
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
}
}
const payload = await globalThis[name](...args);