mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
1 parent
870c29a83f
commit
9fd9ba7a28
20 files changed
+858
-93
No files matched your search
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user