From 9fd9ba7a28fea444c1f04a4e7a68df511f0d1f3d Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Tue, 29 Sep 2026 19:47:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=96=87=E6=A1=A3=E5=8E=8B=E7=BC=A9):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=BE=93=E5=87=BA=E5=8E=8B=E7=BC=A9=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 2 + assets/webui/ofdgo.css | 31 +-- assets/webui/ofdgo.js | 40 ++-- assets/webui/ofdgo_wasm.js | 11 +- go.mod | 2 +- go.sum | 4 +- internal/webui/wasm.go | 124 +++++++++--- ofdgo_archive.go | 100 ++++++++++ ofdgo_compression.go | 320 ++++++++++++++++++++++++++++++ ofdgo_convert.go | 37 +++- ofdgo_editor.go | 1 + ofdgo_editor_package.go | 27 ++- ofdgo_render_output.go | 45 ++++- ofdgo_render_svg.go | 3 + ofdgo_renderer.go | 4 + ofdgo_renderer_eps_compression.go | 104 ++++++++++ ofdgo_renderer_output_canvas.go | 30 ++- ofdgo_renderer_svg_canvas.go | 13 +- ofdgo_sign_write.go | 51 ++++- ofdgo_writer.go | 10 +- 20 files changed, 862 insertions(+), 97 deletions(-) create mode 100644 ofdgo_archive.go create mode 100644 ofdgo_compression.go create mode 100644 ofdgo_renderer_eps_compression.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 76dfe07..b584b4b 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -57,6 +57,7 @@ + @@ -183,6 +184,7 @@
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 74aa749..14dbe7c 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -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; } } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index e010761..d533580 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -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(); diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 32e2bbc..de14879 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -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); diff --git a/go.mod b/go.mod index bb8859c..a930d86 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b7039f3..f2d6f22 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index d77a3b4..9589b35 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -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() +} diff --git a/ofdgo_archive.go b/ofdgo_archive.go new file mode 100644 index 0000000..f2af967 --- /dev/null +++ b/ofdgo_archive.go @@ -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) }) + } +} diff --git a/ofdgo_compression.go b/ofdgo_compression.go new file mode 100644 index 0000000..d2f7228 --- /dev/null +++ b/ofdgo_compression.go @@ -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() +} diff --git a/ofdgo_convert.go b/ofdgo_convert.go index 2bf3970..367cfc4 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -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) } diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 7320c99..d347690 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -64,6 +64,7 @@ type Editor struct { removedPages map[string]bool outlines []byte encryption *encryptionState + output *outputOptimization } // editorResource 文档内嵌资源 diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 8a1ac0e..3bd89c3 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -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 } } diff --git a/ofdgo_render_output.go b/ofdgo_render_output.go index 9b5ba02..66f2ea2 100644 --- a/ofdgo_render_output.go +++ b/ofdgo_render_output.go @@ -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) } diff --git a/ofdgo_render_svg.go b/ofdgo_render_svg.go index c4dbb50..f8ace6a 100644 --- a/ofdgo_render_svg.go +++ b/ofdgo_render_svg.go @@ -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) } diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 942c61e..ca7d4bd 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -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 diff --git a/ofdgo_renderer_eps_compression.go b/ofdgo_renderer_eps_compression.go new file mode 100644 index 0000000..30d1634 --- /dev/null +++ b/ofdgo_renderer_eps_compression.go @@ -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 <>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") +} diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index 6b7d1bd..5dc6992 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -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, ``) 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 } diff --git a/ofdgo_renderer_svg_canvas.go b/ofdgo_renderer_svg_canvas.go index a29e6b2..64262d5 100644 --- a/ofdgo_renderer_svg_canvas.go +++ b/ofdgo_renderer_svg_canvas.go @@ -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 diff --git a/ofdgo_sign_write.go b/ofdgo_sign_write.go index 9ce46f0..7e772c1 100644 --- a/ofdgo_sign_write.go +++ b/ofdgo_sign_write.go @@ -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) diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 0dd1f26..66ac58c 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -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()