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
+858 -93

No files matched your search

+2
View File
@@ -57,6 +57,7 @@
<option value="300" selected>300 dpi</option> <option value="300" selected>300 dpi</option>
<option value="600">600 dpi</option> <option value="600">600 dpi</option>
</select><span id="dpiValue" aria-hidden="true">300</span></label> </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="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> <button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
</div> </div>
@@ -183,6 +184,7 @@
<button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button> <button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button>
<div class="convert-options"> <div class="convert-options">
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div> <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 id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div> <div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
</div> </div>
+19 -12
View File
@@ -723,13 +723,16 @@ button:not(:disabled):hover {
.convert-options { .convert-options {
flex: none; flex: none;
display: grid; 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; gap: 12px;
margin: 0 auto; margin: 0 auto;
} }
.convert-options:has(#convertDPIRow[hidden]) { .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 { .convert-options .form-row {
@@ -906,7 +909,8 @@ button:not(:disabled):hover {
} }
.convert-dialog .form-actions { .convert-dialog .form-actions {
display: flex; display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
margin-top: 12px; margin-top: 12px;
@@ -914,8 +918,14 @@ button:not(:disabled):hover {
.convert-dialog .form-actions .button { .convert-dialog .form-actions .button {
min-width: 3.5em; 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 { .convert-progress progress {
display: block; display: block;
width: 100%; width: 100%;
@@ -951,19 +961,18 @@ button:not(:disabled):hover {
} }
.convert-dialog .form-actions { .convert-dialog .form-actions {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 12px; row-gap: 12px;
} }
.convert-dialog .form-actions > .button { grid-row: 2; } .convert-dialog .form-actions > .button { grid-row: 2; }
.convert-options { .convert-options {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 1; grid-row: 1;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%; width: 100%;
margin: 0; margin: 0;
} }
.convert-options:has(#convertDPIRow[hidden]) { .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 { .convert-options .form-row {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -971,15 +980,10 @@ button:not(:disabled):hover {
text-align: left; text-align: left;
} }
.convert-options:has(#convertDPIRow[hidden]) .form-row { .convert-options:has(#convertDPIRow[hidden]) .form-row {
grid-template-columns: 2em minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 8px; gap: 8px;
} }
.convert-options label { width: auto; } .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) { @media (max-width: 620px) {
@@ -3089,6 +3093,9 @@ button.outline-link:hover {
.toolbar-select > span { .toolbar-select > span {
padding: 0; padding: 0;
justify-content: center;
font-size: 11px; 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 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 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 wasmPromise = null;
let remoteDownload = null; let remoteDownload = null;
@@ -403,6 +403,8 @@ const el = {
continuousButton: document.querySelector("#continuousButton"), continuousButton: document.querySelector("#continuousButton"),
annotationButton: document.querySelector("#annotationButton"), annotationButton: document.querySelector("#annotationButton"),
imageDPI: document.querySelector("#imageDPI"), imageDPI: document.querySelector("#imageDPI"),
outputCompression: document.querySelector("#outputCompression"),
compressionValue: document.querySelector("#compressionValue"),
dpiValue: document.querySelector("#dpiValue"), dpiValue: document.querySelector("#dpiValue"),
formatValue: document.querySelector("#formatValue"), formatValue: document.querySelector("#formatValue"),
exportFormat: document.querySelector("#exportFormat"), exportFormat: document.querySelector("#exportFormat"),
@@ -1250,6 +1252,10 @@ document.addEventListener("cut", copySelection);
el.viewerPanel.addEventListener("paste", pasteEditorContent); el.viewerPanel.addEventListener("paste", pasteEditorContent);
document.addEventListener("selectionchange", syncSelection); document.addEventListener("selectionchange", syncSelection);
el.exportFormat.addEventListener("change", () => updateDPIControl()); 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.exportPageButton.addEventListener("click", () => exportFile(false));
el.exportButton.addEventListener("click", () => openExportPanel()); el.exportButton.addEventListener("click", () => openExportPanel());
el.exportCancel.addEventListener("click", () => el.exportPanel.close()); el.exportCancel.addEventListener("click", () => el.exportPanel.close());
@@ -4640,6 +4646,7 @@ async function signDocument(event) {
pages:el.signPages.value.trim(), placement:el.signPlacement.value, 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), 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, mode:el.signMode.value, lock:el.signLock.checked,
compression: { mode: Number(el.outputCompression.value) || 0 },
}; };
el.signKey.value = ""; el.signKey.value = "";
if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return; if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return;
@@ -4842,7 +4849,8 @@ function renderBatchList() {
} }
function updateBatchControls() { 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.Clear.disabled ||= !batch.items.length;
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length; batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
batchElements.Start.hidden = batch.running; batchElements.Start.hidden = batch.running;
@@ -4957,12 +4965,13 @@ async function runBatch() {
if (!defaultFormat) return; if (!defaultFormat) return;
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim(); const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
const dpi = Number(batchElements.DPI.value); const dpi = Number(batchElements.DPI.value);
const compression = { mode: Number(batchElements.Compression.value) || 0 };
const archive = batchElements.Destination.value === "archive"; const archive = batchElements.Destination.value === "archive";
const backend = state.renderBackend; const backend = state.renderBackend;
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])]; const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
for (const item of batch.items) { for (const item of batch.items) {
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value)); const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const 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) { if (item.settingsKey !== key) {
item.status = "pending"; item.status = "pending";
item.text = "待转"; item.text = "待转";
@@ -5015,7 +5024,7 @@ async function runBatch() {
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase(); const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`; for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
names.add(outputName()); 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()] }; fontNames: [...batch.localFonts.keys()] };
try { try {
for (;;) { 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)); 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); for (const file of result.files) entries.push(file);
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`); item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
if (result.unchanged) { if (result.unchanged && !result.bytes) {
batchStatus(item, "unchanged", "原样"); batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"]; item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成"); } else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break; break;
} catch (err) { } 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?.password?.fill(0);
options.credentials?.key?.fill(0); options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.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 (!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) { } catch (err) {
@@ -5051,7 +5062,7 @@ async function runBatch() {
batchElements.Progress.value = position + 1; batchElements.Progress.value = position + 1;
} }
if (archive && entries.length && !batch.canceled) { 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"); if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
packed = true; packed = true;
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成"); for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
@@ -5085,6 +5096,8 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
return; return;
} }
const saving = value === "ofd"; const saving = value === "ofd";
const compression = { mode: Number(el.outputCompression.value) || 0 };
const outputOptions = compression.mode ? [compression] : [];
if (saving && !state.editorInfo && !state.ofdBytes && !encryption) { if (saving && !state.editorInfo && !state.ofdBytes && !encryption) {
return; return;
} }
@@ -5126,12 +5139,12 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
delete encryption.recipientFiles; delete encryption.recipientFiles;
if (openSeq !== state.openSeq) return; 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 const result = readingSave
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes } ? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, file) : saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole : 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, file) ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, file); : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
if (openSeq !== state.openSeq) { if (openSeq !== state.openSeq) {
return; return;
} }
@@ -7925,6 +7938,7 @@ function updateControls() {
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height"); el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
updateAnnotationButton(); updateAnnotationButton();
el.exportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length; 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.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length; el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
updateDPIControl(); 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)), (action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done)); done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done));
} else { } else {
const [entries, handle] = args; const [entries, handle, compression] = args;
if (handle) output = await handle.createWritable(); if (handle) output = await handle.createWritable();
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })), payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
index => read(entries[index].file), write, index => read(entries[index].file), write,
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); }, (completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
done => finish(done)); done => finish(done), compression);
} }
signal.throwIfAborted(); signal.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload; 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; } if (output) { await output.close(); output = null; }
else result.data.blob = new Blob(chunks, { type: "application/zip" }); else result.data.blob = new Blob(chunks, { type: "application/zip" });
} else { } else {
if (result.data.unchanged) { if (result.data.unchanged && !result.data.bytes) {
if (output) { await output.abort(); output = null; } if (output) { await output.abort(); output = null; }
if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; } if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; }
files = []; files = [];
@@ -200,6 +200,7 @@ async function handleMessage({ id, name, args }) {
if (converting) args.push((name, done) => requestFont(id, signal, name, done)); if (converting) args.push((name, done) => requestFont(id, signal, name, done));
} else { } else {
const file = args.pop(); const file = args.pop();
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
const indices = saving ? args.shift() : null; const indices = saving ? args.shift() : null;
if (file) output = await file.createWritable(); if (file) output = await file.createWritable();
signal.throwIfAborted(); signal.throwIfAborted();
@@ -226,8 +227,10 @@ async function handleMessage({ id, name, args }) {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done); 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); const payload = await globalThis[name](...args);
+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-20260929063458-1c0de5ea8fb5 github.com/xiaoqidun/pdfgo v0.0.0-20260929114121-a594c1621114
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-20260929063458-1c0de5ea8fb5 h1:V6//X67UzIETLaHx4YCNxj0Ai38rylYfMNIM6eOnPs0= github.com/xiaoqidun/pdfgo v0.0.0-20260929114121-a594c1621114 h1:K2U1YTqej82Zh81lS+VqQzfggroGBBfcTsGQh9fD64E=
github.com/xiaoqidun/pdfgo v0.0.0-20260929063458-1c0de5ea8fb5/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= github.com/xiaoqidun/pdfgo v0.0.0-20260929114121-a594c1621114/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=
+94 -30
View File
@@ -17,7 +17,6 @@
package webui package webui
import ( import (
"archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
@@ -440,6 +439,11 @@ func convertFile(args []js.Value) (any, error) {
return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total)
}, },
} }
if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() {
if err := compressionFromJS(value, &options.Compression); err != nil {
return nil, err
}
}
fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} fontSources := []fs.FS{ofdgo.NewFontFS(fonts)}
if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 { if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 {
fontSources = append(fontSources, ofdgo.NewLazyFontFS(stringsFromJS(names), func(name string) ([]byte, error) { fontSources = append(fontSources, ofdgo.NewLazyFontFS(stringsFromJS(names), func(name string) ([]byte, error) {
@@ -454,6 +458,8 @@ func convertFile(args []js.Value) (any, error) {
} }
defer clear(credentials.Password) defer clear(credentials.Password)
options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials))
options.PDF.Password = credentials.Password
options.PDF.PasswordUTF8 = true
} }
var format ofdgo.OutputFormat var format ofdgo.OutputFormat
for _, item := range ofdgo.OutputFormats() { for _, item := range ofdgo.OutputFormats() {
@@ -480,11 +486,11 @@ func convertFile(args []js.Value) (any, error) {
return nil, err return nil, err
} }
source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())} source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())}
report, err := ofdgo.Convert(context.Background(), source, source.size, writer, options) report, err := ofdgo.Convert(newOutputContext(args[6]), source, source.size, writer, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if report.Unchanged { if report.Unchanged && report.Bytes == 0 {
return report, nil return report, nil
} }
if err := writer.Flush(); err != nil { if err := writer.Flush(); err != nil {
@@ -526,45 +532,31 @@ func loadConversionFont(load js.Value, name string) ([]byte, error) {
// 入参: args 文件列表、分块读取、分块写出、进度及取消检查 // 入参: args 文件列表、分块读取、分块写出、进度及取消检查
// 返回: any 输出格式, error 打包错误 // 返回: any 输出格式, error 打包错误
func packFiles(args []js.Value) (any, error) { func packFiles(args []js.Value) (any, error) {
if len(args) != 5 { if len(args) < 5 {
return nil, fmt.Errorf("missing archive arguments") return nil, fmt.Errorf("missing archive arguments")
} }
writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20) writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20)
archive := zip.NewWriter(writer)
files := args[0] files := args[0]
entries := make([]ofdgo.ArchiveFile, files.Length())
for i := 0; i < files.Length(); i++ { for i := 0; i < files.Length(); i++ {
if err := awaitExport(args[3], i, files.Length()); err != nil {
return nil, err
}
file := files.Index(i) file := files.Index(i)
name := file.Get("name").String() 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) read := args[1].Invoke(i)
source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())} 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 { entries[i] = ofdgo.ArchiveFile{Name: name, Source: source, Size: source.size}
}
options := ofdgo.ArchiveOptions{OnProgress: func(completed, total int) error { return awaitExport(args[3], completed, total) }}
if len(args) > 5 && !args[5].IsUndefined() && !args[5].IsNull() {
if err := compressionFromJS(args[5], &options.Compression); err != nil {
return nil, err return nil, err
} }
} }
if err := archive.Close(); err != nil { if _, err := ofdgo.WriteArchive(newOutputContext(args[4]), writer, entries, options); err != nil {
return nil, err return nil, err
} }
if err := writer.Flush(); err != nil { if err := writer.Flush(); err != nil {
return nil, err 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 return map[string]any{"mime": "application/zip"}, nil
} }
@@ -962,6 +954,15 @@ func exportSession(args []js.Value) (*Session, int, error) {
} }
writerIndex++ writerIndex++
} }
if args[writerIndex].Type() == js.TypeObject {
if err := compressionFromJS(args[writerIndex], &renderer.Compression); err != nil {
return nil, 0, err
}
writerIndex++
if len(args) > writerIndex+1 {
renderer.OutputContext = newOutputContext(args[len(args)-1])
}
}
return &session, writerIndex, nil return &session, writerIndex, nil
} }
@@ -4404,6 +4405,11 @@ func saveSigned(args []js.Value) (any, error) {
} }
options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots, options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots,
Intermediates: intermediates, Lock: input.Get("lock").Bool()} Intermediates: intermediates, Lock: input.Get("lock").Bool()}
if compression := input.Get("compression"); !compression.IsUndefined() && !compression.IsNull() {
if err := compressionFromJS(compression, &options.Compression); err != nil {
return nil, err
}
}
switch input.Get("mode").String() { switch input.Get("mode").String() {
case "append": case "append":
options.Mode = ofdgo.SignatureAppend options.Mode = ofdgo.SignatureAppend
@@ -4451,13 +4457,20 @@ func saveSigned(args []js.Value) (any, error) {
// 返回: any 保存结果, error 错误信息 // 返回: any 保存结果, error 错误信息
func saveDocument(args []js.Value) (any, error) { func saveDocument(args []js.Value) (any, error) {
if currentEditor == nil { if currentEditor == nil {
return nil, fmt.Errorf("no document is being edited") if currentSession == nil {
return nil, fmt.Errorf("ofd document is not opened")
}
editor, err := currentSession.Reader.Editor()
if err != nil {
return nil, err
}
return saveEditor(editor, args)
} }
return saveEditor(currentEditor, args) return saveEditor(currentEditor, args)
} }
// saveEditor 统一分块写出及进度取消,加密策略由库层执行 // saveEditor 统一分块写出及进度取消,加密策略由库层执行
// 入参: editor 待写出文档, args 写出回调、进度回调和可选页面索引 // 入参: editor 待写出文档, args 写出回调、进度回调及可选页面索引、压缩配置、取消检查
// 返回: any 保存结果, error 错误信息 // 返回: any 保存结果, error 错误信息
func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) { func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) {
if len(args) > 1 { if len(args) > 1 {
@@ -4466,10 +4479,20 @@ func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) {
} }
writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20)
var err error var err error
if len(args) > 2 { var options ofdgo.WriteOptions
_, err = editor.WritePagesTo(writer, indexesFromJS(args[2])) ctx := context.Background()
if len(args) > 3 {
if err := compressionFromJS(args[3], &options.Compression); err != nil {
return nil, err
}
}
if len(args) > 4 {
ctx = newOutputContext(args[4])
}
if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() {
_, err = editor.WritePagesToWithOptions(ctx, writer, indexesFromJS(args[2]), options)
} else { } else {
_, err = editor.WriteTo(writer) _, err = editor.WriteToWithOptions(ctx, writer, options)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -4479,3 +4502,44 @@ func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) {
} }
return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil
} }
// compressionFromJS 读取压缩配置并交给库层校验
// 入参: value 浏览器配置, options 目标配置
// 返回: error 配置错误
func compressionFromJS(value js.Value, options *ofdgo.CompressionOptions) error {
if err := json.Unmarshal([]byte(js.Global().Get("JSON").Call("stringify", value).String()), options); err != nil {
return err
}
return options.Validate()
}
// outputContext 定期让出浏览器线程,将取消请求传入库层压缩循环
type outputContext struct {
context.Context
cancel context.CancelFunc
checkpoint js.Value
checked time.Time
}
// newOutputContext 创建单次串行导出的取消上下文
// 入参: checkpoint 浏览器取消检查回调
// 返回: context.Context 导出上下文
func newOutputContext(checkpoint js.Value) context.Context {
ctx, cancel := context.WithCancel(context.Background())
return &outputContext{Context: ctx, cancel: cancel, checkpoint: checkpoint}
}
// Err 在取消检查间隙处理浏览器消息,不为每个像素触发跨语言回调
// 返回: error 取消错误
func (c *outputContext) Err() error {
if err := c.Context.Err(); err != nil {
return err
}
if time.Since(c.checked) >= 50*time.Millisecond {
c.checked = time.Now()
if err := awaitExport(c.checkpoint); err != nil {
c.cancel()
}
}
return c.Context.Err()
}
+100
View File
@@ -0,0 +1,100 @@
// 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 (
"archive/zip"
"compress/flate"
"context"
"fmt"
"io"
"io/fs"
"path"
"strings"
)
// ArchiveFile 描述已生成的归档文件,不关闭调用方提供的源读取器
type ArchiveFile struct {
Name string
Source io.ReaderAt
Size int64
}
// ArchiveOptions 设置ZIP封装,压缩不重新编码归档内已生成的文档
type ArchiveOptions struct {
Compression CompressionOptions
OnProgress func(completed, total int) error
}
// WriteArchive 分块归档文件,保持文件内容,有损选项在此仅提高ZIP压缩等级
// 入参: ctx 取消上下文, writer 输出流, files 文件列表, options 归档配置
// 返回: int64 已写字节数, error 读写错误,出错时应丢弃输出
func WriteArchive(ctx context.Context, writer io.Writer, files []ArchiveFile, options ArchiveOptions) (int64, error) {
if err := options.Compression.Validate(); err != nil {
return 0, err
}
names := map[string]bool{}
for _, file := range files {
if !fs.ValidPath(file.Name) || file.Name == "." || strings.Contains(file.Name, "\\") || names[file.Name] || file.Source == nil || file.Size < 0 {
return 0, fmt.Errorf("invalid archive file %q", file.Name)
}
names[file.Name] = true
}
var count int64
archive := zip.NewWriter(convertWriter{context: ctx, writer: writer, count: &count})
configureArchive(archive, options.Compression)
for i, file := range files {
if err := ctx.Err(); err != nil {
return count, err
}
if options.OnProgress != nil {
if err := options.OnProgress(i, len(files)); err != nil {
return count, err
}
}
method := uint16(zip.Deflate)
switch strings.ToLower(path.Ext(file.Name)) {
case ".ofd", ".pdf", ".png", ".jpg", ".jpeg":
method = zip.Store
}
entry, err := archive.CreateHeader(&zip.FileHeader{Name: file.Name, Method: method})
if err != nil {
return count, err
}
input := convertReader{context: ctx, source: file.Source}
written, err := io.Copy(entry, io.NewSectionReader(input, 0, file.Size))
if err != nil {
return count, err
}
if written != file.Size {
return count, io.ErrUnexpectedEOF
}
}
if err := archive.Close(); err != nil {
return count, err
}
if options.OnProgress != nil {
return count, options.OnProgress(len(files), len(files))
}
return count, ctx.Err()
}
// configureArchive 统一逐页导出与批量归档的压缩等级
// 入参: archive 归档写入器, options 压缩配置
func configureArchive(archive *zip.Writer, options CompressionOptions) {
if options.Mode != CompressionUnchanged {
archive.RegisterCompressor(zip.Deflate, func(writer io.Writer) (io.WriteCloser, error) { return flate.NewWriter(writer, flate.BestCompression) })
}
}
+320
View File
@@ -0,0 +1,320 @@
// 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 (
"archive/zip"
"bufio"
"bytes"
"compress/flate"
"context"
"encoding/xml"
"hash/crc32"
"io"
"maps"
"slices"
"strings"
"github.com/xiaoqidun/pdfgo"
)
const (
CompressionUnchanged = pdfgo.CompressionUnchanged
CompressionLossless = pdfgo.CompressionLossless
CompressionLossy = pdfgo.CompressionLossy
)
// CompressionMode 选择不变、无损或有损输出,不改变导出分辨率
type CompressionMode = pdfgo.CompressionMode
// CompressionOptions 配置输出压缩,零值保留现有行为,Quality为有损质量1至100,0使用85
// 无损表示不引入额外损失,不保证跨格式转换可逆;有损仅降低图片画质,不栅格化文字和矢量
type CompressionOptions = pdfgo.CompressionOptions
// WriteOptions 配置OFD输出副本,不修改编辑资源和撤销记录
type WriteOptions struct{ Compression CompressionOptions }
// outputOptimization 保存单次写出的配置及图片路径,不缓存资源数据
type outputOptimization struct {
ctx context.Context
options CompressionOptions
images map[string]bool
protected bool
}
// WriteToWithOptions 按压缩策略写出OFD,签名文档不额外改写资源内容
// 入参: ctx 取消上下文, writer 输出流, options 输出配置
// 返回: int64 已写入字节数, error 错误信息
func (e *Editor) WriteToWithOptions(ctx context.Context, writer io.Writer, options WriteOptions) (int64, error) {
snapshot, err := e.outputSnapshot(ctx, options)
if err != nil {
return 0, err
}
var count int64
return snapshot.WriteTo(convertWriter{context: ctx, writer: writer, count: &count})
}
// WritePagesToWithOptions 按指定顺序另存页面并应用输出压缩
// 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置
// 返回: int64 已写入字节数, error 错误信息
func (e *Editor) WritePagesToWithOptions(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) {
snapshot, err := e.outputSnapshot(ctx, options)
if err != nil {
return 0, err
}
var count int64
return snapshot.WritePagesTo(convertWriter{context: ctx, writer: writer, count: &count}, indexes)
}
// outputSnapshot 创建只读输出配置,原签名存在时仅调整ZIP封装
// 入参: ctx 取消上下文, options 输出配置
// 返回: *Editor 输出快照, error 配置或资源错误
func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := options.Compression.Validate(); err != nil {
return nil, err
}
snapshot := *e
snapshot.OnWriteProgress = func(stage string, completed, total int) error {
if err := ctx.Err(); err != nil {
return err
}
return editorProgress(e.OnWriteProgress).report(stage, completed, total)
}
snapshot.output = &outputOptimization{ctx: ctx, options: options.Compression, images: map[string]bool{}}
if options.Compression.Mode == CompressionUnchanged {
return &snapshot, nil
}
for _, resource := range e.resources {
if resource.image != nil {
snapshot.output.images[strings.ToLower(cleanPackagePath(resource.name))] = true
}
}
if e.source != nil {
protected, err := e.source.reader.hasOutputSignatures()
if err != nil {
return nil, err
}
snapshot.output.protected = protected
if protected {
snapshot.output.images = nil
return &snapshot, nil
}
images, err := e.source.reader.Images(ctx)
if err != nil {
return nil, err
}
for _, img := range images {
snapshot.output.images[strings.ToLower(cleanPackagePath(img.Location))] = true
}
}
if options.Compression.Mode == CompressionLossy {
reader, err := e.reader(editorProgress(snapshot.OnWriteProgress))
if err != nil {
return nil, err
}
defer reader.Close()
images, err := reader.Images(ctx)
if err != nil {
return nil, err
}
masks, err := reader.compressionMaskFiles(ctx, images)
if err != nil {
return nil, err
}
for name := range masks {
snapshot.output.images[name] = false
}
}
return &snapshot, nil
}
// compressionMaskFiles 查找包括模板和注释在内的独立蒙版资源,解析不完整时保留所有图片像素
// 入参: ctx 取消上下文, images 图片资源
// 返回: map[string]bool 只能无损处理的资源路径, error 读取或取消错误
func (r *Reader) compressionMaskFiles(ctx context.Context, images []ImageInfo) (map[string]bool, error) {
names := make(map[string]bool)
for name := range r.fileIndex {
names[name] = true
}
for name := range r.files {
names[name] = true
}
masks := make(map[string]bool)
safe := true
for _, name := range slices.Sorted(maps.Keys(names)) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.HasSuffix(name, "/") {
continue
}
input, err := r.openFile(name)
if err != nil {
return nil, err
}
buffer := bufio.NewReader(imageInput{ReadCloser: input, context: ctx})
header, _ := buffer.Peek(512)
header = bytes.TrimSpace(bytes.TrimPrefix(header, []byte{0xef, 0xbb, 0xbf}))
if len(header) > 0 && header[0] == '<' {
decoder := xml.NewDecoder(buffer)
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
safe = false
break
}
if element, ok := token.(xml.StartElement); ok {
for _, attr := range element.Attr {
if attr.Name.Local == "ImageMask" {
masks[strings.TrimSpace(attr.Value)] = true
}
}
}
}
}
input.Close()
if !safe {
break
}
}
paths := make(map[string]bool)
for _, img := range images {
if !safe || masks[img.ID] {
paths[strings.ToLower(cleanPackagePath(img.Location))] = true
}
}
return paths, ctx.Err()
}
// hasOutputSignatures 检查包中各文档,避免改写其他文档签名保护的共享资源
// 返回: bool 是否存在签名, error 文档读取错误
func (r *Reader) hasOutputSignatures() (bool, error) {
for _, body := range r.OFD.DocBody {
if body.Signatures != "" {
return true, nil
}
data, err := r.readFile(body.DocRoot)
if err != nil {
return false, err
}
var doc struct {
Signatures string `xml:"Signatures"`
}
if err := xml.Unmarshal(data, &doc); err != nil {
return false, err
}
if doc.Signatures != "" {
return true, nil
}
}
return false, nil
}
// writeOutputEntry 逐项优化图片与ZIP编码,保留资源名称及其他文件的原始字节
// 入参: archive 归档写入器, header 条目属性, data 原始数据
// 返回: error 优化或写入错误
func (e *Editor) writeOutputEntry(archive *zip.Writer, header zip.FileHeader, data []byte) error {
if e.output == nil || e.output.options.Mode == CompressionUnchanged || len(data) > 64<<20 {
entry, err := archive.CreateHeader(&header)
if err != nil {
return err
}
_, err = entry.Write(data)
return err
}
if err := editorProgress(e.OnWriteProgress).report("compress", 0, 0); err != nil {
return err
}
if lossy, ok := e.output.images[strings.ToLower(cleanPackagePath(header.Name))]; ok {
options := e.output.options
if !lossy {
options.Mode = CompressionLossless
}
candidate, err := pdfgo.OptimizeImage(e.output.ctx, data, options)
if err != nil {
if e.output.ctx.Err() != nil {
return e.output.ctx.Err()
}
} else {
data = candidate
}
}
var compressed bytes.Buffer
deflater, _ := flate.NewWriter(&compressed, flate.BestCompression)
for offset := 0; offset < len(data); {
if err := e.output.ctx.Err(); err != nil {
deflater.Close()
return err
}
end := min(offset+64<<10, len(data))
if _, err := deflater.Write(data[offset:end]); err != nil {
return err
}
offset = end
}
if err := deflater.Close(); err != nil {
return err
}
payload := data
header.Method = zip.Store
if compressed.Len() < len(data) {
payload = compressed.Bytes()
header.Method = zip.Deflate
}
header.CRC32 = crc32.ChecksumIEEE(data)
header.UncompressedSize64 = uint64(len(data))
header.CompressedSize64 = uint64(len(payload))
header.Flags &^= 8
entry, err := archive.CreateRaw(&header)
if err != nil {
return err
}
_, err = entry.Write(payload)
return err
}
// WithCompression 设置导出策略,不修改文档资源和渲染缓存
// 入参: options 压缩配置,写出时校验
// 返回: RendererOption 渲染器配置
func WithCompression(options CompressionOptions) RendererOption {
return func(r *Renderer) { r.Compression = options }
}
// writeCompressedImage 优化已经编码的图片,再交付调用方输出流
// 入参: writer 输出流, data 图片数据
// 返回: error 优化或写入错误
func (r *Renderer) writeCompressedImage(writer io.Writer, data []byte) error {
optimized, err := pdfgo.OptimizeImage(r.outputContext(), data, r.Compression)
if err != nil {
return err
}
_, err = io.Copy(writer, bytes.NewReader(optimized))
return err
}
// outputContext 获取仅用于输出的取消上下文
// 返回: context.Context 取消上下文
func (r *Renderer) outputContext() context.Context {
if r.OutputContext != nil {
return r.OutputContext
}
return context.Background()
}
+29 -8
View File
@@ -20,6 +20,8 @@ import (
"fmt" "fmt"
"io" "io"
"strings" "strings"
"github.com/xiaoqidun/pdfgo"
) )
// OutputFormat 描述文档输出格式,Paged表示逐页文件,Raster表示受DPI控制的位图 // OutputFormat 描述文档输出格式,Paged表示逐页文件,Raster表示受DPI控制的位图
@@ -42,8 +44,8 @@ type ConvertProgress struct {
// ConvertOptions 设置文档转换格式、页码、阅读和渲染选项 // ConvertOptions 设置文档转换格式、页码、阅读和渲染选项
// Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面 // Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 // PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
// SkipUnchanged在源格式与目标相同且未选页时不写出数据,不校验或重新生成源文档 // SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
// PDF先转换为OFD对象再输出,PDF选项中的进度回调仍会调用 // 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil // PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
@@ -54,6 +56,7 @@ type ConvertOptions struct {
Pages []int Pages []int
PageRange string PageRange string
SkipUnchanged bool SkipUnchanged bool
Compression CompressionOptions
Backends *RenderBackends Backends *RenderBackends
ReaderOptions []ReaderOption ReaderOptions []ReaderOption
RendererOptions []RendererOption RendererOptions []RendererOption
@@ -63,7 +66,7 @@ type ConvertOptions struct {
} }
// ConvertReport 汇总源格式、输出格式、页数、字节数及PDF转换诊断 // ConvertReport 汇总源格式、输出格式、页数、字节数及PDF转换诊断
// Unchanged表示无需转换或完整OFD原样复制,Bytes仅统计实际输出,不包含调用方额外封装 // Unchanged表示无需转换或原样交付源文件,Bytes仅统计实际输出,不包含调用方额外封装
type ConvertReport struct { type ConvertReport struct {
Input string `json:"input"` Input string `json:"input"`
Format OutputFormat `json:"format"` Format OutputFormat `json:"format"`
@@ -101,12 +104,15 @@ func OutputFormats() []OutputFormat {
} }
// Convert 将PDF或OFD转换为指定格式,不关闭调用方的输入和输出 // Convert 将PDF或OFD转换为指定格式,不关闭调用方的输入和输出
// 完整OFD另存保持源字节,包括制作软件、签名及加密;选页和跨格式输出重新生成文档 // 不变模式完整OFD另存保持源字节;选页和跨格式输出重新生成文档,压缩仅作用于输出副本
// 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存 // 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存
// 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功 // 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功
// 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项 // 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项
// 返回: ConvertReport 转换报告, error 错误信息 // 返回: ConvertReport 转换报告, error 错误信息
func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options ConvertOptions) (report ConvertReport, err error) { func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options ConvertOptions) (report ConvertReport, err error) {
if err = options.Compression.Validate(); err != nil {
return report, err
}
if err = ctx.Err(); err != nil { if err = ctx.Err(); err != nil {
return report, err return report, err
} }
@@ -173,13 +179,26 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
if report.Input != "pdf" && report.Input != "ofd" { if report.Input != "pdf" && report.Input != "ofd" {
return report, fmt.Errorf("unsupported input format") return report, fmt.Errorf("unsupported input format")
} }
if options.SkipUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { if options.SkipUnchanged && options.Compression.Mode == CompressionUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" {
report.Unchanged = true report.Unchanged = true
return report, nil return report, nil
} }
if output == nil && options.PageOutput == nil { if output == nil && options.PageOutput == nil {
return report, fmt.Errorf("missing output writer") return report, fmt.Errorf("missing output writer")
} }
if report.Input == "pdf" && format == "pdf" && options.Compression.Mode != CompressionUnchanged && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" {
pdfReader, openErr := pdfgo.NewReaderWithOptions(source, size, pdfgo.ReaderOptions{Password: options.PDF.Password, PasswordUTF8: options.PDF.PasswordUTF8})
if openErr != nil {
return report, openErr
}
defer pdfReader.Close()
if pageErr := pdfReader.WalkPages(ctx, func(_ int, _ *pdfgo.Page) error { report.Pages++; return nil }); pageErr != nil {
return report, pageErr
}
result, optimizeErr := pdfReader.OptimizeTo(ctx, output, pdfgo.OptimizeOptions{Compression: options.Compression, Creator: ofdCreator, OnProgress: writeProgress})
report.Bytes, report.Unchanged = result.Bytes, result.Preserved
return report, optimizeErr
}
switch report.Input { switch report.Input {
case "pdf": case "pdf":
pdfOptions := options.PDF pdfOptions := options.PDF
@@ -227,7 +246,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
report.Pages = len(indices) report.Pages = len(indices)
writer := convertWriter{context: ctx, writer: output, count: &report.Bytes} writer := convertWriter{context: ctx, writer: output, count: &report.Bytes}
if format == "ofd" { if format == "ofd" {
if options.Pages == nil && editor == nil { if options.Pages == nil && editor == nil && options.Compression.Mode == CompressionUnchanged {
if err = progress("write", 0, 0); err != nil { if err = progress("write", 0, 0); err != nil {
return report, err return report, err
} }
@@ -243,9 +262,9 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
} }
editor.OnWriteProgress = writeProgress editor.OnWriteProgress = writeProgress
if options.Pages == nil { if options.Pages == nil {
_, err = editor.WriteTo(writer) _, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression})
} else { } else {
_, err = editor.WritePagesTo(writer, indices) _, err = editor.WritePagesToWithOptions(ctx, writer, indices, WriteOptions{Compression: options.Compression})
} }
return report, err return report, err
} }
@@ -260,6 +279,8 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
defer reader.Close() defer reader.Close()
} }
renderer := NewRenderer(reader, options.RendererOptions...) renderer := NewRenderer(reader, options.RendererOptions...)
renderer.Compression = options.Compression
renderer.OutputContext = ctx
if options.Backends != nil { if options.Backends != nil {
WithRenderBackends(*options.Backends)(renderer) WithRenderBackends(*options.Backends)(renderer)
} }
+1
View File
@@ -64,6 +64,7 @@ type Editor struct {
removedPages map[string]bool removedPages map[string]bool
outlines []byte outlines []byte
encryption *encryptionState encryption *encryptionState
output *outputOptimization
} }
// editorResource 文档内嵌资源 // editorResource 文档内嵌资源
+16 -11
View File
@@ -450,7 +450,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
return 0, err return 0, err
} }
reader := e.source.reader reader := e.source.reader
if len(parts) != 0 || len(removed) != 0 { if len(parts) != 0 || len(removed) != 0 || e.output != nil && e.output.options.Mode != CompressionUnchanged && !e.output.protected {
name := "OFD.xml" name := "OFD.xml"
if file, ok := reader.packageFile(name); ok { if file, ok := reader.packageFile(name); ok {
name = cleanPackagePath(file.Name) name = cleanPackagePath(file.Name)
@@ -500,14 +500,23 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
} }
if data, ok := remaining[name]; ok { if data, ok := remaining[name]; ok {
header := file.FileHeader header := file.FileHeader
entry, err := archive.CreateHeader(&header) if err := e.writeOutputEntry(archive, header, data); err != nil {
if err != nil {
return output.count, err
}
if _, err := entry.Write(data); err != nil {
return output.count, err return output.count, err
} }
delete(remaining, name) delete(remaining, name)
} else if e.output != nil && e.output.options.Mode != CompressionUnchanged && file.UncompressedSize64 <= 64<<20 {
input, err := file.Open()
if err != nil {
return output.count, err
}
data, err := io.ReadAll(imageInput{ReadCloser: input, context: e.output.ctx})
input.Close()
if err != nil {
return output.count, err
}
if err := e.writeOutputEntry(archive, file.FileHeader, data); err != nil {
return output.count, err
}
} else { } else {
input, err := file.OpenRaw() input, err := file.OpenRaw()
if err != nil { if err != nil {
@@ -529,11 +538,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
if strings.HasSuffix(strings.ToLower(name), ".xml") { if strings.HasSuffix(strings.ToLower(name), ".xml") {
method = zip.Deflate method = zip.Deflate
} }
entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method}) if err := e.writeOutputEntry(archive, zip.FileHeader{Name: name, Method: method}, remaining[name]); err != nil {
if err != nil {
return output.count, err
}
if _, err := entry.Write(remaining[name]); err != nil {
return output.count, err return output.count, err
} }
} }
+41
View File
@@ -16,6 +16,7 @@ package ofdgo
import ( import (
"archive/zip" "archive/zip"
"bytes"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"hash/crc32" "hash/crc32"
@@ -26,6 +27,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/xiaoqidun/pdfgo"
"golang.org/x/image/draw" "golang.org/x/image/draw"
) )
@@ -104,6 +106,7 @@ func (r *Renderer) RenderToZIP(writer io.Writer, format string, indices ...int)
return err return err
} }
archive := zip.NewWriter(writer) archive := zip.NewWriter(writer)
configureArchive(archive, r.Compression)
method := zip.Deflate method := zip.Deflate
if extension == "png" || extension == "jpg" { if extension == "png" || extension == "jpg" {
method = zip.Store method = zip.Store
@@ -194,6 +197,9 @@ func (r *Renderer) RenderToImage(page *PageContent) (image.Image, error) {
// 入参: page 页面内容, writer 输出流 // 入参: page 页面内容, writer 输出流
// 返回: error 错误信息 // 返回: error 错误信息
func (r *Renderer) RenderToPNG(page *PageContent, writer io.Writer) error { func (r *Renderer) RenderToPNG(page *PageContent, writer io.Writer) error {
if err := r.Compression.Validate(); err != nil {
return err
}
img, err := r.RenderToImage(page) img, err := r.RenderToImage(page)
if err != nil { if err != nil {
return err return err
@@ -204,13 +210,23 @@ func (r *Renderer) RenderToPNG(page *PageContent, writer io.Writer) error {
copy(chunk[4:], "tEXt") copy(chunk[4:], "tEXt")
copy(chunk[8:], text) copy(chunk[8:], text)
binary.BigEndian.PutUint32(chunk[len(chunk)-4:], crc32.ChecksumIEEE(chunk[4:len(chunk)-4])) binary.BigEndian.PutUint32(chunk[len(chunk)-4:], crc32.ChecksumIEEE(chunk[4:len(chunk)-4]))
if r.Compression.Mode == CompressionUnchanged {
return png.Encode(&imageMetadataWriter{writer: writer, skip: 33, data: chunk}, img) return png.Encode(&imageMetadataWriter{writer: writer, skip: 33, data: chunk}, img)
} }
var encoded bytes.Buffer
if err := png.Encode(&imageMetadataWriter{writer: &encoded, skip: 33, data: chunk}, img); err != nil {
return err
}
return r.writeCompressedImage(writer, encoded.Bytes())
}
// RenderToJPEG 渲染为白底JPEG,像素尺寸由DPI决定 // RenderToJPEG 渲染为白底JPEG,像素尺寸由DPI决定
// 入参: page 页面内容, writer 输出流, options 编码选项,nil使用标准库默认值 // 入参: page 页面内容, writer 输出流, options 编码选项,nil使用标准库默认值
// 返回: error 错误信息 // 返回: error 错误信息
func (r *Renderer) RenderToJPEG(page *PageContent, writer io.Writer, options *jpeg.Options) error { func (r *Renderer) RenderToJPEG(page *PageContent, writer io.Writer, options *jpeg.Options) error {
if err := r.Compression.Validate(); err != nil {
return err
}
img, err := r.RenderToImage(page) img, err := r.RenderToImage(page)
if err != nil { if err != nil {
return err return err
@@ -220,8 +236,27 @@ func (r *Renderer) RenderToJPEG(page *PageContent, writer io.Writer, options *jp
segment[0], segment[1] = 0xff, 0xe1 segment[0], segment[1] = 0xff, 0xe1
binary.BigEndian.PutUint16(segment[2:], uint16(len(metadata)+2)) binary.BigEndian.PutUint16(segment[2:], uint16(len(metadata)+2))
copy(segment[4:], metadata) copy(segment[4:], metadata)
if r.Compression.Mode == CompressionUnchanged {
return jpeg.Encode(&imageMetadataWriter{writer: writer, skip: 2, data: segment}, fillWhiteBackground(img), options) return jpeg.Encode(&imageMetadataWriter{writer: writer, skip: 2, data: segment}, fillWhiteBackground(img), options)
} }
if r.Compression.Mode == CompressionLossy {
quality := jpeg.DefaultQuality
if options != nil {
quality = max(1, min(100, options.Quality))
}
options = &jpeg.Options{Quality: min(quality, r.Compression.ImageQuality())}
}
var encoded bytes.Buffer
if err := jpeg.Encode(&imageMetadataWriter{writer: &encoded, skip: 2, data: segment}, fillWhiteBackground(img), options); err != nil {
return err
}
optimized, err := pdfgo.OptimizeJPEG(r.outputContext(), encoded.Bytes())
if err != nil {
return err
}
_, err = io.Copy(writer, bytes.NewReader(optimized))
return err
}
// RenderToSVG 渲染为SVG // RenderToSVG 渲染为SVG
// 入参: page 页面内容, writer 输出流 // 入参: page 页面内容, writer 输出流
@@ -247,6 +282,9 @@ func (r *Renderer) RenderToPDF(page *PageContent, writer io.Writer) error {
// 入参: page 页面内容, writer 输出流 // 入参: page 页面内容, writer 输出流
// 返回: error 错误信息 // 返回: error 错误信息
func (r *Renderer) RenderToEPS(page *PageContent, writer io.Writer) error { func (r *Renderer) RenderToEPS(page *PageContent, writer io.Writer) error {
if err := r.Compression.Validate(); err != nil {
return err
}
if r.backends.EPS == nil { if r.backends.EPS == nil {
return fmt.Errorf("eps: %w", ErrBackendUnavailable) return fmt.Errorf("eps: %w", ErrBackendUnavailable)
} }
@@ -367,6 +405,9 @@ func fillWhiteBackground(img image.Image) image.Image {
// 入参: pages 页面列表, writer 输出流, progress 页面处理进度 // 入参: pages 页面列表, writer 输出流, progress 页面处理进度
// 返回: error 错误信息 // 返回: error 错误信息
func (r *Renderer) renderPDFPages(pages []RenderDocumentPage, writer io.Writer, progress func(int, int) error) error { func (r *Renderer) renderPDFPages(pages []RenderDocumentPage, writer io.Writer, progress func(int, int) error) error {
if err := r.Compression.Validate(); err != nil {
return err
}
if r.backends.PDF == nil { if r.backends.PDF == nil {
return fmt.Errorf("pdf: %w", ErrBackendUnavailable) return fmt.Errorf("pdf: %w", ErrBackendUnavailable)
} }
+3
View File
@@ -66,6 +66,9 @@ func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (
// 入参: page 页面内容, writer 输出流, mode 资源封装方式 // 入参: page 页面内容, writer 输出流, mode 资源封装方式
// 返回: SVGResources 字体和图片资源, error 输出错误 // 返回: SVGResources 字体和图片资源, error 输出错误
func (r *Renderer) renderSVG(page *PageContent, writer io.Writer, mode SVGMode) (SVGResources, error) { func (r *Renderer) renderSVG(page *PageContent, writer io.Writer, mode SVGMode) (SVGResources, error) {
if err := r.Compression.Validate(); err != nil {
return SVGResources{}, err
}
if r.backends.SVG == nil { if r.backends.SVG == nil {
return SVGResources{}, fmt.Errorf("svg: %w", ErrBackendUnavailable) return SVGResources{}, fmt.Errorf("svg: %w", ErrBackendUnavailable)
} }
+4
View File
@@ -15,6 +15,7 @@
package ofdgo package ofdgo
import ( import (
"context"
"image" "image"
"io/fs" "io/fs"
) )
@@ -25,9 +26,12 @@ import (
// OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观 // OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观
// OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功 // OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功
// TransparentBackground关闭页面白底,供嵌套图案和印章保持透明背景 // TransparentBackground关闭页面白底,供嵌套图案和印章保持透明背景
// Compression仅控制导出编码,OutputContext可取消输出压缩,nil使用后台上下文
type Renderer struct { type Renderer struct {
Reader *Reader Reader *Reader
DPI float64 DPI float64
Compression CompressionOptions
OutputContext context.Context
RenderAnnotations bool RenderAnnotations bool
OnPageText func(*PageContent, *PageText) OnPageText func(*PageContent, *PageText)
OnExportProgress func(completed, total int) error OnExportProgress func(completed, total int) error
+104
View File
@@ -0,0 +1,104 @@
// 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"
"compress/zlib"
"context"
"encoding/ascii85"
"fmt"
"image"
"image/jpeg"
"io"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/ps"
"github.com/xiaoqidun/pdfgo"
)
// epsImageRenderer 在EPS输出端优化图片,不修改页面及渲染缓存
type epsImageRenderer struct {
*ps.PS
writer io.Writer
options CompressionOptions
ctx context.Context
err error
}
// RenderImage 写出Flate或更小的JPEG图片,保留原尺寸与变换
// 入参: img 图片, matrix 绘制变换
func (r *epsImageRenderer) RenderImage(img image.Image, matrix canvas.Matrix) {
if r.err != nil {
return
}
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
pixels := make([]byte, width*3)
var encoded bytes.Buffer
compressor, _ := zlib.NewWriterLevel(&encoded, zlib.BestCompression)
opaque := true
for y := 0; y < height; y++ {
if r.err = r.ctx.Err(); r.err != nil {
return
}
clear(pixels)
for x := 0; x < width; x++ {
red, green, blue, alpha := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
if alpha != 65535 {
opaque = false
}
if alpha != 0 {
offset := x * 3
pixels[offset], pixels[offset+1], pixels[offset+2] = byte(red*65535/alpha>>8), byte(green*65535/alpha>>8), byte(blue*65535/alpha>>8)
}
}
if _, r.err = compressor.Write(pixels); r.err != nil {
return
}
}
if r.err = compressor.Close(); r.err != nil {
return
}
data := encoded.Bytes()
filter := "FlateDecode"
if opaque && r.options.Mode == CompressionLossy {
var candidate bytes.Buffer
if r.err = jpeg.Encode(&candidate, img, &jpeg.Options{Quality: r.options.ImageQuality()}); r.err != nil {
return
}
optimized, err := pdfgo.OptimizeJPEG(r.ctx, candidate.Bytes())
if err != nil {
r.err = err
return
}
if len(optimized) < len(data) {
data, filter = optimized, "DCTDecode"
}
}
m := matrix.Scale(float64(width), float64(height))
_, r.err = fmt.Fprintf(r.writer, " gsave /DeviceRGB setcolorspace [%s %s %s %s %s %s] concat <</ImageType 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /Interpolate true /Width %d /Height %d /ImageMatrix [%d 0 0 -%d 0 %d] /DataSource currentfile /ASCII85Decode filter /%s filter>>image\n", ofdNumber(m[0][0]), ofdNumber(m[1][0]), ofdNumber(m[0][1]), ofdNumber(m[1][1]), ofdNumber(m[0][2]), ofdNumber(m[1][2]), width, height, width, height, height, filter)
if r.err != nil {
return
}
writer := ascii85.NewEncoder(r.writer)
if _, r.err = writer.Write(data); r.err != nil {
return
}
if r.err = writer.Close(); r.err != nil {
return
}
_, r.err = io.WriteString(r.writer, "~>\n grestore")
}
+27 -1
View File
@@ -24,6 +24,7 @@ import (
"github.com/tdewolff/canvas/renderers/pdf" "github.com/tdewolff/canvas/renderers/pdf"
"github.com/tdewolff/canvas/renderers/ps" "github.com/tdewolff/canvas/renderers/ps"
"github.com/tdewolff/canvas/renderers/svg" "github.com/tdewolff/canvas/renderers/svg"
"github.com/xiaoqidun/pdfgo"
) )
// canvasEPSWriter 在EPS头部写入本库制作软件,不改动绘图内容 // canvasEPSWriter 在EPS头部写入本库制作软件,不改动绘图内容
@@ -48,7 +49,7 @@ func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer,
return SVGResources{}, err return SVGResources{}, err
} }
buffer := bufio.NewWriter(writer) buffer := bufio.NewWriter(writer)
renderer := &svgResourceRenderer{SVG: svg.New(buffer, c.W, c.H, nil), writer: buffer, embeddedFonts: true} renderer := &svgResourceRenderer{SVG: svg.New(buffer, c.W, c.H, nil), renderer: r, writer: buffer, embeddedFonts: true}
io.WriteString(buffer, svgCreatorMetadata) io.WriteString(buffer, svgCreatorMetadata)
io.WriteString(buffer, `<g style="image-orientation:none">`) io.WriteString(buffer, `<g style="image-orientation:none">`)
c.RenderTo(renderer) c.RenderTo(renderer)
@@ -77,7 +78,15 @@ func (CanvasBackend) RenderEPS(r *Renderer, page *PageContent, writer io.Writer)
options.Format = ps.EncapsulatedPostScript options.Format = ps.EncapsulatedPostScript
buffer := bufio.NewWriter(writer) buffer := bufio.NewWriter(writer)
renderer := ps.New(canvasEPSWriter{buffer}, c.W, c.H, &options) renderer := ps.New(canvasEPSWriter{buffer}, c.W, c.H, &options)
if r.Compression.Mode == CompressionUnchanged {
c.RenderTo(renderer) c.RenderTo(renderer)
} else {
optimized := &epsImageRenderer{PS: renderer, writer: buffer, options: r.Compression, ctx: r.outputContext()}
c.RenderTo(optimized)
if optimized.err != nil {
return optimized.err
}
}
if err := renderer.Close(); err != nil { if err := renderer.Close(); err != nil {
return err return err
} }
@@ -109,6 +118,9 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
return err return err
} }
buf, direct := writer.(*bytes.Buffer) buf, direct := writer.(*bytes.Buffer)
if r.Compression.Mode != CompressionUnchanged {
direct = false
}
if !direct { if !direct {
buf = &bytes.Buffer{} buf = &bytes.Buffer{}
} }
@@ -142,6 +154,20 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
return err return err
} }
data := replacePDFProducer(buf.Bytes()[start:]) data := replacePDFProducer(buf.Bytes()[start:])
if r.Compression.Mode != CompressionUnchanged {
reader, err := pdfgo.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return err
}
defer reader.Close()
_, err = reader.OptimizeTo(r.outputContext(), writer, pdfgo.OptimizeOptions{Compression: r.Compression, OnProgress: func(_ string, _, _ int) error {
if progress != nil {
return progress(len(pages), len(pages))
}
return nil
}})
return err
}
if direct { if direct {
return nil return nil
} }
+12 -1
View File
@@ -29,6 +29,7 @@ import (
"github.com/tdewolff/canvas" "github.com/tdewolff/canvas"
canvasimage "github.com/tdewolff/canvas/image" canvasimage "github.com/tdewolff/canvas/image"
"github.com/tdewolff/canvas/renderers/svg" "github.com/tdewolff/canvas/renderers/svg"
"github.com/xiaoqidun/pdfgo"
) )
// svgCreatorMetadata 记录SVG制作软件,不参与页面绘制 // svgCreatorMetadata 记录SVG制作软件,不参与页面绘制
@@ -57,7 +58,7 @@ type svgObjectGroup struct {
composite bool composite bool
} }
// RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换 // RenderImage 按输出策略编码图片,分离资源时保持尺寸和变换
// 入参: img 图片对象, m 变换矩阵 // 入参: img 图片对象, m 变换矩阵
func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) {
name, ok := s.imageNames[img] name, ok := s.imageNames[img]
@@ -74,6 +75,16 @@ func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) {
} }
resource.Data = buffer.Bytes() resource.Data = buffer.Bytes()
} }
if s.renderer != nil && s.renderer.Compression.Mode != CompressionUnchanged {
data, err := pdfgo.OptimizeImage(s.renderer.outputContext(), resource.Data, s.renderer.Compression)
if err != nil {
if s.err = s.renderer.outputContext().Err(); s.err != nil {
return
}
} else {
resource.Data = data
}
}
if s.imageNames != nil { if s.imageNames != nil {
name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data)) name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data))
resource.Name = name resource.Name = name
+45 -4
View File
@@ -17,6 +17,7 @@ package ofdgo
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context"
"crypto" "crypto"
"encoding/base64" "encoding/base64"
"encoding/xml" "encoding/xml"
@@ -27,6 +28,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/xiaoqidun/pdfgo"
) )
const ( const (
@@ -61,6 +64,8 @@ type SignatureWriteOptions struct {
// ReaderOptions用于打开加密包;Encryption为空时继承阅读器的加密策略 // ReaderOptions用于打开加密包;Encryption为空时继承阅读器的加密策略
ReaderOptions []ReaderOption ReaderOptions []ReaderOption
Encryption *EncryptionOptions Encryption *EncryptionOptions
// Compression在签名计算前优化图片,已有签名的资源不额外改写
Compression CompressionOptions
progress editorProgress progress editorProgress
} }
@@ -208,11 +213,14 @@ func signatureWriteParts(r *Reader) (map[string][]byte, error) {
return r.packageData() return r.packageData()
} }
// signatureWritePackage 基于未重新编码的文件字节创建签名 // signatureWritePackage 完成输出优化后计算签名,不改写已有签名保护的资源
// 签名目录采用Sign_N兼容OFDRW容器,标识使用独立的sN签章域 // 签名目录采用Sign_N兼容OFDRW容器,标识使用独立的sN签章域
// 入参: parts 独立包文件, options 签署选项 // 入参: parts 独立包文件, options 签署选项
// 返回: []byte 完整包, error 错误信息 // 返回: []byte 完整包, error 错误信息
func signatureWritePackage(parts map[string][]byte, options SignatureWriteOptions) ([]byte, error) { func signatureWritePackage(parts map[string][]byte, options SignatureWriteOptions) ([]byte, error) {
if err := options.Compression.Validate(); err != nil {
return nil, err
}
if options.Mode != SignatureAppend && options.Mode != SignatureReplace { if options.Mode != SignatureAppend && options.Mode != SignatureReplace {
return nil, fmt.Errorf("unsupported signature write mode") return nil, fmt.Errorf("unsupported signature write mode")
} }
@@ -227,6 +235,38 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
if err != nil { if err != nil {
return nil, err return nil, err
} }
if options.Compression.Mode != CompressionUnchanged && doc.Signatures == "" {
images, err := r.Images(context.Background())
if err != nil {
return nil, err
}
var masks map[string]bool
if options.Compression.Mode == CompressionLossy {
masks, err = r.compressionMaskFiles(context.Background(), images)
if err != nil {
return nil, err
}
}
seen := make(map[string]bool)
for i, img := range images {
if err := options.progress.report("compress", i, len(images)); err != nil {
return nil, err
}
if seen[img.Location] {
continue
}
seen[img.Location] = true
if data, ok := parts[img.Location]; ok {
compression := options.Compression
if masks[strings.ToLower(cleanPackagePath(img.Location))] {
compression.Mode = CompressionLossless
}
if optimized, err := pdfgo.OptimizeImage(context.Background(), data, compression); err == nil {
parts[img.Location] = optimized
}
}
}
}
if options.Time.IsZero() { if options.Time.IsZero() {
options.Time = time.Now() options.Time = time.Now()
} }
@@ -358,7 +398,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
if err != nil { if err != nil {
return nil, err return nil, err
} }
output, err := signatureWriteZIP(parts) output, err := signatureWriteZIP(parts, options.Compression)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -477,11 +517,12 @@ func signatureWriteAvailable(parts map[string][]byte, name string) string {
} }
// signatureWriteZIP 写出完整包,不重新编码受保护文件 // signatureWriteZIP 写出完整包,不重新编码受保护文件
// 入参: parts 文件集合 // 入参: parts 文件集合, compression 封装压缩配置
// 返回: []byte 包字节, error 错误信息 // 返回: []byte 包字节, error 错误信息
func signatureWriteZIP(parts map[string][]byte) ([]byte, error) { func signatureWriteZIP(parts map[string][]byte, compression CompressionOptions) ([]byte, error) {
var out bytes.Buffer var out bytes.Buffer
w := zip.NewWriter(&out) w := zip.NewWriter(&out)
configureArchive(w, compression)
names := make([]string, 0, len(parts)) names := make([]string, 0, len(parts))
for name := range parts { for name := range parts {
names = append(names, name) names = append(names, name)
+4 -6
View File
@@ -114,6 +114,9 @@ func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) {
if _, err := selected.ImportPagesWithOptions(reader, positions, 0, PageImportOptions{Outlines: true, OnProgress: e.OnWriteProgress}); err != nil { if _, err := selected.ImportPagesWithOptions(reader, positions, 0, PageImportOptions{Outlines: true, OnProgress: e.OnWriteProgress}); err != nil {
return 0, err return 0, err
} }
if e.output != nil {
return selected.WriteToWithOptions(e.output.ctx, writer, WriteOptions{Compression: e.output.options})
}
return selected.WriteTo(writer) return selected.WriteTo(writer)
} }
@@ -174,12 +177,7 @@ func (e *Editor) writePlaintext(writer io.Writer) (int64, error) {
if compressed { if compressed {
method = zip.Store method = zip.Store
} }
entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method}) return e.writeOutputEntry(archive, zip.FileHeader{Name: name, Method: method}, data)
if err != nil {
return err
}
_, err = entry.Write(data)
return err
}, progress) }, progress)
if err == nil { if err == nil {
err = archive.Close() err = archive.Close()