mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
1 parent
870c29a83f
commit
9fd9ba7a28
20 files changed
+862
-97
No files matched your search
@@ -57,6 +57,7 @@
|
||||
<option value="300" selected>300 dpi</option>
|
||||
<option value="600">600 dpi</option>
|
||||
</select><span id="dpiValue" aria-hidden="true">300</span></label>
|
||||
<label class="toolbar-select"><select id="outputCompression" class="format-select" title="压缩" aria-label="压缩" disabled><option value="0">不变</option><option value="1">无损</option><option value="2">有损</option></select><span id="compressionValue" data-short="原" aria-hidden="true">不变</span></label>
|
||||
<button id="exportPageButton" class="button" type="button" data-short="单" title="导出单页" aria-label="导出单页" disabled>单页</button>
|
||||
<button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
|
||||
</div>
|
||||
@@ -183,6 +184,7 @@
|
||||
<button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button>
|
||||
<div class="convert-options">
|
||||
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
|
||||
<div class="form-row"><label for="convertCompression">压缩</label><select id="convertCompression"><option value="0">不变</option><option value="1">无损</option><option value="2">有损</option></select></div>
|
||||
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
|
||||
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
|
||||
</div>
|
||||
|
||||
+19
-12
@@ -723,13 +723,16 @@ button:not(:disabled):hover {
|
||||
.convert-options {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: calc(2em + 8px + var(--convert-control-width)) 132px calc(2em + 8px + var(--convert-control-width));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.convert-options:has(#convertDPIRow[hidden]) {
|
||||
grid-template-columns: repeat(2, calc(2em + 8px + var(--convert-control-width)));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.convert-options .form-row {
|
||||
@@ -906,7 +909,8 @@ button:not(:disabled):hover {
|
||||
}
|
||||
|
||||
.convert-dialog .form-actions {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
@@ -914,8 +918,14 @@ button:not(:disabled):hover {
|
||||
|
||||
.convert-dialog .form-actions .button {
|
||||
min-width: 3.5em;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
#convertAdd { grid-column: 1; }
|
||||
#convertClear { grid-column: 2; }
|
||||
#convertClose { grid-column: 4; justify-self: end; }
|
||||
#convertCancel, #convertStart { grid-column: 5; justify-self: end; }
|
||||
|
||||
.convert-progress progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -951,19 +961,18 @@ button:not(:disabled):hover {
|
||||
}
|
||||
.convert-dialog .form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
row-gap: 12px;
|
||||
}
|
||||
.convert-dialog .form-actions > .button { grid-row: 2; }
|
||||
.convert-options {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.convert-options:has(#convertDPIRow[hidden]) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.convert-options .form-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -971,15 +980,10 @@ button:not(:disabled):hover {
|
||||
text-align: left;
|
||||
}
|
||||
.convert-options:has(#convertDPIRow[hidden]) .form-row {
|
||||
grid-template-columns: 2em minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.convert-options label { width: auto; }
|
||||
#convertAdd { grid-column: 1; }
|
||||
#convertClear { grid-column: 2; }
|
||||
#convertClose { grid-column: 3; }
|
||||
#convertCancel,
|
||||
#convertStart { grid-column: 4; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
@@ -3089,6 +3093,9 @@ button.outline-link:hover {
|
||||
|
||||
.toolbar-select > span {
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
#compressionValue { font-size: 0; }
|
||||
#compressionValue::after { content: attr(data-short); font-size: 12px; }
|
||||
}
|
||||
+27
-13
@@ -20,7 +20,7 @@ const STATUS = {
|
||||
const wasmRequests = new Map();
|
||||
const metaContents = new WeakMap();
|
||||
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 };
|
||||
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
|
||||
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
|
||||
|
||||
let wasmPromise = null;
|
||||
let remoteDownload = null;
|
||||
@@ -403,6 +403,8 @@ const el = {
|
||||
continuousButton: document.querySelector("#continuousButton"),
|
||||
annotationButton: document.querySelector("#annotationButton"),
|
||||
imageDPI: document.querySelector("#imageDPI"),
|
||||
outputCompression: document.querySelector("#outputCompression"),
|
||||
compressionValue: document.querySelector("#compressionValue"),
|
||||
dpiValue: document.querySelector("#dpiValue"),
|
||||
formatValue: document.querySelector("#formatValue"),
|
||||
exportFormat: document.querySelector("#exportFormat"),
|
||||
@@ -1250,6 +1252,10 @@ document.addEventListener("cut", copySelection);
|
||||
el.viewerPanel.addEventListener("paste", pasteEditorContent);
|
||||
document.addEventListener("selectionchange", syncSelection);
|
||||
el.exportFormat.addEventListener("change", () => updateDPIControl());
|
||||
el.outputCompression.addEventListener("change", () => {
|
||||
el.compressionValue.textContent = el.outputCompression.selectedOptions[0].textContent;
|
||||
el.compressionValue.dataset.short = ["原", "无", "有"][Number(el.outputCompression.value)];
|
||||
});
|
||||
el.exportPageButton.addEventListener("click", () => exportFile(false));
|
||||
el.exportButton.addEventListener("click", () => openExportPanel());
|
||||
el.exportCancel.addEventListener("click", () => el.exportPanel.close());
|
||||
@@ -4640,6 +4646,7 @@ async function signDocument(event) {
|
||||
pages:el.signPages.value.trim(), placement:el.signPlacement.value,
|
||||
x:Number(el.signX.value), y:Number(el.signY.value), width:Number(el.signWidth.value), height:Number(el.signHeight.value),
|
||||
mode:el.signMode.value, lock:el.signLock.checked,
|
||||
compression: { mode: Number(el.outputCompression.value) || 0 },
|
||||
};
|
||||
el.signKey.value = "";
|
||||
if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return;
|
||||
@@ -4842,7 +4849,8 @@ function renderBatchList() {
|
||||
}
|
||||
|
||||
function updateBatchControls() {
|
||||
for (const name of ["Add", "Clear", "Format", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
|
||||
for (const name of ["Add", "Clear", "Format", "Compression", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
|
||||
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
|
||||
batchElements.Clear.disabled ||= !batch.items.length;
|
||||
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
|
||||
batchElements.Start.hidden = batch.running;
|
||||
@@ -4957,12 +4965,13 @@ async function runBatch() {
|
||||
if (!defaultFormat) return;
|
||||
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
|
||||
const dpi = Number(batchElements.DPI.value);
|
||||
const compression = { mode: Number(batchElements.Compression.value) || 0 };
|
||||
const archive = batchElements.Destination.value === "archive";
|
||||
const backend = state.renderBackend;
|
||||
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
|
||||
for (const item of batch.items) {
|
||||
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
|
||||
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey]);
|
||||
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression]);
|
||||
if (item.settingsKey !== key) {
|
||||
item.status = "pending";
|
||||
item.text = "待转";
|
||||
@@ -5015,7 +5024,7 @@ async function runBatch() {
|
||||
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
|
||||
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
|
||||
names.add(outputName());
|
||||
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null,
|
||||
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
|
||||
fontNames: [...batch.localFonts.keys()] };
|
||||
try {
|
||||
for (;;) {
|
||||
@@ -5023,19 +5032,21 @@ async function runBatch() {
|
||||
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
|
||||
for (const file of result.files) entries.push(file);
|
||||
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
|
||||
if (result.unchanged) {
|
||||
if (result.unchanged && !result.bytes) {
|
||||
batchStatus(item, "unchanged", "原样");
|
||||
item.details = ["无需转换,源文件未改动"];
|
||||
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
|
||||
break;
|
||||
} catch (err) {
|
||||
if (batch.canceled || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err;
|
||||
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
|
||||
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
|
||||
options.credentials?.password?.fill(0);
|
||||
options.credentials?.key?.fill(0);
|
||||
options.credentials?.keyPassword?.fill(0);
|
||||
options.credentials = await requestCredentials(err.code, state.openSeq);
|
||||
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword");
|
||||
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
|
||||
if (!window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
|
||||
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
|
||||
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -5051,7 +5062,7 @@ async function runBatch() {
|
||||
batchElements.Progress.value = position + 1;
|
||||
}
|
||||
if (archive && entries.length && !batch.canceled) {
|
||||
const result = await batchCall("ofdgoPackFiles", [entries, zipFile], progress => batchProgress(null, 0, 1, progress));
|
||||
const result = await batchCall("ofdgoPackFiles", [entries, zipFile, compression], progress => batchProgress(null, 0, 1, progress));
|
||||
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
|
||||
packed = true;
|
||||
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
|
||||
@@ -5085,6 +5096,8 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
|
||||
return;
|
||||
}
|
||||
const saving = value === "ofd";
|
||||
const compression = { mode: Number(el.outputCompression.value) || 0 };
|
||||
const outputOptions = compression.mode ? [compression] : [];
|
||||
if (saving && !state.editorInfo && !state.ofdBytes && !encryption) {
|
||||
return;
|
||||
}
|
||||
@@ -5126,12 +5139,12 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
|
||||
delete encryption.recipientFiles;
|
||||
if (openSeq !== state.openSeq) return;
|
||||
}
|
||||
const readingSave = saving && !state.editorInfo && !encryption && indices === null;
|
||||
const readingSave = saving && !state.editorInfo && !encryption && indices === null && compression.mode === 0;
|
||||
const result = readingSave
|
||||
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
|
||||
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, file) : saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole
|
||||
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, file)
|
||||
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, file);
|
||||
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, ...outputOptions, file) : saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole
|
||||
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
|
||||
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
|
||||
if (openSeq !== state.openSeq) {
|
||||
return;
|
||||
}
|
||||
@@ -7925,6 +7938,7 @@ function updateControls() {
|
||||
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
|
||||
updateAnnotationButton();
|
||||
el.exportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||||
el.outputCompression.disabled = !hasDoc || state.exporting;
|
||||
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||||
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||||
updateDPIControl();
|
||||
|
||||
@@ -120,12 +120,12 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
|
||||
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done));
|
||||
} else {
|
||||
const [entries, handle] = args;
|
||||
const [entries, handle, compression] = args;
|
||||
if (handle) output = await handle.createWritable();
|
||||
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
|
||||
index => read(entries[index].file), write,
|
||||
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
|
||||
done => finish(done));
|
||||
done => finish(done), compression);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
@@ -134,7 +134,7 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
if (output) { await output.close(); output = null; }
|
||||
else result.data.blob = new Blob(chunks, { type: "application/zip" });
|
||||
} else {
|
||||
if (result.data.unchanged) {
|
||||
if (result.data.unchanged && !result.data.bytes) {
|
||||
if (output) { await output.abort(); output = null; }
|
||||
if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; }
|
||||
files = [];
|
||||
@@ -200,6 +200,7 @@ async function handleMessage({ id, name, args }) {
|
||||
if (converting) args.push((name, done) => requestFont(id, signal, name, done));
|
||||
} else {
|
||||
const file = args.pop();
|
||||
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
|
||||
const indices = saving ? args.shift() : null;
|
||||
if (file) output = await file.createWritable();
|
||||
signal.throwIfAborted();
|
||||
@@ -226,8 +227,10 @@ async function handleMessage({ id, name, args }) {
|
||||
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
|
||||
finish(done);
|
||||
});
|
||||
if (indices != null) args.push(indices);
|
||||
if (compression) args.push(indices, compression, done => finish(done));
|
||||
else if (indices != null) args.push(indices);
|
||||
}
|
||||
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
|
||||
}
|
||||
}
|
||||
const payload = await globalThis[name](...args);
|
||||
|
||||
@@ -9,7 +9,7 @@ require (
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
||||
github.com/xiaoqidun/jbig2 v1.1.1
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20260929063458-1c0de5ea8fb5
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20260929114121-a594c1621114
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
|
||||
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
|
||||
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20260929063458-1c0de5ea8fb5 h1:V6//X67UzIETLaHx4YCNxj0Ai38rylYfMNIM6eOnPs0=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20260929063458-1c0de5ea8fb5/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20260929114121-a594c1621114 h1:K2U1YTqej82Zh81lS+VqQzfggroGBBfcTsGQh9fD64E=
|
||||
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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
|
||||
+94
-30
@@ -17,7 +17,6 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -440,6 +439,11 @@ func convertFile(args []js.Value) (any, error) {
|
||||
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)}
|
||||
if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 {
|
||||
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)
|
||||
options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials))
|
||||
options.PDF.Password = credentials.Password
|
||||
options.PDF.PasswordUTF8 = true
|
||||
}
|
||||
var format ofdgo.OutputFormat
|
||||
for _, item := range ofdgo.OutputFormats() {
|
||||
@@ -480,11 +486,11 @@ func convertFile(args []js.Value) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())}
|
||||
report, err := ofdgo.Convert(context.Background(), source, source.size, writer, options)
|
||||
report, err := ofdgo.Convert(newOutputContext(args[6]), source, source.size, writer, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if report.Unchanged {
|
||||
if report.Unchanged && report.Bytes == 0 {
|
||||
return report, nil
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
@@ -526,45 +532,31 @@ func loadConversionFont(load js.Value, name string) ([]byte, error) {
|
||||
// 入参: args 文件列表、分块读取、分块写出、进度及取消检查
|
||||
// 返回: any 输出格式, error 打包错误
|
||||
func packFiles(args []js.Value) (any, error) {
|
||||
if len(args) != 5 {
|
||||
if len(args) < 5 {
|
||||
return nil, fmt.Errorf("missing archive arguments")
|
||||
}
|
||||
writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20)
|
||||
archive := zip.NewWriter(writer)
|
||||
files := args[0]
|
||||
entries := make([]ofdgo.ArchiveFile, files.Length())
|
||||
for i := 0; i < files.Length(); i++ {
|
||||
if err := awaitExport(args[3], i, files.Length()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := files.Index(i)
|
||||
name := file.Get("name").String()
|
||||
if !fs.ValidPath(name) || strings.Contains(name, "\\") {
|
||||
return nil, fmt.Errorf("invalid archive path %q", name)
|
||||
}
|
||||
method := uint16(zip.Deflate)
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".ofd", ".pdf", ".png", ".jpg", ".jpeg":
|
||||
method = zip.Store
|
||||
}
|
||||
entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
read := args[1].Invoke(i)
|
||||
source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())}
|
||||
if _, err := io.Copy(entry, io.NewSectionReader(source, 0, source.size)); err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
if _, err := ofdgo.WriteArchive(newOutputContext(args[4]), writer, entries, options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := awaitExport(args[3], files.Length(), files.Length()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"mime": "application/zip"}, nil
|
||||
}
|
||||
|
||||
@@ -962,6 +954,15 @@ func exportSession(args []js.Value) (*Session, int, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4404,6 +4405,11 @@ func saveSigned(args []js.Value) (any, error) {
|
||||
}
|
||||
options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots,
|
||||
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() {
|
||||
case "append":
|
||||
options.Mode = ofdgo.SignatureAppend
|
||||
@@ -4451,13 +4457,20 @@ func saveSigned(args []js.Value) (any, error) {
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
func saveDocument(args []js.Value) (any, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// saveEditor 统一分块写出及进度取消,加密策略由库层执行
|
||||
// 入参: editor 待写出文档, args 写出回调、进度回调和可选页面索引
|
||||
// 入参: editor 待写出文档, args 写出回调、进度回调及可选页面索引、压缩配置、取消检查
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) {
|
||||
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)
|
||||
var err error
|
||||
if len(args) > 2 {
|
||||
_, err = editor.WritePagesTo(writer, indexesFromJS(args[2]))
|
||||
var options ofdgo.WriteOptions
|
||||
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 {
|
||||
_, err = editor.WriteTo(writer)
|
||||
_, err = editor.WriteToWithOptions(ctx, writer, options)
|
||||
}
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// OutputFormat 描述文档输出格式,Paged表示逐页文件,Raster表示受DPI控制的位图
|
||||
@@ -42,8 +44,8 @@ type ConvertProgress struct {
|
||||
// ConvertOptions 设置文档转换格式、页码、阅读和渲染选项
|
||||
// Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面
|
||||
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
|
||||
// SkipUnchanged在源格式与目标相同且未选页时不写出数据,不校验或重新生成源文档
|
||||
// PDF先转换为OFD对象再输出,PDF选项中的进度回调仍会调用
|
||||
// SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
|
||||
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
|
||||
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
|
||||
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
|
||||
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
|
||||
@@ -54,6 +56,7 @@ type ConvertOptions struct {
|
||||
Pages []int
|
||||
PageRange string
|
||||
SkipUnchanged bool
|
||||
Compression CompressionOptions
|
||||
Backends *RenderBackends
|
||||
ReaderOptions []ReaderOption
|
||||
RendererOptions []RendererOption
|
||||
@@ -63,7 +66,7 @@ type ConvertOptions struct {
|
||||
}
|
||||
|
||||
// ConvertReport 汇总源格式、输出格式、页数、字节数及PDF转换诊断
|
||||
// Unchanged表示无需转换或完整OFD原样复制,Bytes仅统计实际输出,不包含调用方额外封装
|
||||
// Unchanged表示无需转换或原样交付源文件,Bytes仅统计实际输出,不包含调用方额外封装
|
||||
type ConvertReport struct {
|
||||
Input string `json:"input"`
|
||||
Format OutputFormat `json:"format"`
|
||||
@@ -101,12 +104,15 @@ func OutputFormats() []OutputFormat {
|
||||
}
|
||||
|
||||
// Convert 将PDF或OFD转换为指定格式,不关闭调用方的输入和输出
|
||||
// 完整OFD另存保持源字节,包括制作软件、签名及加密;选页和跨格式输出重新生成文档
|
||||
// 不变模式完整OFD另存保持源字节;选页和跨格式输出重新生成文档,压缩仅作用于输出副本
|
||||
// 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存
|
||||
// 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功
|
||||
// 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项
|
||||
// 返回: ConvertReport 转换报告, error 错误信息
|
||||
func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options ConvertOptions) (report ConvertReport, err error) {
|
||||
if err = options.Compression.Validate(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
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" {
|
||||
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
|
||||
return report, nil
|
||||
}
|
||||
if output == nil && options.PageOutput == nil {
|
||||
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 {
|
||||
case "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)
|
||||
writer := convertWriter{context: ctx, writer: output, count: &report.Bytes}
|
||||
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 {
|
||||
return report, err
|
||||
}
|
||||
@@ -243,9 +262,9 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
||||
}
|
||||
editor.OnWriteProgress = writeProgress
|
||||
if options.Pages == nil {
|
||||
_, err = editor.WriteTo(writer)
|
||||
_, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression})
|
||||
} else {
|
||||
_, err = editor.WritePagesTo(writer, indices)
|
||||
_, err = editor.WritePagesToWithOptions(ctx, writer, indices, WriteOptions{Compression: options.Compression})
|
||||
}
|
||||
return report, err
|
||||
}
|
||||
@@ -260,6 +279,8 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
||||
defer reader.Close()
|
||||
}
|
||||
renderer := NewRenderer(reader, options.RendererOptions...)
|
||||
renderer.Compression = options.Compression
|
||||
renderer.OutputContext = ctx
|
||||
if options.Backends != nil {
|
||||
WithRenderBackends(*options.Backends)(renderer)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type Editor struct {
|
||||
removedPages map[string]bool
|
||||
outlines []byte
|
||||
encryption *encryptionState
|
||||
output *outputOptimization
|
||||
}
|
||||
|
||||
// editorResource 文档内嵌资源
|
||||
|
||||
+16
-11
@@ -450,7 +450,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
|
||||
return 0, err
|
||||
}
|
||||
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"
|
||||
if file, ok := reader.packageFile(name); ok {
|
||||
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 {
|
||||
header := file.FileHeader
|
||||
entry, err := archive.CreateHeader(&header)
|
||||
if err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
if _, err := entry.Write(data); err != nil {
|
||||
if err := e.writeOutputEntry(archive, header, data); err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
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 {
|
||||
input, err := file.OpenRaw()
|
||||
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") {
|
||||
method = zip.Deflate
|
||||
}
|
||||
entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method})
|
||||
if err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
if _, err := entry.Write(remaining[name]); err != nil {
|
||||
if err := e.writeOutputEntry(archive, zip.FileHeader{Name: name, Method: method}, remaining[name]); err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
}
|
||||
|
||||
+43
-2
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
@@ -104,6 +106,7 @@ func (r *Renderer) RenderToZIP(writer io.Writer, format string, indices ...int)
|
||||
return err
|
||||
}
|
||||
archive := zip.NewWriter(writer)
|
||||
configureArchive(archive, r.Compression)
|
||||
method := zip.Deflate
|
||||
if extension == "png" || extension == "jpg" {
|
||||
method = zip.Store
|
||||
@@ -194,6 +197,9 @@ func (r *Renderer) RenderToImage(page *PageContent) (image.Image, error) {
|
||||
// 入参: page 页面内容, 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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -204,13 +210,23 @@ func (r *Renderer) RenderToPNG(page *PageContent, writer io.Writer) error {
|
||||
copy(chunk[4:], "tEXt")
|
||||
copy(chunk[8:], text)
|
||||
binary.BigEndian.PutUint32(chunk[len(chunk)-4:], crc32.ChecksumIEEE(chunk[4:len(chunk)-4]))
|
||||
return png.Encode(&imageMetadataWriter{writer: writer, skip: 33, data: chunk}, img)
|
||||
if r.Compression.Mode == CompressionUnchanged {
|
||||
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决定
|
||||
// 入参: page 页面内容, writer 输出流, options 编码选项,nil使用标准库默认值
|
||||
// 返回: 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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -220,7 +236,26 @@ func (r *Renderer) RenderToJPEG(page *PageContent, writer io.Writer, options *jp
|
||||
segment[0], segment[1] = 0xff, 0xe1
|
||||
binary.BigEndian.PutUint16(segment[2:], uint16(len(metadata)+2))
|
||||
copy(segment[4:], metadata)
|
||||
return jpeg.Encode(&imageMetadataWriter{writer: writer, skip: 2, data: segment}, fillWhiteBackground(img), options)
|
||||
if r.Compression.Mode == CompressionUnchanged {
|
||||
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
|
||||
@@ -247,6 +282,9 @@ func (r *Renderer) RenderToPDF(page *PageContent, writer io.Writer) error {
|
||||
// 入参: page 页面内容, 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 {
|
||||
return fmt.Errorf("eps: %w", ErrBackendUnavailable)
|
||||
}
|
||||
@@ -367,6 +405,9 @@ func fillWhiteBackground(img image.Image) image.Image {
|
||||
// 入参: pages 页面列表, writer 输出流, progress 页面处理进度
|
||||
// 返回: 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 {
|
||||
return fmt.Errorf("pdf: %w", ErrBackendUnavailable)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (
|
||||
// 入参: page 页面内容, writer 输出流, mode 资源封装方式
|
||||
// 返回: 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 {
|
||||
return SVGResources{}, fmt.Errorf("svg: %w", ErrBackendUnavailable)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"io/fs"
|
||||
)
|
||||
@@ -25,9 +26,12 @@ import (
|
||||
// OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观
|
||||
// OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功
|
||||
// TransparentBackground关闭页面白底,供嵌套图案和印章保持透明背景
|
||||
// Compression仅控制导出编码,OutputContext可取消输出压缩,nil使用后台上下文
|
||||
type Renderer struct {
|
||||
Reader *Reader
|
||||
DPI float64
|
||||
Compression CompressionOptions
|
||||
OutputContext context.Context
|
||||
RenderAnnotations bool
|
||||
OnPageText func(*PageContent, *PageText)
|
||||
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/ps"
|
||||
"github.com/tdewolff/canvas/renderers/svg"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// canvasEPSWriter 在EPS头部写入本库制作软件,不改动绘图内容
|
||||
@@ -48,7 +49,7 @@ func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer,
|
||||
return SVGResources{}, err
|
||||
}
|
||||
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, `<g style="image-orientation:none">`)
|
||||
c.RenderTo(renderer)
|
||||
@@ -77,7 +78,15 @@ func (CanvasBackend) RenderEPS(r *Renderer, page *PageContent, writer io.Writer)
|
||||
options.Format = ps.EncapsulatedPostScript
|
||||
buffer := bufio.NewWriter(writer)
|
||||
renderer := ps.New(canvasEPSWriter{buffer}, c.W, c.H, &options)
|
||||
c.RenderTo(renderer)
|
||||
if r.Compression.Mode == CompressionUnchanged {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -109,6 +118,9 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
||||
return err
|
||||
}
|
||||
buf, direct := writer.(*bytes.Buffer)
|
||||
if r.Compression.Mode != CompressionUnchanged {
|
||||
direct = false
|
||||
}
|
||||
if !direct {
|
||||
buf = &bytes.Buffer{}
|
||||
}
|
||||
@@ -142,6 +154,20 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/tdewolff/canvas"
|
||||
canvasimage "github.com/tdewolff/canvas/image"
|
||||
"github.com/tdewolff/canvas/renderers/svg"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// svgCreatorMetadata 记录SVG制作软件,不参与页面绘制
|
||||
@@ -57,7 +58,7 @@ type svgObjectGroup struct {
|
||||
composite bool
|
||||
}
|
||||
|
||||
// RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换
|
||||
// RenderImage 按输出策略编码图片,分离资源时保持尺寸和变换
|
||||
// 入参: img 图片对象, m 变换矩阵
|
||||
func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) {
|
||||
name, ok := s.imageNames[img]
|
||||
@@ -74,6 +75,16 @@ func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) {
|
||||
}
|
||||
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 {
|
||||
name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data))
|
||||
resource.Name = name
|
||||
|
||||
+46
-5
@@ -17,6 +17,7 @@ package ofdgo
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
@@ -27,6 +28,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -61,7 +64,9 @@ type SignatureWriteOptions struct {
|
||||
// ReaderOptions用于打开加密包;Encryption为空时继承阅读器的加密策略
|
||||
ReaderOptions []ReaderOption
|
||||
Encryption *EncryptionOptions
|
||||
progress editorProgress
|
||||
// Compression在签名计算前优化图片,已有签名的资源不额外改写
|
||||
Compression CompressionOptions
|
||||
progress editorProgress
|
||||
}
|
||||
|
||||
// signatureWriteInfo 写出签名信息,省略数字签名中不存在的印章
|
||||
@@ -208,11 +213,14 @@ func signatureWriteParts(r *Reader) (map[string][]byte, error) {
|
||||
return r.packageData()
|
||||
}
|
||||
|
||||
// signatureWritePackage 基于未重新编码的文件字节创建签名
|
||||
// signatureWritePackage 完成输出优化后计算签名,不改写已有签名保护的资源
|
||||
// 签名目录采用Sign_N兼容OFDRW容器,标识使用独立的sN签章域
|
||||
// 入参: parts 独立包文件, options 签署选项
|
||||
// 返回: []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 {
|
||||
return nil, fmt.Errorf("unsupported signature write mode")
|
||||
}
|
||||
@@ -227,6 +235,38 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
|
||||
if err != nil {
|
||||
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() {
|
||||
options.Time = time.Now()
|
||||
}
|
||||
@@ -358,7 +398,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := signatureWriteZIP(parts)
|
||||
output, err := signatureWriteZIP(parts, options.Compression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -477,11 +517,12 @@ func signatureWriteAvailable(parts map[string][]byte, name string) string {
|
||||
}
|
||||
|
||||
// signatureWriteZIP 写出完整包,不重新编码受保护文件
|
||||
// 入参: parts 文件集合
|
||||
// 入参: parts 文件集合, compression 封装压缩配置
|
||||
// 返回: []byte 包字节, error 错误信息
|
||||
func signatureWriteZIP(parts map[string][]byte) ([]byte, error) {
|
||||
func signatureWriteZIP(parts map[string][]byte, compression CompressionOptions) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
w := zip.NewWriter(&out)
|
||||
configureArchive(w, compression)
|
||||
names := make([]string, 0, len(parts))
|
||||
for name := range parts {
|
||||
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 {
|
||||
return 0, err
|
||||
}
|
||||
if e.output != nil {
|
||||
return selected.WriteToWithOptions(e.output.ctx, writer, WriteOptions{Compression: e.output.options})
|
||||
}
|
||||
return selected.WriteTo(writer)
|
||||
}
|
||||
|
||||
@@ -174,12 +177,7 @@ func (e *Editor) writePlaintext(writer io.Writer) (int64, error) {
|
||||
if compressed {
|
||||
method = zip.Store
|
||||
}
|
||||
entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = entry.Write(data)
|
||||
return err
|
||||
return e.writeOutputEntry(archive, zip.FileHeader{Name: name, Method: method}, data)
|
||||
}, progress)
|
||||
if err == nil {
|
||||
err = archive.Close()
|
||||
|
||||
Reference in new issue
Block a user