From 60eb5ac6a1e7ec1e82cce4b72a03544263a063ae Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 4 Oct 2026 16:29:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BD=AC=E6=8D=A2=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B0=B4=E5=8D=B0=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E4=B8=8E=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_editor_batch.go | 20 ++++++++ ofdgo_font_cff.go | 114 ++++++++++++++++++++++++++++++----------- ofdgo_font_type2.go | 10 +++- ofdgo_pdf.go | 91 +++++++++++++++++++++++++------- ofdgo_pdf_clip.go | 100 ++++++++++++++++++++++++++++++++++++ ofdgo_pdf_composite.go | 37 ++++++++++++- ofdgo_pdf_font.go | 49 ++++++++++++++++++ ofdgo_pdf_group.go | 66 ++++++++++++++++++++---- ofdgo_pdf_object.go | 26 ++++++++-- ofdgo_writer.go | 72 +++++++++++++++++++------- 12 files changed, 504 insertions(+), 87 deletions(-) create mode 100644 ofdgo_pdf_clip.go diff --git a/go.mod b/go.mod index 0524b95..303389f 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,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-20261004052854-254c40ac6764 + github.com/xiaoqidun/pdfgo v0.0.0-20261004082616-4fa2e8738fca 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 9a3b83d..36687d3 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-20261004052854-254c40ac6764 h1:YKsnM25IZ2xqdcm6Gl30boDID7ctPxNa/QPxUoCjmKQ= -github.com/xiaoqidun/pdfgo v0.0.0-20261004052854-254c40ac6764/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20261004082616-4fa2e8738fca h1:9Q47dgBr5UXsOcXZ9X5xYO4xzxaJYldslT07WRiFCak= +github.com/xiaoqidun/pdfgo v0.0.0-20261004082616-4fa2e8738fca/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_editor_batch.go b/ofdgo_editor_batch.go index 8f306b2..bd07025 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -16,6 +16,7 @@ package ofdgo import ( "cmp" + "context" "fmt" "reflect" "slices" @@ -73,6 +74,16 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) { // 入参: page 目标页面索引, objects 对象快照, dx、dy 毫米位移 // 返回: []string 按绘制顺序排列的新对象标识, error 错误信息 func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) ([]string, error) { + return e.CopyObjectsContext(context.Background(), page, objects, dx, dy) +} + +// CopyObjectsContext 复制对象快照,准备期间取消不提交对象或撤销记录 +// 入参: ctx 取消上下文, page 目标页, objects 对象快照, dx、dy 毫米位移 +// 返回: []string 新对象标识, error 校验或取消错误 +func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []GraphicObject, dx, dy float64) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if !finite(dx) || !finite(dy) { return nil, fmt.Errorf("copy requires finite offsets") } @@ -86,12 +97,18 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) if err := e.prepareSourceIDs(); err != nil { return nil, err } + if err := ctx.Err(); err != nil { + return nil, err + } prepared := make([]GraphicObject, len(objects)) origins := make([]*editorObjectOrigin, len(objects)) result := make([]string, len(objects)) maximum := e.maxID + len(objects) validation := &editorValidation{Editor: e} for i, object := range objects { + if err := ctx.Err(); err != nil { + return nil, err + } result[i] = strconv.Itoa(e.maxID + i + 1) object, err := validation.prepareCopiedObject(result[i], object) if err != nil { @@ -111,6 +128,9 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) return nil, err } } + if err := ctx.Err(); err != nil { + return nil, err + } e.maxID = maximum for i, origin := range origins { if origin != nil { diff --git a/ofdgo_font_cff.go b/ofdgo_font_cff.go index 579bac4..04471dc 100644 --- a/ofdgo_font_cff.go +++ b/ofdgo_font_cff.go @@ -99,7 +99,10 @@ func normalizeCFFCharstrings(data []byte) ([]byte, error) { if topData == nil || topStart+topSize > len(data) { return nil, fmt.Errorf("invalid CFF top dictionary") } - dict := parseCFFDict(topData) + dict, err := readCFFDict(topData) + if err != nil { + return nil, err + } charOffset := dict[17] if len(charOffset) != 1 { return nil, fmt.Errorf("missing CFF charstrings") @@ -216,7 +219,10 @@ func sanitizeCFF(data []byte) ([]byte, error) { } _, glbSz := getCFFIndexCount(data, offset) globalSubrIndexData := data[offset : offset+glbSz] - topDict := parseCFFDict(topDictData) + topDict, err := readCFFDict(topDictData) + if err != nil { + return nil, err + } if _, isCID := topDict[1230]; !isCID { return data, nil } @@ -233,7 +239,10 @@ func sanitizeCFF(data []byte) ([]byte, error) { return sanitizeMultiFDCFF(data, hdrSize, nameIndexData, topDict, stringIndexData, globalSubrIndexData, fdArrOff, fdCount) } fontDictData, _ := getCFFIndexData(data, fdArrOff) - fontDict := parseCFFDict(fontDictData) + fontDict, err := readCFFDict(fontDictData) + if err != nil { + return nil, err + } if fdMat, ok := fontDict[1207]; ok && len(fdMat) == 6 { topMat, hasTop := topDict[1207] if !hasTop || len(topMat) != 6 { @@ -255,7 +264,10 @@ func sanitizeCFF(data []byte) ([]byte, error) { } var subrsOffRel int if len(privDictData) > 0 { - pDict := parseCFFDict(privDictData) + pDict, err := readCFFDict(privDictData) + if err != nil { + return nil, err + } if sVals, ok := pDict[19]; ok && len(sVals) > 0 { subrsOffRel = int(sVals[0]) } @@ -284,7 +296,10 @@ func sanitizeCFF(data []byte) ([]byte, error) { delete(topDict, 16) var finalPrivData []byte if len(privDictData) > 0 { - pDict := parseCFFDict(privDictData) + pDict, err := readCFFDict(privDictData) + if err != nil { + return nil, err + } if err := normalizeCFFPrivate(pDict); err != nil { return nil, err } @@ -358,7 +373,11 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict privateDicts := make([]cffDict, fdCount) for fd, item := range fdItems { fonts[fd] = type2Font{data: data, chars: charStrings, globals: globalSubrs, seed: 1} - privateDicts[fd], err = readType2Private(data, parseCFFDict(item)[18], &fonts[fd]) + fontDict, dictErr := readCFFDict(item) + if dictErr != nil { + return nil, dictErr + } + privateDicts[fd], err = readType2Private(data, fontDict[18], &fonts[fd]) if err != nil { return nil, fmt.Errorf("CFF font dictionary %d: %w", fd, err) } @@ -452,7 +471,10 @@ func parseCFFAndCountGlyphs(data []byte) (int, error) { } topDictData, _ := getCFFIndexData(data, offset) if topDictData != nil { - dict := parseCFFDict(topDictData) + dict, err := readCFFDict(topDictData) + if err != nil { + return 0, err + } if offsetVals, ok := dict[17]; ok && len(offsetVals) > 0 { charStrOff := int(offsetVals[0]) if charStrOff > 0 && charStrOff < len(data) { @@ -478,62 +500,87 @@ func multiplyAffine(a, b []float64) []float64 { } } -// parseCFFDict 解析CFF字典数据 +// parseCFFDict 读取可选CFF字典,编码无效时返回nil // 入参: data 字典数据 // 返回: cffDict 解析后的字典映射 func parseCFFDict(data []byte) cffDict { + dict, _ := readCFFDict(data) + return dict +} + +// readCFFDict 校验CFF字典编码、数值和48项操作数上限 +// 入参: data 字典数据 +// 返回: cffDict 字典映射, error 编码或操作数错误 +func readCFFDict(data []byte) (cffDict, error) { dict := make(cffDict) var operands []float64 i := 0 for i < len(data) { b := data[i] i++ - if b <= 27 { + if b <= 21 { op := int(b) if b == 12 { if i >= len(data) { - break + return nil, fmt.Errorf("truncated CFF operator") } op = 1200 + int(data[i]) i++ } + if len(operands) == 0 { + return nil, fmt.Errorf("missing CFF dictionary operands") + } dict[op] = operands operands = nil } else if b == 28 { - if i+1 < len(data) { - val := int(int16(binary.BigEndian.Uint16(data[i:]))) - operands = append(operands, float64(val)) - i += 2 + if len(data)-i < 2 { + return nil, fmt.Errorf("truncated CFF integer") } + val := int(int16(binary.BigEndian.Uint16(data[i:]))) + operands = append(operands, float64(val)) + i += 2 } else if b == 29 { - if i+3 < len(data) { - val := int(int32(binary.BigEndian.Uint32(data[i:]))) - operands = append(operands, float64(val)) - i += 4 + if len(data)-i < 4 { + return nil, fmt.Errorf("truncated CFF integer") } + val := int(int32(binary.BigEndian.Uint32(data[i:]))) + operands = append(operands, float64(val)) + i += 4 } else if b == 30 { s, n := parseCFFReal(data[i:]) - if f, err := strconv.ParseFloat(s, 64); err == nil { - operands = append(operands, f) + f, err := strconv.ParseFloat(s, 64) + if n == 0 || err != nil || !finite(f) { + return nil, fmt.Errorf("invalid CFF real") } + operands = append(operands, f) i += n } else if b >= 32 && b <= 246 { operands = append(operands, float64(int(b)-139)) } else if b >= 247 && b <= 250 { - if i < len(data) { - b1 := int(data[i]) - i++ - operands = append(operands, float64((int(b)-247)*256+b1+108)) + if i == len(data) { + return nil, fmt.Errorf("truncated CFF integer") } + b1 := int(data[i]) + i++ + operands = append(operands, float64((int(b)-247)*256+b1+108)) } else if b >= 251 && b <= 254 { - if i < len(data) { - b1 := int(data[i]) - i++ - operands = append(operands, float64(-(int(b)-251)*256-b1-108)) + if i == len(data) { + return nil, fmt.Errorf("truncated CFF integer") } + b1 := int(data[i]) + i++ + operands = append(operands, float64(-(int(b)-251)*256-b1-108)) + } else { + return nil, fmt.Errorf("invalid CFF dictionary byte") + } + if len(operands) > 48 { + return nil, fmt.Errorf("CFF dictionary operand limit exceeded") } } - return dict + if len(operands) != 0 { + return nil, fmt.Errorf("unused CFF dictionary operands") + } + return dict, nil } // parseCFFReal 解析CFF实数编码 @@ -549,6 +596,9 @@ func parseCFFReal(data []byte) (string, int) { nibbles := []byte{b >> 4, b & 0x0F} for _, n := range nibbles { if n == 0xF { + if b>>4 == 15 && b&15 != 15 { + return "", 0 + } done = true break } @@ -567,8 +617,14 @@ func parseCFFReal(data []byte) (string, int) { if n == 0xE { sb.WriteString("-") } + if n == 0xD { + return "", 0 + } } } + if !done { + return "", 0 + } return sb.String(), i } diff --git a/ofdgo_font_type2.go b/ofdgo_font_type2.go index 3ef9e37..2a61fe6 100644 --- a/ofdgo_font_type2.go +++ b/ofdgo_font_type2.go @@ -122,7 +122,10 @@ func readType2Font(data []byte) (*type2Font, error) { if err != nil { return nil, err } - dict := parseCFFDict(tops[0]) + dict, err := readCFFDict(tops[0]) + if err != nil { + return nil, err + } if value, ok := dict[1206]; ok && (len(value) != 1 || value[0] != 2) { return nil, fmt.Errorf("unsupported CFF charstring type") } @@ -158,7 +161,10 @@ func readType2Private(data []byte, values []float64, font *type2Font) (cffDict, return nil, fmt.Errorf("invalid CFF private range") } start := int(values[1]) - private = parseCFFDict(data[start : start+int(values[0])]) + private, err = readCFFDict(data[start : start+int(values[0])]) + if err != nil { + return nil, err + } for op, target := range map[int]*float64{20: &font.def, 21: &font.nominal} { if value, ok := private[op]; ok { if len(value) != 1 || !finite(value[0]) { diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 9dbc5e3..37d4caa 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -75,6 +75,7 @@ type pdfImporter struct { fonts map[*pdfgo.Font]*pdfImportedFont fontWarnings map[string]bool clipTexts map[*pdfgo.TextClip][]TextObject + clipPaths *renderCache[[32]byte, pdfClipPath] cmykSpace string objects []GraphicObject pages map[pdfgo.Reference]*pdfgo.Page @@ -204,6 +205,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI importer.compositeNodes = nil importer.compositeSpace = nil importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject) + importer.clipPaths = nil importer.annotationData = nil if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) @@ -279,7 +281,7 @@ func (p *pdfImporter) commitObjects() error { if len(p.objects) == 0 { return nil } - if _, err := p.editor.CopyObjects(p.page, p.objects, 0, 0); err != nil { + if _, err := p.editor.CopyObjectsContext(p.ctx, p.page, p.objects, 0, 0); err != nil { return err } p.objects = nil @@ -340,6 +342,40 @@ func pdfBounds(points []pdfgo.Point) Box { return Box{minX, minY, math.Max(maxX-minX, 1e-6), math.Max(maxY-minY, 1e-6)} } +// pathBoundsContext 逐段计算变换后的控制点边界,不创建临时点集合 +// 入参: ctx 取消上下文, path PDF路径 +// 返回: Box 控制点边界,空路径返回零值, error 取消错误 +func (p *pdfImporter) pathBoundsContext(ctx context.Context, path pdfgo.Path) (Box, error) { + if err := ctx.Err(); err != nil { + return Box{}, err + } + minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) + found := false + for _, segment := range path.Segments { + if err := ctx.Err(); err != nil { + return Box{}, err + } + for index, point := range segment.Points { + if index&255 == 0 { + if err := ctx.Err(); err != nil { + return Box{}, err + } + } + point = p.matrix.Apply(point) + minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y) + maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y) + found = true + } + } + if err := ctx.Err(); err != nil { + return Box{}, err + } + if !found { + return Box{}, nil + } + return Box{minX, minY, math.Max(maxX-minX, 1e-6), math.Max(maxY-minY, 1e-6)}, nil +} + // color 保留设备色彩分量并复用文档颜色空间 // 入参: paint PDF颜色与不透明度 // 返回: *FillColor OFD颜色 @@ -360,8 +396,22 @@ func (p *pdfImporter) color(paint pdfgo.Paint) *FillColor { // 入参: path PDF路径, origin 对象边界 // 返回: string OFD路径数据 func (p *pdfImporter) pathData(path pdfgo.Path, origin Box) string { + data, _ := p.pathDataContext(context.Background(), path, origin) + return data +} + +// pathDataContext 转换路径并在分段处理时检查取消 +// 入参: ctx 取消上下文, path PDF路径, origin 对象边界 +// 返回: string OFD路径数据, error 取消错误 +func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, origin Box) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } data := make([]byte, 0, len(path.Segments)*48) for index, segment := range path.Segments { + if err := ctx.Err(); err != nil { + return "", err + } if index != 0 { data = append(data, ' ') } @@ -374,7 +424,10 @@ func (p *pdfImporter) pathData(path pdfgo.Path, origin Box) string { data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64) } } - return string(data) + if err := ctx.Err(); err != nil { + return "", err + } + return string(data), nil } // clips 保持裁剪路径的交集,不随对象CTM重复变换 @@ -387,20 +440,21 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { flag := false clips := &Clips{TransFlag: &flag} for _, path := range paths { - area := ClipArea{} - var points []pdfgo.Point - for _, segment := range path.Segments { - for _, point := range segment.Points { - points = append(points, p.matrix.Apply(point)) - } + if err := p.ctx.Err(); err != nil { + return nil, err } + area := ClipArea{} if len(path.Segments) != 0 { - box := pdfBounds(points) + encoded, err := p.clipPath(path) + if err != nil { + return nil, err + } + box := encoded.box rule := "NonZero" if path.EvenOdd { rule = "Even-Odd" } - area.Path = append(area.Path, PathObject{Boundary: pdfBoundary(Box{box.X - origin.X, box.Y - origin.Y, box.W, box.H}), AbbreviatedData: p.pathData(path, box), Rule: rule}) + area.Path = append(area.Path, PathObject{Boundary: pdfBoundary(Box{box.X - origin.X, box.Y - origin.Y, box.W, box.H}), AbbreviatedData: encoded.data, Rule: rule}) } for _, mark := range path.Text { objects, ok := p.clipTexts[mark] @@ -491,16 +545,13 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { if p.warning != nil && (mark.Fill && pdfGradientError(mark.Style.Fill) != nil || mark.Stroke && pdfGradientError(mark.Style.Stroke) != nil) { return p.gradientPath(mark) } - var points []pdfgo.Point - for _, segment := range mark.Path.Segments { - for _, point := range segment.Points { - points = append(points, p.matrix.Apply(point)) - } + box, err := p.pathBoundsContext(p.ctx, mark.Path) + if err != nil { + return err } - if len(points) == 0 { + if box == (Box{}) { return fmt.Errorf("empty PDF painted path") } - box := pdfBounds(points) scale := math.Hypot(p.matrix[0], p.matrix[1]) drawing, origin, ctm := *p, box, "" marginScale := scale @@ -548,7 +599,11 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { if err != nil { return err } - object := PathObject{Boundary: pdfBoundary(box), CTM: ctm, AbbreviatedData: drawing.pathData(mark.Path, origin), Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: fillColor, StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: clips} + data, err := drawing.pathDataContext(p.ctx, mark.Path, origin) + if err != nil { + return err + } + object := PathObject{Boundary: pdfBoundary(box), CTM: ctm, AbbreviatedData: data, Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: fillColor, StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: clips} object.LineWidthSet = object.LineWidth == 0 if mark.Path.EvenOdd { object.Rule = "Even-Odd" diff --git a/ofdgo_pdf_clip.go b/ofdgo_pdf_clip.go new file mode 100644 index 0000000..e1e1bcb --- /dev/null +++ b/ofdgo_pdf_clip.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 ( + "context" + "crypto/sha256" + "encoding/binary" + "math" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfClipPath 保存不可变的裁剪路径编码,不缓存对象位移或文字裁剪 +type pdfClipPath struct { + box Box + data string +} + +// pdfClipPathKey 按路径完整内容与变换生成键,不依赖可变切片的地址 +// 入参: ctx 取消上下文, path 裁剪路径, matrix 页面变换 +// 返回: [32]byte 内容摘要, error 取消错误 +func pdfClipPathKey(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix) ([32]byte, error) { + hash := sha256.New() + var value [16]byte + for _, number := range matrix { + binary.LittleEndian.PutUint64(value[:8], math.Float64bits(number)) + hash.Write(value[:8]) + } + binary.LittleEndian.PutUint64(value[:8], uint64(len(path.Segments))) + value[8] = 0 + if path.EvenOdd { + value[8] = 1 + } + hash.Write(value[:9]) + for _, segment := range path.Segments { + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + binary.LittleEndian.PutUint64(value[:8], uint64(len(segment.Operator))) + binary.LittleEndian.PutUint64(value[8:], uint64(len(segment.Points))) + hash.Write(value[:]) + hash.Write([]byte(segment.Operator)) + for index, point := range segment.Points { + if index&255 == 0 { + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + } + binary.LittleEndian.PutUint64(value[:8], math.Float64bits(point.X)) + binary.LittleEndian.PutUint64(value[8:], math.Float64bits(point.Y)) + hash.Write(value[:]) + } + } + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + var key [32]byte + hash.Sum(key[:0]) + return key, nil +} + +// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误 +// 入参: path 裁剪路径 +// 返回: pdfClipPath 变换后的边界及编码, error 取消错误 +func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) { + key, err := pdfClipPathKey(p.ctx, path, p.matrix) + if err != nil { + return pdfClipPath{}, err + } + if p.clipPaths == nil { + p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} + } + if cached, ok := p.clipPaths.get(key); ok { + return cached, nil + } + box, err := p.pathBoundsContext(p.ctx, path) + if err != nil { + return pdfClipPath{}, err + } + data, err := p.pathDataContext(p.ctx, path, box) + if err != nil { + return pdfClipPath{}, err + } + result := pdfClipPath{box: box, data: data} + p.clipPaths.put(key, result, len(data)+256) + return result, nil +} diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index f9800db..c17f878 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -242,6 +242,8 @@ func (p *pdfImporter) compositeTextBounds(mark pdfgo.TextMark, stroke bool) Box // 返回: []pdfCompositeNode 原始图元, error 解析错误 func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]pdfCompositeNode, error) { active := make(map[*pdfgo.Font]map[string]bool) + var externalNames map[string]bool + var embeddedFonts map[*pdfgo.Font]bool var collect func(func(pdfgo.Visitor) error) ([]pdfCompositeNode, error) collect = func(walk func(pdfgo.Visitor) error) ([]pdfCompositeNode, error) { var nodes []pdfCompositeNode @@ -257,6 +259,24 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p return nil } v.Text = func(mark pdfgo.TextMark) error { + if mark.Font.Subtype != "Type3" { + if len(mark.Font.Program) == 0 { + if externalNames == nil { + externalNames = make(map[string]bool) + } + name := pdfEmbeddedFontName(mark.Font.Name) + if !externalNames[fontNormalizeName(name)] { + for _, candidate := range fontExactCandidateNames(name) { + externalNames[fontNormalizeName(candidate)] = true + } + } + } else { + if embeddedFonts == nil { + embeddedFonts = make(map[*pdfgo.Font]bool) + } + embeddedFonts[mark.Font] = true + } + } var text []pdfCompositeNode if mark.Font.Subtype != "Type3" { if err := p.halftone(mark.Style); err != nil { @@ -327,7 +347,22 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p err := walk(v) return nodes, err } - return collect(walk) + nodes, err := collect(walk) + if err != nil { + return nil, err + } + for font := range embeddedFonts { + if !externalNames[fontNormalizeName(pdfEmbeddedFontName(font.Name))] { + continue + } + if err := p.ctx.Err(); err != nil { + return nil, err + } + if _, err := p.importedFont(font); err != nil { + return nil, err + } + } + return nodes, nil } // compositePage 保留独立不透明对象,仅将需要背景参与的效果在局部区域合成 diff --git a/ofdgo_pdf_font.go b/ofdgo_pdf_font.go index fe7b99f..5b9b104 100644 --- a/ofdgo_pdf_font.go +++ b/ofdgo_pdf_font.go @@ -36,6 +36,55 @@ type pdfImportedFont struct { repairLimit uint16 } +// embeddedTextFont 匹配同名内嵌字体,仅复用唯一且覆盖全部字符的字形程序 +// 入参: source 外部字体, glyphs 当前文字字形 +// 返回: *pdfImportedFont 可复用字体,无可靠匹配时为空 +func (p *pdfImporter) embeddedTextFont(source *pdfgo.Font, glyphs []pdfgo.Glyph) *pdfImportedFont { + name := pdfEmbeddedFontName(source.Name) + if name == "" || len(glyphs) == 0 { + return nil + } + var matched *pdfImportedFont + for _, imported := range p.fonts { + if pdfEmbeddedFontName(imported.resource.Font.FontName) != name { + continue + } + if matched != nil && matched.checksum != imported.checksum { + return nil + } + matched = imported + } + if matched == nil { + return nil + } + for _, glyph := range glyphs { + if utf8.RuneCountInString(glyph.Text) != 1 { + return nil + } + char, _ := utf8.DecodeRuneInString(glyph.Text) + id := matched.metrics.GlyphIndex(char) + if id == 0 || id >= matched.metrics.NumGlyphs() || matched.repairLimit != 0 && id >= matched.repairLimit { + return nil + } + } + return matched +} + +// pdfEmbeddedFontName 去除标准六位大写子集前缀,保留字体名称和样式后缀 +// 入参: name PDF或内嵌字体名称 +// 返回: string 原字体名称 +func pdfEmbeddedFontName(name string) string { + if len(name) <= 7 || name[6] != '+' { + return name + } + for i := range 6 { + if name[i] < 'A' || name[i] > 'Z' { + return name + } + } + return name[7:] +} + // importedFont 按原PDF字体复用包装和解析结果,失败结果不进入缓存 // 入参: source 源字体 // 返回: *pdfImportedFont 只读导入字体, error 字体程序或后端错误 diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index fa6ea12..d7a717d 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -146,10 +146,17 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) Group: func(pdfgo.GroupMark, func(pdfgo.Visitor) error) error { return errPDFGroupRaster }, } collect.Path = func(path pdfgo.PathMark) error { + if err := p.ctx.Err(); err != nil { + return err + } if path.Style.SoftMask != nil || path.Style.BlendMode != "" && path.Style.BlendMode != "Normal" && path.Style.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "masked or blended transparent group content"} } - shape, err := ParseGeometryPath(p.pathData(path.Path, Box{})) + data, err := p.pathDataContext(p.ctx, path.Path, Box{}) + if err != nil { + return err + } + shape, err := ParseGeometryPath(data) if err != nil { return err } @@ -191,7 +198,11 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) return fmt.Errorf("invalid PDF stroke matrix") } local := pdfImporter{matrix: inverse} - strokeShape, err = ParseGeometryPath(local.pathData(path.Path, Box{})) + data, err = local.pathDataContext(p.ctx, path.Path, Box{}) + if err != nil { + return err + } + strokeShape, err = ParseGeometryPath(data) if err != nil { return err } @@ -219,6 +230,9 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if err != nil { return err } + if err := p.ctx.Err(); err != nil { + return err + } combined = append(combined, outline...) } return nil @@ -226,6 +240,9 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if err := walk(collect); err != nil { return err } + if err := p.ctx.Err(); err != nil { + return err + } if len(combined) == 0 { return nil } @@ -238,6 +255,9 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) } var covered GeometryPath for i := len(regions) - 1; i >= 0; i-- { + if err := p.ctx.Err(); err != nil { + return err + } region := regions[i] visible := region.path if opaque && len(covered) > 0 { @@ -344,10 +364,16 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, if p.fonts == nil { p.fonts = make(map[*pdfgo.Font]*pdfImportedFont) } - local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts} + if p.clipPaths == nil { + p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} + } + local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, clipPaths: p.clipPaths} if err := build(&local); err != nil { return nil, box, err } + if err := p.ctx.Err(); err != nil { + return nil, box, err + } if err := local.flushPath(); err != nil { return nil, box, err } @@ -357,19 +383,19 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, if err := local.commitObjects(); err != nil { return nil, box, err } - reader, err := editor.Reader() + reader, err := editor.resourceReader() if err != nil { return nil, box, err } defer reader.Close() - page, err := reader.PageContentByIndex(0) - if err != nil { - return nil, box, err - } + page := &editor.pages[0] renderer := p.renderer.childRenderer(reader) renderer.TransparentBackground = true for _, layer := range page.Content.Layer { for _, object := range layer.Objects { + if err := p.ctx.Err(); err != nil { + return nil, box, err + } bounds, err := renderer.ObjectBounds(object, layer.DrawParam) if err != nil { return nil, box, err @@ -388,6 +414,9 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, return nil, box, nil } scene, err := renderer.CompilePage(page) + if canceled := p.ctx.Err(); canceled != nil { + return nil, box, canceled + } return scene, box, err } @@ -521,6 +550,9 @@ func pdfClippedVisitor(visitor pdfgo.Visitor, clip pdfgo.Path) pdfgo.Visitor { // 入参: mask PDF蒙版 // 返回: pdfgo.Path 页面坐标裁剪路径, error 不可精确转换或几何错误 func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { + if err := p.ctx.Err(); err != nil { + return pdfgo.Path{}, err + } if mask.Subtype != "Luminosity" || mask.ColorSpace == nil || mask.ColorSpace.Model != "DeviceGray" || mask.ColorSpace.Calibrated() { return pdfgo.Path{}, &pdfgo.UnsupportedError{Feature: "nonbinary luminosity mask"} } @@ -576,6 +608,9 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { return walk(visitor) } visitor.Path = func(mark pdfgo.PathMark) error { + if err := p.ctx.Err(); err != nil { + return err + } paint := mark.Style.Fill if !mark.Fill || mark.Stroke || paint.Alpha != 1 || paint.CMYK != nil || paint.Axial != nil || paint.Radial != nil || paint.Function != nil || paint.Mesh != nil || paint.Tiling != nil || paint.Space != nil && paint.Space.Calibrated() || paint.RGB[0] != paint.RGB[1] || paint.RGB[1] != paint.RGB[2] || mark.Style.SoftMask != nil || !pdfNormalBlend(mark.Style.BlendMode) { return &pdfgo.UnsupportedError{Feature: "nonbinary mask graphic"} @@ -584,7 +619,11 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { if err != nil { return err } - shape, err := ParseGeometryPath(identity.pathData(mark.Path, Box{})) + data, err := identity.pathDataContext(p.ctx, mark.Path, Box{}) + if err != nil { + return err + } + shape, err := ParseGeometryPath(data) if err != nil { return err } @@ -596,7 +635,11 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { if len(clip.Text) != 0 { return &pdfgo.UnsupportedError{Feature: "text-clipped binary mask"} } - c, err := ParseGeometryPath(identity.pathData(clip, Box{})) + data, err := identity.pathDataContext(p.ctx, clip, Box{}) + if err != nil { + return err + } + c, err := ParseGeometryPath(data) if err != nil { return err } @@ -621,6 +664,9 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { } path := pdfgo.Path{} for _, segment := range region { + if err := p.ctx.Err(); err != nil { + return pdfgo.Path{}, err + } point := func(p Point) pdfgo.Point { return pdfgo.Point{X: p.X, Y: p.Y} } switch segment.Verb { case GeometryMove: diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index cc36ac5..f2307f5 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -21,6 +21,7 @@ import ( "image/color" "math" "reflect" + "slices" "strconv" "unicode/utf8" @@ -120,11 +121,11 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { } } if id == "" { - jbig2Original, err := source.JBIG2File() + jbig2Original, err := source.JBIG2FileContext(p.ctx) if err != nil { return err } - jpegOriginal, err := source.JPEGFile() + jpegOriginal, err := source.JPEGFileContext(p.ctx) if err != nil { return err } @@ -192,7 +193,7 @@ func (p *pdfImporter) appendImage(mark pdfgo.ImageMark, id string) error { return nil } -// text 保留内嵌字形及逐字基线,外部字体保留名称引用 +// text 保留字形及逐字基线,外部字体优先复用同名内嵌资源 // 入参: mark PDF文字绘制信息 // 返回: error 错误信息 func (p *pdfImporter) text(mark pdfgo.TextMark) error { @@ -256,14 +257,29 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { return &pdfgo.UnsupportedError{Feature: "font program " + string(font.ProgramType)} } var imported *pdfImportedFont + reused := false if embedded { imported, err = p.importedFont(font) if err != nil { return err } + } else if imported = p.embeddedTextFont(font, mark.Glyphs); imported != nil { + reused, embedded = true, true + mark.Glyphs = slices.Clone(mark.Glyphs) + for index := range mark.Glyphs { + char, _ := utf8.DecodeRuneInString(mark.Glyphs[index].Text) + mark.Glyphs[index].ID = imported.metrics.GlyphIndex(char) + mark.Glyphs[index].HasID = true + } } id := p.fontIDs[font] - if id == "" { + if reused { + id, err = p.editor.addFontResource(imported.resource, imported.checksum) + if err != nil { + return fmt.Errorf("PDF font %s resource: %w", font.Name, err) + } + p.editor.fontMetrics[id] = imported.metrics + } else if id == "" { var err error if embedded { if imported.repairLimit != 0 { @@ -324,7 +340,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { return err } gid := glyph.ID - if embedded && font.ProgramType == "FontFile" { + if embedded && !reused && font.ProgramType == "FontFile" { gid = imported.type1Glyphs[glyph.Name] glyph.HasID = true } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index d2fdc51..e629c3c 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -241,6 +241,31 @@ func (e *Editor) reader(progress editorProgress) (*Reader, error) { return r, nil } +// resourceReader 为临时图元编译准备资源索引,不序列化页面或作为公共快照 +// 返回: *Reader 资源阅读器, error 资源错误 +func (e *Editor) resourceReader() (*Reader, error) { + if e.source != nil || len(e.origins) != 0 { + return nil, fmt.Errorf("resource reader requires created objects") + } + if err := e.validate(); err != nil { + return nil, err + } + r := &Reader{files: make(map[string][]byte)} + if err := e.writePartsWithPages(func(name string, data []byte, _ bool) error { + r.files[name] = data + return nil + }, nil, false); err != nil { + return nil, err + } + if err := r.initRoot(); err != nil { + return nil, err + } + if _, err := r.Doc(); err != nil { + return nil, err + } + return r, nil +} + // validate 校验文档保存条件 // 返回: error 错误信息 func (e *Editor) validate() error { @@ -269,6 +294,13 @@ func (e *Editor) validate() error { // 入参: write 条目写入方法, progress 保存进度回调,预览时为nil // 返回: error 错误信息 func (e *Editor) writeParts(write func(string, []byte, bool) error, progress editorProgress) error { + return e.writePartsWithPages(write, progress, true) +} + +// writePartsWithPages 统一生成包条目,临时编译可省去页面XML +// 入参: write 条目写入方法, progress 保存进度回调, includePages 是否写出页面 +// 返回: error 写入错误 +func (e *Editor) writePartsWithPages(write func(string, []byte, bool) error, progress editorProgress, includePages bool) error { if len(e.removedPages) != 0 { output := write write = func(name string, data []byte, binary bool) error { @@ -359,27 +391,29 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi return err } } - for i, page := range e.pages { - if err := progress.report("pages", i, len(e.pages)); err != nil { + if includePages { + for i, page := range e.pages { + if err := progress.report("pages", i, len(e.pages)); err != nil { + return err + } + name := packagePagePath(e.packageDirectory(), page.ID) + if len(e.origins) == 0 { + if err := writeXML(name, func(x *ofdXML) { x.page(page) }); err != nil { + return err + } + } else { + data, err := e.sourceNewPageXML(page) + if err != nil { + return err + } + if err := write(name, data, false); err != nil { + return err + } + } + } + if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil { return err } - name := packagePagePath(e.packageDirectory(), page.ID) - if len(e.origins) == 0 { - if err := writeXML(name, func(x *ofdXML) { x.page(page) }); err != nil { - return err - } - } else { - data, err := e.sourceNewPageXML(page) - if err != nil { - return err - } - if err := write(name, data, false); err != nil { - return err - } - } - } - if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil { - return err } for _, resources := range [][]editorResource{fonts, images} { for _, resource := range resources {