diff --git a/assets/webui/index.html b/assets/webui/index.html index 088282d..aaa4603 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -57,7 +57,7 @@ - + @@ -184,7 +184,7 @@
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 14dbe7c..d4103dd 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -961,9 +961,12 @@ 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-dialog .form-actions > .button { grid-row: 2; width: 100%; } + #convertClose { grid-column: 3; } + #convertCancel, #convertStart { grid-column: 4; } .convert-options { grid-column: 1 / -1; grid-row: 1; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 71d6a42..cd6b6dc 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1254,7 +1254,7 @@ 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.compressionValue.dataset.short = ["默", "无", "轻", "中", "强"][Number(el.outputCompression.value)]; }); el.exportPageButton.addEventListener("click", () => exportFile(false)); el.exportButton.addEventListener("click", () => openExportPanel()); @@ -4646,7 +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 }, + compression: compressionOptions(el.outputCompression.value), }; el.signKey.value = ""; if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return; @@ -4966,7 +4966,7 @@ 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 compression = compressionOptions(batchElements.Compression.value); 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])]; @@ -5092,12 +5092,17 @@ async function runBatch() { } } +function compressionOptions(value) { + const preset = Number(value) || 0; + return preset > 2 ? { mode: 2, level: preset - 2 } : { mode: preset }; +} + async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } const saving = value === "ofd"; - const compression = { mode: Number(el.outputCompression.value) || 0 }; + const compression = compressionOptions(el.outputCompression.value); const outputOptions = compression.mode ? [compression] : []; if (saving && !state.editorInfo && !state.ofdBytes && !encryption) { return; diff --git a/go.mod b/go.mod index 11e1ea1..2530145 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-20260929115319-4a25c95d8fef + github.com/xiaoqidun/pdfgo v0.0.0-20260929132513-f5d1ff8ce60a 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 6314662..fb349bf 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-20260929115319-4a25c95d8fef h1:AYQXIlR7DHcRaP6+ssVSRYMjmb5FhQkQZiex5P+P1so= -github.com/xiaoqidun/pdfgo v0.0.0-20260929115319-4a25c95d8fef/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260929132513-f5d1ff8ce60a h1:+zzZs2zRkhs+HAX70k2ATDbmvEvV4feEJNavl7NCu80= +github.com/xiaoqidun/pdfgo v0.0.0-20260929132513-f5d1ff8ce60a/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/ofdgo_compression.go b/ofdgo_compression.go index 61eea48..322bb48 100644 --- a/ofdgo_compression.go +++ b/ofdgo_compression.go @@ -22,6 +22,7 @@ import ( "context" "encoding/xml" "hash/crc32" + "image" "io" "maps" "slices" @@ -34,14 +35,20 @@ const ( CompressionUnchanged = pdfgo.CompressionUnchanged CompressionLossless = pdfgo.CompressionLossless CompressionLossy = pdfgo.CompressionLossy + CompressionLight = pdfgo.CompressionLight + CompressionMedium = pdfgo.CompressionMedium + CompressionStrong = pdfgo.CompressionStrong ) -// CompressionMode 选择默认、无损或有损输出,不改变导出分辨率 +// CompressionMode 选择默认、无损或有损输出策略 type CompressionMode = pdfgo.CompressionMode +// CompressionLevel 选择轻压、中压或强压,仅用于有损模式 +type CompressionLevel = pdfgo.CompressionLevel + // CompressionOptions 配置输出压缩,默认模式沿用现有输出策略,不额外优化 -// Mode零值CompressionUnchanged表示默认模式,Quality为有损质量1至100,0使用85 -// 无损表示不引入额外损失,不保证跨格式转换可逆;有损仅降低图片画质,不栅格化文字和矢量 +// Quality和MaxDPI为零时按Level使用预设,不改变页面尺寸或整页导出精度 +// 无损表示不引入额外损失,不保证跨格式转换可逆;有损不栅格化文字和矢量 type CompressionOptions = pdfgo.CompressionOptions // WriteOptions 配置OFD输出副本,不修改编辑资源和撤销记录 @@ -52,6 +59,7 @@ type outputOptimization struct { ctx context.Context options CompressionOptions images map[string]bool + sizes map[string]image.Point protected bool completed int deflater *flate.Writer @@ -135,21 +143,27 @@ func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Edi if err != nil { return nil, err } - masks, err := reader.compressionMaskFiles(ctx, images) + masks, geometry, err := reader.compressionImageSafety(ctx, images) if err != nil { return nil, err } for name := range masks { snapshot.output.images[name] = false } + if geometry { + snapshot.output.sizes, err = reader.compressionImageSizes(ctx, images, options.Compression.ImageDPI()) + if err != nil { + return nil, err + } + } } return &snapshot, nil } -// compressionMaskFiles 查找包括模板和注释在内的独立蒙版资源,解析不完整时保留所有图片像素 +// compressionImageSafety 保护蒙版资源,识别不能可靠降采样的图案及隐藏内容 // 入参: ctx 取消上下文, images 图片资源 -// 返回: map[string]bool 只能无损处理的资源路径, error 读取或取消错误 -func (r *Reader) compressionMaskFiles(ctx context.Context, images []ImageInfo) (map[string]bool, error) { +// 返回: map[string]bool 只能无损处理的资源路径, bool 是否可分析尺寸, error 读取或取消错误 +func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo) (map[string]bool, bool, error) { names := make(map[string]bool) for name := range r.fileIndex { names[name] = true @@ -159,16 +173,17 @@ func (r *Reader) compressionMaskFiles(ctx context.Context, images []ImageInfo) ( } masks := make(map[string]bool) safe := true + geometry := len(r.OFD.DocBody) == 1 for _, name := range slices.Sorted(maps.Keys(names)) { if err := ctx.Err(); err != nil { - return nil, err + return nil, false, err } if strings.HasSuffix(name, "/") { continue } input, err := r.openFile(name) if err != nil { - return nil, err + return nil, false, err } buffer := bufio.NewReader(imageInput{ReadCloser: input, context: ctx}) header, _ := buffer.Peek(512) @@ -185,11 +200,25 @@ func (r *Reader) compressionMaskFiles(ctx context.Context, images []ImageInfo) ( break } if element, ok := token.(xml.StartElement); ok { + if element.Name.Local == "Pattern" { + geometry = false + } + resource, masked := "", false for _, attr := range element.Attr { + if attr.Name.Local == "Visible" && (attr.Value == "false" || attr.Value == "0") { + geometry = false + } + if attr.Name.Local == "ResourceID" { + resource = strings.TrimSpace(attr.Value) + } if attr.Name.Local == "ImageMask" { masks[strings.TrimSpace(attr.Value)] = true + masked = true } } + if masked { + masks[resource] = true + } } } } @@ -204,7 +233,7 @@ func (r *Reader) compressionMaskFiles(ctx context.Context, images []ImageInfo) ( paths[strings.ToLower(cleanPackagePath(img.Location))] = true } } - return paths, ctx.Err() + return paths, safe && geometry, ctx.Err() } // hasOutputSignatures 检查包中各文档,避免改写其他文档签名保护的共享资源 @@ -251,7 +280,7 @@ func (e *Editor) writeOutputEntry(archive *zip.Writer, header zip.FileHeader, da if !lossy { options.Mode = CompressionLossless } - candidate, err := pdfgo.OptimizeImage(e.output.ctx, data, options) + candidate, err := pdfgo.OptimizeImageSize(e.output.ctx, data, options, e.output.sizes[strings.ToLower(cleanPackagePath(header.Name))]) if err != nil { if e.output.ctx.Err() != nil { return e.output.ctx.Err() diff --git a/ofdgo_compression_images.go b/ofdgo_compression_images.go new file mode 100644 index 0000000..830b593 --- /dev/null +++ b/ofdgo_compression_images.go @@ -0,0 +1,116 @@ +// 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 ( + "context" + "fmt" + "image" + "math" + "strings" +) + +// compressionImages 记录图片各处使用的最大像素需求 +type compressionImages struct { + ctx context.Context + dpi int + sizes map[string]image.Point +} + +// compressionImageSize 将毫米显示尺寸换算为像素,非法尺寸不降采样 +// 入参: width 显示宽度, height 显示高度, dpi 分辨率上限 +// 返回: image.Point 像素需求 +func compressionImageSize(width, height float64, dpi int) image.Point { + w, h := math.Ceil(width*float64(dpi)/25.4), math.Ceil(height*float64(dpi)/25.4) + if w <= 0 || h <= 0 || math.IsNaN(w) || math.IsNaN(h) || math.IsInf(w, 0) || math.IsInf(h, 0) || w > 1<<30 || h > 1<<30 { + return image.Point{} + } + return image.Pt(int(w), int(h)) +} + +// compressionImageSizes 按正文、模板及注解的实际变换汇总共享图片尺寸 +// 入参: ctx 取消上下文, images 图片资源, dpi 分辨率上限 +// 返回: map[string]image.Point 资源像素需求, error 取消错误 +func (r *Reader) compressionImageSizes(ctx context.Context, images []ImageInfo, dpi int) (map[string]image.Point, error) { + if dpi == 0 { + return nil, nil + } + count, err := r.PageCount() + if err != nil { + return nil, ctx.Err() + } + renderer := NewRenderer(r) + defer renderer.ClearCache() + visitor := &compressionImages{ctx: ctx, dpi: dpi, sizes: make(map[string]image.Point)} + for index := 0; index < count; index++ { + if err := ctx.Err(); err != nil { + return nil, err + } + page, err := r.PageContentByIndex(index) + if err != nil { + return nil, ctx.Err() + } + for _, ref := range page.Template { + if renderer.loadTemplate(ref.TemplateID) == nil { + return nil, ctx.Err() + } + } + if err := renderer.WalkPage(page, visitor); err != nil { + return nil, ctx.Err() + } + } + result := make(map[string]image.Point) + for _, img := range images { + name := strings.ToLower(cleanPackagePath(img.Location)) + size := visitor.sizes[img.ID] + old := result[name] + result[name] = image.Pt(max(old.X, size.X), max(old.Y, size.Y)) + } + return result, ctx.Err() +} + +// DrawObject 收集图片的完整显示尺寸,不使用裁剪后的局部面积 +// 入参: object 图形对象, state 继承状态 +// 返回: error 取消或几何错误 +func (v *compressionImages) DrawObject(object *GraphicObject, state RenderState) error { + if err := v.ctx.Err(); err != nil { + return err + } + if object.Type != "ImageObject" { + return nil + } + img := object.ImageObject + box, err := ParseBox(img.Boundary) + if err != nil { + return err + } + local := NewMatrix(img.CTM) + if img.CTM == "" { + local = Matrix{a: box.W, d: box.H} + } + matrix, _ := renderObjectMatrix(img.Boundary, local, state) + size := compressionImageSize(math.Hypot(matrix.a, matrix.b), math.Hypot(matrix.c, matrix.d), v.dpi) + if size.X == 0 || size.Y == 0 { + return fmt.Errorf("invalid image placement") + } + old := v.sizes[img.ResourceID] + v.sizes[img.ResourceID] = image.Pt(max(old.X, size.X), max(old.Y, size.Y)) + return nil +} + +// DrawStamp 保留签章内容,不参与图片降采样 +// 入参: stamp 印章 +// 返回: error 始终为空 +func (v *compressionImages) DrawStamp(stamp Stamp) error { return nil } diff --git a/ofdgo_renderer_eps_compression.go b/ofdgo_renderer_eps_compression.go index 30d1634..7ab6ee6 100644 --- a/ofdgo_renderer_eps_compression.go +++ b/ofdgo_renderer_eps_compression.go @@ -23,6 +23,7 @@ import ( "image" "image/jpeg" "io" + "math" "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/renderers/ps" @@ -38,12 +39,24 @@ type epsImageRenderer struct { err error } -// RenderImage 写出Flate或更小的JPEG图片,保留原尺寸与变换 +// RenderImage 写出Flate或更小的JPEG图片,保留显示尺寸与变换 // 入参: img 图片, matrix 绘制变换 func (r *epsImageRenderer) RenderImage(img image.Image, matrix canvas.Matrix) { if r.err != nil { return } + if dpi := r.options.ImageDPI(); dpi > 0 { + original := img.Bounds().Size() + target := compressionImageSize(math.Hypot(matrix[0][0], matrix[1][0])*float64(original.X), math.Hypot(matrix[0][1], matrix[1][1])*float64(original.Y), dpi) + img, r.err = pdfgo.ResizeImage(r.ctx, img, target) + if r.err != nil { + return + } + size := img.Bounds().Size() + if size.X > 0 && size.Y > 0 { + matrix = matrix.Scale(float64(original.X)/float64(size.X), float64(original.Y)/float64(size.Y)) + } + } bounds := img.Bounds() width, height := bounds.Dx(), bounds.Dy() pixels := make([]byte, width*3) diff --git a/ofdgo_renderer_svg_canvas.go b/ofdgo_renderer_svg_canvas.go index 64262d5..acea07a 100644 --- a/ofdgo_renderer_svg_canvas.go +++ b/ofdgo_renderer_svg_canvas.go @@ -24,6 +24,7 @@ import ( "image" "image/png" "io" + "math" "strings" "github.com/tdewolff/canvas" @@ -42,7 +43,7 @@ type svgResourceRenderer struct { writer io.Writer fonts []SVGFont images []SVGImage - imageNames map[image.Image]string + imageNames map[svgImageKey]string seen map[string]bool styles strings.Builder err error @@ -51,6 +52,12 @@ type svgResourceRenderer struct { embeddedFonts bool } +// svgImageKey 按原图及输出精度复用图片编码,避免不同显示尺寸共用低清结果 +type svgImageKey struct { + image image.Image + size image.Point +} + // svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 type svgObjectGroup struct { path string @@ -61,7 +68,13 @@ type svgObjectGroup struct { // RenderImage 按输出策略编码图片,分离资源时保持尺寸和变换 // 入参: img 图片对象, m 变换矩阵 func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { - name, ok := s.imageNames[img] + var target image.Point + if s.renderer != nil && s.renderer.Compression.ImageDPI() > 0 { + bounds := img.Bounds() + target = compressionImageSize(math.Hypot(m[0][0], m[1][0])*float64(bounds.Dx()), math.Hypot(m[0][1], m[1][1])*float64(bounds.Dy()), s.renderer.Compression.ImageDPI()) + } + key := svgImageKey{image: img, size: target} + name, ok := s.imageNames[key] var resource SVGImage if !ok { resource = SVGImage{MIME: "image/png"} @@ -76,7 +89,7 @@ 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) + data, err := pdfgo.OptimizeImageSize(s.renderer.outputContext(), resource.Data, s.renderer.Compression, target) if err != nil { if s.err = s.renderer.outputContext().Err(); s.err != nil { return @@ -88,7 +101,7 @@ func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { if s.imageNames != nil { name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data)) resource.Name = name - s.imageNames[img] = name + s.imageNames[key] = name if !s.seen[name] { s.seen[name] = true s.images = append(s.images, resource) @@ -174,7 +187,7 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image // 图片方向由文档变换决定,不再由浏览器应用EXIF方向。 io.WriteString(buffer, `