diff --git a/go.mod b/go.mod index 4a42d1a..c7011ba 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-20261006094416-cd6540bbc9f7 + github.com/xiaoqidun/pdfgo v0.0.0-20261006150546-e717d961d710 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 6b8daec..84ef425 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-20261006094416-cd6540bbc9f7 h1:ZvZaxIYiPd8BlcGDztowwmSHT1vFQK3mV3RBS2p27Cs= -github.com/xiaoqidun/pdfgo v0.0.0-20261006094416-cd6540bbc9f7/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20261006150546-e717d961d710 h1:Czwt4LQ2IVRFS8+kb+WIsm2u9MP4AsT4dLb/9ahg7Pw= +github.com/xiaoqidun/pdfgo v0.0.0-20261006150546-e717d961d710/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.go b/ofdgo_editor.go index 4498ff5..c1350fe 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -70,6 +70,7 @@ type Editor struct { outlines []byte encryption *encryptionState output *outputOptimization + validatedPaths *renderCache[string, bool] } // editorResource 文档内嵌资源 @@ -948,8 +949,17 @@ func (v *editorValidation) validateObjectClips(clips *Clips) error { return fmt.Errorf("clip must contain an area") } for _, area := range clip.Area { - if len(area.Path)+len(area.Text) == 0 || area.DrawParam != "" { - return fmt.Errorf("clip area requires paths or text without draw parameter references") + if len(area.Path)+len(area.Text) == 0 { + return fmt.Errorf("clip area requires paths or text") + } + if area.DrawParam != "" { + dp, err := v.editorDrawParam(area.DrawParam, make(map[string]bool)) + if err != nil { + return err + } + if err := validateEditorStroke(PathObject{LineWidth: dp.LineWidth, Cap: dp.Cap, Join: dp.Join, MiterLimit: dp.MiterLimit, DashPattern: dp.DashPattern, DashOffset: dp.DashOffset}); err != nil { + return err + } } if area.CTM != "" { if _, err := creationNumbers(area.CTM, 6); err != nil { @@ -1194,22 +1204,35 @@ func creationDeltas(value string) error { // 入参: value 路径数据 // 返回: error 错误信息 func creationPath(value string) error { - path, err := ParseGeometryPath(value) - if err == nil && len(path) == 0 { + count, err := walkGeometryPath(value, nil) + if err == nil && count == 0 { return fmt.Errorf("empty path") } return err } -// creationPath 在同次校验中复用相同路径的成功结果,不缓存错误或依赖可变资源 +// creationPath 复用相同路径的成功校验,不缓存错误或依赖可变资源 // 入参: value 路径数据 // 返回: error 错误信息 func (v *editorValidation) creationPath(value string) error { if v.paths[value] { return nil } - if err := creationPath(value); err != nil { - return err + var cache *renderCache[string, bool] + valid := false + if v.Editor != nil { + cache = v.pathValidationCache() + valid, _ = cache.get(value) + } + if !valid { + if err := creationPath(value); err != nil { + return err + } + if cache != nil { + if cost := len(value) + 128; cost <= cache.limit { + cache.put(strings.Clone(value), true, cost) + } + } } if v.paths == nil || len(v.paths) >= editorPathValidationLimit { v.paths = make(map[string]bool) @@ -1218,6 +1241,15 @@ func (v *editorValidation) creationPath(value string) error { return nil } +// pathValidationCache 获取不依赖可变资源的路径语法缓存,按字节预算淘汰 +// 返回: *renderCache[string, bool] 校验缓存 +func (e *Editor) pathValidationCache() *renderCache[string, bool] { + if e.validatedPaths == nil { + e.validatedPaths = &renderCache[string, bool]{limit: 8 << 20} + } + return e.validatedPaths +} + // creationNumbers 解析创建接口的有限十进制数值序列 // 入参: value 数值序列, count 数值数量 // 返回: []float64 数值, error 错误信息 diff --git a/ofdgo_editor_documents.go b/ofdgo_editor_documents.go index eb7e694..b884bb0 100644 --- a/ofdgo_editor_documents.go +++ b/ofdgo_editor_documents.go @@ -309,6 +309,7 @@ func (e *Editor) editDocuments(edit func(*Reader, []byte, *editorXML) (int, erro next.OnWriteProgress, next.output = e.OnWriteProgress, e.output next.encryption = e.encryption next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS + next.validatedPaths = e.validatedPaths *e = *next } e.recordTransaction(before) diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index 098d63d..e982296 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -161,6 +161,7 @@ func (e *Editor) restoreTransaction(state Editor) { state.serial = max(e.serial, state.serial) state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS state.fontSourcesCache = e.fontSourcesCache + state.validatedPaths = e.validatedPaths state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress *e = state } diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index 6a87bee..a443194 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -254,9 +254,9 @@ func editorXMLSupported(node *editorXML) bool { allowed, children = "CodePosition CodeCount GlyphCount", "Glyphs" case "Glyphs": case "PathObject", "Path": - allowed, children = "Boundary CTM LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData" + allowed, children = "Boundary CTM DrawParam LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData" if node.name.Local == "PathObject" { - allowed += " ID DrawParam" + allowed += " ID" children += " Clips" } case "ImageObject": @@ -270,7 +270,7 @@ func editorXMLSupported(node *editorXML) bool { case "Clip": children = "Area" case "Area": - allowed, children = "CTM", "Path Text" + allowed, children = "CTM DrawParam", "Path Text" case "AbbreviatedData": default: return false diff --git a/ofdgo_geom_backend.go b/ofdgo_geom_backend.go index eefbb9e..fdc660a 100644 --- a/ofdgo_geom_backend.go +++ b/ofdgo_geom_backend.go @@ -214,5 +214,14 @@ func clipGeometry(geometry GeometryBackend, path GeometryPath, clip *GeometryPat return path, nil } } + if rect, ok := geometryRectangleBounds(path); ok { + bounds, err := geometry.Bounds(*clip) + if err != nil { + return nil, err + } + if bounds.X >= rect.X && bounds.Y >= rect.Y && bounds.X+bounds.W <= rect.X+rect.W && bounds.Y+bounds.H <= rect.Y+rect.H { + return *clip, nil + } + } return geometry.Combine(path, *clip, GeometryIntersect) } diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go index d36998b..36590cd 100644 --- a/ofdgo_geom_path.go +++ b/ofdgo_geom_path.go @@ -51,72 +51,109 @@ type GeometrySegment struct { // 入参: data 紧缩路径 // 返回: GeometryPath 独立路径, error 指令或参数错误 func ParseGeometryPath(data string) (GeometryPath, error) { - tokens := strings.Fields(data) - if len(tokens) == 0 { - return nil, nil + count, err := walkGeometryPath(data, nil) + if err != nil || count == 0 { + return nil, err } - if tokens[0] != "M" && tokens[0] != "S" { - return nil, fmt.Errorf("path must start with M or S") - } - var result GeometryPath - for i := 0; i < len(tokens); { - command := tokens[i] - i++ - count := 0 - switch command { - case "M", "S", "L": - count = 2 - case "Q": - count = 4 - case "B": - count = 6 - case "A": - count = 7 - case "C": - default: - return nil, fmt.Errorf("invalid path command %q", command) - } - if len(tokens)-i < count { - return nil, fmt.Errorf("missing arguments for path command %s", command) - } - var values [7]float64 - for j, token := range tokens[i : i+count] { - if command == "A" && (j == 3 || j == 4) && (token == "true" || token == "false") { - if token == "true" { - values[j] = 1 - } - continue - } - value, err := strconv.ParseFloat(token, 64) - if err != nil || !finite(value) || strings.ContainsAny(token, "xX_") { - return nil, fmt.Errorf("invalid number %q", token) - } - values[j] = value - } - i += count - segment := GeometrySegment{} - switch command { - case "M", "S": - segment.Verb, segment.End = GeometryMove, Point{values[0], values[1]} - case "L": - segment.Verb, segment.End = GeometryLine, Point{values[0], values[1]} - case "Q": - segment.Verb, segment.Control1, segment.End = GeometryQuad, Point{values[0], values[1]}, Point{values[2], values[3]} - case "B": - segment.Verb, segment.Control1, segment.Control2, segment.End = GeometryCubic, Point{values[0], values[1]}, Point{values[2], values[3]}, Point{values[4], values[5]} - case "A": - if values[0] < 0 || values[1] < 0 || values[3] != 0 && values[3] != 1 || values[4] != 0 && values[4] != 1 { - return nil, fmt.Errorf("invalid arc radii or flags") - } - segment = GeometrySegment{Verb: GeometryArc, RadiusX: values[0], RadiusY: values[1], Rotation: values[2], Large: values[3] != 0, Sweep: values[4] != 0, End: Point{values[5], values[6]}} - case "C": - segment.Verb = GeometryClose - } + result := make(GeometryPath, 0, count) + _, err = walkGeometryPath(data, func(segment GeometrySegment) { result = append(result, segment) + }) + if err != nil { + return nil, err } return result, nil } +// walkGeometryPath 逐指令解析紧缩路径,纯校验时不分配路径或词元列表 +// 入参: data 紧缩路径, visit 指令访问器,nil仅校验 +// 返回: int 指令数量, error 指令或参数错误 +func walkGeometryPath(data string, visit func(GeometrySegment)) (int, error) { + var args [7]string + command := "" + count, read, segments := 0, 0, 0 + for token := range strings.FieldsSeq(data) { + if command == "" { + if segments == 0 && token != "M" && token != "S" { + return 0, fmt.Errorf("path must start with M or S") + } + command, read = token, 0 + switch command { + case "M", "S", "L": + count = 2 + case "Q": + count = 4 + case "B": + count = 6 + case "A": + count = 7 + case "C": + count = 0 + default: + return 0, fmt.Errorf("invalid path command %q", command) + } + } else { + args[read] = token + read++ + } + if read != count { + continue + } + segment, err := geometryPathSegment(command, args[:count]) + if err != nil { + return 0, err + } + if visit != nil { + visit(segment) + } + segments++ + command = "" + } + if command != "" { + return 0, fmt.Errorf("missing arguments for path command %s", command) + } + return segments, nil +} + +// geometryPathSegment 校验单条指令参数并保留原始数值精度 +// 入参: command 标准指令, args 已按指令数量收集的参数 +// 返回: GeometrySegment 路径段, error 参数错误 +func geometryPathSegment(command string, args []string) (GeometrySegment, error) { + var values [7]float64 + for i, token := range args { + if command == "A" && (i == 3 || i == 4) && (token == "true" || token == "false") { + if token == "true" { + values[i] = 1 + } + continue + } + value, err := strconv.ParseFloat(token, 64) + if err != nil || !finite(value) || strings.ContainsAny(token, "xX_") { + return GeometrySegment{}, fmt.Errorf("invalid number %q", token) + } + values[i] = value + } + segment := GeometrySegment{} + switch command { + case "M", "S": + segment.Verb, segment.End = GeometryMove, Point{values[0], values[1]} + case "L": + segment.Verb, segment.End = GeometryLine, Point{values[0], values[1]} + case "Q": + segment.Verb, segment.Control1, segment.End = GeometryQuad, Point{values[0], values[1]}, Point{values[2], values[3]} + case "B": + segment.Verb, segment.Control1, segment.Control2, segment.End = GeometryCubic, Point{values[0], values[1]}, Point{values[2], values[3]}, Point{values[4], values[5]} + case "A": + if values[0] < 0 || values[1] < 0 || values[3] != 0 && values[3] != 1 || values[4] != 0 && values[4] != 1 { + return GeometrySegment{}, fmt.Errorf("invalid arc radii or flags") + } + segment = GeometrySegment{Verb: GeometryArc, RadiusX: values[0], RadiusY: values[1], Rotation: values[2], Large: values[3] != 0, Sweep: values[4] != 0, End: Point{values[5], values[6]}} + case "C": + segment.Verb = GeometryClose + } + return segment, nil +} + // SVG 将几何路径编码为SVG路径,不改变曲线或坐标精度 // 返回: string SVG路径, error 不支持的路径指令 func (p GeometryPath) SVG() (string, error) { @@ -225,10 +262,15 @@ func (p GeometryPath) encode(svg bool) (string, error) { // 入参: path 页面毫米路径 // 返回: PathObject 裁剪路径, error 无效指令错误 func geometryClipPath(path GeometryPath) (PathObject, error) { + box, err := path.Bounds() + if err != nil { + return PathObject{}, err + } data, err := path.OFD() if err != nil { return PathObject{}, err } fill, stroke := true, false - return PathObject{Boundary: "0 0 1 1", Fill: &fill, Stroke: &stroke, AbbreviatedData: data}, nil + box.W, box.H = max(box.W, 1e-6), max(box.H, 1e-6) + return PathObject{Boundary: editorBoxString(box), CTM: TranslationMatrix(-box.X, -box.Y).String(), Fill: &fill, Stroke: &stroke, AbbreviatedData: data}, nil } diff --git a/ofdgo_ink.go b/ofdgo_ink.go index 5df4491..fbc66ef 100644 --- a/ofdgo_ink.go +++ b/ofdgo_ink.go @@ -95,11 +95,11 @@ func NewInk(points []InkPoint, width float64, pressure bool) (PathObject, error) path[i].End.X -= boundary.X path[i].End.Y -= boundary.Y } - object, err := geometryClipPath(path) + data, err := path.OFD() if err != nil { return PathObject{}, err } - object.Boundary = editorBoxString(boundary) + object := PathObject{Boundary: editorBoxString(boundary), AbbreviatedData: data} stroke := !filled object.Fill, object.Stroke = &filled, &stroke object.Cap, object.Join, object.LineWidth = "Round", "Round", width diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 1b5ad13..0a198f2 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -523,6 +523,10 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { area.Text = append(area.Text, objects...) } } + if len(area.Path)+len(area.Text) == 0 { + fill, stroke := true, false + area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}} + } clips.Clip[index].Area = areas[index : index+1 : index+1] } return clips, nil diff --git a/ofdgo_pdf_clip.go b/ofdgo_pdf_clip.go index ae12434..e046678 100644 --- a/ofdgo_pdf_clip.go +++ b/ofdgo_pdf_clip.go @@ -100,7 +100,8 @@ func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) { rule = "Even-Odd" } boundary := pdfBoundary(box) - result := pdfClipPath{box: box, data: data, path: &[1]PathObject{{Boundary: boundary, AbbreviatedData: data, Rule: rule}}} + fill, stroke := true, false + result := pdfClipPath{box: box, data: data, path: &[1]PathObject{{Boundary: boundary, AbbreviatedData: data, Rule: rule, Fill: &fill, Stroke: &stroke}}} p.clipPaths.put(key, result, len(data)+len(boundary)+512) return result, nil } diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 199bded..ed25cc0 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -67,6 +67,12 @@ type pdfCompositeKey struct { stroke bool } +// pdfCompositeSceneKey 隔离不同图案坐标系,在单页内共享场景缓存预算 +type pdfCompositeSceneKey struct { + owner *pdfCompositeCache + mark pdfCompositeKey +} + // pdfCompositeGeometry 保存后端中立的绘制命令及其页面边界 type pdfCompositeGeometry struct { scene *RasterPage @@ -88,7 +94,9 @@ type pdfCompositeColorSampler struct { // pdfCompositeCache 在单页内复用几何、图像分量及蒙版内容 type pdfCompositeCache struct { - geometry map[pdfCompositeKey]pdfCompositeGeometry + geometry *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] + clips *renderCache[[32]byte, GeometryPath] + bounds map[pdfCompositeKey]Box images map[*pdfgo.Image]*pdfgo.ImageComponents imageMatrix map[*pdfgo.ImageMark]pdfgo.Matrix masks map[*pdfgo.SoftMask][]pdfCompositeNode @@ -106,16 +114,38 @@ type pdfCompositeCache struct { // 返回: *pdfCompositeCache 当前页面缓存 func (p *pdfImporter) compositingCache() *pdfCompositeCache { if p.compositeCache == nil { - p.compositeCache = &pdfCompositeCache{geometry: map[pdfCompositeKey]pdfCompositeGeometry{}, images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]pdfMeshTriangle{}} + p.compositeCache = &pdfCompositeCache{images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]pdfMeshTriangle{}} } return p.compositeCache } +// geometryCache 限制完整覆盖场景的保留量,边界独立保存以便排除无交集图元 +// 返回: *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] 单页场景缓存 +func (c *pdfCompositeCache) geometryCache() *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] { + if c.geometry == nil { + c.geometry = &renderCache[pdfCompositeSceneKey, pdfCompositeGeometry]{limit: 64 << 20} + } + return c.geometry +} + +// clipCache 限制页面及嵌套图案共享的只读裁剪轮廓 +// 返回: *renderCache[[32]byte, GeometryPath] 裁剪缓存 +func (c *pdfCompositeCache) clipCache() *renderCache[[32]byte, GeometryPath] { + if c.clips == nil { + c.clips = &renderCache[[32]byte, GeometryPath]{limit: 8 << 20} + } + return c.clips +} + // coverage 复用已编译的覆盖场景,跳过与当前区域无交集的图元 // 几何编译前让出调度,避免单线程运行时积累已失效的临时对象 // 入参: node 图元, stroke 是否描边 // 返回: image.Image 当前区域覆盖或空值, error 编译或渲染错误 func (c *pdfCompositor) coverage(node pdfCompositeNode, stroke bool) (image.Image, error) { + key := pdfCompositeKey{path: node.path, text: node.text, image: node.image, stroke: stroke} + if b, ok := c.cache.bounds[key]; ok && (b.X >= c.box.X+c.box.W || b.Y >= c.box.Y+c.box.H || b.X+b.W <= c.box.X || b.Y+b.H <= c.box.Y) { + return nil, nil + } if node.text != nil && len(node.text.Font.Program) == 0 && node.text.Font.BoundingBox != nil { b := c.importer.compositeTextBounds(*node.text, stroke) if b.X >= c.box.X+c.box.W || b.Y >= c.box.Y+c.box.H || b.X+b.W <= c.box.X || b.Y+b.H <= c.box.Y { @@ -138,19 +168,48 @@ func (c *pdfCompositor) coverage(node pdfCompositeNode, stroke bool) (image.Imag // 返回: pdfCompositeGeometry 覆盖场景, error 编译错误 func (c *pdfCompositor) geometry(node pdfCompositeNode, stroke bool) (pdfCompositeGeometry, error) { key := pdfCompositeKey{path: node.path, text: node.text, image: node.image, stroke: stroke} - geometry, ok := c.cache.geometry[key] + cache := c.cache.geometryCache() + sceneKey := pdfCompositeSceneKey{owner: c.cache, mark: key} + geometry, ok := cache.get(sceneKey) if !ok { runtime.Gosched() var err error - geometry.scene, geometry.box, err = c.importer.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) }) + geometry.scene, geometry.box, err = c.importer.compileCoverage(node, stroke) if err != nil { return geometry, err } - c.cache.geometry[key] = geometry + if c.cache.bounds == nil { + c.cache.bounds = make(map[pdfCompositeKey]Box) + } + c.cache.bounds[key] = geometry.box + cost := 256 + if geometry.scene != nil { + for _, command := range geometry.scene.Commands { + part := rasterCommandCost(command) + if part <= 0 || part > cache.limit-cost { + cost = cache.limit + 1 + break + } + cost += part + } + } + cache.put(sceneKey, geometry, cost) } return geometry, nil } +// geometryBounds 复用已度量边界,场景淘汰后不因范围判断而重新编译 +// 入参: node 图元, stroke 是否描边 +// 返回: Box 覆盖范围, error 编译错误 +func (c *pdfCompositor) geometryBounds(node pdfCompositeNode, stroke bool) (Box, error) { + key := pdfCompositeKey{path: node.path, text: node.text, image: node.image, stroke: stroke} + if box, ok := c.cache.bounds[key]; ok { + return box, nil + } + geometry, err := c.geometry(node, stroke) + return geometry.box, err +} + // groupBounds 缓存组内实际覆盖范围,外部字体沿用描述符保守边界 // 入参: node 透明组 // 返回: Box 组覆盖范围, error 几何编译错误 @@ -198,11 +257,11 @@ func (c *pdfCompositor) markBounds(node pdfCompositeNode) (Box, error) { box = unionTextBox(box, c.importer.compositeTextBounds(*node.text, outline)) continue } - geometry, err := c.geometry(node, outline) + bounds, err := c.geometryBounds(node, outline) if err != nil { return Box{}, err } - box = unionTextBox(box, geometry.box) + box = unionTextBox(box, bounds) } return box, nil } diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index 2d1aaff..b1606d9 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -188,11 +188,11 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf if g := paint.Radial; node.image == nil && shading == nil && g != nil && g.Bounds == nil && g.Background == nil { sampler = &pdfCompositeColorSampler{paint: paint, space: space, intent: style.RenderingIntent, conversion: style.ColorConversion} } - geometry, err := c.geometry(node, outline) + bounds, err := c.geometryBounds(node, outline) if err != nil { return err } - region := c.pixelBounds(geometry.box) + region := c.pixelBounds(bounds) for y := region.Min.Y; y < region.Max.Y; y++ { if err := c.importer.ctx.Err(); err != nil { return err diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index 91b5225..fdcc9a4 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -197,6 +197,8 @@ func (p *pdfImporter) compositePattern(paint pdfgo.Paint) (*pdfCompositePattern, local.matrix[5] -= box.Y local.pageWidth, local.pageHeight = box.W, box.H local.compositeCache = nil + local.compositingCache().geometry = cache.geometryCache() + local.compositingCache().clips = cache.clipCache() pattern := &pdfCompositePattern{nodes: nodes, local: local, box: box} cache.patterns[paint] = pattern return pattern, nil diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go new file mode 100644 index 0000000..b2b28a6 --- /dev/null +++ b/ofdgo_pdf_geometry.go @@ -0,0 +1,228 @@ +// 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" + "fmt" + "image/color" + "math" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfGeometryPath 直接转换PDF路径坐标,不经文本编码或曲线展平 +// 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换 +// 返回: GeometryPath 独立几何路径, error 指令、坐标或取消错误 +func pdfGeometryPath(ctx context.Context, source pdfgo.Path, matrix pdfgo.Matrix) (GeometryPath, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + path := make(GeometryPath, len(source.Segments)) + for i, segment := range source.Segments { + if err := ctx.Err(); err != nil { + return nil, err + } + var count int + switch segment.Operator { + case "M": + path[i].Verb, count = GeometryMove, 1 + case "L": + path[i].Verb, count = GeometryLine, 1 + case "B": + path[i].Verb, count = GeometryCubic, 3 + case "C": + path[i].Verb = GeometryClose + default: + return nil, fmt.Errorf("invalid PDF path operator %q", segment.Operator) + } + if len(segment.Points) != count { + return nil, fmt.Errorf("invalid PDF path operands for %s", segment.Operator) + } + var points [3]Point + for j, point := range segment.Points { + point = matrix.Apply(point) + points[j] = Point{X: point.X, Y: point.Y} + } + if count != 0 { + path[i].End = points[count-1] + } + if count == 3 { + path[i].Control1, path[i].Control2 = points[0], points[1] + } + } + if err := path.validate(); err != nil { + return nil, err + } + return path, ctx.Err() +} + +// compileCoverage 直接编译无文字裁剪的填充覆盖,其他语义交给配置的页面编译器 +// 入参: node 图元, stroke 是否描边 +// 返回: *RasterPage 覆盖场景, Box 页面区域, error 编译错误 +func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*RasterPage, Box, error) { + fallback := func() (*RasterPage, Box, error) { + return p.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) }) + } + if stroke || node.path == nil && node.image == nil { + return fallback() + } + deferredClip := false + switch p.editor.Backends().Compiler.(type) { + case CanvasBackend, *CanvasBackend: + case OFDCompiler, *OFDCompiler: + deferredClip = true + default: + return fallback() + } + style, _, _ := node.style() + for _, clip := range style.Clips { + if len(clip.Text) != 0 { + return fallback() + } + } + geometry, err := p.renderer.Geometry() + if err != nil { + return nil, Box{}, err + } + switch geometry.(type) { + case CanvasBackend, *CanvasBackend: + default: + return fallback() + } + var path GeometryPath + evenOdd := false + if node.path != nil { + path, err = pdfGeometryPath(p.ctx, node.path.Path, p.matrix) + evenOdd = node.path.Path.EvenOdd + } else { + path, err = geometry.Transform(geometryRectangle(Box{W: 1, H: 1}), MatrixFromValues([6]float64(p.matrix.Mul(node.image.Matrix)))) + } + if err != nil { + return nil, Box{}, err + } + if len(path) == 0 { + return nil, Box{}, fmt.Errorf("empty PDF painted path") + } + path = closedGeometry(path) + clip, err := p.coverageClip(style.Clips, geometry) + if err != nil || len(clip) == 0 { + return nil, Box{}, err + } + compiler := semanticCompiler{geometry: geometry, page: &RasterPage{Width: p.pageWidth, Height: p.pageHeight, DPI: p.rasterDPI}} + var bounds pdfPointBounds + for _, segment := range path { + if segment.Verb != GeometryClose { + bounds.add(pdfgo.Point{X: segment.End.X, Y: segment.End.Y}) + } + if segment.Verb == GeometryCubic { + bounds.add(pdfgo.Point{X: segment.Control1.X, Y: segment.Control1.Y}) + bounds.add(pdfgo.Point{X: segment.Control2.X, Y: segment.Control2.Y}) + } + } + boundary := geometryRectangle(bounds.box()) + clip, err = clipGeometry(geometry, clip, &boundary) + if err != nil { + return nil, Box{}, err + } + if deferredClip { + if err := compiler.command(path, Paint{Color: color.RGBA{255, 255, 255, 255}}, evenOdd, &clip, IdentityMatrix, nil); err != nil { + return nil, Box{}, err + } + return p.groupSceneBounds(compiler.page) + } + box, err := geometry.Bounds(path) + if err != nil { + return nil, Box{}, err + } + rect, rectangle := geometryRectangleBounds(clip) + if !rectangle || box.X < rect.X || box.Y < rect.Y || box.X+box.W > rect.X+rect.W || box.Y+box.H > rect.Y+rect.H { + if evenOdd { + path, err = geometry.Normalize(path, true) + if err != nil { + return nil, Box{}, err + } + evenOdd = false + } + path, err = clipGeometry(geometry, path, &clip) + if err != nil { + return nil, Box{}, err + } + } + if err := compiler.command(path, Paint{Color: color.RGBA{255, 255, 255, 255}}, evenOdd, nil, IdentityMatrix, nil); err != nil { + return nil, Box{}, err + } + return p.groupSceneBounds(compiler.page) +} + +// coverageClip 按完整路径、变换和页面范围复用只读裁剪,不缓存失败结果 +// 入参: paths 无文字裁剪的路径集合, geometry 几何后端 +// 返回: GeometryPath 共享只读轮廓, error 几何或取消错误 +func (p *pdfImporter) coverageClip(paths []pdfgo.Path, geometry GeometryBackend) (GeometryPath, error) { + if err := p.ctx.Err(); err != nil { + return nil, err + } + clip := geometryRectangle(Box{W: p.pageWidth, H: p.pageHeight}) + if len(paths) == 0 { + return clip, nil + } + hash := sha256.New() + var size [16]byte + binary.LittleEndian.PutUint64(size[:8], math.Float64bits(p.pageWidth)) + binary.LittleEndian.PutUint64(size[8:], math.Float64bits(p.pageHeight)) + hash.Write(size[:]) + for _, path := range paths { + key, err := pdfClipPathKey(p.ctx, path, p.matrix) + if err != nil { + return nil, err + } + hash.Write(key[:]) + } + var key [32]byte + hash.Sum(key[:0]) + cache := p.compositingCache().clipCache() + if cached, ok := cache.get(key); ok { + return cached, nil + } + for _, source := range paths { + part, err := pdfGeometryPath(p.ctx, source, p.matrix) + if err != nil { + return nil, err + } + part = closedGeometry(part) + if _, rectangle := geometryRectangleBounds(part); source.EvenOdd && !rectangle { + part, err = geometry.Normalize(part, true) + if err != nil { + return nil, err + } + } + clip, err = clipGeometry(geometry, part, &clip) + if err != nil { + return nil, err + } + if len(clip) == 0 { + break + } + } + if err := p.ctx.Err(); err != nil { + return nil, err + } + if len(clip) <= (cache.limit-128)/112 { + cache.put(key, clip, 128+len(clip)*112) + } + return clip, nil +} diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index d1c480e..58bc6c9 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -359,6 +359,7 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, editor.SetFontDirs(p.editor.fontDirs...) editor.SetFontFS(p.editor.fontFS...) editor.fontSourcesCache = p.renderer.fontSourcesCache + editor.validatedPaths = p.editor.pathValidationCache() if _, err := editor.AddPage(p.pageWidth, p.pageHeight); err != nil { return nil, box, err } @@ -402,11 +403,18 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, if scene == nil { return nil, box, fmt.Errorf("PDF local compositing: invalid compiled scene") } - geometry, err := renderer.Geometry() + return p.groupSceneBounds(scene) +} + +// groupSceneBounds 计算已编译场景在页面内的像素对齐区域 +// 入参: scene 中立绘制场景 +// 返回: *RasterPage 非空场景, Box 页面区域, error 度量错误 +func (p *pdfImporter) groupSceneBounds(scene *RasterPage) (*RasterPage, Box, error) { + geometry, err := p.renderer.Geometry() if err != nil { - return nil, box, err + return nil, Box{}, err } - box, err = scene.BoundsContext(p.ctx, geometry) + box, err := scene.BoundsContext(p.ctx, geometry) if err != nil { return nil, Box{}, err } diff --git a/ofdgo_pdf_mesh.go b/ofdgo_pdf_mesh.go index 6cc77fa..9886be9 100644 --- a/ofdgo_pdf_mesh.go +++ b/ofdgo_pdf_mesh.go @@ -17,6 +17,8 @@ package ofdgo import ( "fmt" "math" + "runtime" + "sync" "github.com/xiaoqidun/pdfgo" ) @@ -268,47 +270,72 @@ func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace, return nil, fmt.Errorf("singular mesh bounds transform") } } - colors := make(map[[4]float64][4]float64) - for y := 0; y < h; y++ { - if err := c.importer.ctx.Err(); err != nil { - return nil, err - } - for x := 0; x < w; x++ { - if box := mesh.Bounds; box != nil { - point := inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)/scale, Y: c.box.Y + (float64(y)+.5)/scale}) - if point.X < box.XMin || point.X > box.XMax || point.Y < box.YMin || point.Y > box.YMax { + sampleRows := func(start, end int) error { + colors := make(map[[4]float64][4]float64) + sampler := mesh.NewSampler() + for y := start * samples; y < end*samples; y++ { + if err := c.importer.ctx.Err(); err != nil { + return err + } + for x := 0; x < w; x++ { + if box := mesh.Bounds; box != nil { + point := inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)/scale, Y: c.box.Y + (float64(y)+.5)/scale}) + if point.X < box.XMin || point.X > box.XMax || point.Y < box.YMin || point.Y > box.YMax { + continue + } + } + sample := grid[y*w+x] + if sample.patch == 0 && mesh.Background == nil { continue } - } - sample := grid[y*w+x] - if sample.patch == 0 && mesh.Background == nil { - continue - } - values := background - if sample.patch != 0 { - var err error - values, err = mesh.ValuesAt(sample.patch-1, sample.u, sample.v) - if err != nil { - return nil, err - } - if cached, ok := colors[values]; ok { - values = cached - } else { - source := values - values, err = converter.Convert(values[:mesh.Space.Components()]) + values := background + if sample.patch != 0 { + var err error + values, err = sampler.ValuesAt(sample.patch-1, sample.u, sample.v) if err != nil { - return nil, err + return err } - if len(colors) < 2*pdfCompositeTileSize { + if cached, ok := colors[values]; ok { + values = cached + } else { + source := values + values, err = converter.Convert(values[:mesh.Space.Components()]) + if err != nil { + return err + } + if len(colors) == 2*pdfCompositeTileSize { + clear(colors) + } colors[source] = values } } + pixel := &pixels[(y/samples)*c.width+x/samples] + for i, value := range values { + pixel.values[i] += value + } + pixel.shape++ } - pixel := &pixels[(y/samples)*c.width+x/samples] - for i, value := range values { - pixel.values[i] += value + } + return c.importer.ctx.Err() + } + workers := min(runtime.GOMAXPROCS(0), 4, max(1, c.height/32)) + if workers == 1 { + if err := sampleRows(0, c.height); err != nil { + return nil, err + } + } else { + var group sync.WaitGroup + errors := make([]error, workers) + for worker := range workers { + group.Go(func() { + errors[worker] = sampleRows(worker*c.height/workers, (worker+1)*c.height/workers) + }) + } + group.Wait() + for _, err := range errors { + if err != nil { + return nil, err } - pixel.shape++ } } for i := range pixels { diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 8153f44..6b53fe3 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -146,6 +146,7 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { break } } + streamCached := id != "" var contentKey pdfImageContentKey cacheable := false if id == "" { @@ -207,13 +208,15 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { if err != nil { return err } + if cacheable { + p.imageContents.put(contentKey, pdfImageContent{source: source, id: id}, len(source.Stream.Data)+65536) + } + } + if !streamCached { if p.imageIDs == nil { p.imageIDs = map[pdfImageKey][]pdfImageResource{} } p.imageIDs[key] = append(p.imageIDs[key], pdfImageResource{space: source.ColorSpace, id: id}) - if cacheable { - p.imageContents.put(contentKey, pdfImageContent{source: source, id: id}, len(source.Stream.Data)+65536) - } } return p.appendImage(mark, id) } diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index fee0fe5..8c9f6dd 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -15,7 +15,10 @@ package ofdgo import ( + "crypto/sha256" + "encoding/binary" "image" + "math" "slices" "github.com/tdewolff/canvas" @@ -101,14 +104,10 @@ func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, ob areaCTM = objectCTM.Multiply(areaCTM) } for _, pathObj := range area.Path { - ctm := areaCTM.Multiply(NewMatrix(pathObj.CTM)) - cp := r.buildPath(pathObj, pageH, ctm, true) - cp.Translate(bx, -by) - cp.Close() - if pathObj.Rule == "Even-Odd" { - cp = cp.Settle(canvas.EvenOdd) + renderer.add(r.buildClipAreaPath(pathObj, area.DrawParam, areaCTM, pageH, bx, by)) + if r.renderError != nil { + return nil } - renderer.add(cp) } for _, textObj := range area.Text { box, _ := ParseBox(textObj.Boundary) @@ -138,11 +137,96 @@ func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, ob if renderer.path == nil { renderer.path = &canvas.Path{} } - p = intersectClipPath(p, renderer.path) + p = r.intersectCachedClipPath(p, renderer.path) } return p } +// buildClipAreaPath 按绘制参数展开裁剪路径的填充及描边,再应用区域变换 +// 入参: obj 裁剪路径, drawParam 区域绘制参数, parent 区域变换, pageH 页面高度, bx 横向位移, by 纵向位移 +// 返回: *canvas.Path 裁剪轮廓 +func (r *Renderer) buildClipAreaPath(obj PathObject, drawParam string, parent Matrix, pageH, bx, by float64) *canvas.Path { + if obj.Visible != nil && !*obj.Visible { + return &canvas.Path{} + } + box, _ := ParseBox(obj.Boundary) + local := NewMatrix(obj.CTM) + m := parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(local) + clip := r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, local, &parent, true) + obj.Boundary = "" + p := r.buildPath(obj, 0, IdentityMatrix, false) + var result *canvas.Path + if obj.Fill != nil && *obj.Fill { + paths := canvas.Paths(p.Copy().Split()) + for _, path := range paths { + path.Close() + } + result = paths.Merge() + if obj.Rule == "Even-Odd" { + result = result.Settle(canvas.EvenOdd) + } + } + if obj.Stroke == nil || *obj.Stroke { + dp := r.drawParamDefaults(obj.DrawParam, r.drawParamDefaults(drawParam, nil)) + if dp != nil { + copy := *dp + copy.FillColor, copy.StrokeColor = nil, nil + dp = © + } + obj.FillColor, obj.StrokeColor = nil, nil + style := r.newPathStyle(dp, 0, 0, 0, nil) + style.applyPathObject(r, obj, 0, 0, 0) + if style.lineWidth == 0 { + style.lineWidth = 25.4 / r.DPI + } + result = unionClipPath(result, r.strokeDashedCanvasPath(p, style.lineWidth, style.lineCap, style.lineJoin, style.dashOffset, style.dashPattern, false)) + } + if result == nil { + return &canvas.Path{} + } + result = result.Transform(canvas.Matrix{{m.a, -m.c, m.e}, {-m.b, m.d, pageH - m.f}}) + return applyClipPath(result, clip).Translate(bx, -by) +} + +// intersectCachedClipPath 按完整几何内容复用复杂裁剪交集,缓存命中时返回独立副本 +// 入参: parent 父级裁剪路径, current 当前裁剪路径 +// 返回: *canvas.Path 相交后的裁剪路径 +func (r *Renderer) intersectCachedClipPath(parent, current *canvas.Path) *canvas.Path { + if parent == nil || current == nil || len(parent.Data())+len(current.Data()) < 256 { + return intersectClipPath(parent, current) + } + hash := sha256.New() + var data [512]byte + binary.LittleEndian.PutUint64(data[:8], math.Float64bits(canvas.Tolerance)) + binary.LittleEndian.PutUint64(data[8:16], math.Float64bits(canvas.Epsilon)) + binary.LittleEndian.PutUint64(data[16:24], math.Float64bits(canvas.BentleyOttmannEpsilon)) + hash.Write(data[:24]) + for _, path := range []*canvas.Path{parent, current} { + values := path.Data() + binary.LittleEndian.PutUint64(data[:8], uint64(len(values))) + hash.Write(data[:8]) + for len(values) > 0 { + count := min(len(values), len(data)/8) + for i, value := range values[:count] { + binary.LittleEndian.PutUint64(data[i*8:], math.Float64bits(value)) + } + hash.Write(data[:count*8]) + values = values[count:] + } + } + var key [32]byte + hash.Sum(key[:0]) + cache := r.canvasClipIntersections() + if path, ok := cache.get(key); ok { + return path.Copy() + } + path := intersectClipPath(parent, current) + if cost := len(path.Data())*8 + 256; cost <= cache.limit { + cache.put(key, path.Copy(), cost) + } + return path +} + // intersectClipPath 求裁剪路径交集 // 入参: parent 父级裁剪路径, current 当前裁剪路径 // 返回: *canvas.Path 相交后的裁剪路径 @@ -185,6 +269,12 @@ func intersectClipPath(parent, current *canvas.Path) *canvas.Path { if result, ok := intersectConvexCanvasPaths(parent, current); ok { return result } + if result, ok := intersectLinearCanvasContours(parent, current); ok { + return result.Settle(canvas.NonZero) + } + if result, ok := intersectLinearCanvasContours(current, parent); ok { + return result.Settle(canvas.NonZero) + } return parent.And(current) } @@ -241,6 +331,12 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path { if result, ok := intersectConvexCanvasPaths(path, clip); ok { return result } + if result, ok := intersectLinearCanvasContours(path, clip); ok { + return result.Settle(canvas.NonZero) + } + if result, ok := intersectLinearCanvasContours(clip, path); ok { + return result.Settle(canvas.NonZero) + } return path.And(clip) } @@ -248,6 +344,11 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path { // 入参: path 非零填充描边, clip 裁剪路径 // 返回: *canvas.Path 绘制轮廓 func applyFillClipPath(path, clip *canvas.Path) *canvas.Path { + if path != nil && clip != nil && len(path.Data()) >= 64 { + if result, ok := intersectLinearCanvasContours(path, clip); ok { + return result + } + } if path != nil && clip != nil && path.HasSubpaths() && len(path.Split()) >= 8 { if result, ok := intersectCanvasContours(path, clip); ok { return result diff --git a/ofdgo_renderer_clip_convex_canvas.go b/ofdgo_renderer_clip_convex_canvas.go index 62c4d07..ab80470 100644 --- a/ofdgo_renderer_clip_convex_canvas.go +++ b/ofdgo_renderer_clip_convex_canvas.go @@ -35,6 +35,22 @@ func intersectConvexCanvasPaths(left, right *canvas.Path) (*canvas.Path, bool) { return intersectCanvasPolygons(a, b) } +// intersectLinearCanvasContours 精确裁剪直线轮廓,不展开曲线或改变填充绕数 +// 入参: path 绘制路径, clip 单个凸裁剪窗口 +// 返回: *canvas.Path 交集路径, bool 是否适用直线轮廓裁剪 +func intersectLinearCanvasContours(path, clip *canvas.Path) (*canvas.Path, bool) { + if path == nil { + return nil, false + } + data := path.Data() + for i := 0; i < len(data); i += 4 { + if i+4 > len(data) || data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd { + return nil, false + } + } + return intersectCanvasContours(path, clip) +} + // intersectCanvasContours 按凸窗口裁剪闭合轮廓,保留绕向、孔洞和重叠透明度 // 入参: path 绘制路径, clip 单个凸裁剪路径 // 返回: *canvas.Path 交集路径, bool 是否适用分段裁剪 diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 6616b77..71d464e 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -194,7 +194,7 @@ func (CanvasBackend) Clip(r *Renderer, clips *Clips, matrix Matrix, parent *Geom if renderer.renderError != nil { return nil, renderer.renderError } - return geometryFromCanvasPath(intersectClipPath(p, clip)), nil + return geometryFromCanvasPath(r.intersectCachedClipPath(p, clip)), nil } // canvasNativeStroke 判断描边是否由Canvas提供 @@ -303,11 +303,14 @@ func (r *Renderer) strokeDashedCanvasPath(path *canvas.Path, width float64, cap // geometryToCanvasPath 将页面坐标路径转换为默认几何引擎的向上纵轴 // 入参: path 独立几何路径,nil表示不裁剪 -// 返回: *canvas.Path 默认引擎路径, error 不支持的路径指令 +// 返回: *canvas.Path 默认引擎路径, error 无效路径 func geometryToCanvasPath(path *GeometryPath) (*canvas.Path, error) { if path == nil { return nil, nil } + if err := path.validate(); err != nil { + return nil, err + } p := &canvas.Path{} for _, s := range *path { switch s.Verb { diff --git a/ofdgo_renderer_image_canvas.go b/ofdgo_renderer_image_canvas.go index a76ad48..4bfa8ed 100644 --- a/ofdgo_renderer_image_canvas.go +++ b/ofdgo_renderer_image_canvas.go @@ -95,7 +95,7 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float {-ctm.b / imgW, ctm.d / imgH, pageH - box.Y - ctm.d - ctm.f}, } } - clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) + clipPath := r.intersectCachedClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) imageClip := clipPath compiler, compiled := ctx.Renderer.(*canvasPageCompiler) if compiled && imageClip != nil && imageClipContains(img, imageClip, m) { diff --git a/ofdgo_renderer_measure_canvas.go b/ofdgo_renderer_measure_canvas.go index ccfefb7..6715086 100644 --- a/ofdgo_renderer_measure_canvas.go +++ b/ofdgo_renderer_measure_canvas.go @@ -158,7 +158,7 @@ func (r *Renderer) measureImage(ctx *canvas.Context, obj ImageObject, pageH floa } } p := canvas.Rectangle(1, 1).Transform(canvas.Matrix{{m.a, m.c, m.e}, {-m.b, -m.d, pageH - m.f}}) - clip := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, ctm, parentCTM, boundaryInCTM)) + clip := r.intersectCachedClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, ctm, parentCTM, boundaryInCTM)) ctx.Renderer.(*boundsRenderer).add(applyClipPath(p, clip)) if obj.Border != nil { border := *obj.Border diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index 537f7b4..d432837 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -19,6 +19,7 @@ import ( "image/color" "math" "slices" + "strings" "github.com/tdewolff/canvas" ) @@ -235,7 +236,7 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 {-parentCTM.b, parentCTM.d, pageH*(1-parentCTM.d) - parentCTM.f}, }) } - clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) + clipPath := r.intersectCachedClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) shouldFill := false if obj.Fill != nil { shouldFill = *obj.Fill @@ -245,7 +246,7 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 } if shouldFill { fillPaint, shadingClip, fillView := resolveShdPaint(ctx, style.fillPaint) - fillClip := intersectClipPath(clipPath, shadingClip) + fillClip := r.intersectCachedClipPath(clipPath, shadingClip) paths := canvas.Paths(p.Copy().Split()) for _, path := range paths { path.Close() @@ -254,6 +255,9 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 fillRule := canvas.NonZero if obj.Rule == "Even-Odd" { fillRule = canvas.EvenOdd + if bounds, ok := rectangularPath(fillClip); ok && bounds.Contains(fp.Bounds()) { + fillClip = nil + } if fillClip != nil || style.fillPattern != nil { fp = fp.Settle(fillRule) } @@ -294,7 +298,7 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 sp = sp.Dash(style.dashOffset, style.dashPattern...) ctx.SetDashes(0) } - strokeClip := intersectClipPath(clipPath, shadingClip) + strokeClip := r.intersectCachedClipPath(clipPath, shadingClip) _, repeat := strokePaint.(*repeatAxialGradient) if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !canvasNativeStroke(r.backends.Geometry) || zeroDash { sp = sp.Copy() @@ -424,12 +428,7 @@ func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *PatternPaint, pag // 入参: obj 路径对象, pageH 页面高度, ctm 变换矩阵, boundaryInCTM 边界是否参与CTM变换 // 返回: *canvas.Path 路径对象 func (r *Renderer) buildPath(obj PathObject, pageH float64, ctm Matrix, boundaryInCTM bool) *canvas.Path { - path, err := ParseGeometryPath(obj.AbbreviatedData) - if err != nil { - r.renderError = err - return &canvas.Path{} - } - p, err := canvasObjectPath(path) + p, err := r.parseCanvasPath(obj.AbbreviatedData) if err != nil { r.renderError = err return &canvas.Path{} @@ -446,6 +445,30 @@ func (r *Renderer) buildPath(obj PathObject, pageH float64, ctm Matrix, boundary return p.Transform(canvas.Matrix{{ctm.a, ctm.c, ctm.e}, {-ctm.b, -ctm.d, pageH - ctm.f}}) } +// parseCanvasPath 复用局部路径解析,返回独立副本且不缓存错误或超预算结果 +// 入参: data 紧缩路径 +// 返回: *canvas.Path 局部路径, error 解析错误 +func (r *Renderer) parseCanvasPath(data string) (*canvas.Path, error) { + key := canvasPathKey{data: data, epsilon: canvas.Epsilon} + cache := r.canvasParsedPaths() + if path, ok := cache.get(key); ok { + return path.Copy(), nil + } + geometry, err := ParseGeometryPath(data) + if err != nil { + return nil, err + } + path, err := canvasObjectPath(geometry) + if err != nil { + return nil, err + } + if cost := len(data) + len(path.Data())*8 + 256; cost <= cache.limit { + key.data = strings.Clone(data) + cache.put(key, path.Copy(), cost) + } + return path, nil +} + // buildTinyFillRectPath 构建微小填充矩形路径 // 入参: obj 路径对象, pageH 页面高度, ctm 变换矩阵, bx 边界X坐标, by 边界Y坐标 // 返回: *canvas.Path 路径对象 diff --git a/ofdgo_renderer_state_canvas.go b/ofdgo_renderer_state_canvas.go index 42eb685..845dc0b 100644 --- a/ofdgo_renderer_state_canvas.go +++ b/ofdgo_renderer_state_canvas.go @@ -27,6 +27,18 @@ type canvasFontKey struct { style canvas.FontStyle } +// canvasClipCacheKey 区分不依赖文档资源的共享裁剪缓存 +type canvasClipCacheKey struct{} + +// canvasPathCacheKey 区分不依赖文档资源的共享局部路径缓存 +type canvasPathCacheKey struct{} + +// canvasPathKey 按完整路径文本与几何容差区分解析结果 +type canvasPathKey struct { + data string + epsilon float64 +} + // canvasBackendState 保存Canvas字体、字形和编码图片适配缓存 type canvasBackendState struct { images renderCache[*EncodedImage, image.Image] @@ -36,6 +48,36 @@ type canvasBackendState struct { fontOutlines map[*canvas.Font]*sfntOutliner } +// canvasClipIntersections 获取子渲染器共享的几何交集缓存,限制结果保留量 +// 返回: *renderCache[[32]byte, *canvas.Path] 裁剪缓存 +func (r *Renderer) canvasClipIntersections() *renderCache[[32]byte, *canvas.Path] { + key := canvasClipCacheKey{} + if state, ok := r.sharedBackendStates[key]; ok { + return state.(*renderCache[[32]byte, *canvas.Path]) + } + if r.sharedBackendStates == nil { + r.sharedBackendStates = make(map[any]any) + } + cache := &renderCache[[32]byte, *canvas.Path]{limit: 8 << 20} + r.sharedBackendStates[key] = cache + return cache +} + +// canvasParsedPaths 获取子渲染器共享的局部路径缓存,不保留对象变换 +// 返回: *renderCache[canvasPathKey, *canvas.Path] 路径缓存 +func (r *Renderer) canvasParsedPaths() *renderCache[canvasPathKey, *canvas.Path] { + key := canvasPathCacheKey{} + if state, ok := r.sharedBackendStates[key]; ok { + return state.(*renderCache[canvasPathKey, *canvas.Path]) + } + if r.sharedBackendStates == nil { + r.sharedBackendStates = make(map[any]any) + } + cache := &renderCache[canvasPathKey, *canvas.Path]{limit: 16 << 20} + r.sharedBackendStates[key] = cache + return cache +} + // canvasFontFamilies 获取子渲染器共享的只读字体族缓存,限制解析数据保留量 // 返回: *renderCache[canvasFontKey, *canvas.FontFamily] 字体族缓存 func (r *Renderer) canvasFontFamilies() *renderCache[canvasFontKey, *canvas.FontFamily] { diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index 1c02394..64d8584 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -50,7 +50,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if boundaryInCTM && parentCTM != nil { objectCTM = parentCTM.Multiply(TranslationMatrix(bx, by)).Multiply(localCTM) } - clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) + clipPath := r.intersectCachedClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) var dp *DrawParam if obj.DrawParam != "" { dp = r.getDrawParam(obj.DrawParam, nil) @@ -99,7 +99,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 } transformShdPaint(fillPaint, parentCTM, bx, by, pageH, boundaryInCTM) fillPaint, shadingClip, fillView := resolveShdPaint(ctx, fillPaint) - fillClip := intersectClipPath(clipPath, shadingClip) + fillClip := r.intersectCachedClipPath(clipPath, shadingClip) var strokeStyle pathStyle strokeClip := clipPath strokeView := canvas.Identity @@ -124,7 +124,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 } transformShdPaint(strokeStyle.strokePaint, parentCTM, bx, by, pageH, boundaryInCTM) strokeStyle.strokePaint, shadingClip, strokeView = resolveShdPaint(ctx, strokeStyle.strokePaint) - strokeClip = intersectClipPath(strokeClip, shadingClip) + strokeClip = r.intersectCachedClipPath(strokeClip, shadingClip) } fontStyle := canvas.FontRegular weight := obj.Weight diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 204dadc..c6f7006 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -936,20 +936,22 @@ func (x *ofdXML) clips(clips *Clips) { var attrs ofdAttrs attrs.add("CTM", area.CTM) attrs.add("DrawParam", area.DrawParam) - x.start("Area", attrs) for _, path := range area.Path { if x.err != nil { return } + x.start("Area", attrs) x.object(GraphicObject{Type: "Path", PathObject: path}, false) + x.end("Area") } for _, text := range area.Text { if x.err != nil { return } + x.start("Area", attrs) x.object(GraphicObject{Type: "Text", TextObject: text}, false) + x.end("Area") } - x.end("Area") } x.end("Clip") }