feat(压缩能力): 接入分级压缩优化窄屏布局
build.yaml / build (push) Successful in 51s

This commit is contained in:
xiaoqidun committed 2026-09-29 21:28:44 +08:00
1 parent f39675233c
commit 773b4195f5
10 files changed
+217 -29

No files matched your search

+2 -2
View File
@@ -57,7 +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>
<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><option value="3">中压</option><option value="4">强压</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>
@@ -184,7 +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 class="form-row"><label for="convertCompression">压缩</label><select id="convertCompression"><option value="0">默认</option><option value="1">无损</option><option value="2">轻压</option><option value="3">中压</option><option value="4">强压</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>
+4 -1
View File
@@ -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;
+9 -4
View File
@@ -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;
+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-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
+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-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=
+40 -11
View File
@@ -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()
+116
View File
@@ -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 }
+14 -1
View File
@@ -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)
+18 -5
View File
@@ -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, `<g style="image-orientation:none">`)
if images {
s.imageNames = make(map[image.Image]string)
s.imageNames = make(map[svgImageKey]string)
}
if objects {
s.objects = make(map[*GraphicObject]string)
+11 -2
View File
@@ -241,12 +241,21 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
return nil, err
}
var masks map[string]bool
geometry := false
if options.Compression.Mode == CompressionLossy {
masks, err = r.compressionMaskFiles(context.Background(), images)
masks, geometry, err = r.compressionImageSafety(context.Background(), images)
if err != nil {
return nil, err
}
}
dpi := 0
if geometry {
dpi = options.Compression.ImageDPI()
}
sizes, err := r.compressionImageSizes(context.Background(), images, dpi)
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 {
@@ -261,7 +270,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
if masks[strings.ToLower(cleanPackagePath(img.Location))] {
compression.Mode = CompressionLossless
}
if optimized, err := pdfgo.OptimizeImage(context.Background(), data, compression); err == nil {
if optimized, err := pdfgo.OptimizeImageSize(context.Background(), data, compression, sizes[strings.ToLower(cleanPackagePath(img.Location))]); err == nil {
parts[img.Location] = optimized
}
}