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

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

No files matched your search

+2
View File
@@ -57,6 +57,7 @@
<option value="300" selected>300 dpi</option>
<option value="600">600 dpi</option>
</select><span id="dpiValue" aria-hidden="true">300</span></label>
<label class="toolbar-select"><select id="outputCompression" class="format-select" title="压缩" aria-label="压缩" disabled><option value="0">不变</option><option value="1">无损</option><option value="2">有损</option></select><span id="compressionValue" data-short="原" aria-hidden="true">不变</span></label>
<button id="exportPageButton" class="button" type="button" data-short="单" title="导出单页" aria-label="导出单页" disabled>单页</button>
<button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
</div>
@@ -183,6 +184,7 @@
<button id="convertAdd" class="button" type="button">添加</button><button id="convertClear" class="button" type="button">清空</button>
<div class="convert-options">
<div class="form-row"><label for="convertFormat">格式</label><select id="convertFormat" required></select></div>
<div class="form-row"><label for="convertCompression">压缩</label><select id="convertCompression"><option value="0">不变</option><option value="1">无损</option><option value="2">有损</option></select></div>
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><select id="convertDPI"><option value="96">96 dpi</option><option value="150">150 dpi</option><option value="300" selected>300 dpi</option><option value="600">600 dpi</option></select></div>
<div class="form-row"><label for="convertDestination">保存</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
</div>
+19 -12
View File
@@ -723,13 +723,16 @@ button:not(:disabled):hover {
.convert-options {
flex: none;
display: grid;
grid-template-columns: calc(2em + 8px + var(--convert-control-width)) 132px calc(2em + 8px + var(--convert-control-width));
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-column: 1 / -1;
grid-row: 1;
width: 100%;
gap: 12px;
margin: 0 auto;
}
.convert-options:has(#convertDPIRow[hidden]) {
grid-template-columns: repeat(2, calc(2em + 8px + var(--convert-control-width)));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.convert-options .form-row {
@@ -906,7 +909,8 @@ button:not(:disabled):hover {
}
.convert-dialog .form-actions {
display: flex;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
justify-content: flex-end;
margin-top: 12px;
@@ -914,8 +918,14 @@ button:not(:disabled):hover {
.convert-dialog .form-actions .button {
min-width: 3.5em;
grid-row: 2;
}
#convertAdd { grid-column: 1; }
#convertClear { grid-column: 2; }
#convertClose { grid-column: 4; justify-self: end; }
#convertCancel, #convertStart { grid-column: 5; justify-self: end; }
.convert-progress progress {
display: block;
width: 100%;
@@ -951,19 +961,18 @@ button:not(:disabled):hover {
}
.convert-dialog .form-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 12px;
}
.convert-dialog .form-actions > .button { grid-row: 2; }
.convert-options {
grid-column: 1 / -1;
grid-row: 1;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
margin: 0;
}
.convert-options:has(#convertDPIRow[hidden]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.convert-options .form-row {
grid-template-columns: minmax(0, 1fr);
@@ -971,15 +980,10 @@ button:not(:disabled):hover {
text-align: left;
}
.convert-options:has(#convertDPIRow[hidden]) .form-row {
grid-template-columns: 2em minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.convert-options label { width: auto; }
#convertAdd { grid-column: 1; }
#convertClear { grid-column: 2; }
#convertClose { grid-column: 3; }
#convertCancel,
#convertStart { grid-column: 4; }
}
@media (max-width: 620px) {
@@ -3089,6 +3093,9 @@ button.outline-link:hover {
.toolbar-select > span {
padding: 0;
justify-content: center;
font-size: 11px;
}
#compressionValue { font-size: 0; }
#compressionValue::after { content: attr(data-short); font-size: 12px; }
}
+27 -13
View File
@@ -20,7 +20,7 @@ const STATUS = {
const wasmRequests = new Map();
const metaContents = new WeakMap();
const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 };
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)]));
let wasmPromise = null;
let remoteDownload = null;
@@ -403,6 +403,8 @@ const el = {
continuousButton: document.querySelector("#continuousButton"),
annotationButton: document.querySelector("#annotationButton"),
imageDPI: document.querySelector("#imageDPI"),
outputCompression: document.querySelector("#outputCompression"),
compressionValue: document.querySelector("#compressionValue"),
dpiValue: document.querySelector("#dpiValue"),
formatValue: document.querySelector("#formatValue"),
exportFormat: document.querySelector("#exportFormat"),
@@ -1250,6 +1252,10 @@ document.addEventListener("cut", copySelection);
el.viewerPanel.addEventListener("paste", pasteEditorContent);
document.addEventListener("selectionchange", syncSelection);
el.exportFormat.addEventListener("change", () => updateDPIControl());
el.outputCompression.addEventListener("change", () => {
el.compressionValue.textContent = el.outputCompression.selectedOptions[0].textContent;
el.compressionValue.dataset.short = ["原", "无", "有"][Number(el.outputCompression.value)];
});
el.exportPageButton.addEventListener("click", () => exportFile(false));
el.exportButton.addEventListener("click", () => openExportPanel());
el.exportCancel.addEventListener("click", () => el.exportPanel.close());
@@ -4640,6 +4646,7 @@ async function signDocument(event) {
pages:el.signPages.value.trim(), placement:el.signPlacement.value,
x:Number(el.signX.value), y:Number(el.signY.value), width:Number(el.signWidth.value), height:Number(el.signHeight.value),
mode:el.signMode.value, lock:el.signLock.checked,
compression: { mode: Number(el.outputCompression.value) || 0 },
};
el.signKey.value = "";
if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return;
@@ -4842,7 +4849,8 @@ function renderBatchList() {
}
function updateBatchControls() {
for (const name of ["Add", "Clear", "Format", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
for (const name of ["Add", "Clear", "Format", "Compression", "Destination", "DPI"]) batchElements[name].disabled = batch.running;
batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt");
batchElements.Clear.disabled ||= !batch.items.length;
batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length;
batchElements.Start.hidden = batch.running;
@@ -4957,12 +4965,13 @@ async function runBatch() {
if (!defaultFormat) return;
const pageRange = item => item.pages.trim() === "全部" ? "" : item.pages.trim();
const dpi = Number(batchElements.DPI.value);
const compression = { mode: Number(batchElements.Compression.value) || 0 };
const archive = batchElements.Destination.value === "archive";
const backend = state.renderBackend;
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
for (const item of batch.items) {
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey]);
const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression]);
if (item.settingsKey !== key) {
item.status = "pending";
item.text = "待转";
@@ -5015,7 +5024,7 @@ async function runBatch() {
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
names.add(outputName());
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null,
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null,
fontNames: [...batch.localFonts.keys()] };
try {
for (;;) {
@@ -5023,19 +5032,21 @@ async function runBatch() {
const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress));
for (const file of result.files) entries.push(file);
item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`);
if (result.unchanged) {
if (result.unchanged && !result.bytes) {
batchStatus(item, "unchanged", "原样");
item.details = ["无需转换,源文件未改动"];
} else batchStatus(item, archive ? "ready" : "done", archive ? "待存" : "完成");
break;
} catch (err) {
if (batch.canceled || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err;
if (batch.canceled || !["credentialsRequired", "invalidCredentials", "pdfPassword"].includes(err.code)) throw err;
const code = err.code === "pdfPassword" ? options.credentials ? "invalidCredentials" : "credentialsRequired" : err.code;
options.credentials?.password?.fill(0);
options.credentials?.key?.fill(0);
options.credentials?.keyPassword?.fill(0);
options.credentials = await requestCredentials(err.code, state.openSeq);
options.credentials = await requestCredentials(code, state.openSeq, err.code === "pdfPassword");
if (!options.credentials) throw new DOMException("已取消解锁", "AbortError");
if (!window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
const preservesEncryption = err.code === "pdfPassword" && format.value === "pdf" && compression.mode !== 0 && !options.pages;
if (!preservesEncryption && !window.confirm("转换结果将不保留原加密,是否继续?")) throw new DOMException("已取消转换", "AbortError");
}
}
} catch (err) {
@@ -5051,7 +5062,7 @@ async function runBatch() {
batchElements.Progress.value = position + 1;
}
if (archive && entries.length && !batch.canceled) {
const result = await batchCall("ofdgoPackFiles", [entries, zipFile], progress => batchProgress(null, 0, 1, progress));
const result = await batchCall("ofdgoPackFiles", [entries, zipFile, compression], progress => batchProgress(null, 0, 1, progress));
if (result.blob) downloadBytes(result.blob, "application/zip", "转换结果.zip");
packed = true;
for (const item of jobs) if (item.status === "ready") batchStatus(item, "done", "完成");
@@ -5085,6 +5096,8 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
return;
}
const saving = value === "ofd";
const compression = { mode: Number(el.outputCompression.value) || 0 };
const outputOptions = compression.mode ? [compression] : [];
if (saving && !state.editorInfo && !state.ofdBytes && !encryption) {
return;
}
@@ -5126,12 +5139,12 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value,
delete encryption.recipientFiles;
if (openSeq !== state.openSeq) return;
}
const readingSave = saving && !state.editorInfo && !encryption && indices === null;
const readingSave = saving && !state.editorInfo && !encryption && indices === null && compression.mode === 0;
const result = readingSave
? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes }
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, file) : saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, file);
: encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, ...outputOptions, file) : saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole
? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file)
: await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file);
if (openSeq !== state.openSeq) {
return;
}
@@ -7925,6 +7938,7 @@ function updateControls() {
el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height");
updateAnnotationButton();
el.exportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.outputCompression.disabled = !hasDoc || state.exporting;
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
updateDPIControl();
+7 -4
View File
@@ -120,12 +120,12 @@ async function handleBatchMessage({ id, name, args }) {
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done));
} else {
const [entries, handle] = args;
const [entries, handle, compression] = args;
if (handle) output = await handle.createWritable();
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
index => read(entries[index].file), write,
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
done => finish(done));
done => finish(done), compression);
}
signal.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
@@ -134,7 +134,7 @@ async function handleBatchMessage({ id, name, args }) {
if (output) { await output.close(); output = null; }
else result.data.blob = new Blob(chunks, { type: "application/zip" });
} else {
if (result.data.unchanged) {
if (result.data.unchanged && !result.data.bytes) {
if (output) { await output.abort(); output = null; }
if (root && created) { await root.removeEntry(created, { recursive: true }); created = null; }
files = [];
@@ -200,6 +200,7 @@ async function handleMessage({ id, name, args }) {
if (converting) args.push((name, done) => requestFont(id, signal, name, done));
} else {
const file = args.pop();
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
const indices = saving ? args.shift() : null;
if (file) output = await file.createWritable();
signal.throwIfAborted();
@@ -226,8 +227,10 @@ async function handleMessage({ id, name, args }) {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done);
});
if (indices != null) args.push(indices);
if (compression) args.push(indices, compression, done => finish(done));
else if (indices != null) args.push(indices);
}
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
}
}
const payload = await globalThis[name](...args);
+1 -1
View File
@@ -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
+2 -2
View File
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/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
View File
@@ -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()
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"archive/zip"
"compress/flate"
"context"
"fmt"
"io"
"io/fs"
"path"
"strings"
)
// ArchiveFile 描述已生成的归档文件,不关闭调用方提供的源读取器
type ArchiveFile struct {
Name string
Source io.ReaderAt
Size int64
}
// ArchiveOptions 设置ZIP封装,压缩不重新编码归档内已生成的文档
type ArchiveOptions struct {
Compression CompressionOptions
OnProgress func(completed, total int) error
}
// WriteArchive 分块归档文件,保持文件内容,有损选项在此仅提高ZIP压缩等级
// 入参: ctx 取消上下文, writer 输出流, files 文件列表, options 归档配置
// 返回: int64 已写字节数, error 读写错误,出错时应丢弃输出
func WriteArchive(ctx context.Context, writer io.Writer, files []ArchiveFile, options ArchiveOptions) (int64, error) {
if err := options.Compression.Validate(); err != nil {
return 0, err
}
names := map[string]bool{}
for _, file := range files {
if !fs.ValidPath(file.Name) || file.Name == "." || strings.Contains(file.Name, "\\") || names[file.Name] || file.Source == nil || file.Size < 0 {
return 0, fmt.Errorf("invalid archive file %q", file.Name)
}
names[file.Name] = true
}
var count int64
archive := zip.NewWriter(convertWriter{context: ctx, writer: writer, count: &count})
configureArchive(archive, options.Compression)
for i, file := range files {
if err := ctx.Err(); err != nil {
return count, err
}
if options.OnProgress != nil {
if err := options.OnProgress(i, len(files)); err != nil {
return count, err
}
}
method := uint16(zip.Deflate)
switch strings.ToLower(path.Ext(file.Name)) {
case ".ofd", ".pdf", ".png", ".jpg", ".jpeg":
method = zip.Store
}
entry, err := archive.CreateHeader(&zip.FileHeader{Name: file.Name, Method: method})
if err != nil {
return count, err
}
input := convertReader{context: ctx, source: file.Source}
written, err := io.Copy(entry, io.NewSectionReader(input, 0, file.Size))
if err != nil {
return count, err
}
if written != file.Size {
return count, io.ErrUnexpectedEOF
}
}
if err := archive.Close(); err != nil {
return count, err
}
if options.OnProgress != nil {
return count, options.OnProgress(len(files), len(files))
}
return count, ctx.Err()
}
// configureArchive 统一逐页导出与批量归档的压缩等级
// 入参: archive 归档写入器, options 压缩配置
func configureArchive(archive *zip.Writer, options CompressionOptions) {
if options.Mode != CompressionUnchanged {
archive.RegisterCompressor(zip.Deflate, func(writer io.Writer) (io.WriteCloser, error) { return flate.NewWriter(writer, flate.BestCompression) })
}
}
+320
View File
@@ -0,0 +1,320 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"archive/zip"
"bufio"
"bytes"
"compress/flate"
"context"
"encoding/xml"
"hash/crc32"
"io"
"maps"
"slices"
"strings"
"github.com/xiaoqidun/pdfgo"
)
const (
CompressionUnchanged = pdfgo.CompressionUnchanged
CompressionLossless = pdfgo.CompressionLossless
CompressionLossy = pdfgo.CompressionLossy
)
// CompressionMode 选择不变、无损或有损输出,不改变导出分辨率
type CompressionMode = pdfgo.CompressionMode
// CompressionOptions 配置输出压缩,零值保留现有行为,Quality为有损质量1至100,0使用85
// 无损表示不引入额外损失,不保证跨格式转换可逆;有损仅降低图片画质,不栅格化文字和矢量
type CompressionOptions = pdfgo.CompressionOptions
// WriteOptions 配置OFD输出副本,不修改编辑资源和撤销记录
type WriteOptions struct{ Compression CompressionOptions }
// outputOptimization 保存单次写出的配置及图片路径,不缓存资源数据
type outputOptimization struct {
ctx context.Context
options CompressionOptions
images map[string]bool
protected bool
}
// WriteToWithOptions 按压缩策略写出OFD,签名文档不额外改写资源内容
// 入参: ctx 取消上下文, writer 输出流, options 输出配置
// 返回: int64 已写入字节数, error 错误信息
func (e *Editor) WriteToWithOptions(ctx context.Context, writer io.Writer, options WriteOptions) (int64, error) {
snapshot, err := e.outputSnapshot(ctx, options)
if err != nil {
return 0, err
}
var count int64
return snapshot.WriteTo(convertWriter{context: ctx, writer: writer, count: &count})
}
// WritePagesToWithOptions 按指定顺序另存页面并应用输出压缩
// 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置
// 返回: int64 已写入字节数, error 错误信息
func (e *Editor) WritePagesToWithOptions(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) {
snapshot, err := e.outputSnapshot(ctx, options)
if err != nil {
return 0, err
}
var count int64
return snapshot.WritePagesTo(convertWriter{context: ctx, writer: writer, count: &count}, indexes)
}
// outputSnapshot 创建只读输出配置,原签名存在时仅调整ZIP封装
// 入参: ctx 取消上下文, options 输出配置
// 返回: *Editor 输出快照, error 配置或资源错误
func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := options.Compression.Validate(); err != nil {
return nil, err
}
snapshot := *e
snapshot.OnWriteProgress = func(stage string, completed, total int) error {
if err := ctx.Err(); err != nil {
return err
}
return editorProgress(e.OnWriteProgress).report(stage, completed, total)
}
snapshot.output = &outputOptimization{ctx: ctx, options: options.Compression, images: map[string]bool{}}
if options.Compression.Mode == CompressionUnchanged {
return &snapshot, nil
}
for _, resource := range e.resources {
if resource.image != nil {
snapshot.output.images[strings.ToLower(cleanPackagePath(resource.name))] = true
}
}
if e.source != nil {
protected, err := e.source.reader.hasOutputSignatures()
if err != nil {
return nil, err
}
snapshot.output.protected = protected
if protected {
snapshot.output.images = nil
return &snapshot, nil
}
images, err := e.source.reader.Images(ctx)
if err != nil {
return nil, err
}
for _, img := range images {
snapshot.output.images[strings.ToLower(cleanPackagePath(img.Location))] = true
}
}
if options.Compression.Mode == CompressionLossy {
reader, err := e.reader(editorProgress(snapshot.OnWriteProgress))
if err != nil {
return nil, err
}
defer reader.Close()
images, err := reader.Images(ctx)
if err != nil {
return nil, err
}
masks, err := reader.compressionMaskFiles(ctx, images)
if err != nil {
return nil, err
}
for name := range masks {
snapshot.output.images[name] = false
}
}
return &snapshot, nil
}
// compressionMaskFiles 查找包括模板和注释在内的独立蒙版资源,解析不完整时保留所有图片像素
// 入参: ctx 取消上下文, images 图片资源
// 返回: map[string]bool 只能无损处理的资源路径, error 读取或取消错误
func (r *Reader) compressionMaskFiles(ctx context.Context, images []ImageInfo) (map[string]bool, error) {
names := make(map[string]bool)
for name := range r.fileIndex {
names[name] = true
}
for name := range r.files {
names[name] = true
}
masks := make(map[string]bool)
safe := true
for _, name := range slices.Sorted(maps.Keys(names)) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.HasSuffix(name, "/") {
continue
}
input, err := r.openFile(name)
if err != nil {
return nil, err
}
buffer := bufio.NewReader(imageInput{ReadCloser: input, context: ctx})
header, _ := buffer.Peek(512)
header = bytes.TrimSpace(bytes.TrimPrefix(header, []byte{0xef, 0xbb, 0xbf}))
if len(header) > 0 && header[0] == '<' {
decoder := xml.NewDecoder(buffer)
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
safe = false
break
}
if element, ok := token.(xml.StartElement); ok {
for _, attr := range element.Attr {
if attr.Name.Local == "ImageMask" {
masks[strings.TrimSpace(attr.Value)] = true
}
}
}
}
}
input.Close()
if !safe {
break
}
}
paths := make(map[string]bool)
for _, img := range images {
if !safe || masks[img.ID] {
paths[strings.ToLower(cleanPackagePath(img.Location))] = true
}
}
return paths, ctx.Err()
}
// hasOutputSignatures 检查包中各文档,避免改写其他文档签名保护的共享资源
// 返回: bool 是否存在签名, error 文档读取错误
func (r *Reader) hasOutputSignatures() (bool, error) {
for _, body := range r.OFD.DocBody {
if body.Signatures != "" {
return true, nil
}
data, err := r.readFile(body.DocRoot)
if err != nil {
return false, err
}
var doc struct {
Signatures string `xml:"Signatures"`
}
if err := xml.Unmarshal(data, &doc); err != nil {
return false, err
}
if doc.Signatures != "" {
return true, nil
}
}
return false, nil
}
// writeOutputEntry 逐项优化图片与ZIP编码,保留资源名称及其他文件的原始字节
// 入参: archive 归档写入器, header 条目属性, data 原始数据
// 返回: error 优化或写入错误
func (e *Editor) writeOutputEntry(archive *zip.Writer, header zip.FileHeader, data []byte) error {
if e.output == nil || e.output.options.Mode == CompressionUnchanged || len(data) > 64<<20 {
entry, err := archive.CreateHeader(&header)
if err != nil {
return err
}
_, err = entry.Write(data)
return err
}
if err := editorProgress(e.OnWriteProgress).report("compress", 0, 0); err != nil {
return err
}
if lossy, ok := e.output.images[strings.ToLower(cleanPackagePath(header.Name))]; ok {
options := e.output.options
if !lossy {
options.Mode = CompressionLossless
}
candidate, err := pdfgo.OptimizeImage(e.output.ctx, data, options)
if err != nil {
if e.output.ctx.Err() != nil {
return e.output.ctx.Err()
}
} else {
data = candidate
}
}
var compressed bytes.Buffer
deflater, _ := flate.NewWriter(&compressed, flate.BestCompression)
for offset := 0; offset < len(data); {
if err := e.output.ctx.Err(); err != nil {
deflater.Close()
return err
}
end := min(offset+64<<10, len(data))
if _, err := deflater.Write(data[offset:end]); err != nil {
return err
}
offset = end
}
if err := deflater.Close(); err != nil {
return err
}
payload := data
header.Method = zip.Store
if compressed.Len() < len(data) {
payload = compressed.Bytes()
header.Method = zip.Deflate
}
header.CRC32 = crc32.ChecksumIEEE(data)
header.UncompressedSize64 = uint64(len(data))
header.CompressedSize64 = uint64(len(payload))
header.Flags &^= 8
entry, err := archive.CreateRaw(&header)
if err != nil {
return err
}
_, err = entry.Write(payload)
return err
}
// WithCompression 设置导出策略,不修改文档资源和渲染缓存
// 入参: options 压缩配置,写出时校验
// 返回: RendererOption 渲染器配置
func WithCompression(options CompressionOptions) RendererOption {
return func(r *Renderer) { r.Compression = options }
}
// writeCompressedImage 优化已经编码的图片,再交付调用方输出流
// 入参: writer 输出流, data 图片数据
// 返回: error 优化或写入错误
func (r *Renderer) writeCompressedImage(writer io.Writer, data []byte) error {
optimized, err := pdfgo.OptimizeImage(r.outputContext(), data, r.Compression)
if err != nil {
return err
}
_, err = io.Copy(writer, bytes.NewReader(optimized))
return err
}
// outputContext 获取仅用于输出的取消上下文
// 返回: context.Context 取消上下文
func (r *Renderer) outputContext() context.Context {
if r.OutputContext != nil {
return r.OutputContext
}
return context.Background()
}
+29 -8
View File
@@ -20,6 +20,8 @@ import (
"fmt"
"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)
}
+1
View File
@@ -64,6 +64,7 @@ type Editor struct {
removedPages map[string]bool
outlines []byte
encryption *encryptionState
output *outputOptimization
}
// editorResource 文档内嵌资源
+16 -11
View File
@@ -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
}
}
+41
View File
@@ -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]))
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,8 +236,27 @@ 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)
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
// 入参: page 页面内容, writer 输出流
@@ -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)
}
+3
View File
@@ -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)
}
+4
View File
@@ -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
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"bytes"
"compress/zlib"
"context"
"encoding/ascii85"
"fmt"
"image"
"image/jpeg"
"io"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/ps"
"github.com/xiaoqidun/pdfgo"
)
// epsImageRenderer 在EPS输出端优化图片,不修改页面及渲染缓存
type epsImageRenderer struct {
*ps.PS
writer io.Writer
options CompressionOptions
ctx context.Context
err error
}
// RenderImage 写出Flate或更小的JPEG图片,保留原尺寸与变换
// 入参: img 图片, matrix 绘制变换
func (r *epsImageRenderer) RenderImage(img image.Image, matrix canvas.Matrix) {
if r.err != nil {
return
}
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
pixels := make([]byte, width*3)
var encoded bytes.Buffer
compressor, _ := zlib.NewWriterLevel(&encoded, zlib.BestCompression)
opaque := true
for y := 0; y < height; y++ {
if r.err = r.ctx.Err(); r.err != nil {
return
}
clear(pixels)
for x := 0; x < width; x++ {
red, green, blue, alpha := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
if alpha != 65535 {
opaque = false
}
if alpha != 0 {
offset := x * 3
pixels[offset], pixels[offset+1], pixels[offset+2] = byte(red*65535/alpha>>8), byte(green*65535/alpha>>8), byte(blue*65535/alpha>>8)
}
}
if _, r.err = compressor.Write(pixels); r.err != nil {
return
}
}
if r.err = compressor.Close(); r.err != nil {
return
}
data := encoded.Bytes()
filter := "FlateDecode"
if opaque && r.options.Mode == CompressionLossy {
var candidate bytes.Buffer
if r.err = jpeg.Encode(&candidate, img, &jpeg.Options{Quality: r.options.ImageQuality()}); r.err != nil {
return
}
optimized, err := pdfgo.OptimizeJPEG(r.ctx, candidate.Bytes())
if err != nil {
r.err = err
return
}
if len(optimized) < len(data) {
data, filter = optimized, "DCTDecode"
}
}
m := matrix.Scale(float64(width), float64(height))
_, r.err = fmt.Fprintf(r.writer, " gsave /DeviceRGB setcolorspace [%s %s %s %s %s %s] concat <</ImageType 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /Interpolate true /Width %d /Height %d /ImageMatrix [%d 0 0 -%d 0 %d] /DataSource currentfile /ASCII85Decode filter /%s filter>>image\n", ofdNumber(m[0][0]), ofdNumber(m[1][0]), ofdNumber(m[0][1]), ofdNumber(m[1][1]), ofdNumber(m[0][2]), ofdNumber(m[1][2]), width, height, width, height, height, filter)
if r.err != nil {
return
}
writer := ascii85.NewEncoder(r.writer)
if _, r.err = writer.Write(data); r.err != nil {
return
}
if r.err = writer.Close(); r.err != nil {
return
}
_, r.err = io.WriteString(r.writer, "~>\n grestore")
}
+27 -1
View File
@@ -24,6 +24,7 @@ import (
"github.com/tdewolff/canvas/renderers/pdf"
"github.com/tdewolff/canvas/renderers/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)
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
}
+12 -1
View File
@@ -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
+45 -4
View File
@@ -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,6 +64,8 @@ type SignatureWriteOptions struct {
// ReaderOptions用于打开加密包;Encryption为空时继承阅读器的加密策略
ReaderOptions []ReaderOption
Encryption *EncryptionOptions
// Compression在签名计算前优化图片,已有签名的资源不额外改写
Compression CompressionOptions
progress editorProgress
}
@@ -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
View File
@@ -114,6 +114,9 @@ func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) {
if _, err := selected.ImportPagesWithOptions(reader, positions, 0, PageImportOptions{Outlines: true, OnProgress: e.OnWriteProgress}); err != nil {
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()