diff --git a/go.mod b/go.mod index 8e618ac..869a4ff 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-20261001010247-f9839c149d47 + github.com/xiaoqidun/pdfgo v0.0.0-20261002050201-1eed5d9912bf 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 a65517f..e2e0373 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-20261001010247-f9839c149d47 h1:K81kklR5kcVJzV7EWUNCpQJHxw1v2vLI7eegfOs0xbI= -github.com/xiaoqidun/pdfgo v0.0.0-20261001010247-f9839c149d47/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20261002050201-1eed5d9912bf h1:sST236XvU4MWCZjaH3d0hg4SsDJl6SNGL3qQdfgHP8w= +github.com/xiaoqidun/pdfgo v0.0.0-20261002050201-1eed5d9912bf/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 960393e..3e15120 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -70,17 +70,19 @@ type Editor struct { // editorResource 文档内嵌资源 type editorResource struct { - name string - data []byte - font *Font - image *MultiMedia - space *ColorSpace - composite string - references []string - subset *editorFontSubset - states map[string]editorCompositeState - draw *DrawParam - drawSource string + name string + data []byte + font *Font + image *MultiMedia + space *ColorSpace + composite string + vectorKey [32]byte + vectorStates map[string]editorCompositeState + references []string + subset *editorFontSubset + states map[string]editorCompositeState + draw *DrawParam + drawSource string } // editorResourceKey 资源内容和字体集合索引 @@ -495,8 +497,8 @@ func (e *Editor) addImage(data []byte, config image.Config, format string, decod return id, nil } -// AddObject 按添加顺序放入正文图层,支持文字、路径和图片,自动分配对象ID -// 支持已注册资源、颜色、渐变、裁剪和动作;复合图元仅支持当前文档原对象的保真复制 +// AddObject 按添加顺序放入正文图层,支持文字、路径、图片和矢量资源实例,自动分配对象ID +// 支持已注册资源、颜色、渐变、裁剪和动作;原对象复制保留原文结构 // 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据 // 返回: string 对象标识, error 错误信息 func (e *Editor) AddObject(page int, object GraphicObject) (string, error) { @@ -533,6 +535,8 @@ func (e *Editor) UpdateObject(page int, id string, object GraphicObject) error { object.PathObject.ID = id case "ImageObject": object.ImageObject.ID = id + case "CompositeObject": + object.CompositeGraphicUnit.ID = id default: return fmt.Errorf("unsupported object type %q", object.Type) } @@ -781,6 +785,18 @@ func (e *Editor) findObject(page int, id string) (*Layer, int, error) { // 入参: id 对象标识, object 对象内容 // 返回: GraphicObject 对象内容, error 错误信息 func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, error) { + object, err := (&editorValidation{Editor: e}).prepareObject(id, object) + if err != nil { + return GraphicObject{}, err + } + return cloneEditorObject(object) +} + +// prepareObject 校验对象及嵌套颜色,不重复复制底纹单元 +// 入参: id 对象标识, object 对象内容 +// 返回: GraphicObject 对象内容, error 错误信息 +func (v *editorValidation) prepareObject(id string, object GraphicObject) (GraphicObject, error) { + e := v.Editor var boundary, ctm, drawParam, join string var alpha *int var clips *Clips @@ -828,13 +844,24 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, } } if obj.Border != nil { - if err := e.validateImageBorder(obj.Border); err != nil { + if err := v.validateImageBorder(obj.Border); err != nil { return GraphicObject{}, err } } obj.ID = id boundary, ctm = obj.Boundary, obj.CTM alpha, clips, actions = obj.Alpha, obj.Clips, obj.Actions + case "CompositeObject": + obj := &object.CompositeGraphicUnit + if obj.ResourceID == "" || len(obj.Objects)+len(obj.TextObject)+len(obj.PathObject)+len(obj.ImageObject)+len(obj.CompositeGraphicUnit) != 0 || obj.extentSet || obj.Width != 0 || obj.Height != 0 || obj.Thumbnail != "" || obj.Substitution != "" { + return GraphicObject{}, fmt.Errorf("composite requires a vector resource without inline content") + } + if err := v.validateVector(obj.ResourceID); err != nil { + return GraphicObject{}, err + } + obj.ID = id + boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam + alpha, clips, actions = obj.Alpha, obj.Clips, obj.Actions default: return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type) } @@ -850,34 +877,48 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H)) } if drawParam != "" { - if _, err := e.editorDrawParam(drawParam, make(map[string]bool)); err != nil { + defaults, err := e.editorDrawParam(drawParam, make(map[string]bool)) + if err != nil { return GraphicObject{}, err } + if fill == nil { + fill = defaults.FillColor + } + if stroke == nil { + stroke = (*FillColor)(defaults.StrokeColor) + } } if err := validateObjectActions(actions); err != nil { return GraphicObject{}, err } - if err := e.validateObjectClips(clips); err != nil { + if err := v.validateObjectClips(clips); err != nil { return GraphicObject{}, err } if alpha != nil && (*alpha < 0 || *alpha > 255) { return GraphicObject{}, fmt.Errorf("alpha must be between 0 and 255") } for _, color := range []*FillColor{fill, stroke} { - if err := e.editorColor(color); err != nil { + if err := v.editorColor(color); err != nil { return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: err} } } if join != "" && join != "Miter" && join != "Round" && join != "Bevel" { return GraphicObject{}, fmt.Errorf("invalid line join %q", join) } - return cloneEditorObject(object) + return object, nil } // validateObjectClips 校验对象的路径与文字裁剪,拒绝嵌套裁剪和无效资源 // 入参: clips 裁剪集合,nil表示无裁剪 // 返回: error 错误信息 func (e *Editor) validateObjectClips(clips *Clips) error { + return (&editorValidation{Editor: e}).validateObjectClips(clips) +} + +// validateObjectClips 沿当前颜色引用路径校验裁剪对象 +// 入参: clips 裁剪集合 +// 返回: error 错误信息 +func (v *editorValidation) validateObjectClips(clips *Clips) error { if clips == nil { return nil } @@ -901,7 +942,7 @@ func (e *Editor) validateObjectClips(clips *Clips) error { if path.ID != "" || path.Clips != nil { return fmt.Errorf("clip paths must not have object IDs or nested clips") } - if _, err := e.prepareObject("", GraphicObject{Type: "PathObject", PathObject: path}); err != nil { + if _, err := v.prepareObject("", GraphicObject{Type: "PathObject", PathObject: path}); err != nil { return err } } @@ -909,7 +950,7 @@ func (e *Editor) validateObjectClips(clips *Clips) error { if text.ID != "" || text.Clips != nil || text.Actions != nil { return fmt.Errorf("clip text must not have object IDs, actions or nested clips") } - if _, err := e.prepareObject("", GraphicObject{Type: "TextObject", TextObject: text}); err != nil { + if _, err := v.prepareObject("", GraphicObject{Type: "TextObject", TextObject: text}); err != nil { return err } } diff --git a/ofdgo_editor_annot_content.go b/ofdgo_editor_annot_content.go index edc5c5d..2bc2011 100644 --- a/ofdgo_editor_annot_content.go +++ b/ofdgo_editor_annot_content.go @@ -32,94 +32,9 @@ import ( func (e *Editor) AddAnnotation(index int, annotation Annotation) (string, error) { var id string err := e.Transaction(func(next *Editor) error { - if index < 0 || index >= len(next.pages) { - return fmt.Errorf("page index %d out of range", index) - } - if !slices.Contains([]string{"Link", "Path", "Highlight", "Stamp", "Watermark"}, annotation.Type) { - return fmt.Errorf("invalid annotation type %q", annotation.Type) - } - if _, err := creationBox(annotation.Appearance.Boundary); err != nil { - return err - } - if err := next.prepareSourceIDs(); err != nil { - return err - } - id = next.nextID() - objects := annotation.Appearance.Objects - if len(objects) == 0 { - for _, object := range annotation.Appearance.TextObject { - objects = append(objects, GraphicObject{Type: "TextObject", TextObject: object}) - } - for _, object := range annotation.Appearance.PathObject { - objects = append(objects, GraphicObject{Type: "PathObject", PathObject: object}) - } - for _, object := range annotation.Appearance.ImageObject { - objects = append(objects, GraphicObject{Type: "ImageObject", ImageObject: object}) - } - if len(annotation.Appearance.CompositeGraphicUnit) != 0 { - return fmt.Errorf("use composite copy to insert existing complex content") - } - } - var content []byte - for _, object := range objects { - prepared, err := next.prepareObject(next.nextID(), cloneEditorData(object)) - if err != nil { - return err - } - data, err := editorObjectXML(prepared) - if err != nil { - return err - } - content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) - } - appearance, err := editorXMLContainer("Appearance", ofdAttrs{{Name: xml.Name{Local: "Boundary"}, Value: annotation.Appearance.Boundary}}, content) - if err != nil { - return err - } - date := annotation.LastModDate - if date == "" { - date = time.Now().Format("2006-01-02") - } else if _, err := time.Parse("2006-01-02", date); err != nil { - return fmt.Errorf("invalid annotation date %q: %w", date, err) - } - attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Type"}, Value: annotation.Type}, - {Name: xml.Name{Local: "Creator"}, Value: annotation.Creator}, {Name: xml.Name{Local: "LastModDate"}, Value: date}} - attrs.add("Subtype", annotation.Subtype) - attrs.flag("Visible", annotation.Visible) - if annotation.NoZoom { - attrs.add("NoZoom", "true") - } - if annotation.NoRotate { - attrs.add("NoRotate", "true") - } - content = nil - if annotation.Remark != "" { - content = editorXMLText("Remark", annotation.Remark) - } - if annotation.Parameters != nil && len(annotation.Parameters.Parameter) != 0 { - var parameters []byte - for _, parameter := range annotation.Parameters.Parameter { - if parameter.Name == "" { - return fmt.Errorf("empty annotation parameter name") - } - var value bytes.Buffer - if err := xml.EscapeText(&value, []byte(parameter.Value)); err != nil { - return err - } - data, err := editorXMLContainer("Parameter", ofdAttrs{{Name: xml.Name{Local: "Name"}, Value: parameter.Name}}, value.Bytes()) - if err != nil { - return err - } - parameters = append(parameters, bytes.TrimPrefix(data, []byte(xml.Header))...) - } - data, err := editorXMLContainer("Parameters", nil, parameters) - if err != nil { - return err - } - content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) - } - content = append(content, bytes.TrimPrefix(appearance, []byte(xml.Header))...) - data, err := editorXMLContainer("Annot", attrs, content) + var data []byte + var err error + id, data, err = next.prepareAnnotation(index, annotation) if err != nil { return err } @@ -131,6 +46,102 @@ func (e *Editor) AddAnnotation(index int, annotation Annotation) (string, error) return id, nil } +// prepareAnnotation 校验注解并分配外观标识,提交由调用方事务或独立导入文档负责 +// 入参: index 页面索引, annotation 注解内容 +// 返回: string 注解标识, []byte 标准XML, error 校验错误 +func (e *Editor) prepareAnnotation(index int, annotation Annotation) (string, []byte, error) { + if index < 0 || index >= len(e.pages) { + return "", nil, fmt.Errorf("page index %d out of range", index) + } + if !slices.Contains([]string{"Link", "Path", "Highlight", "Stamp", "Watermark"}, annotation.Type) { + return "", nil, fmt.Errorf("invalid annotation type %q", annotation.Type) + } + if _, err := creationBox(annotation.Appearance.Boundary); err != nil { + return "", nil, err + } + if err := e.prepareSourceIDs(); err != nil { + return "", nil, err + } + id := e.nextID() + objects := annotation.Appearance.Objects + if len(objects) == 0 { + objects = nil + for _, object := range annotation.Appearance.TextObject { + objects = append(objects, GraphicObject{Type: "TextObject", TextObject: object}) + } + for _, object := range annotation.Appearance.PathObject { + objects = append(objects, GraphicObject{Type: "PathObject", PathObject: object}) + } + for _, object := range annotation.Appearance.ImageObject { + objects = append(objects, GraphicObject{Type: "ImageObject", ImageObject: object}) + } + if len(annotation.Appearance.CompositeGraphicUnit) != 0 { + return "", nil, fmt.Errorf("use composite copy to insert existing complex content") + } + } + var content []byte + for _, object := range objects { + prepared, err := e.prepareObject(e.nextID(), cloneEditorData(object)) + if err != nil { + return "", nil, err + } + data, err := editorObjectXML(prepared) + if err != nil { + return "", nil, err + } + content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) + } + appearance, err := editorXMLContainer("Appearance", ofdAttrs{{Name: xml.Name{Local: "Boundary"}, Value: annotation.Appearance.Boundary}}, content) + if err != nil { + return "", nil, err + } + date := annotation.LastModDate + if date == "" { + date = time.Now().Format("2006-01-02") + } else if _, err := time.Parse("2006-01-02", date); err != nil { + return "", nil, fmt.Errorf("invalid annotation date %q: %w", date, err) + } + attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Type"}, Value: annotation.Type}, + {Name: xml.Name{Local: "Creator"}, Value: annotation.Creator}, {Name: xml.Name{Local: "LastModDate"}, Value: date}} + attrs.add("Subtype", annotation.Subtype) + attrs.flag("Visible", annotation.Visible) + if annotation.NoZoom { + attrs.add("NoZoom", "true") + } + if annotation.NoRotate { + attrs.add("NoRotate", "true") + } + content = nil + if annotation.Remark != "" { + content = editorXMLText("Remark", annotation.Remark) + } + if annotation.Parameters != nil && len(annotation.Parameters.Parameter) != 0 { + var parameters []byte + for _, parameter := range annotation.Parameters.Parameter { + if parameter.Name == "" { + return "", nil, fmt.Errorf("empty annotation parameter name") + } + var value bytes.Buffer + if err := xml.EscapeText(&value, []byte(parameter.Value)); err != nil { + return "", nil, err + } + data, err := editorXMLContainer("Parameter", ofdAttrs{{Name: xml.Name{Local: "Name"}, Value: parameter.Name}}, value.Bytes()) + if err != nil { + return "", nil, err + } + parameters = append(parameters, bytes.TrimPrefix(data, []byte(xml.Header))...) + } + data, err := editorXMLContainer("Parameters", nil, parameters) + if err != nil { + return "", nil, err + } + content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) + } + content = append(content, bytes.TrimPrefix(appearance, []byte(xml.Header))...) + data, err := editorXMLContainer("Annot", attrs, content) + return id, data, err +} + // Annotation 获取注解的独立内容快照,修改原注解应使用局部编辑方法以保留扩展字段 // 入参: index 页面索引, id 注解标识 // 返回: Annotation 注解内容, error 错误信息 diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go index d276d94..bf0e9b3 100644 --- a/ofdgo_editor_color.go +++ b/ofdgo_editor_color.go @@ -32,6 +32,14 @@ type PatternStyle struct { CTM *string } +// editorValidation 保存同次对象校验的底纹和矢量引用路径,允许共享但拒绝循环 +type editorValidation struct { + *Editor + patterns map[*Pattern]bool + composites map[string]bool + vectors map[string]*editorCompositeNode +} + // GradientStops 解析当前文档的渐变分段,保留顺序并合并透明度 // 入参: segments 渐变分段, alpha 外层透明度 // 返回: []ColorStop 后端无关的色标, error 颜色解析错误 @@ -308,7 +316,8 @@ func collectPaintReferences(paint *FillColor, used map[string]bool) { used[back.ColorSpace] = true } if paint.Pattern != nil { - for _, object := range paint.Pattern.CellContent.Objects { + used[paint.Pattern.CellContent.Thumbnail] = true + for object := range paint.Pattern.CellContent.objects() { collectObjectReferences(object, used) } } @@ -401,18 +410,40 @@ func (e *Editor) editorMeshColor(value *FillColor) error { return err } -// editorColor 校验当前文档的纯色和渐变 +// editorColor 校验当前文档的纯色、渐变和底纹 // 入参: value 颜色 // 返回: error 错误信息 func (e *Editor) editorColor(value *FillColor) error { + return (&editorValidation{Editor: e}).editorColor(value) +} + +// editorColor 沿当前引用路径校验底纹单元及绘制颜色 +// 入参: value 颜色 +// 返回: error 错误信息 +func (v *editorValidation) editorColor(value *FillColor) error { + e := v.Editor + if value != nil && value.Pattern != nil { + pattern := value.Pattern + if v.patterns[pattern] { + return fmt.Errorf("cyclic pattern paint") + } + if v.patterns == nil { + v.patterns = make(map[*Pattern]bool) + } + v.patterns[pattern] = true + defer delete(v.patterns, pattern) + } if value != nil && (value.GouraudShd != nil || value.LaGouraudShd != nil) { return e.editorMeshColor(value) } if value != nil && value.Pattern != nil { pattern := value.Pattern - if value.unsupported || value.AxialShd != nil || value.RadialShd != nil || !finite(pattern.Width) || !finite(pattern.Height) || pattern.Width <= 0 || pattern.Height <= 0 || !finite(pattern.XStep) || !finite(pattern.YStep) || pattern.XStep < pattern.Width || pattern.YStep < pattern.Height { + if value.unsupported || value.AxialShd != nil || value.RadialShd != nil || !finite(pattern.Width) || !finite(pattern.Height) || pattern.Width <= 0 || pattern.Height <= 0 || !finite(pattern.XStep) || !finite(pattern.YStep) { return fmt.Errorf("invalid pattern paint") } + if !slices.Contains([]string{"", "Normal", "Row", "Column", "RowAndColumn"}, pattern.ReflectMethod) { + return fmt.Errorf("invalid pattern reflection") + } if pattern.RelativeTo != "" && pattern.RelativeTo != "Page" && pattern.RelativeTo != "Object" { return fmt.Errorf("invalid pattern reference") } @@ -423,14 +454,20 @@ func (e *Editor) editorColor(value *FillColor) error { } base := *value base.Pattern = nil - if _, err := e.Color(&base); err != nil { + if base.Value == "" && base.Index == nil { + if base.Alpha != nil && (*base.Alpha < 0 || *base.Alpha > 255) { + return fmt.Errorf("color alpha must be between 0 and 255") + } + } else if _, err := e.Color(&base); err != nil { return err } - for _, object := range pattern.CellContent.Objects { - if object.Type == "PathObject" && (object.PathObject.FillColor != nil && object.PathObject.FillColor.Pattern != nil || object.PathObject.StrokeColor != nil && object.PathObject.StrokeColor.Pattern != nil) || object.Type == "TextObject" && (object.TextObject.FillColor != nil && object.TextObject.FillColor.Pattern != nil || object.TextObject.StrokeColor != nil && object.TextObject.StrokeColor.Pattern != nil) { - return fmt.Errorf("nested pattern paint is not supported") + if pattern.CellContent.Thumbnail != "" { + if _, err := e.editorImage(pattern.CellContent.Thumbnail); err != nil { + return err } - if _, err := e.prepareObject("", object); err != nil { + } + for object := range pattern.CellContent.objects() { + if _, err := v.prepareObject("", object); err != nil { return err } } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 81c98d6..5647ada 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -474,12 +474,23 @@ func (e *Editor) compositeMembers(n *editorCompositeNode, reader *Reader, render clips, clipMatrix = ©, boundary } clip := n.clip + var err error + if c.Boundary != "" { + clip, err = renderer.geometryExtentClip(box.W, box.H, boundary, clip) + if err != nil { + return nil, err + } + } + clip, err = renderer.compositeExtentClip(c, matrix, clip) + if err != nil { + return nil, err + } if clips != nil { geometry, err := renderer.Geometry() if err != nil { return nil, err } - clip, err = geometry.Clip(renderer, clips, clipMatrix, n.clip) + clip, err = geometry.Clip(renderer, clips, clipMatrix, clip) if err != nil { return nil, err } @@ -1043,6 +1054,11 @@ func editorResourceReferences(data []byte) ([]string, error) { var collect func(*editorXML) collect = func(node *editorXML) { if node.name.Space == "" || node.name.Space == ofdNamespace || node.name.Space == "http://www.ofdspec.org" { + if node.name.Local == "Thumbnail" || node.name.Local == "Substitution" { + if id := editorResourceID(editorImportText(data, node)); id != "" { + refs[id] = true + } + } for _, attr := range node.attrs { if attr.Name.Space == "" && editorObjectReference(attr.Name.Local) { refs[attr.Value] = true @@ -1062,7 +1078,53 @@ func editorResourceReferences(data []byte) ([]string, error) { func collectCompositeReferences(composite CompositeGraphicUnit, used map[string]bool) { used[composite.ResourceID] = true used[composite.DrawParam] = true - for _, object := range composite.Objects { + used[composite.Thumbnail] = true + used[composite.Substitution] = true + for object := range composite.objects() { collectObjectReferences(object, used) } } + +// validateVector 校验共享矢量资源及嵌套绘制引用,不修改资源内容 +// 入参: id 矢量资源标识 +// 返回: error 尺寸、内容或引用错误 +func (v *editorValidation) validateVector(id string) error { + if v.composites[id] { + return fmt.Errorf("cyclic composite resource %q", id) + } + if v.composites == nil { + v.composites = make(map[string]bool) + v.vectors = make(map[string]*editorCompositeNode) + } + v.composites[id] = true + defer delete(v.composites, id) + node := v.vectors[id] + if node == nil { + var err error + node, err = v.compositeDefinition(id) + if err != nil { + return err + } + v.vectors[id] = node + } + unit := node.object.CompositeGraphicUnit + if node.node.attr("Width") == "" || node.node.attr("Height") == "" || !finite(unit.Width) || !finite(unit.Height) || unit.Width < 0 || unit.Height < 0 { + return fmt.Errorf("invalid vector dimensions") + } + if node.node.child("Content") == nil { + return fmt.Errorf("vector content is missing") + } + for _, image := range []string{unit.Thumbnail, unit.Substitution} { + if image != "" { + if _, err := v.editorImage(image); err != nil { + return err + } + } + } + for object := range unit.objects() { + if _, err := v.prepareObject(editorObjectID(object), object); err != nil { + return err + } + } + return nil +} diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index c26c9ab..68bf9ae 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -231,7 +231,14 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu if source.object.Type != "PathObject" || editReason(styleErr) != EditUnsupportedColor { return styleErr } - copy, err = e.wrapCompositePath(copy) + copy.setStates(source.states) + copy.defaults = source.defaults + if !source.boundaryInCTM { + if err := copy.convertCoordinates(source.parent, true); err != nil { + return err + } + } + copy, err = e.wrapCompositePath(renderer, copy) if err != nil { return err } @@ -260,7 +267,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu if err := copy.update(object); err != nil { return err } - if !source.boundaryInCTM { + if !source.boundaryInCTM && styleErr == nil { if err := copy.convertCoordinates(source.parent, true); err != nil { return err } diff --git a/ofdgo_editor_composite_group.go b/ofdgo_editor_composite_group.go index 13cb06f..efc8be7 100644 --- a/ofdgo_editor_composite_group.go +++ b/ofdgo_editor_composite_group.go @@ -16,13 +16,15 @@ package ofdgo import ( "bytes" + "crypto/sha256" "encoding/xml" "fmt" "maps" "slices" + "strconv" ) -// GroupCompositeObjects 组合内部同一容器的连续成员,保留标识、绘制顺序及共享资源 +// GroupCompositeObjects 组合内部同一容器的连续成员,保留绘制顺序及共享资源 // 入参: page 页面索引, path 父路径, indexes 至少两个成员序号 // 返回: int 新组合序号, error 错误信息 func (e *Editor) GroupCompositeObjects(page int, path ObjectPath, indexes []int) (int, error) { @@ -30,7 +32,7 @@ func (e *Editor) GroupCompositeObjects(page int, path ObjectPath, indexes []int) return 0, fmt.Errorf("grouping requires at least two objects") } var result int - err := e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { for _, member := range members { if !member.Capabilities.Copy || !member.Capabilities.Order { return fmt.Errorf("composite member cannot be grouped") @@ -39,25 +41,32 @@ func (e *Editor) GroupCompositeObjects(page int, path ObjectPath, indexes []int) slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index }) siblings := nodes[0].siblings() first := slices.Index(siblings, nodes[0]) - var content []byte + copies := make([]*editorCompositeNode, len(nodes)) for i, node := range nodes { if first+i >= len(siblings) || siblings[first+i] != node { return fmt.Errorf("grouping requires consecutive members in the same container") } - content = append(content, bytes.TrimPrefix(node.data, []byte(xml.Header))...) + copy, err := newEditorCompositeNode(node.data) + if err != nil { + return err + } + copy.setStates(node.states) + copy.defaults = node.defaults + if !node.boundaryInCTM { + if err := copy.convertCoordinates(node.parent, true); err != nil { + return err + } + } + copies[i] = copy } - content, err := editorXMLContainer("Content", nil, content) - if err != nil { - return err - } - content, err = editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: e.nextID()}, {Name: xml.Name{Local: "Boundary"}, Value: "0 0 1 1"}}, bytes.TrimPrefix(content, []byte(xml.Header))) + group, err := e.vectorInstance(renderer, copies) if err != nil { return err } for i, node := range nodes { var replacement []byte if i == 0 { - replacement = bytes.TrimPrefix(content, []byte(xml.Header)) + replacement = bytes.TrimPrefix(group.data, []byte(xml.Header)) } node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, replacement}) } @@ -67,6 +76,74 @@ func (e *Editor) GroupCompositeObjects(page int, path ObjectPath, indexes []int) return result, err } +// vectorInstance 将同一坐标系的内容封装为标准矢量资源,独立分配内部标识 +// 入参: renderer 资源度量器, nodes 局部内容节点 +// 返回: *editorCompositeNode 资源实例, error 尺寸、内容或资源错误 +func (e *Editor) vectorInstance(renderer *Renderer, nodes []*editorCompositeNode) (*editorCompositeNode, error) { + if err := e.prepareSourceIDs(); err != nil { + return nil, err + } + var extent Box + for _, node := range nodes { + boundary, _ := editorGeometry(node.object) + box, err := creationBox(boundary) + if err != nil { + return nil, err + } + visible, err := renderer.MeasureObject(node.object, MeasureOptions{Defaults: node.defaults}) + if err != nil { + return nil, err + } + extent = unionTextBox(extent, unionTextBox(box, visible.Bounds)) + } + if !finite(extent.X) || !finite(extent.Y) || !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 { + return nil, fmt.Errorf("invalid vector dimensions") + } + var content []byte + states := make(map[string]editorCompositeState) + for _, node := range nodes { + object := transformEditorMatrix(cloneEditorData(node.object), TranslationMatrix(-extent.X, -extent.Y)) + data, err := editorXMLObject(node.data, node.node, node.object, object) + if err != nil { + return nil, err + } + content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) + maps.Copy(states, node.states) + } + content, err := editorXMLContainer("Content", nil, content) + if err != nil { + return nil, err + } + hash := sha256.New() + hash.Write([]byte(ofdNumber(extent.W) + " " + ofdNumber(extent.H))) + hash.Write(content) + key := [32]byte(hash.Sum(nil)) + var resource string + for _, candidate := range e.resources { + if candidate.vectorKey == key && maps.Equal(candidate.vectorStates, states) { + resource = candidate.composite + break + } + } + if resource == "" { + data, err := editorXMLContainer("CompositeGraphicUnit", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: strconv.Itoa(e.maxID + 1)}, {Name: xml.Name{Local: "Width"}, Value: ofdNumber(extent.W)}, {Name: xml.Name{Local: "Height"}, Value: ofdNumber(extent.H)}}, bytes.TrimPrefix(content, []byte(xml.Header))) + if err != nil { + return nil, err + } + resource, err = e.addCompositeResource(data, states) + if err != nil { + return nil, err + } + added := &e.resources[len(e.resources)-1] + added.vectorKey, added.vectorStates = key, maps.Clone(states) + } + data, err := editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: e.nextID()}, {Name: xml.Name{Local: "Boundary"}, Value: editorBoxString(extent)}, {Name: xml.Name{Local: "ResourceID"}, Value: resource}}, nil) + if err != nil { + return nil, err + } + return newEditorCompositeNode(data) +} + // UngroupCompositeObject 展开内部组合,隔离引用资源并保留父容器的样式和裁剪 // 入参: page 页面索引, path 父路径, index 组合序号 // 返回: []int 展开后的成员序号, error 错误信息 diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index 11817b6..f116b3f 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -93,11 +93,12 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return err } if node.object.Type == "PathObject" { + node.defaults = renderer.drawParamDefaults(e.copiedLayerStyle(source), nil) style, styleErr := e.resolveEditorStyle(node.object, e.copiedLayerStyle(source)) _, fillErr := e.Color(style.PathObject.FillColor) _, strokeErr := e.Color((*FillColor)(style.PathObject.StrokeColor)) if styleErr != nil && editReason(styleErr) == EditUnsupportedColor || fillErr != nil || strokeErr != nil { - node, err = e.wrapCompositePath(node) + node, err = e.wrapCompositePath(renderer, node) if err != nil { return err } @@ -166,19 +167,11 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return result, nil } -// wrapCompositePath 通过独立容器变换复杂路径,保持渐变坐标与原始颜色节点 -// 入参: node 复杂路径 +// wrapCompositePath 通过标准矢量实例变换复杂路径,保持渐变坐标与原始颜色节点 +// 入参: renderer 资源度量器, node 复杂路径 // 返回: *editorCompositeNode 包装节点, error 错误信息 -func (e *Editor) wrapCompositePath(node *editorCompositeNode) (*editorCompositeNode, error) { - content, err := editorXMLContainer("Content", nil, bytes.TrimPrefix(node.data, []byte(xml.Header))) - if err != nil { - return nil, err - } - data, err := editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: e.nextID()}, {Name: xml.Name{Local: "Boundary"}, Value: "0 0 1 1"}}, bytes.TrimPrefix(content, []byte(xml.Header))) - if err != nil { - return nil, err - } - return newEditorCompositeNode(data) +func (e *Editor) wrapCompositePath(renderer *Renderer, node *editorCompositeNode) (*editorCompositeNode, error) { + return e.vectorInstance(renderer, []*editorCompositeNode{node}) } // isolateCompositeStyle 固定对象的绘制参数,避免目标容器改变颜色、虚线和线帽 diff --git a/ofdgo_editor_copy.go b/ofdgo_editor_copy.go index 82e045c..1b29758 100644 --- a/ofdgo_editor_copy.go +++ b/ofdgo_editor_copy.go @@ -180,7 +180,7 @@ func editorXMLRemapIDs(data []byte, root *editorXML, ids map[string]string) ([]b } patches = append(patches, editorXMLPatch{node.start, node.open, value}) } - if node.name.Local == "Font" || node.name.Local == "Substitution" { + if node.name.Local == "Font" || node.name.Local == "Substitution" || node.name.Local == "Thumbnail" { if value := ids[editorResourceID(editorImportText(data, node))]; value != "" { patches = append(patches, editorXMLContent(data, node, []byte(value))) } diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 67b8e1c..9a4a9d5 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -49,8 +49,14 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) { if err != nil { return "", err } - data := parsed.Data - key := editorResourceKey{checksum: sha256.Sum256(data)} + return e.addFontResource(parsed, sha256.Sum256(parsed.Data)) +} + +// addFontResource 注册已解析的只读字体,各文档独立分配标识和包内路径 +// 入参: parsed 字体资源, checksum 字体数据摘要 +// 返回: string 字体标识, error 标识准备错误 +func (e *Editor) addFontResource(parsed *FontResource, checksum [32]byte) (string, error) { + key := editorResourceKey{checksum: checksum} if id, ok := e.resourceID[key]; ok { return id, nil } @@ -62,7 +68,7 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) { definition.ID = id resource := editorResource{ name: e.packageName("Res/Fonts/Font_" + id + parsed.Extension), - data: data, + data: parsed.Data, font: &definition, } resource.font.FontFile = "/" + resource.name diff --git a/ofdgo_editor_group.go b/ofdgo_editor_group.go index 59af34f..29df555 100644 --- a/ofdgo_editor_group.go +++ b/ofdgo_editor_group.go @@ -26,7 +26,7 @@ import ( // GroupObjects 将同一容器内连续的对象组合,保留标识、绘制顺序及原始内容 // 入参: page 页面索引, ids 至少两个对象标识 // 返回: string 组合标识, error 错误信息 -func (e *Editor) GroupObjects(page int, ids []string) (string, error) { +func (e *Editor) GroupObjects(page int, ids []string) (id string, err error) { _, indexes, err := e.selectedObjects(page, ids) if err != nil { return "", err @@ -48,11 +48,32 @@ func (e *Editor) GroupObjects(page int, ids []string) (string, error) { return "", fmt.Errorf("grouping requires consecutive objects in the same container") } } + reader, err := e.Reader() + if err != nil { + return "", err + } + defer reader.Close() + renderer := e.newRenderer(reader) + var extent Box + for _, index := range indexes { + object := layer.Objects[index.index] + boundary, _ := editorGeometry(object) + box, err := creationBox(boundary) + if err != nil { + return "", err + } + visible, err := renderer.ObjectBounds(object, layer.DrawParam) + if err != nil { + return "", err + } + extent = unionTextBox(extent, unionTextBox(box, visible)) + } var content []byte var source *editorSourcePage states := make(map[string]editorCompositeState) for _, index := range indexes { - object := layer.Objects[index.index] + before := layer.Objects[index.index] + object := transformEditorMatrix(cloneEditorData(before), TranslationMatrix(-extent.X, -extent.Y)) var data []byte if origin := e.objectOrigin(editorObjectID(object)); origin != nil { source = origin.page @@ -79,12 +100,36 @@ func (e *Editor) GroupObjects(page int, ids []string) (string, error) { if err := e.prepareSourceIDs(); err != nil { return "", err } - id := strconv.Itoa(e.maxID + 1) + maxID, resourceCount := e.maxID, len(e.resources) + defer func() { + if err != nil { + e.maxID, e.resources = maxID, e.resources[:resourceCount] + } + }() + id, resourceID := e.nextID(), e.nextID() content, err = editorXMLContainer("Content", nil, content) if err != nil { return "", err } - data, err := editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Boundary"}, Value: "0 0 1 1"}}, bytes.TrimPrefix(content, []byte(xml.Header))) + data, err := editorXMLContainer("CompositeGraphicUnit", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: resourceID}, {Name: xml.Name{Local: "Width"}, Value: ofdNumber(extent.W)}, {Name: xml.Name{Local: "Height"}, Value: ofdNumber(extent.H)}}, bytes.TrimPrefix(content, []byte(xml.Header))) + if err != nil { + return "", err + } + data, err = editorXMLContainer("CompositeGraphicUnits", nil, bytes.TrimPrefix(data, []byte(xml.Header))) + if err != nil { + return "", err + } + data, err = editorXMLContainer("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}, bytes.TrimPrefix(data, []byte(xml.Header))) + if err != nil { + return "", err + } + resource, err := e.compositeResource(resourceID, data) + if err != nil { + return "", err + } + resource.states = states + e.resources = append(e.resources, resource) + data, err = editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Boundary"}, Value: editorBoxString(extent)}, {Name: xml.Name{Local: "ResourceID"}, Value: resourceID}}, nil) if err != nil { return "", err } @@ -98,7 +143,6 @@ func (e *Editor) GroupObjects(page int, ids []string) (string, error) { group.node.parent = parent group.object.CompositeGraphicUnit.states = states origin := &editorObjectOrigin{page: source, data: data, node: group.node, object: group.object} - e.maxID++ e.replaceGroup(page, indexes, []GraphicObject{group.object}, map[string]*editorObjectOrigin{id: origin}) return id, nil } @@ -148,16 +192,34 @@ func editorUngroupable(node *editorXML) bool { if !editorXMLAttributes(node, "ID Boundary CTM DrawParam Alpha Visible ResourceID Width Height") { return false } - for _, child := range node.children { - switch child.name.Local { - case "Content": - if !editorXMLAttributes(child, "") { + var contentSupported func(*editorXML) bool + contentSupported = func(content *editorXML) bool { + attributes := "" + if content.name.Local == "PageBlock" { + attributes = "ID" + } + if !editorXMLAttributes(content, attributes) { + return false + } + for _, member := range content.children { + if member.name.Space != "" && member.name.Space != ofdNamespace && member.name.Space != "http://www.ofdspec.org" { return false } - for _, member := range child.children { - if member.name.Space != "" && member.name.Space != ofdNamespace && member.name.Space != "http://www.ofdspec.org" || !slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit"}, member.name.Local) { + if member.name.Local == "PageBlock" { + if !contentSupported(member) { return false } + } else if !slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit"}, member.name.Local) { + return false + } + } + return true + } + for _, child := range node.children { + switch child.name.Local { + case "Content", "PageBlock": + if !contentSupported(child) { + return false } case "Clips": if !editorXMLSupported(child) { diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index f5938ff..c3f6e45 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -1077,7 +1077,7 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b candidate := path.Join(m.directory, "Annotations", "Page_"+m.id(node.parent.attr("PageID"))+".xml") value, err = m.copyXML(loc, candidate) } - case "Font", "Substitution": + case "Font", "Substitution", "Thumbnail": value, err = m.reference(strings.TrimSpace(value)) } if err != nil { diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index e77558c..877cf9b 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -702,6 +702,13 @@ func editorStrokeScale(ctm string) float64 { // 入参: border 边框 // 返回: error 错误信息 func (e *Editor) validateImageBorder(border *ImageBorder) error { + return (&editorValidation{Editor: e}).validateImageBorder(border) +} + +// validateImageBorder 沿当前颜色引用路径校验图片边框 +// 入参: border 边框 +// 返回: error 错误信息 +func (v *editorValidation) validateImageBorder(border *ImageBorder) error { if border == nil { return nil } @@ -717,7 +724,7 @@ func (e *Editor) validateImageBorder(border *ImageBorder) error { if err := validateEditorStroke(stroke); err != nil { return err } - return e.editorColor((*FillColor)(border.BorderColor)) + return v.editorColor((*FillColor)(border.BorderColor)) } // validateEditorStroke 校验路径描边尺寸、端点和虚线 diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index a3f60dd..a82184c 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -421,8 +421,8 @@ func (e *Editor) transformBorderedImage(object GraphicObject, matrix Matrix, ori return transformEditorMatrix(object, matrix), nil } -// transformEditorMatrix 在页面坐标中变换基本对象,保留局部绘制数据和裁剪 -// 入参: object 对象, matrix 页面变换 +// transformEditorMatrix 在所在坐标系中变换对象,保留局部绘制数据和裁剪 +// 入参: object 对象, matrix 所在坐标系中的变换 // 返回: GraphicObject 变换后的独立对象 func transformEditorMatrix(object GraphicObject, matrix Matrix) GraphicObject { var boundary, ctm *string @@ -447,6 +447,30 @@ func transformEditorMatrix(object GraphicObject, matrix Matrix) GraphicObject { if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag { *clips = transformObjectClips(*clips, local) } + if !axisAlignedMatrix(matrix) && before.W > 0 && before.H > 0 { + clips := editorObjectClips(&object) + if *clips == nil { + *clips = &Clips{} + } else { + copy := **clips + copy.Clip = slices.Clone(copy.Clip) + *clips = © + } + clipMatrix := local + if (*clips).TransFlag == nil || *(*clips).TransFlag { + if inverse, ok := NewMatrix(*ctm).Invert(); ok { + clipMatrix = inverse.Multiply(local) + } else { + *clips = transformObjectClips(*clips, NewMatrix(*ctm)) + flag := false + (*clips).TransFlag = &flag + } + } + fill, stroke := true, false + path := PathObject{Boundary: editorBoxString(Box{W: before.W, H: before.H}), Fill: &fill, Stroke: &stroke, + AbbreviatedData: "M 0 0 L " + ofdNumber(before.W) + " 0 L " + ofdNumber(before.W) + " " + ofdNumber(before.H) + " L 0 " + ofdNumber(before.H) + " C"} + (*clips).Clip = append((*clips).Clip, Clip{Area: []ClipArea{{CTM: clipMatrix.String(), Path: []PathObject{path}}}}) + } return object } diff --git a/ofdgo_editor_watermark.go b/ofdgo_editor_watermark.go index 1b1c456..94d61f1 100644 --- a/ofdgo_editor_watermark.go +++ b/ofdgo_editor_watermark.go @@ -81,10 +81,16 @@ func (e *Editor) AddWatermarks(ctx context.Context, indexes []int, object Graphi if _, err := creationNumbers(*ctm, 6); err != nil { return nil, err } - } else if object.Type == "ImageObject" { - *ctm = (Matrix{a: box.W, d: box.H}).String() } - *ctm = rotation.Multiply(NewMatrix(*ctm)).String() + object = transformEditorMatrix(object, rotation) + if object.Type == "TextObject" { + boundary = &object.TextObject.Boundary + } else { + boundary = &object.ImageObject.Boundary + } + extent, _ := ParseBox(*boundary) + extent.X, extent.Y = 0, 0 + *boundary = editorBoxString(extent) preview := e.transactionSnapshot() preview.SetHistoryLimit(0) id, err := preview.AddObject(indexes[0], object) @@ -169,13 +175,15 @@ func layoutWatermark(ctx context.Context, base GraphicObject, bounds, box Box, o } if scale != 1 { matrix := Matrix{a: scale, d: scale} - if base.Type == "TextObject" { - base.TextObject.CTM = matrix.Multiply(NewMatrix(base.TextObject.CTM)).String() - } else { - base.ImageObject.CTM = matrix.Multiply(NewMatrix(base.ImageObject.CTM)).String() - } + base = transformEditorMatrix(base, matrix) bounds = matrix.TransformBox(bounds) } + var extent Box + if base.Type == "TextObject" { + extent, _ = ParseBox(base.TextObject.Boundary) + } else { + extent, _ = ParseBox(base.ImageObject.Boundary) + } columns, rows := 1.0, 1.0 if options.Tile { angle := math.Mod(options.Angle, 360) * math.Pi / 180 @@ -199,7 +207,7 @@ func layoutWatermark(ctx context.Context, base GraphicObject, bounds, box Box, o x := (float64(column)+0.5)*area.W/columns - bounds.X - bounds.W/2 y := (float64(row)+0.5)*area.H/rows - bounds.Y - bounds.H/2 object := base - boundary := editorBoxString(Box{X: x, Y: y, W: box.W, H: box.H}) + boundary := editorBoxString(Box{X: x, Y: y, W: extent.W, H: extent.H}) if object.Type == "TextObject" { object.TextObject.Boundary = boundary } else { diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go index 8ece160..23c8ec4 100644 --- a/ofdgo_font_backend.go +++ b/ofdgo_font_backend.go @@ -205,7 +205,7 @@ func (r *Renderer) resetFontBackendCache() { r.fontSourcesCache.fallback = ResolvedFont{} r.fontSourcesCache.fallbackRead = false } - r.fontMetrics = renderCache[[32]byte, FontMetrics]{limit: 32 << 20} + r.fontMetrics = &renderCache[[32]byte, FontMetrics]{limit: 32 << 20} r.glyphOutlines = renderCache[glyphOutlineKey, GeometryPath]{limit: 16 << 20} for _, state := range r.backendStates { if fonts, ok := state.(interface{ resetFonts() }); ok { diff --git a/ofdgo_font_hint.go b/ofdgo_font_hint.go index 9cf9d65..ab654d1 100644 --- a/ofdgo_font_hint.go +++ b/ofdgo_font_hint.go @@ -177,13 +177,15 @@ func ttProject(x, y int32, vector [2]float64) int32 { // 入参: point 点, distance 投影距离, touch 是否标记触及状态 func (v *ttInterpreter) move(point *ttPoint, distance int32, touch bool) { g := &v.graphics - dot := g.freedom[0]*g.projection[0] + g.freedom[1]*g.projection[1] - if math.Abs(dot) < 1.0/16384 { - v.err = fmt.Errorf("perpendicular TrueType movement vectors") - return + if distance != 0 { + dot := g.freedom[0]*g.projection[0] + g.freedom[1]*g.projection[1] + if math.Abs(dot) < 1.0/16384 { + v.err = fmt.Errorf("perpendicular TrueType movement vectors") + return + } + point.x += int32(math.Round(float64(distance) * g.freedom[0] / dot)) + point.y += int32(math.Round(float64(distance) * g.freedom[1] / dot)) } - point.x += int32(math.Round(float64(distance) * g.freedom[0] / dot)) - point.y += int32(math.Round(float64(distance) * g.freedom[1] / dot)) if touch { point.tx = point.tx || g.freedom[0] != 0 point.ty = point.ty || g.freedom[1] != 0 diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index 2db0d38..0926067 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -66,6 +66,12 @@ type fontUsagePage struct { usage map[string]int } +// fontUsageReferences 保存字体统计中的当前引用路径,不合并共享内容的用量 +type fontUsageReferences struct { + composites map[string]bool + patterns map[*Pattern]bool +} + // Fonts 获取OFD声明的字体列表 // 返回: []Font 字体列表, error 错误信息 func (r *Reader) Fonts() ([]Font, error) { @@ -492,8 +498,8 @@ func (r *Renderer) countLayerFonts(layer Layer, usage map[string]int) { } // countObjectFonts 统计图元字体使用次数 -// 入参: obj 图元对象, usage 字体使用次数, defaults 默认绘制参数, visited 已访问资源 -func (r *Renderer) countObjectFonts(obj GraphicObject, usage map[string]int, defaults *DrawParam, visited map[string]bool) { +// 入参: obj 图元对象, usage 字体使用次数, defaults 默认绘制参数, visited 当前引用路径 +func (r *Renderer) countObjectFonts(obj GraphicObject, usage map[string]int, defaults *DrawParam, visited *fontUsageReferences) { var fill *FillColor var stroke *StrokeColor var shouldFill, shouldStroke bool @@ -539,50 +545,49 @@ func (r *Renderer) countObjectFonts(obj GraphicObject, usage map[string]int, def } // countCompositeFonts 统计复合图元字体使用次数 -// 入参: cgu 复合图元, usage 字体使用次数, defaults 默认绘制参数, visited 已访问资源 -func (r *Renderer) countCompositeFonts(cgu CompositeGraphicUnit, usage map[string]int, defaults *DrawParam, visited map[string]bool) { +// 入参: cgu 复合图元, usage 字体使用次数, defaults 默认绘制参数, visited 当前引用路径 +func (r *Renderer) countCompositeFonts(cgu CompositeGraphicUnit, usage map[string]int, defaults *DrawParam, visited *fontUsageReferences) { r.countClipFonts(cgu.Clips, usage) defaults = r.drawParamDefaults(cgu.DrawParam, defaults) if cgu.ResourceID != "" { if visited == nil { - visited = make(map[string]bool) + visited = &fontUsageReferences{} } - if visited[cgu.ResourceID] { + if visited.composites[cgu.ResourceID] { return } - visited[cgu.ResourceID] = true + if visited.composites == nil { + visited.composites = make(map[string]bool) + } + visited.composites[cgu.ResourceID] = true if ref := r.CompositeGraphicUnits[cgu.ResourceID]; ref != nil { r.countCompositeFonts(*ref, usage, defaults, visited) } - defer delete(visited, cgu.ResourceID) + defer delete(visited.composites, cgu.ResourceID) } - if len(cgu.Objects) > 0 { - for _, obj := range cgu.Objects { - r.countObjectFonts(obj, usage, defaults, visited) - } - return - } - for _, text := range cgu.TextObject { - r.countObjectFonts(GraphicObject{Type: "TextObject", TextObject: text}, usage, defaults, visited) - } - for _, path := range cgu.PathObject { - r.countObjectFonts(GraphicObject{Type: "PathObject", PathObject: path}, usage, defaults, visited) - } - for _, image := range cgu.ImageObject { - r.countObjectFonts(GraphicObject{Type: "ImageObject", ImageObject: image}, usage, defaults, visited) - } - for _, sub := range cgu.CompositeGraphicUnit { - r.countCompositeFonts(sub, usage, defaults, visited) + for object := range cgu.objects() { + r.countObjectFonts(object, usage, defaults, visited) } } // countPatternFonts 统计底纹单元字体使用次数 -// 入参: fill 填充或描边颜色, usage 字体使用次数, visited 已访问资源 -func (r *Renderer) countPatternFonts(fill *FillColor, usage map[string]int, visited map[string]bool) { +// 入参: fill 填充或描边颜色, usage 字体使用次数, visited 当前引用路径 +func (r *Renderer) countPatternFonts(fill *FillColor, usage map[string]int, visited *fontUsageReferences) { if fill == nil || fill.Pattern == nil { return } - for _, obj := range fill.Pattern.CellContent.Objects { + if visited == nil { + visited = &fontUsageReferences{} + } + if visited.patterns[fill.Pattern] { + return + } + if visited.patterns == nil { + visited.patterns = make(map[*Pattern]bool) + } + visited.patterns[fill.Pattern] = true + defer delete(visited.patterns, fill.Pattern) + for obj := range fill.Pattern.CellContent.objects() { r.countObjectFonts(obj, usage, nil, visited) } } diff --git a/ofdgo_geom_backend.go b/ofdgo_geom_backend.go index d061589..eefbb9e 100644 --- a/ofdgo_geom_backend.go +++ b/ofdgo_geom_backend.go @@ -145,11 +145,74 @@ func geometryRectangleBounds(path GeometryPath) (Box, bool) { return Box{}, false } a, b, c, d := path[0].End, path[1].End, path[2].End, path[3].End + for _, point := range [...]Point{a, b, c, d} { + if !finite(point.X) || !finite(point.Y) { + return Box{}, false + } + } if a.X == c.X || a.Y == c.Y { return Box{}, false } if !(a.X == b.X && b.Y == c.Y && c.X == d.X && d.Y == a.Y || a.Y == b.Y && b.X == c.X && c.Y == d.Y && d.X == a.X) { return Box{}, false } - return Box{X: min(a.X, c.X), Y: min(a.Y, c.Y), W: math.Abs(c.X - a.X), H: math.Abs(c.Y - a.Y)}, true + width, height := math.Abs(c.X-a.X), math.Abs(c.Y-a.Y) + if !finite(width) || !finite(height) { + return Box{}, false + } + return Box{X: min(a.X, c.X), Y: min(a.Y, c.Y), W: width, H: height}, true +} + +// clipGeometry 将非零填充路径与页面裁剪相交 +// 入参: geometry 几何后端, path 页面路径, clip 页面裁剪 +// 返回: GeometryPath 裁剪路径, error 几何错误 +func clipGeometry(geometry GeometryBackend, path GeometryPath, clip *GeometryPath) (GeometryPath, error) { + if clip == nil { + return path, nil + } + if len(*clip) == 0 { + return nil, nil + } + if rect, ok := geometryRectangleBounds(*clip); ok { + if bounds, ok := geometryRectangleBounds(path); ok { + left, top := max(rect.X, bounds.X), max(rect.Y, bounds.Y) + right, bottom := min(rect.X+rect.W, bounds.X+bounds.W), min(rect.Y+rect.H, bounds.Y+bounds.H) + if right <= left || bottom <= top { + return nil, nil + } + return geometryRectangle(Box{X: left, Y: top, W: right - left, H: bottom - top}), nil + } + if err := path.validate(); err != nil { + return nil, err + } + contained := true + for _, segment := range path { + if segment.Verb == GeometryArc { + contained = false + break + } + points := [3]Point{segment.End, segment.Control1, segment.Control2} + count := 1 + if segment.Verb == GeometryQuad { + count = 2 + } else if segment.Verb == GeometryCubic { + count = 3 + } else if segment.Verb == GeometryClose { + continue + } + for _, point := range points[:count] { + if point.X < rect.X || point.Y < rect.Y || point.X > rect.X+rect.W || point.Y > rect.Y+rect.H { + contained = false + break + } + } + if !contained { + break + } + } + if contained { + return path, nil + } + } + return geometry.Combine(path, *clip, GeometryIntersect) } diff --git a/ofdgo_geom_contain.go b/ofdgo_geom_contain.go new file mode 100644 index 0000000..7543a46 --- /dev/null +++ b/ofdgo_geom_contain.go @@ -0,0 +1,85 @@ +// 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 "math" + +// geometryConvexContains 判断单个非退化凸直线区域是否包含另一闭合直线区域 +// 入参: outer 外部区域, inner 内部区域 +// 返回: bool 是否完全包含,曲线及多轮廓返回false +func geometryConvexContains(outer, inner GeometryPath) bool { + a, b := geometryPolygonPoints(outer), geometryPolygonPoints(inner) + if len(a) < 3 || len(b) < 3 { + return false + } + nondegenerate := false + for i, start := range a { + end := a[(i+1)%len(a)] + dx, dy := end.X-start.X, end.Y-start.Y + length := math.Hypot(dx, dy) + if !finite(length) { + return false + } + if length == 0 { + continue + } + dx, dy = dx/length, dy/length + distance := func(point Point) float64 { + x, y := point.X-start.X, point.Y-start.Y + value := math.FMA(dx, y, -dy*x) + bound := (math.Abs(dx*y) + math.Abs(dy*x) + math.Abs(dx)*(math.Abs(point.Y)+math.Abs(start.Y)) + math.Abs(dy)*(math.Abs(point.X)+math.Abs(start.X))) * 0x1p-49 + if finite(bound) && math.Abs(value) <= bound { + return 0 + } + return value + } + sign := 0.0 + for _, point := range a { + value := distance(point) + if !finite(value) || sign*value < 0 { + return false + } + if value != 0 { + sign, nondegenerate = math.Copysign(1, value), true + } + } + for _, point := range b { + value := distance(point) + if !finite(value) || sign*value < 0 { + return false + } + } + } + return nondegenerate +} + +// geometryPolygonPoints 读取单个显式闭合直线轮廓的顶点,不修改原路径 +// 入参: path 直线轮廓 +// 返回: []Point 独立顶点,其他路径返回nil +func geometryPolygonPoints(path GeometryPath) []Point { + if len(path) < 4 || path[0].Verb != GeometryMove || path[len(path)-1].Verb != GeometryClose { + return nil + } + for i, segment := range path[:len(path)-1] { + if i > 0 && segment.Verb != GeometryLine || !finite(segment.End.X) || !finite(segment.End.Y) { + return nil + } + } + points := make([]Point, len(path)-1) + for i := range points { + points[i] = path[i].End + } + return points +} diff --git a/ofdgo_geom_stroke.go b/ofdgo_geom_stroke.go index 15ebf79..f574ca5 100644 --- a/ofdgo_geom_stroke.go +++ b/ofdgo_geom_stroke.go @@ -216,9 +216,10 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g remaining := pattern[index] - phase var result []geometryPolyline var run []Point + var direction Point flush := func() { if len(run) != 0 { - result = append(result, geometryPolyline{points: run}) + result = append(result, geometryPolyline{points: run, direction: direction}) run = nil } } @@ -255,8 +256,12 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g if index%2 == 0 { if len(run) == 0 { run = append(run, geometryLerp(a, b, at/length)) + direction = Point{(b.X - a.X) / length, (b.Y - a.Y) / length} + } + end := geometryLerp(a, b, (at+step)/length) + if end != run[len(run)-1] { + run = append(run, end) } - run = append(run, geometryLerp(a, b, (at+step)/length)) } at += step remaining -= step diff --git a/ofdgo_geom_transform.go b/ofdgo_geom_transform.go index 7ad17ed..5592684 100644 --- a/ofdgo_geom_transform.go +++ b/ofdgo_geom_transform.go @@ -129,28 +129,29 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) { func (p GeometryPath) validate() error { started := false for _, s := range p { - points := []Point{s.End} + points := [3]Point{s.End, s.Control1, s.Control2} + count := 1 switch s.Verb { case GeometryMove: started = true case GeometryLine: case GeometryQuad: - points = append(points, s.Control1) + count = 2 case GeometryCubic: - points = append(points, s.Control1, s.Control2) + count = 3 case GeometryArc: if !finite(s.RadiusX) || !finite(s.RadiusY) || !finite(s.Rotation) || s.RadiusX < 0 || s.RadiusY < 0 { return fmt.Errorf("invalid arc parameters") } case GeometryClose: - points = nil + count = 0 default: return fmt.Errorf("unsupported geometry command %d", s.Verb) } if !started { return fmt.Errorf("geometry path must start with move") } - for _, point := range points { + for _, point := range points[:count] { if !finite(point.X) || !finite(point.Y) { return fmt.Errorf("non-finite geometry coordinate") } diff --git a/ofdgo_object.go b/ofdgo_object.go index fc2ff22..8abfdb4 100644 --- a/ofdgo_object.go +++ b/ofdgo_object.go @@ -16,7 +16,10 @@ package ofdgo import ( "encoding/xml" + "fmt" + "iter" "strconv" + "strings" ) // graphicObjectTarget 图形对象集合 @@ -144,6 +147,19 @@ func (c *CompositeGraphicUnit) UnmarshalXML(d *xml.Decoder, start xml.StartEleme c.Boundary = attrValue(start, "Boundary") c.CTM = attrValue(start, "CTM") c.DrawParam = attrValue(start, "DrawParam") + for _, dimension := range []struct { + name string + value *float64 + }{{"Width", &c.Width}, {"Height", &c.Height}} { + if value := attrValue(start, dimension.name); value != "" { + c.extentSet = true + var err error + *dimension.value, err = strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return fmt.Errorf("invalid vector %s: %w", dimension.name, err) + } + } + } if value := attrValue(start, "Alpha"); value != "" { if alpha, err := strconv.Atoi(value); err == nil { c.Alpha = &alpha @@ -170,10 +186,57 @@ func (a *Appearance) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 func (p *PatternContent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - *p = PatternContent{} + *p = PatternContent{Thumbnail: attrValue(start, "Thumbnail")} return decodeObjectContainer(d, start, p.decodeObject) } +// objects 按绘制顺序访问底纹单元,Objects优先于分类数组 +// 返回: iter.Seq[GraphicObject] 图形对象序列 +func (p PatternContent) objects() iter.Seq[GraphicObject] { + return func(yield func(GraphicObject) bool) { + if len(p.Objects) != 0 { + for _, object := range p.Objects { + if !yield(object) { + return + } + } + return + } + for _, object := range p.TextObject { + if !yield(GraphicObject{Type: "TextObject", TextObject: object}) { + return + } + } + for _, object := range p.PathObject { + if !yield(GraphicObject{Type: "PathObject", PathObject: object}) { + return + } + } + for _, object := range p.ImageObject { + if !yield(GraphicObject{Type: "ImageObject", ImageObject: object}) { + return + } + } + for _, object := range p.CompositeGraphicUnit { + if !yield(GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: object}) { + return + } + } + } +} + +// objects 按绘制顺序访问矢量内容,Objects优先于分类数组 +// 返回: iter.Seq[GraphicObject] 图形对象序列 +func (c CompositeGraphicUnit) objects() iter.Seq[GraphicObject] { + return (PatternContent{Objects: c.Objects, TextObject: c.TextObject, PathObject: c.PathObject, ImageObject: c.ImageObject, CompositeGraphicUnit: c.CompositeGraphicUnit}).objects() +} + +// empty 检查底纹单元是否没有绘制内容,缩略图不参与绘制 +// 返回: bool 是否为空 +func (p PatternContent) empty() bool { + return len(p.Objects) == 0 && len(p.TextObject) == 0 && len(p.PathObject) == 0 && len(p.ImageObject) == 0 && len(p.CompositeGraphicUnit) == 0 +} + // UnmarshalXML 解析渐变分段并保留位置的缺省状态 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 @@ -279,6 +342,16 @@ func (c *CompositeGraphicUnit) decodeObject(d *xml.Decoder, start xml.StartEleme return err } switch start.Name.Local { + case "Thumbnail", "Substitution": + var reference string + if err := d.DecodeElement(&reference, &start); err != nil { + return err + } + if start.Name.Local == "Thumbnail" { + c.Thumbnail = strings.TrimSpace(reference) + } else { + c.Substitution = strings.TrimSpace(reference) + } case "Clips": var clips Clips if err := d.DecodeElement(&clips, &start); err != nil { diff --git a/ofdgo_page.go b/ofdgo_page.go index c8a1e8a..d402be6 100644 --- a/ofdgo_page.go +++ b/ofdgo_page.go @@ -109,8 +109,8 @@ type TextObject struct { TextCode []TextCode `xml:"TextCode"` Clips *Clips `xml:"Clips"` Actions []Action `xml:"Actions>Action"` - LineWidthSet bool `xml:"-"` - layout *textLayout + LineWidthSet bool `xml:"-"` + layout *textLayout } // FillColor 填充颜色 @@ -140,7 +140,9 @@ type Pattern struct { } // PatternContent 图案单元内容 +// Objects非空时保留绘制顺序,否则依次使用文字、路径、图片和复合图元数组 type PatternContent struct { + Thumbnail string `xml:"Thumbnail,attr"` Objects []GraphicObject `xml:"-"` TextObject []TextObject `xml:"TextObject"` PathObject []PathObject `xml:"PathObject"` @@ -189,8 +191,8 @@ type PathObject struct { AbbreviatedData string `xml:"AbbreviatedData"` Clips *Clips `xml:"Clips"` Actions []Action `xml:"Actions>Action"` - LineWidthSet bool `xml:"-"` - dashPatternSet bool + LineWidthSet bool `xml:"-"` + dashPatternSet bool } // StrokeColor 勾边颜色 diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 87d3903..c5aee69 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -68,10 +68,8 @@ type pdfImporter struct { matrix pdfgo.Matrix page int fontIDs map[*pdfgo.Font]string - fontMetrics map[string]FontMetrics - fontRepairLimit map[*pdfgo.Font]uint16 + fonts map[*pdfgo.Font]*pdfImportedFont fontWarnings map[string]bool - type1Glyphs map[*pdfgo.Font]map[string]uint16 clipTexts map[*pdfgo.TextClip]TextObject cmykSpace string objects []GraphicObject @@ -90,8 +88,10 @@ type pdfImporter struct { compositeCache *pdfCompositeCache compositeNodes []pdfCompositeNode compositeSpace *pdfgo.ColorSpace + annotationData []byte resolveFile pdfgo.FileResolver halftoneWarnings map[*pdfgo.Halftone]bool + transferBackdrop bool } // ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果 @@ -132,7 +132,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI editor := NewEditor() editor.SetRenderBackends(renderer.Backends()) editor.fontDirs, editor.fontFS = renderer.FontSources() - importer := pdfImporter{ctx: ctx, reader: reader, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fontMetrics: map[string]FontMetrics{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} + importer := pdfImporter{ctx: ctx, reader: reader, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} importer.report.Warnings = diagnostics importer.pageIndexes = make(map[pdfgo.Reference]int) importer.resolveFile = options.ResolveFile @@ -188,6 +188,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.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) } @@ -197,6 +198,12 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI if err := importer.annotations(ctx, page, options.Strict); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } + if len(importer.annotationData) != 0 { + if err := editor.appendAnnotations(index, importer.annotationData); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) + } + importer.annotationData = nil + } if err := importer.commitObjects(); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } @@ -545,6 +552,9 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { // 入参: paint PDF画刷, box 对象边界 // 返回: *FillColor OFD颜色或渐变, error 图案转换错误 func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) { + if err := pdfGradientError(paint); err != nil { + return nil, err + } if paint.Mesh != nil { return p.meshColor(paint, box) } @@ -704,5 +714,6 @@ func pdfOpaqueBlack(paint pdfgo.Paint) bool { // 入参: paint PDF颜色与不透明度 // 返回: bool 是否需要保留分色语义 func pdfOverprintNeedsSeparation(paint pdfgo.Paint) bool { - return paint.CMYK != nil && !pdfOpaqueBlack(paint) + _, process := paint.Process.CMYKMask() + return (paint.CMYK != nil || process) && !pdfOpaqueBlack(paint) } diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index 1b37347..8699ca9 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -15,7 +15,9 @@ package ofdgo import ( + "bytes" "context" + "encoding/xml" "fmt" "maps" "strconv" @@ -42,7 +44,7 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page if err != nil { return err } - if annotation.Subtype == "FreeText" { + if annotation.Subtype == "FreeText" || annotation.Subtype == "Line" { annotation.Dictionary = maps.Clone(annotation.Dictionary) annotation.Dictionary["Contents"] = pdfgo.String("\xef\xbb\xbf" + remark) } @@ -101,8 +103,12 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page } if flags&(2|32) == 0 { p.compositeNodes = stamp.compositeNodes + p.transferBackdrop = stamp.transferBackdrop } converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Remark: remark, Appearance: Appearance{Boundary: pdfBoundary(box), Objects: stamp.objects}} + if annotation.Subtype == "Redact" { + converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Redact.Pending", Value: "true"}}} + } if annotation.Subtype == "Text" { value, err := p.reader.Resolve(annotation.Dictionary["Open"]) if err != nil { @@ -159,9 +165,11 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page } converted.Parameters.Parameter = append(converted.Parameters.Parameter, AnnotationParameter{Name: "PDF.M", Value: modified}) } - if _, err := p.editor.AddAnnotation(p.page, converted); err != nil { + _, data, err := p.editor.prepareAnnotation(p.page, converted) + if err != nil { return fmt.Errorf("PDF annotation: %w", err) } + p.annotationData = append(p.annotationData, bytes.TrimPrefix(data, []byte(xml.Header))...) p.report.TextObjects += stamp.report.TextObjects p.report.PathObjects += stamp.report.PathObjects p.report.ImageObjects += stamp.report.ImageObjects @@ -183,7 +191,7 @@ func pdfAnnotationType(subtype pdfgo.Name) string { return "Watermark" case "Highlight", "Underline", "Squiggly", "StrikeOut": return "Highlight" - case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup", "Widget", "PrinterMark", "TrapNet", "3D", "RichMedia": + case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup", "Widget", "PrinterMark", "TrapNet", "3D", "RichMedia", "Redact": return "Path" } return "" diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index b1b3b1f..6d13fec 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -26,11 +26,13 @@ import ( // pdfCompositeNode 保留局部合成所需的原始图元和透明组层级 type pdfCompositeNode struct { - path *pdfgo.PathMark - text *pdfgo.TextMark - image *pdfgo.ImageMark - group *pdfgo.GroupMark - children []pdfCompositeNode + path *pdfgo.PathMark + text *pdfgo.TextMark + image *pdfgo.ImageMark + group *pdfgo.GroupMark + children []pdfCompositeNode + textObject *pdfgo.TextObject + glyph *pdfgo.TextMark } // pdfCompositePixel 保存非预乘源空间分量及累计透明度 @@ -43,13 +45,16 @@ type pdfCompositePixel struct { // pdfCompositor 在指定局部区域中按源颜色空间合成PDF图元 type pdfCompositor struct { - importer *pdfImporter - box Box - width, height int - inverse pdfgo.Matrix - cache *pdfCompositeCache - masks map[*pdfgo.SoftMask][]float64 - meshes map[pdfMeshKey][]pdfMeshPixel + importer *pdfImporter + box Box + width, height int + inverse pdfgo.Matrix + cache *pdfCompositeCache + masks map[*pdfgo.SoftMask][]float64 + meshes map[pdfMeshKey][]pdfShadingPixel + gradients map[pdfGradientKey][]pdfShadingPixel + transfers []*pdfgo.TransferFunction + transferBlocked bool } // pdfCompositeKey 区分图元的填充与描边几何 @@ -68,11 +73,16 @@ type pdfCompositeGeometry struct { // pdfCompositeCache 在单页内复用几何、图像分量及蒙版内容 type pdfCompositeCache struct { - geometry map[pdfCompositeKey]pdfCompositeGeometry - images map[*pdfgo.Image]*pdfgo.ImageComponents - masks map[*pdfgo.SoftMask][]pdfCompositeNode - meshes map[*pdfgo.MeshGradient][]pdfMeshTriangle - patterns map[pdfgo.Paint]*pdfCompositePattern + geometry map[pdfCompositeKey]pdfCompositeGeometry + images map[*pdfgo.Image]*pdfgo.ImageComponents + imageMatrix map[*pdfgo.ImageMark]pdfgo.Matrix + masks map[*pdfgo.SoftMask][]pdfCompositeNode + meshes map[*pdfgo.MeshGradient][]pdfMeshTriangle + patterns map[pdfgo.Paint]*pdfCompositePattern + shadings map[pdfGradientKey]pdfCompositeGeometry + transfers map[pdfTransferKey]*pdfgo.TransferFunction + textDisjoint map[*pdfgo.GroupMark]bool + groups map[*pdfgo.GroupMark]Box } // compositingCache 按需创建单页合成缓存,页面切换时由导入器释放 @@ -95,6 +105,21 @@ func (c *pdfCompositor) coverage(node pdfCompositeNode, stroke bool) (image.Imag return nil, nil } } + geometry, err := c.geometry(node, stroke) + if err != nil { + return nil, err + } + b := geometry.box + if geometry.scene == nil || 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 + } + return c.importer.renderGroupScene(geometry.scene, c.box) +} + +// geometry 缓存图元或组的实际覆盖场景与像素对齐边界 +// 入参: node 图元或组, stroke 是否描边 +// 返回: 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] if !ok { @@ -102,15 +127,82 @@ func (c *pdfCompositor) coverage(node pdfCompositeNode, stroke bool) (image.Imag var err error geometry.scene, geometry.box, err = c.importer.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) }) if err != nil { - return nil, err + return geometry, err } c.cache.geometry[key] = geometry } - b := geometry.box - if geometry.scene == nil || 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 + return geometry, nil +} + +// groupBounds 缓存组内实际覆盖范围,外部字体沿用描述符保守边界 +// 入参: node 透明组 +// 返回: Box 组覆盖范围, error 几何编译错误 +func (c *pdfCompositor) groupBounds(node pdfCompositeNode) (Box, error) { + if box, found := c.cache.groups[node.group]; found { + return box, nil } - return c.importer.renderGroupScene(geometry.scene, c.box) + var box Box + for _, child := range node.children { + if err := c.importer.ctx.Err(); err != nil { + return Box{}, err + } + if child.group != nil { + bounds, err := c.groupBounds(child) + if err != nil { + return Box{}, err + } + box = unionTextBox(box, bounds) + continue + } + bounds, err := c.markBounds(child) + if err != nil { + return Box{}, err + } + box = unionTextBox(box, bounds) + } + if c.cache.groups == nil { + c.cache.groups = make(map[*pdfgo.GroupMark]Box) + } + c.cache.groups[node.group] = box + return box, nil +} + +// markBounds 合并图元的填充与描边范围,外部字体不在排除阶段加载 +// 入参: node 基本图元 +// 返回: Box 图元覆盖范围, error 几何编译错误 +func (c *pdfCompositor) markBounds(node pdfCompositeNode) (Box, error) { + _, fill, stroke := node.style() + var box Box + for _, outline := range []bool{false, true} { + if outline && !stroke || !outline && !fill { + continue + } + if node.text != nil && len(node.text.Font.Program) == 0 && node.text.Font.BoundingBox != nil { + box = unionTextBox(box, c.importer.compositeTextBounds(*node.text, outline)) + continue + } + geometry, err := c.geometry(node, outline) + if err != nil { + return Box{}, err + } + box = unionTextBox(box, geometry.box) + } + return box, nil +} + +// pixelBounds 将页面范围映射到当前块,保留一像素余量供浮点边界与抗锯齿使用 +// 入参: box 页面范围 +// 返回: image.Rectangle 当前块内的像素范围 +func (c *pdfCompositor) pixelBounds(box Box) image.Rectangle { + if box.W <= 0 || box.H <= 0 { + return image.Rectangle{} + } + step := 25.4 / c.importer.rasterDPI + x0 := int(math.Max(0, math.Min(float64(c.width), math.Floor((box.X-c.box.X)/step)-1))) + y0 := int(math.Max(0, math.Min(float64(c.height), math.Floor((box.Y-c.box.Y)/step)-1))) + x1 := int(math.Max(0, math.Min(float64(c.width), math.Ceil((box.X+box.W-c.box.X)/step)+1))) + y1 := int(math.Max(0, math.Min(float64(c.height), math.Ceil((box.Y+box.H-c.box.Y)/step)+1))) + return image.Rect(x0, y0, x1, y1) } // compositeTextBounds 按字体描述符计算保守边界,仅用于排除不相交的外部字体 @@ -158,35 +250,59 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p if err := p.halftone(mark.Style); err != nil { return err } + if err := p.resolveTransfer(&mark.Style); err != nil { + return err + } nodes = append(nodes, pdfCompositeNode{path: &mark}) return nil } v.Text = func(mark pdfgo.TextMark) error { - if err := p.halftone(mark.Style); err != nil { - return err - } + var text []pdfCompositeNode if mark.Font.Subtype != "Type3" { - nodes = append(nodes, pdfCompositeNode{text: &mark}) - return nil - } - if mark.Clip != nil { - return &pdfgo.UnsupportedError{Feature: "Type3 text clipping"} - } - glyphs := active[mark.Font] - if glyphs == nil { - glyphs = make(map[string]bool) - active[mark.Font] = glyphs - } - for index, glyph := range mark.Glyphs { - if glyphs[glyph.Name] { - return fmt.Errorf("recursive PDF Type3 glyph %q", glyph.Name) - } - glyphs[glyph.Name] = true - err := p.reader.WalkType3Glyph(p.ctx, mark, index, v) - delete(glyphs, glyph.Name) - if err != nil { + if err := p.halftone(mark.Style); err != nil { return err } + if err := p.resolveTransfer(&mark.Style); err != nil { + return err + } + if len(mark.Glyphs) > 1 && (mark.Object != nil && !mark.Object.Knockout || mark.Style.AlphaIsShape || mark.Style.Fill.Alpha != 1 || mark.Style.Stroke.Alpha != 1 || mark.Style.SoftMask != nil || !pdfNormalBlend(mark.Style.BlendMode) || mark.Mode%4 == 1 || mark.Mode%4 == 2) { + for i := range mark.Glyphs { + glyph := mark + glyph.Glyphs, glyph.Positions, glyph.Clip = mark.Glyphs[i:i+1], mark.Positions[i:i+1], nil + text = append(text, pdfCompositeNode{text: &glyph}) + } + } else { + text = append(text, pdfCompositeNode{text: &mark}) + } + } else { + glyphs := active[mark.Font] + if glyphs == nil { + glyphs = make(map[string]bool) + active[mark.Font] = glyphs + } + for index, glyph := range mark.Glyphs { + if glyphs[glyph.Name] { + return fmt.Errorf("recursive PDF Type3 glyph %q", glyph.Name) + } + glyphs[glyph.Name] = true + children, err := collect(func(visitor pdfgo.Visitor) error { return p.reader.WalkType3Glyph(p.ctx, mark, index, visitor) }) + delete(glyphs, glyph.Name) + if err != nil { + return err + } + part := mark + part.Glyphs, part.Positions = mark.Glyphs[index:index+1], mark.Positions[index:index+1] + text = append(text, pdfCompositeNode{group: &pdfgo.GroupMark{Alpha: 1}, children: children, glyph: &part}) + } + } + if mark.Object != nil && mark.Object.Knockout { + if len(nodes) == 0 || nodes[len(nodes)-1].textObject != mark.Object { + nodes = append(nodes, pdfCompositeNode{group: &pdfgo.GroupMark{Alpha: 1, Knockout: true}, textObject: mark.Object}) + } + node := &nodes[len(nodes)-1] + node.children = append(node.children, text...) + } else { + nodes = append(nodes, text...) } return nil } @@ -194,6 +310,9 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p if err := p.halftone(mark.Style); err != nil { return err } + if err := p.resolveTransfer(&mark.Style); err != nil { + return err + } nodes = append(nodes, pdfCompositeNode{image: &mark}) return nil } @@ -236,7 +355,24 @@ func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Vis } } p.compositeNodes = nil + p.transferBackdrop = false p.compositeSpace = space + if pdfCompositeHasTransfer(nodes) { + if p.warning == nil { + return &pdfgo.UnsupportedError{Feature: "device transfer function rasterization"} + } + if overprint, err := p.processOverprint(nodes); err != nil { + return err + } else if overprint { + return &pdfgo.UnsupportedError{Feature: "per-colorant transfer function overprinting"} + } + if err := p.compositeRegion(nil, pdfCompositeNode{group: &pdfgo.GroupMark{Alpha: 1, ColorSpace: space}, children: nodes}, space, true); err != nil { + return err + } + p.compositeNodes = nodes + p.transferBackdrop = true + return nil + } return p.compositeObjects(nodes) } @@ -250,6 +386,17 @@ func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { if err != nil { return fmt.Errorf("inspect graphic %d: %w", i+1, err) } + transfer := pdfCompositeHasTransfer([]pdfCompositeNode{node}) + if p.transferBackdrop || transfer { + direct = false + for _, source := range [][]pdfCompositeNode{p.compositeNodes, {node}} { + if overprint, err := p.processOverprint(source); err != nil { + return err + } else if overprint { + return &pdfgo.UnsupportedError{Feature: "per-colorant transfer function overprinting"} + } + } + } if direct { if err := node.emit(p.visitor()); err != nil { return fmt.Errorf("convert graphic %d: %w", i+1, err) @@ -266,6 +413,7 @@ func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { } } p.compositeNodes = append(p.compositeNodes, node) + p.transferBackdrop = p.transferBackdrop || transfer } return p.flushPath() } @@ -276,7 +424,7 @@ func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool { if n.group != nil { g := n.group - if g.Knockout || g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.Equal(space) { + if g.Knockout && n.textObject == nil || g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.Equal(space) { return false } for _, child := range n.children { @@ -309,7 +457,8 @@ func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool { // 返回: bool 是否可直接转换 func (n pdfCompositeNode) direct() bool { if g := n.group; g != nil { - if g.Knockout || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.SRGBEquivalent() { + single := n.textObject != nil && len(n.children) == 1 && (n.children[0].glyph != nil || n.children[0].text != nil && len(n.children[0].text.Glyphs) == 1) + if g.Knockout && !single || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.SRGBEquivalent() { return false } for _, child := range n.children { @@ -320,6 +469,9 @@ func (n pdfCompositeNode) direct() bool { return true } s, fill, stroke := n.style() + if fill && stroke && (s.Fill.Alpha != 1 || s.Stroke.Alpha != 1 || s.SoftMask != nil) { + return false + } if n.image != nil && s.FillOverprint { return false } @@ -352,6 +504,14 @@ func (n pdfCompositeNode) emit(visitor pdfgo.Visitor) error { if n.image != nil { return visitor.Image(*n.image) } + if n.textObject != nil || n.glyph != nil { + for _, child := range n.children { + if err := child.emit(visitor); err != nil { + return err + } + } + return nil + } return visitor.Group(*n.group, func(v pdfgo.Visitor) error { for _, child := range n.children { if err := child.emit(v); err != nil { @@ -391,6 +551,7 @@ func (n pdfCompositeNode) coverage(p *pdfImporter, stroke bool) error { style, _, _ := n.style() style.Fill, style.Stroke = pdfgo.Paint{RGB: [3]float64{1, 1, 1}, Alpha: 1}, pdfgo.Paint{RGB: [3]float64{1, 1, 1}, Alpha: 1} style.SoftMask, style.BlendMode = nil, "Normal" + style.Transfer = nil style.FillOverprint, style.StrokeOverprint = false, false if n.path != nil { mark := *n.path @@ -452,11 +613,21 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo cache := p.compositingCache() step := 25.4 / p.rasterDPI buffer := make([]pdfCompositePixel, min(256, w)*min(256, h)) + var transfers []*pdfgo.TransferFunction + var previousTransfers []*pdfgo.TransferFunction + if pdfCompositeHasTransfer(backdrop) || pdfCompositeHasTransfer([]pdfCompositeNode{node}) { + transfers = make([]*pdfgo.TransferFunction, len(buffer)) + previousTransfers = make([]*pdfgo.TransferFunction, len(buffer)) + } for y := 0; y < h; y += 256 { for x := 0; x < w; x += 256 { runtime.Gosched() width, height := min(256, w-x), min(256, h-y) c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, masks: map[*pdfgo.SoftMask][]float64{}} + if transfers != nil { + c.transfers = transfers[:width*height] + clear(c.transfers) + } pixels := buffer[:width*height] clear(pixels) if err := c.draw(backdrop, pixels, space); err != nil { @@ -465,14 +636,21 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo for i := range pixels { pixels[i].effect = 0 } + copy(previousTransfers, c.transfers) if err := c.draw([]pdfCompositeNode{node}, pixels, space); err != nil { return err } for i, pixel := range pixels { - if pixel.effect == 0 { + if pixel.effect == 0 && (c.transfers == nil || c.transfers[i] == previousTransfers[i]) { continue } - rgb, err := pdfCompositeOutputColor(space, pixel.values) + var rgb [3]float64 + var err error + if c.transfers != nil { + rgb, err = space.RGB(pixel.values[:space.Components()], "RelativeColorimetric") + } else { + rgb, err = pdfCompositeOutputColor(space, pixel.values) + } if err != nil { return err } @@ -483,6 +661,13 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo } alpha = 1 } + if c.transfers != nil && c.transfers[i] != nil { + values, err := c.transfers[i].Apply(rgb[:], "DeviceRGB", "") + if err != nil { + return err + } + copy(rgb[:], values[:3]) + } output.SetNRGBA(x+i%width, y+i/width, color.NRGBA{R: uint8(math.Round(rgb[0] * 255)), G: uint8(math.Round(rgb[1] * 255)), B: uint8(math.Round(rgb[2] * 255)), A: uint8(math.Round(alpha * 255))}) } } diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index b47fb4b..81398a1 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -46,6 +46,10 @@ func (c *pdfCompositor) draw(nodes []pdfCompositeNode, pixels []pdfCompositePixe // 入参: node 图元, pixels 背景与输出像素, space 混合空间 // 返回: error 几何或颜色错误 func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error { + box, err := c.markBounds(node) + if err != nil || c.pixelBounds(box).Empty() { + return err + } style, fill, stroke := node.style() mask, err := c.mask(style.SoftMask, space) if err != nil { @@ -84,7 +88,7 @@ func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePix } } if grouped { - return pdfCompositeGroup(pixels, initial, result, space, space, style.Fill.Alpha, mask, style.BlendMode) + return pdfCompositeGroup(pixels, initial, result, space, space, style.Fill.Alpha, mask, style.BlendMode, style.RenderingIntent, style.ColorConversion) } return nil } @@ -98,17 +102,36 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf return err } style, _, _ := node.style() - var processMask [4]bool - processOverprint := false - if node.image != nil && overprint && space.Model == "DeviceCMYK" && !space.Calibrated() { - processMask, processOverprint, err = pdfImageProcessColorants(node.image.Image) + var transfer *pdfgo.TransferFunction + imageShape := false + if c.transfers != nil { + transfer, err = c.selectTransfer(node, outline) if err != nil { return err } + if node.image != nil && (node.image.Image.ImageMask || node.image.Image.Mask != nil) { + soft, err := node.image.Image.HasSoftMask() + if err != nil { + return err + } + imageShape = !soft + } + } + var processMask [4]bool + processOverprint := false + if overprint && space.Model == "DeviceCMYK" && !space.Calibrated() { + if node.image != nil { + processMask, processOverprint, err = pdfImageProcessColorants(node.image.Image) + if err != nil { + return err + } + } else { + processMask, processOverprint = paint.Process.CMYKMask() + } } - compatible := overprint && style.OverprintMode == 1 && node.image == nil && space.Model == "DeviceCMYK" && !space.Calibrated() && (paint.CMYK != nil || paint.Space != nil && paint.Space.Model == "DeviceCMYK" && !paint.Space.Calibrated()) step := 25.4 / c.importer.rasterDPI - uniform := node.image == nil && paint.Axial == nil && paint.Radial == nil && paint.Tiling == nil && paint.Mesh == nil + uniform := node.image == nil && paint.Axial == nil && paint.Radial == nil && paint.Function == nil && paint.Tiling == nil && paint.Mesh == nil + compatible := overprint && !processOverprint && style.OverprintMode == 1 && uniform && paint.SourceSpace == "DeviceCMYK" && space.Model == "DeviceCMYK" && !space.Calibrated() var pattern []pdfCompositePixel if paint.Tiling != nil && (node.image == nil || node.image.Image.ImageMask) { backdrop := pixels @@ -120,26 +143,37 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf return err } } - var mesh []pdfMeshPixel - if paint.Mesh != nil { - mesh, err = c.mesh(paint.Mesh, space) + var shading []pdfShadingPixel + if paint.Mesh != nil && (node.image == nil || node.image.Image.ImageMask) { + shading, err = c.mesh(paint.Mesh, space, style.ColorConversion) + if err != nil { + return err + } + } else if (paint.Axial != nil || paint.Radial != nil || paint.Function != nil) && (node.image == nil || node.image.Image.ImageMask) { + shading, err = c.gradient(paint, space, &node, outline) if err != nil { return err } } var uniformValues [4]float64 uniformReady := false - for y := 0; y < c.height; y++ { + geometry, err := c.geometry(node, outline) + if err != nil { + return err + } + region := c.pixelBounds(geometry.box) + for y := region.Min.Y; y < region.Max.Y; y++ { if err := c.importer.ctx.Err(); err != nil { return err } - for x := 0; x < c.width; x++ { + for x := region.Min.X; x < region.Max.X; x++ { _, _, _, a := coverage.At(x, y).RGBA() if a == 0 { continue } index := y*c.width + x shape, opacity := float64(a)/65535, paint.Alpha + selectionShape := 1.0 if mask != nil { opacity *= mask[index] } @@ -154,15 +188,31 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf if node.image != nil { point := c.inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)*step, Y: c.box.Y + (float64(y)+.5)*step}) var alpha float64 - _, alpha, err = c.imageColor(*node.image, point, space) + _, alpha, err = c.imageColor(node.image, point, space) + if imageShape { + selectionShape = alpha + } + opacity *= alpha + } + } else if shading != nil { + values = shading[index].values + if paint.Mesh == nil { + shape = shading[index].shape + } else { + shape *= shading[index].shape + } + if node.image != nil && paint.Mesh != nil { + point := c.inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)*step, Y: c.box.Y + (float64(y)+.5)*step}) + var alpha float64 + _, alpha, err = c.imageColor(node.image, point, space) + if imageShape { + selectionShape = alpha + } opacity *= alpha } - } else if mesh != nil { - values = mesh[index].values - shape *= mesh[index].shape } else if uniform { if !uniformReady { - uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent) + uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent, style.ColorConversion) uniformReady = true } values = uniformValues @@ -170,11 +220,14 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf point := c.inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)*step, Y: c.box.Y + (float64(y)+.5)*step}) if node.image != nil { var alpha float64 - values, alpha, err = c.imageColor(*node.image, point, space) + values, alpha, err = c.imageColor(node.image, point, space) + if imageShape { + selectionShape = alpha + } opacity *= alpha } else { var visible bool - values, visible, err = pdfCompositeColor(paint, point, space, style.RenderingIntent) + values, visible, err = pdfCompositeColor(paint, point, space, style.RenderingIntent, style.ColorConversion) if !visible { shape = 0 } @@ -212,6 +265,9 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf } } pixels[index].shape += shape * (1 - pixels[index].shape) + if c.transfers != nil && shape != 0 && selectionShape != 0 { + c.transfers[index] = transfer + } } } return nil @@ -235,10 +291,35 @@ func pdfCompositeInterpolate(target *pdfCompositePixel, source pdfCompositePixel // 返回: error 颜色或合成错误 func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePixel, parent *pdfgo.ColorSpace) error { g := node.group + b, err := c.groupBounds(node) + if err != nil { + return err + } + if b.W <= 0 || b.H <= 0 || 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 + } + blocked := c.transferBlocked + c.transferBlocked = blocked || g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode) + defer func() { c.transferBlocked = blocked }() + if c.transfers != nil && node.textObject != nil && !c.transferBlocked { + for _, child := range node.children { + opaque, err := c.importer.transferOpaque(child) + if err != nil { + return err + } + if !opaque { + c.transferBlocked = true + break + } + } + } space := g.ColorSpace if space == nil { space = parent } + if !g.Isolated && !g.Knockout && g.Alpha == 1 && g.SoftMask == nil && pdfNormalBlend(g.BlendMode) && space.Equal(parent) { + return c.draw(node.children, pixels, space) + } initial := make([]pdfCompositePixel, len(pixels)) if !g.Isolated { sameSpace := space.Equal(parent) @@ -277,7 +358,7 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi } } } - return pdfCompositeGroup(pixels, initial, result, space, parent, g.Alpha, mask, g.BlendMode) + return pdfCompositeGroup(pixels, initial, result, space, parent, g.Alpha, mask, g.BlendMode, g.RenderingIntent, g.ColorConversion) } // drawKnockout 将每个对象与组初始背景合成,再按对象形状替换先前对象 @@ -286,12 +367,32 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi func (c *pdfCompositor) drawKnockout(nodes []pdfCompositeNode, initial, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error { candidate := make([]pdfCompositePixel, len(initial)) for _, node := range nodes { - copy(candidate, initial) + var box Box + var err error + if node.group != nil { + box, err = c.groupBounds(node) + } else { + box, err = c.markBounds(node) + } + if err != nil { + return err + } + region := c.pixelBounds(box) + if region.Empty() { + continue + } + for y := region.Min.Y; y < region.Max.Y; y++ { + start, end := y*c.width+region.Min.X, y*c.width+region.Max.X + copy(candidate[start:end], initial[start:end]) + } if err := c.draw([]pdfCompositeNode{node}, candidate, space); err != nil { return err } - for i, source := range candidate { - pdfCompositeKnockout(&pixels[i], initial[i], source) + for y := region.Min.Y; y < region.Max.Y; y++ { + for x := region.Min.X; x < region.Max.X; x++ { + i := y*c.width + x + pdfCompositeKnockout(&pixels[i], initial[i], candidate[i]) + } } } return nil @@ -316,9 +417,9 @@ func pdfCompositeKnockout(target *pdfCompositePixel, initial, source pdfComposit } // pdfCompositeGroup 移除组初始背景贡献后,将组结果合成到父空间 -// 入参: pixels 父组输出, initial 初始背景, result 组结果, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式 +// 入参: pixels 父组输出, initial 初始背景, result 组结果, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式, intent 渲染意图, conversion 设备转换函数 // 返回: error 颜色或混合错误 -func pdfCompositeGroup(pixels, initial, result []pdfCompositePixel, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode pdfgo.Name) error { +func pdfCompositeGroup(pixels, initial, result []pdfCompositePixel, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode, intent pdfgo.Name, conversion pdfgo.ColorConversion) error { sameSpace := space.Equal(parent) components := space.Components() for i, pixel := range result { @@ -332,7 +433,7 @@ func pdfCompositeGroup(pixels, initial, result []pdfCompositePixel, space, paren } if !sameSpace { var err error - values, err = parent.Convert(values[:components], space, "RelativeColorimetric") + values, err = parent.ConvertWith(values[:components], space, intent, conversion) if err != nil { return err } @@ -378,7 +479,9 @@ func (c *pdfCompositor) mask(mask *pdfgo.SoftMask, inherited *pdfgo.ColorSpace) pixels[i].alpha = 1 } } - if err := c.draw(nodes, pixels, space); err != nil { + local := *c + local.transfers = nil + if err := local.draw(nodes, pixels, space); err != nil { return nil, err } result := make([]float64, len(pixels)) @@ -402,7 +505,7 @@ func (c *pdfCompositor) mask(mask *pdfgo.SoftMask, inherited *pdfgo.ColorSpace) // imageColor 采样映射后的原始图像分量,插值在源空间内进行 // 入参: mark 图像, point 页面坐标, space 混合空间 // 返回: [4]float64 源颜色, float64 图像透明度, error 解码或变换错误 -func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, space *pdfgo.ColorSpace) ([4]float64, float64, error) { +func (c *pdfCompositor) imageColor(mark *pdfgo.ImageMark, point pdfgo.Point, space *pdfgo.ColorSpace) ([4]float64, float64, error) { source := c.cache.images[mark.Image] if source == nil { var err error @@ -412,18 +515,27 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac } c.cache.images[mark.Image] = source } - inverse, ok := mark.Matrix.Inverse() - if !ok { - return [4]float64{}, 0, fmt.Errorf("invalid PDF image matrix") + inverse, found := c.cache.imageMatrix[mark] + if !found { + var ok bool + inverse, ok = mark.Matrix.Inverse() + if !ok { + return [4]float64{}, 0, fmt.Errorf("invalid PDF image matrix") + } + if c.cache.imageMatrix == nil { + c.cache.imageMatrix = make(map[*pdfgo.ImageMark]pdfgo.Matrix) + } + c.cache.imageMatrix[mark] = inverse } point = inverse.Apply(point) if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 { return [4]float64{}, 0, nil } x, y := point.X*float64(source.Rect.Dx())-.5, (1-point.Y)*float64(source.Rect.Dy())-.5 + _, nativeProcess := source.Process.CMYKMask() process := mark.Style.FillOverprint && space.Model == "DeviceCMYK" && !space.Calibrated() && len(source.Colorants) > 0 var indices [4]int - if process { + if process && !nativeProcess { for i, name := range source.Colorants { index := -1 for j, known := range []pdfgo.Name{"Cyan", "Magenta", "Yellow", "Black"} { @@ -441,7 +553,7 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac sample := func(x, y int) ([4]float64, float64) { x, y = max(0, min(source.Rect.Dx()-1, x)), max(0, min(source.Rect.Dy()-1, y)) values, alpha := source.ValuesAt(x, y) - if process { + if process && !nativeProcess { values = [4]float64{} for j, tint := range source.TintsAt(x, y) { values[indices[j]] = float64(tint) / 65535 @@ -483,16 +595,16 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac if mark.Style.Fill.Tiling != nil { return [4]float64{}, alpha * (1 - values[0]), nil } - color, visible, err := pdfCompositeColor(mark.Style.Fill, mark.Matrix.Apply(point), space, mark.Style.RenderingIntent) + color, visible, err := pdfCompositeColor(mark.Style.Fill, mark.Matrix.Apply(point), space, mark.Style.RenderingIntent, mark.Style.ColorConversion) if !visible { return color, 0, err } return color, alpha * (1 - values[0]), err } - if process { + if process || space.Equal(source.Space) { return values, alpha, nil } - values, err := space.Convert(values[:source.Space.Components()], source.Space, mark.Style.RenderingIntent) + values, err := space.ConvertWith(values[:source.Space.Components()], source.Space, mark.Style.RenderingIntent, mark.Style.ColorConversion) return values, alpha, err } @@ -501,6 +613,13 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac // 返回: [4]bool 参与绘制的四色通道, bool 是否全部属于印刷原色, error 色空间错误 func pdfImageProcessColorants(image *pdfgo.Image) ([4]bool, bool, error) { var mask [4]bool + process, err := image.ProcessColorants() + if err != nil { + return mask, false, err + } + if mask, native := process.CMYKMask(); native { + return mask, true, nil + } names, err := image.Colorants() if err != nil { return mask, false, err @@ -525,27 +644,80 @@ func pdfImageProcessColorants(image *pdfgo.Image) ([4]bool, bool, error) { } // pdfCompositeColor 将原始画刷变换到混合空间,渐变在源空间求值 -// 入参: paint 画刷, point 页面坐标, space 混合空间, intent 渲染意图 +// 入参: paint 画刷, point 页面坐标, space 混合空间, intent 渲染意图, conversion 设备转换函数 // 返回: [4]float64 混合分量, bool 是否着色, error 颜色错误 -func pdfCompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorSpace, intent pdfgo.Name) ([4]float64, bool, error) { +func pdfCompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorSpace, intent pdfgo.Name, conversion pdfgo.ColorConversion) ([4]float64, bool, error) { if paint.Tiling != nil { return [4]float64{}, false, &pdfgo.UnsupportedError{Feature: "local tiling pattern compositing"} } var values [4]float64 var source *pdfgo.ColorSpace - if paint.Axial != nil || paint.Radial != nil { - t, visible := pdfShadingPosition(paint, point) - if !visible { - return values, false, nil + if paint.Function != nil { + g := paint.Function + if g.Bounds != nil { + inverse, ok := g.PatternMatrix.Inverse() + if !ok { + return values, false, fmt.Errorf("singular function shading bounds transform") + } + local := inverse.Apply(point) + if local.X < g.Bounds.XMin || local.X > g.Bounds.XMax || local.Y < g.Bounds.YMin || local.Y > g.Bounds.YMax { + return values, false, nil + } } - var err error + inverse, ok := g.Matrix.Inverse() + if !ok { + return values, false, fmt.Errorf("singular function shading transform") + } + local := inverse.Apply(point) + source, intent = g.Space, g.Intent + if local.X < g.Domain.XMin || local.X > g.Domain.XMax || local.Y < g.Domain.YMin || local.Y > g.Domain.YMax { + if g.Background == nil { + return values, false, nil + } + values = *g.Background + } else { + var err error + values, err = g.ValuesAt(local) + if err != nil { + return values, false, err + } + } + } else if paint.Axial != nil || paint.Radial != nil { + var background *[4]float64 + var bounds *pdfgo.Rectangle + var matrix pdfgo.Matrix if g := paint.Axial; g != nil { - values, err = g.ValuesAt(t) source, intent = g.Space, g.Intent + background, bounds, matrix = g.Background, g.Bounds, g.Matrix } else { g := paint.Radial - values, err = g.ValuesAt(t) source, intent = g.Space, g.Intent + background, bounds, matrix = g.Background, g.Bounds, g.Matrix + } + if bounds != nil { + local := point + if matrix != (pdfgo.Matrix{}) { + inverse, ok := matrix.Inverse() + if !ok { + return values, false, fmt.Errorf("singular shading transform") + } + local = inverse.Apply(point) + } + if local.X < bounds.XMin || local.X > bounds.XMax || local.Y < bounds.YMin || local.Y > bounds.YMax { + return values, false, nil + } + } + t, visible := pdfShadingPosition(paint, point) + var err error + if !visible { + if background == nil { + return values, false, nil + } + values = *background + } else if paint.Axial != nil { + values, err = paint.Axial.ValuesAt(t) + } else { + values, err = paint.Radial.ValuesAt(t) } if err != nil { return values, false, err @@ -554,11 +726,13 @@ func pdfCompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorS values, source = paint.Values, paint.Space } else if paint.CMYK != nil { values, source = *paint.CMYK, &pdfgo.ColorSpace{Model: "DeviceCMYK"} + } else if paint.SourceSpace == "DeviceGray" { + values[0], source = paint.RGB[0], &pdfgo.ColorSpace{Model: "DeviceGray"} } else { copy(values[:], paint.RGB[:]) source = &pdfgo.ColorSpace{Model: "DeviceRGB"} } - values, err := space.Convert(values[:source.Components()], source, intent) + values, err := space.ConvertWith(values[:source.Components()], source, intent, conversion) return values, true, err } diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index 3658c23..5d6ef63 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -23,9 +23,11 @@ import ( // pdfCompositePattern 保存图案单元的原始图元和独立几何缓存 type pdfCompositePattern struct { - nodes []pdfCompositeNode - local pdfImporter - box Box + nodes []pdfCompositeNode + local pdfImporter + box Box + transferChecked bool + transferOpaque bool } // tiling 将图案作为非隔离组绘制,保留背景贡献及单元间隙 @@ -59,6 +61,9 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, for i, pixel := range backdrop { result[i] = pdfCompositePixel{values: pixel.values, alpha: pixel.alpha} } + candidate := make([]pdfCompositePixel, len(backdrop)) + disjoint := source.BBox.XMax-source.BBox.XMin <= xstep && source.BBox.YMax-source.BBox.YMin <= ystep + pixelStep := 25.4 / c.importer.rasterDPI localInverse, _ := pattern.local.matrix.Inverse() for row := int(top); row <= int(bottom); row++ { for column := int(left); column <= int(right); column++ { @@ -67,11 +72,36 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, } x, y := float64(column)*xstep, float64(row)*ystep dx, dy := matrix[0]*x+matrix[2]*y+pattern.box.X, matrix[1]*x+matrix[3]*y+pattern.box.Y - cell := pdfCompositor{importer: &pattern.local, box: Box{X: c.box.X - dx, Y: c.box.Y - dy, W: c.box.W, H: c.box.H}, - width: c.width, height: c.height, inverse: localInverse, cache: pattern.local.compositingCache(), masks: map[*pdfgo.SoftMask][]float64{}} - if err := cell.draw(pattern.nodes, result, space); err != nil { + left := int(math.Max(0, math.Min(float64(c.width), math.Floor((dx-c.box.X)/pixelStep)-1))) + right := int(math.Max(0, math.Min(float64(c.width), math.Ceil((dx+pattern.box.W-c.box.X)/pixelStep)+1))) + top := int(math.Max(0, math.Min(float64(c.height), math.Floor((dy-c.box.Y)/pixelStep)-1))) + bottom := int(math.Max(0, math.Min(float64(c.height), math.Ceil((dy+pattern.box.H-c.box.Y)/pixelStep)+1))) + width, height := right-left, bottom-top + if width <= 0 || height <= 0 { + continue + } + pixels := candidate[:width*height] + for y := range height { + for x := range width { + index := (top+y)*c.width + left + x + initial := result[index] + if disjoint { + initial = backdrop[index] + } + pixels[y*width+x] = pdfCompositePixel{values: initial.values, alpha: initial.alpha} + } + } + cell := pdfCompositor{importer: &pattern.local, box: Box{X: c.box.X + float64(left)*pixelStep - dx, Y: c.box.Y + float64(top)*pixelStep - dy, W: float64(width) * pixelStep, H: float64(height) * pixelStep}, + width: width, height: height, inverse: localInverse, cache: pattern.local.compositingCache(), masks: map[*pdfgo.SoftMask][]float64{}} + if err := cell.draw(pattern.nodes, pixels, space); err != nil { return nil, err } + for y := range height { + for x := range width { + index := (top+y)*c.width + left + x + pdfCompositePatternCell(&result[index], backdrop[index], pixels[y*width+x], disjoint) + } + } } } for i := range result { @@ -86,6 +116,29 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, return result, nil } +// pdfCompositePatternCell 合并互不重叠单元的覆盖贡献,避免相邻抗锯齿边缘重复混合 +// 入参: target 图案累计结果, initial 原始背景, source 单元结果, disjoint 单元边界是否互不重叠 +func pdfCompositePatternCell(target *pdfCompositePixel, initial, source pdfCompositePixel, disjoint bool) { + if source.shape == 0 { + return + } + if disjoint { + alpha := math.Max(0, math.Min(1, target.alpha+source.alpha-initial.alpha)) + if alpha != 0 { + for i := range target.values { + target.values[i] = (target.values[i]*target.alpha + source.values[i]*source.alpha - initial.values[i]*initial.alpha) / alpha + } + } + target.alpha = alpha + target.effect = math.Min(1, target.effect+source.effect) + target.shape = math.Min(1, target.shape+source.shape) + } else { + target.values, target.alpha = source.values, source.alpha + target.effect += source.effect * (1 - target.effect) + target.shape += source.shape * (1 - target.shape) + } +} + // compositePattern 复用图案单元解析及几何上下文,供检查和局部合成共同使用 // 入参: paint 图案画刷 // 返回: *pdfCompositePattern 单元内容, error 解析或边界错误 @@ -127,7 +180,54 @@ func (p *pdfImporter) compositePattern(paint pdfgo.Paint) (*pdfCompositePattern, // 入参: node 图元, space 混合空间 // 返回: bool 是否可直接转换, error 图案解析错误 func (p *pdfImporter) directCompositeNode(node pdfCompositeNode, space *pdfgo.ColorSpace) (bool, error) { + if node.group == nil && space.Model == "DeviceCMYK" && !space.Calibrated() { + style, fill, stroke := node.style() + if style.ColorConversion != (pdfgo.ColorConversion{}) { + if node.image != nil && !node.image.Image.ImageMask { + source := p.compositingCache().images[node.image.Image] + if source == nil { + var err error + source, err = node.image.Image.DecodeComponents() + if err != nil { + return false, err + } + p.compositingCache().images[node.image.Image] = source + } + if source.Space.Model == "DeviceRGB" && source.Space.Device() { + return false, nil + } + } else { + for i, paint := range [2]pdfgo.Paint{style.Fill, style.Stroke} { + if i == 0 && !fill || i == 1 && !stroke || paint.None || paint.Tiling != nil { + continue + } + source := paint.Space + switch { + case paint.Axial != nil: + source = paint.Axial.Space + case paint.Radial != nil: + source = paint.Radial.Space + case paint.Function != nil: + source = paint.Function.Space + case paint.Mesh != nil: + source = paint.Mesh.Space + } + if source != nil && source.Model == "DeviceRGB" && source.Device() || source == nil && paint.CMYK == nil && paint.SourceSpace != "DeviceGray" { + return false, nil + } + } + } + } + } if !node.opaque(space) && !(space.SRGBEquivalent() && node.direct()) { + if node.textObject != nil && space.SRGBEquivalent() { + for _, child := range node.children { + if direct, err := p.directCompositeNode(child, space); err != nil || !direct { + return direct, err + } + } + return p.disjointText(node) + } return false, nil } if node.group != nil { @@ -138,6 +238,9 @@ func (p *pdfImporter) directCompositeNode(node pdfCompositeNode, space *pdfgo.Co } return true, nil } + if node.image != nil && !node.image.Image.ImageMask { + return true, nil + } style, fill, stroke := node.style() for i, paint := range []pdfgo.Paint{style.Fill, style.Stroke} { if i == 0 && !fill || i == 1 && !stroke || paint.Tiling == nil { @@ -173,6 +276,9 @@ func (p *pdfImporter) processOverprint(nodes []pdfCompositeNode) (bool, error) { return found, err } } + if node.image != nil && !node.image.Image.ImageMask { + continue + } if fill && style.FillOverprint && pdfOverprintNeedsSeparation(style.Fill) || stroke && style.StrokeOverprint && pdfOverprintNeedsSeparation(style.Stroke) { return true, nil } diff --git a/ofdgo_pdf_font.go b/ofdgo_pdf_font.go new file mode 100644 index 0000000..fe7b99f --- /dev/null +++ b/ofdgo_pdf_font.go @@ -0,0 +1,340 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" + "maps" + "slices" + "unicode/utf16" + "unicode/utf8" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfImportedFont 保存单次导入共用的字体资源、度量及源字形映射,不保存文档标识 +type pdfImportedFont struct { + resource *FontResource + checksum [32]byte + metrics FontMetrics + type1Glyphs map[string]uint16 + repairLimit uint16 +} + +// importedFont 按原PDF字体复用包装和解析结果,失败结果不进入缓存 +// 入参: source 源字体 +// 返回: *pdfImportedFont 只读导入字体, error 字体程序或后端错误 +func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error) { + if cached := p.fonts[source]; cached != nil { + return cached, nil + } + var type1 *type1Program + result := &pdfImportedFont{} + if source.ProgramType == "FontFile" { + parsed, err := parseType1Program(source.Program) + if err != nil { + return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err) + } + type1 = &parsed + result.type1Glyphs = parsed.glyphIDs() + } + program, limit, err := pdfFontProgram(source, type1) + if err != nil { + return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err) + } + if p.editor.backends.FontResources == nil { + return nil, fmt.Errorf("PDF font %s resource: font resources: %w", source.Name, ErrBackendUnavailable) + } + result.resource, err = p.editor.backends.FontResources.OpenFontResource(FontFile{Name: source.Name + ".ttf", Data: program}, 0) + if err != nil { + return nil, fmt.Errorf("PDF font %s resource: %w", source.Name, err) + } + if p.editor.backends.Fonts == nil { + return nil, fmt.Errorf("PDF font backend unavailable") + } + result.metrics, err = p.editor.backends.Fonts.OpenFont(result.resource.Data) + if err != nil { + return nil, fmt.Errorf("PDF font %s metrics: %w", source.Name, err) + } + if _, ok := result.metrics.(FontOutlines); !ok { + return nil, fmt.Errorf("PDF font backend does not provide glyph outlines") + } + result.checksum, result.repairLimit = sha256.Sum256(result.resource.Data), limit + if p.fonts == nil { + p.fonts = make(map[*pdfgo.Font]*pdfImportedFont) + } + p.fonts[source] = result + return result, nil +} + +// pdfFontProgram 为PDF子集字体补齐封装表,保留字形轮廓和编号 +// 复合字体的重复映射区段按PDF字符映射重建,不改变CID对应的字形 +// 入参: source PDF字体, type1 已解析的Type1程序,nil时按需解析 +// 返回: []byte 封装后的字体数据, uint16 缺失度量前的完整字形数, error 错误信息 +func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, error) { + program := source.Program + if source.ProgramType == "FontFile" { + if type1 == nil { + parsed, err := parseType1Program(program) + if err != nil { + return nil, 0, err + } + type1 = &parsed + } + var err error + program, err = type1.toCFF(pdfFontIdentity(source)) + if err != nil { + return nil, 0, err + } + } + bareCFF := len(program) >= 4 && program[0] == 1 && program[1] == 0 && program[2] >= 4 && program[3] >= 1 && program[3] <= 4 + if source.ProgramType == "Type1C" || source.ProgramType == "CIDFontType0C" || bareCFF { + var err error + program, _, err = wrapCFFToOTF(program) + if err != nil { + return nil, 0, err + } + } + tables, err := fontFileTables(program, 0) + if err != nil { + return nil, 0, err + } + if len(tables["head"]) < 54 || len(tables["maxp"]) < 6 || len(tables["hhea"]) < 36 || len(tables["hmtx"]) == 0 { + return nil, 0, fmt.Errorf("embedded PDF font lacks required metrics") + } + count := binary.BigEndian.Uint16(tables["maxp"][4:6]) + if count == 0 { + return nil, 0, fmt.Errorf("embedded PDF font has no glyphs") + } + changed := false + var repairedLimit uint16 + metrics := int(binary.BigEndian.Uint16(tables["hhea"][34:36])) + if metrics == 0 || metrics > int(count) { + return nil, 0, fmt.Errorf("invalid embedded PDF font metric count") + } + metricLength := 4*metrics + 2*(int(count)-metrics) + if len(tables["hmtx"]) < metricLength { + if len(tables["hmtx"]) < 4*metrics || len(tables["hmtx"])%2 != 0 { + return nil, 0, fmt.Errorf("incomplete embedded PDF font metrics: have %d, need %d", len(tables["hmtx"]), metricLength) + } + limit := metrics + (len(tables["hmtx"])-4*metrics)/2 + missing, err := pdfMissingLeftBearings(tables, limit, int(count)) + if err != nil { + return nil, 0, err + } + tables["hmtx"] = append(bytes.Clone(tables["hmtx"]), missing...) + repairedLimit = uint16(limit) + changed = true + } + if len(tables["hmtx"]) > metricLength { + tables["hmtx"] = tables["hmtx"][:metricLength] + changed = true + } + invalidCmap := false + if source.Subtype == "TrueType" || source.Subtype == "Type0" { + mapping := parseCmapMappings(tables["cmap"]) + for char, glyph := range mapping { + if glyph >= count { + delete(mapping, char) + invalidCmap = true + } + } + if invalidCmap && source.Subtype == "TrueType" { + tables["cmap"] = buildCmapTable(count, mapping) + changed = true + } + } + if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && (invalidCmap || pdfCmapOverlaps(tables["cmap"])) { + mapping := map[rune]uint16{} + for _, code := range slices.Sorted(maps.Keys(source.Unicode)) { + glyphs, err := source.Decode([]byte(code)) + if err != nil { + return nil, 0, err + } + if len(glyphs) != 1 || !glyphs[0].HasID { + return nil, 0, &pdfgo.UnsupportedError{Feature: "font without explicit glyph mapping"} + } + glyph := glyphs[0] + if glyph.ID >= count { + return nil, 0, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph.ID, count) + } + if utf8.RuneCountInString(glyph.Text) == 1 { + char, _ := utf8.DecodeRuneInString(glyph.Text) + if _, exists := mapping[char]; !exists { + mapping[char] = glyph.ID + } + } + } + addPackedGlyphMapping(mapping, count) + tables["cmap"] = buildCmapTable(count, mapping) + changed = true + } + if len(fontNamesFromTable(tables["name"])) == 0 { + fontName := pdfFontIdentity(source) + name := utf16.Encode([]rune(fontName)) + if len(name) > 32767 { + return nil, 0, fmt.Errorf("PDF font name exceeds name table capacity") + } + data := make([]byte, 42+len(name)*2) + binary.BigEndian.PutUint16(data[2:], 3) + binary.BigEndian.PutUint16(data[4:], 42) + for n, id := range []uint16{1, 4, 6} { + record := data[6+n*12:] + binary.BigEndian.PutUint16(record, 3) + binary.BigEndian.PutUint16(record[2:], 1) + binary.BigEndian.PutUint16(record[4:], 0x409) + binary.BigEndian.PutUint16(record[6:], id) + binary.BigEndian.PutUint16(record[8:], uint16(len(name)*2)) + } + for n, v := range name { + binary.BigEndian.PutUint16(data[42+n*2:], v) + } + tables["name"] = data + changed = true + } + if len(tables["post"]) == 0 { + tables["post"] = buildPostTable() + changed = true + } + if len(tables["OS/2"]) == 0 { + hhea := tables["hhea"] + tables["OS/2"] = buildOS2TableWithMetrics(int16(binary.BigEndian.Uint16(hhea[4:6])), int16(binary.BigEndian.Uint16(hhea[6:8]))) + changed = true + } + for _, tag := range []string{"cvt ", "fpgm", "prep"} { + if data, ok := tables[tag]; ok && len(data) == 0 { + delete(tables, tag) + changed = true + } + } + if !changed && !pdfSFNTMissingPadding(program) { + return program, repairedLimit, nil + } + result, err := serializeOTF(tables) + return result, repairedLimit, err +} + +// pdfFontIdentity 返回字体名称或由原始程序生成的稳定封装标识 +// 入参: source PDF字体 +// 返回: string 字体标识 +func pdfFontIdentity(source *pdfgo.Font) string { + if source.Name != "" { + return source.Name + } + checksum := sha256.Sum256(source.Program) + return fmt.Sprintf("PDF-%x", checksum[:28]) +} + +// pdfCmapOverlaps 检查格式4字符映射是否包含交叠区段 +// 入参: data cmap表数据 +// 返回: bool 是否存在交叠 +func pdfCmapOverlaps(data []byte) bool { + if len(data) < 4 { + return false + } + count := int(binary.BigEndian.Uint16(data[2:])) + if count > (len(data)-4)/8 { + return false + } + for index := range count { + offset := uint64(binary.BigEndian.Uint32(data[8+8*index:])) + if offset > uint64(len(data)) || uint64(len(data))-offset < 16 { + continue + } + sub := data[int(offset):] + if binary.BigEndian.Uint16(sub) != 4 { + continue + } + length := int(binary.BigEndian.Uint16(sub[2:])) + segments := int(binary.BigEndian.Uint16(sub[6:])) / 2 + if length > len(sub) || 16+8*segments > length { + continue + } + for i := 1; i < segments; i++ { + previous := binary.BigEndian.Uint16(sub[14+2*(i-1):]) + start := binary.BigEndian.Uint16(sub[16+2*segments+2*i:]) + if start <= previous { + return true + } + } + } + return false +} + +// pdfSFNTMissingPadding 判断字体表目录是否引用了未写入的末尾对齐字节 +// 入参: program OpenType字体数据 +// 返回: bool 是否需要重新封装 +func pdfSFNTMissingPadding(program []byte) bool { + if len(program) < 12 { + return true + } + count := int(binary.BigEndian.Uint16(program[4:6])) + if count > (len(program)-12)/16 { + return true + } + for index := range count { + record := program[12+16*index:] + offset := int(binary.BigEndian.Uint32(record[8:12])) + length := int(binary.BigEndian.Uint32(record[12:16])) + if offset > len(program) || length > len(program)-offset || (4-length%4)%4 > len(program)-offset-length { + return true + } + } + return false +} + +// pdfMissingLeftBearings 从轮廓边界恢复缺失的尾部左侧边距 +// 入参: tables 字体表, start 首个缺失字形, count 字形总数 +// 返回: []byte 补齐的hmtx数据, error 无法读取轮廓 +func pdfMissingLeftBearings(tables map[string][]byte, start, count int) ([]byte, error) { + head, loca, glyf := tables["head"], tables["loca"], tables["glyf"] + if len(head) < 54 || len(glyf) == 0 { + return nil, fmt.Errorf("embedded PDF font lacks glyph outlines for missing metrics") + } + format := int(int16(binary.BigEndian.Uint16(head[50:52]))) + entrySize := 2 + if format == 1 { + entrySize = 4 + } else if format != 0 { + return nil, fmt.Errorf("invalid embedded PDF font location format") + } + if len(loca) < (count+1)*entrySize { + return nil, fmt.Errorf("incomplete embedded PDF font glyph locations") + } + location := func(index int) int { + if format == 0 { + return int(binary.BigEndian.Uint16(loca[index*2:])) * 2 + } + return int(binary.BigEndian.Uint32(loca[index*4:])) + } + result := make([]byte, 2*(count-start)) + for index := start; index < count; index++ { + from, to := location(index), location(index+1) + if from > to || to > len(glyf) { + return nil, fmt.Errorf("invalid embedded PDF font glyph location") + } + if from == to { + continue + } + if to-from < 10 { + return nil, fmt.Errorf("incomplete embedded PDF font glyph outline") + } + copy(result[(index-start)*2:], glyf[from+2:from+4]) + } + return result, nil +} diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 78f47d9..f36f2fa 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -155,7 +155,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if stroke { color, overprint = path.Style.Stroke, path.Style.StrokeOverprint } - if color.Tiling != nil || color.Axial != nil || color.Radial != nil || color.Mesh != nil || overprint && pdfOverprintNeedsSeparation(color) { + if color.Tiling != nil || color.Axial != nil || color.Radial != nil || color.Function != nil || color.Mesh != nil || overprint && pdfOverprintNeedsSeparation(color) { return fmt.Errorf("%w: patterned or overprinted group content", errPDFGroupRaster) } if color.Alpha == 0 { @@ -165,7 +165,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) if paint == nil { paint, clips = &color, path.Style.Clips } else if !reflect.DeepEqual(clips, path.Style.Clips) { - return &pdfgo.UnsupportedError{Feature: "independently clipped transparent group"} + return fmt.Errorf("%w: independently clipped group content", errPDFGroupRaster) } else if color.Alpha != 1 || !reflect.DeepEqual(*paint, color) { regions = append(regions, pdfPaintRegion{*paint, combined}) combined = nil @@ -335,7 +335,10 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, if _, err := editor.AddPage(p.pageWidth, p.pageHeight); err != nil { return nil, box, err } - 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{}, fontMetrics: map[string]FontMetrics{}} + 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 err := build(&local); err != nil { return nil, box, err } @@ -387,6 +390,7 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, // 返回: image.Image 透明图像, error 后端渲染错误 func (p *pdfImporter) renderGroupScene(source *RasterPage, box Box) (image.Image, error) { scene := *source + scene.DPI = p.rasterDPI scene.Commands = append([]RasterCommand(nil), source.Commands...) scene.Width, scene.Height = box.W, box.H for i := range scene.Commands { @@ -567,7 +571,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { } visitor.Path = func(mark pdfgo.PathMark) error { paint := mark.Style.Fill - if !mark.Fill || mark.Stroke || paint.Alpha != 1 || paint.CMYK != nil || paint.Axial != nil || paint.Radial != 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) { + 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"} } visible, err := opacity(paint.RGB[0]) diff --git a/ofdgo_pdf_halftone.go b/ofdgo_pdf_halftone.go index 30a273b..6831798 100644 --- a/ofdgo_pdf_halftone.go +++ b/ofdgo_pdf_halftone.go @@ -16,21 +16,14 @@ package ofdgo import "github.com/xiaoqidun/pdfgo" -// halftone 检查连续色调转换及不可忽略的传递函数,避免将打印网屏误用于屏幕显示 +// halftone 检查连续色调转换,避免将打印网屏误用于屏幕显示 // 入参: style 图形状态 -// 返回: error 严格模式或非恒等传递函数无法表达 +// 返回: error 严格模式无法保留设备网屏 func (p *pdfImporter) halftone(style pdfgo.Style) error { h := style.Halftone if h == nil || h.Type == 0 { return nil } - identity, err := h.IdentityTransfer(p.reader) - if err != nil { - return err - } - if !identity { - return &pdfgo.UnsupportedError{Feature: "halftone transfer function conversion"} - } if p.warning == nil { return &pdfgo.UnsupportedError{Feature: "device halftone conversion"} } diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index d8b3509..7fd3213 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -205,6 +205,16 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } continue } + if annotation.Subtype == "Redact" { + if strict { + return &pdfgo.UnsupportedError{Feature: "pending PDF redaction conversion"} + } + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } + p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF pending redaction appearance imported; content removal not applied; overlay data not transferred"}) + continue + } if annotation.Subtype != "Link" && pdfAnnotationType(annotation.Subtype) != "" { if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { return err diff --git a/ofdgo_pdf_mesh.go b/ofdgo_pdf_mesh.go index 06d681b..ca32731 100644 --- a/ofdgo_pdf_mesh.go +++ b/ofdgo_pdf_mesh.go @@ -35,14 +35,9 @@ type pdfMeshTriangle struct { // pdfMeshKey 区分网格及其输出混合空间 type pdfMeshKey struct { - mesh *pdfgo.MeshGradient - space *pdfgo.ColorSpace -} - -// pdfMeshPixel 保存抗锯齿覆盖率与源空间合成分量 -type pdfMeshPixel struct { - values [4]float64 - shape float64 + mesh *pdfgo.MeshGradient + space *pdfgo.ColorSpace + conversion pdfgo.ColorConversion } // pdfMeshSample 保存采样点参数,patch为一基编号,零表示未覆盖 @@ -77,7 +72,7 @@ func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error) } color := pdfgo.Paint{RGB: rgb, Alpha: 1} if mesh.Space.Model == "DeviceCMYK" && !mesh.Space.Calibrated() { - color.CMYK = &values + color.CMYK = (*[4]float64)(values) } converted := p.color(color) shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, Color: ShdColor{Value: converted.Value, ColorSpace: converted.ColorSpace}}) @@ -160,10 +155,10 @@ func (p *pdfImporter) meshTriangles(mesh *pdfgo.MeshGradient) ([]pdfMeshTriangle } // mesh 对当前图块执行四倍超采样,曲面重叠按后补片及较大v、u取值 -// 入参: mesh 原曲面, space 输出混合空间 -// 返回: []pdfMeshPixel 颜色及覆盖, error 几何或颜色错误 -func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace) ([]pdfMeshPixel, error) { - key := pdfMeshKey{mesh, space} +// 入参: mesh 原曲面, space 输出混合空间, conversion 设备转换函数 +// 返回: []pdfShadingPixel 颜色及覆盖, error 几何或颜色错误 +func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace, conversion pdfgo.ColorConversion) ([]pdfShadingPixel, error) { + key := pdfMeshKey{mesh, space, conversion} if pixels, ok := c.meshes[key]; ok { return pixels, nil } @@ -222,23 +217,49 @@ func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace) } } } - pixels := make([]pdfMeshPixel, c.width*c.height) + pixels := make([]pdfShadingPixel, c.width*c.height) + var background [4]float64 + if mesh.Background != nil { + var err error + background, err = space.ConvertWith(mesh.Background[:mesh.Space.Components()], mesh.Space, mesh.Intent, conversion) + if err != nil { + return nil, err + } + } + var inverse pdfgo.Matrix + if mesh.Bounds != nil { + var ok bool + inverse, ok = c.importer.matrix.Mul(mesh.Matrix).Inverse() + if !ok { + return nil, fmt.Errorf("singular mesh bounds transform") + } + } 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 { + continue + } + } sample := grid[y*w+x] - if sample.patch == 0 { + if sample.patch == 0 && mesh.Background == nil { continue } - values, err := mesh.ValuesAt(sample.patch-1, sample.u, sample.v) - if err != nil { - return nil, err - } - values, err = space.Convert(values[:mesh.Space.Components()], mesh.Space, mesh.Intent) - if err != nil { - return nil, err + 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 + } + values, err = space.ConvertWith(values[:mesh.Space.Components()], mesh.Space, mesh.Intent, conversion) + if err != nil { + return nil, err + } } pixel := &pixels[(y/samples)*c.width+x/samples] for i, value := range values { @@ -258,7 +279,7 @@ func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace) pixel.shape /= samples * samples } if c.meshes == nil { - c.meshes = map[pdfMeshKey][]pdfMeshPixel{} + c.meshes = map[pdfMeshKey][]pdfShadingPixel{} } c.meshes[key] = pixels return pixels, nil diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 22417a6..df5aee3 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -16,17 +16,12 @@ package ofdgo import ( "bytes" - "crypto/sha256" - "encoding/binary" "fmt" "image" "image/color" "image/png" - "maps" "math" - "slices" "strconv" - "unicode/utf16" "unicode/utf8" "github.com/xiaoqidun/pdfgo" @@ -81,7 +76,10 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { if err := p.flushPath(); err != nil { return err } - if mark.Image.ImageMask && (mark.Style.Fill.Axial != nil || mark.Style.Fill.Radial != nil) { + if mark.Image.ImageMask && (mark.Style.Fill.Axial != nil || mark.Style.Fill.Radial != nil || mark.Style.Fill.Function != nil || mark.Style.Fill.Mesh != nil) { + if p.warning != nil { + return p.compositeRegion(nil, pdfCompositeNode{image: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false) + } return &pdfgo.UnsupportedError{Feature: "gradient stencil image"} } var err error @@ -180,13 +178,13 @@ func (p *pdfImporter) appendImage(mark pdfgo.ImageMark, id string) error { // 入参: mark PDF文字绘制信息 // 返回: error 错误信息 func (p *pdfImporter) text(mark pdfgo.TextMark) error { + if mark.Font.Subtype == pdfgo.Name("Type3") && mark.Mode == 3 { + return nil + } if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { return &pdfgo.UnsupportedError{Feature: "text blend mode " + string(mark.Style.BlendMode)} } if mark.Font.Subtype == pdfgo.Name("Type3") { - if mark.Clip != nil { - return &pdfgo.UnsupportedError{Feature: "Type3 text clipping"} - } if err := p.flushPath(); err != nil { return err } @@ -226,36 +224,24 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { if embedded && font.ProgramType != "FontFile" && font.ProgramType != "FontFile2" && font.ProgramType != "OpenType" && font.ProgramType != "Type1C" && font.ProgramType != "CIDFontType0C" { return &pdfgo.UnsupportedError{Feature: "font program " + string(font.ProgramType)} } + var imported *pdfImportedFont + if embedded { + imported, err = p.importedFont(font) + if err != nil { + return err + } + } id := p.fontIDs[font] if id == "" { var err error if embedded { - var type1 *type1Program - if font.ProgramType == "FontFile" { - parsed, err := parseType1Program(font.Program) - if err != nil { - return fmt.Errorf("PDF font %s program: %w", font.Name, err) - } - type1 = &parsed - if p.type1Glyphs == nil { - p.type1Glyphs = make(map[*pdfgo.Font]map[string]uint16) - } - p.type1Glyphs[font] = parsed.glyphIDs() - } - var program []byte - var limit uint16 - program, limit, err = pdfFontProgram(font, type1) - if err != nil { - return fmt.Errorf("PDF font %s program: %w", font.Name, err) - } - if limit != 0 { - if p.fontRepairLimit == nil { - p.fontRepairLimit = make(map[*pdfgo.Font]uint16) - } - p.fontRepairLimit[font] = limit + if imported.repairLimit != 0 { p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF embedded font lacks trailing side bearings; unused metrics reconstructed from glyph bounds"}) } - id, err = p.editor.AddFont(FontFile{Name: font.Name + ".ttf", Data: program}, 0) + id, err = p.editor.addFontResource(imported.resource, imported.checksum) + if err == nil { + p.editor.fontMetrics[id] = imported.metrics + } } else { id, err = p.editor.AddExternalFont(font.Name) } @@ -264,9 +250,9 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { } p.fontIDs[font] = id } - if limit := p.fontRepairLimit[font]; limit != 0 { + if embedded && imported.repairLimit != 0 { for _, glyph := range mark.Glyphs { - if glyph.HasID && glyph.ID >= limit { + if glyph.HasID && glyph.ID >= imported.repairLimit { return &pdfgo.UnsupportedError{Feature: "embedded font glyph with missing side bearing"} } } @@ -274,33 +260,39 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { var metrics FontMetrics var outline FontOutlines if embedded { - metrics = p.fontMetrics[id] - if metrics == nil { - if p.editor.backends.Fonts == nil { - return fmt.Errorf("PDF font backend unavailable") + metrics = imported.metrics + outline = metrics.(FontOutlines) + } + fill, stroke, visible := paintMode == 0 || paintMode == 2, paintMode == 1 || paintMode == 2, paintMode != 3 + if stroke && !embedded { + if resolved, err := p.editor.editorFont(id); err == nil { + if provider, ok := resolved.(FontOutlines); ok { + metrics, outline = resolved, provider + for _, glyph := range mark.Glyphs { + char, _ := utf8.DecodeRuneInString(glyph.Text) + if utf8.RuneCountInString(glyph.Text) != 1 || resolved.GlyphIndex(char) == 0 && char != 0 { + metrics, outline = nil, nil + break + } + } } - metrics, err = p.editor.backends.Fonts.OpenFont(p.editor.fonts[id].Data) - if err != nil { - return fmt.Errorf("PDF font %s metrics: %w", font.Name, err) - } - p.fontMetrics[id] = metrics - } - var ok bool - outline, ok = metrics.(FontOutlines) - if !ok { - return fmt.Errorf("PDF font backend does not provide glyph outlines") } } + outlineStroke := stroke && outline != nil + var glyphPaths []pdfgo.Path const unit = 25.4 / 72 m := p.matrix.Mul(mark.Matrix).Mul(pdfgo.Matrix{1 / unit, 0, 0, -1 / unit, 0, 0}) var points []pdfgo.Point object := TextObject{Font: id, Size: mark.Size * unit, HScale: mark.HorizontalScale} position := 0 for n, glyph := range mark.Glyphs { + if err := p.ctx.Err(); err != nil { + return err + } gid := glyph.ID if embedded && font.ProgramType == "FontFile" { var found bool - gid, found = p.type1Glyphs[font][glyph.Name] + gid, found = imported.type1Glyphs[glyph.Name] if !found { return fmt.Errorf("missing Type1 glyph %q in PDF font %s", glyph.Name, font.Name) } @@ -329,12 +321,18 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { if !embedded && glyph.Text == "" { return &pdfgo.UnsupportedError{Feature: "unmapped external font character"} } + if outlineStroke && !embedded { + char, _ := utf8.DecodeRuneInString(glyph.Text) + gid = metrics.GlyphIndex(char) + } origin := mark.Positions[n] object.TextCode = append(object.TextCode, TextCode{X: pdfNumbers(origin.X * unit), Y: pdfNumbers(-origin.Y * unit), Value: glyph.Text}) if embedded { count := utf8.RuneCountInString(glyph.Text) object.CGTransform = append(object.CGTransform, CGTransform{CodePosition: position, CodeCount: count, GlyphCount: 1, Glyphs: strconv.Itoa(int(gid))}) position += count + } + if embedded || outlineStroke { path, err := outline.GlyphOutline(gid, object.Size) if err != nil { return fmt.Errorf("PDF font %s outline: %w", font.Name, err) @@ -360,6 +358,14 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { for _, point := range []pdfgo.Point{{X: bounds.X, Y: bounds.Y}, {X: bounds.X + bounds.W, Y: bounds.Y}, {X: bounds.X, Y: bounds.Y + bounds.H}, {X: bounds.X + bounds.W, Y: bounds.Y + bounds.H}} { points = append(points, m.Apply(pdfgo.Point{X: origin.X*unit + point.X*mark.HorizontalScale, Y: -origin.Y*unit + point.Y})) } + if outlineStroke { + matrix := mark.Matrix.Mul(pdfgo.Matrix{mark.HorizontalScale / unit, 0, 0, -1 / unit, origin.X, origin.Y}) + glyphPath, err := pdfGlyphPath(path, matrix) + if err != nil { + return err + } + glyphPaths = append(glyphPaths, glyphPath) + } } else { width := glyph.Width / 1000 * object.Size * mark.HorizontalScale for _, point := range []pdfgo.Point{{X: 0, Y: -object.Size}, {X: width, Y: -object.Size}, {X: 0, Y: object.Size / 4}, {X: width, Y: object.Size / 4}} { @@ -368,8 +374,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { } } box := pdfBounds(points) - fill, stroke, visible := paintMode == 0 || paintMode == 2, paintMode == 1 || paintMode == 2, paintMode != 3 - if stroke { + if stroke && !outlineStroke { strokeMatrix := mark.StrokeMatrix if strokeMatrix == (pdfgo.Matrix{}) { strokeMatrix = pdfgo.Identity() @@ -394,7 +399,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { } } object.Boundary = pdfBoundary(box) - if stroke { + if stroke && !outlineStroke { strokePaint, err := p.paintColor(mark.Style.Stroke, box) if err != nil { return err @@ -410,7 +415,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { object.Stroke = &stroke object.Visible = &visible object.FillColor = p.color(mark.Style.Fill) - if fill { + if fill && !outlineStroke { object.FillColor, err = p.paintColor(mark.Style.Fill, box) if err != nil { return err @@ -432,264 +437,26 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { } p.clipTexts[mark.Clip] = clipObject } + if outlineStroke { + for _, path := range glyphPaths { + if len(path.Segments) == 0 { + continue + } + if err := p.ctx.Err(); err != nil { + return err + } + matrix := mark.StrokeMatrix + if matrix == (pdfgo.Matrix{}) { + matrix = pdfgo.Identity() + } + if err := p.appendPath(pdfgo.PathMark{Path: path, Style: mark.Style, Fill: fill, Stroke: true, StrokeMatrix: matrix}); err != nil { + return err + } + } + object.Visible = new(bool) + object.Fill, object.Stroke = new(bool), new(bool) + } p.objects = append(p.objects, GraphicObject{Type: "TextObject", TextObject: object}) p.report.TextObjects++ return nil } - -// pdfFontProgram 为PDF子集字体补齐封装表,保留字形轮廓和编号 -// 复合字体的重复映射区段按PDF字符映射重建,不改变CID对应的字形 -// 入参: source PDF字体, type1 已解析的Type1程序,nil时按需解析 -// 返回: []byte 封装后的字体数据, uint16 缺失度量前的完整字形数, error 错误信息 -func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, error) { - program := source.Program - if source.ProgramType == "FontFile" { - if type1 == nil { - parsed, err := parseType1Program(program) - if err != nil { - return nil, 0, err - } - type1 = &parsed - } - var err error - program, err = type1.toCFF(pdfFontIdentity(source)) - if err != nil { - return nil, 0, err - } - } - bareCFF := len(program) >= 4 && program[0] == 1 && program[1] == 0 && program[2] >= 4 && program[3] >= 1 && program[3] <= 4 - if source.ProgramType == "Type1C" || source.ProgramType == "CIDFontType0C" || bareCFF { - var err error - program, _, err = wrapCFFToOTF(program) - if err != nil { - return nil, 0, err - } - } - tables, err := fontFileTables(program, 0) - if err != nil { - return nil, 0, err - } - if len(tables["head"]) < 54 || len(tables["maxp"]) < 6 || len(tables["hhea"]) < 36 || len(tables["hmtx"]) == 0 { - return nil, 0, fmt.Errorf("embedded PDF font lacks required metrics") - } - count := binary.BigEndian.Uint16(tables["maxp"][4:6]) - if count == 0 { - return nil, 0, fmt.Errorf("embedded PDF font has no glyphs") - } - changed := false - var repairedLimit uint16 - metrics := int(binary.BigEndian.Uint16(tables["hhea"][34:36])) - if metrics == 0 || metrics > int(count) { - return nil, 0, fmt.Errorf("invalid embedded PDF font metric count") - } - metricLength := 4*metrics + 2*(int(count)-metrics) - if len(tables["hmtx"]) < metricLength { - if len(tables["hmtx"]) < 4*metrics || len(tables["hmtx"])%2 != 0 { - return nil, 0, fmt.Errorf("incomplete embedded PDF font metrics: have %d, need %d", len(tables["hmtx"]), metricLength) - } - limit := metrics + (len(tables["hmtx"])-4*metrics)/2 - missing, err := pdfMissingLeftBearings(tables, limit, int(count)) - if err != nil { - return nil, 0, err - } - tables["hmtx"] = append(bytes.Clone(tables["hmtx"]), missing...) - repairedLimit = uint16(limit) - changed = true - } - if len(tables["hmtx"]) > metricLength { - tables["hmtx"] = tables["hmtx"][:metricLength] - changed = true - } - invalidCmap := false - if source.Subtype == "TrueType" || source.Subtype == "Type0" { - mapping := parseCmapMappings(tables["cmap"]) - for char, glyph := range mapping { - if glyph >= count { - delete(mapping, char) - invalidCmap = true - } - } - if invalidCmap && source.Subtype == "TrueType" { - tables["cmap"] = buildCmapTable(count, mapping) - changed = true - } - } - if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && (invalidCmap || pdfCmapOverlaps(tables["cmap"])) { - mapping := map[rune]uint16{} - for _, code := range slices.Sorted(maps.Keys(source.Unicode)) { - glyphs, err := source.Decode([]byte(code)) - if err != nil { - return nil, 0, err - } - if len(glyphs) != 1 || !glyphs[0].HasID { - return nil, 0, &pdfgo.UnsupportedError{Feature: "font without explicit glyph mapping"} - } - glyph := glyphs[0] - if glyph.ID >= count { - return nil, 0, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph.ID, count) - } - if utf8.RuneCountInString(glyph.Text) == 1 { - char, _ := utf8.DecodeRuneInString(glyph.Text) - if _, exists := mapping[char]; !exists { - mapping[char] = glyph.ID - } - } - } - addPackedGlyphMapping(mapping, count) - tables["cmap"] = buildCmapTable(count, mapping) - changed = true - } - if len(fontNamesFromTable(tables["name"])) == 0 { - fontName := pdfFontIdentity(source) - name := utf16.Encode([]rune(fontName)) - if len(name) > 32767 { - return nil, 0, fmt.Errorf("PDF font name exceeds name table capacity") - } - data := make([]byte, 42+len(name)*2) - binary.BigEndian.PutUint16(data[2:], 3) - binary.BigEndian.PutUint16(data[4:], 42) - for n, id := range []uint16{1, 4, 6} { - record := data[6+n*12:] - binary.BigEndian.PutUint16(record, 3) - binary.BigEndian.PutUint16(record[2:], 1) - binary.BigEndian.PutUint16(record[4:], 0x409) - binary.BigEndian.PutUint16(record[6:], id) - binary.BigEndian.PutUint16(record[8:], uint16(len(name)*2)) - } - for n, v := range name { - binary.BigEndian.PutUint16(data[42+n*2:], v) - } - tables["name"] = data - changed = true - } - if len(tables["post"]) == 0 { - tables["post"] = buildPostTable() - changed = true - } - if len(tables["OS/2"]) == 0 { - hhea := tables["hhea"] - tables["OS/2"] = buildOS2TableWithMetrics(int16(binary.BigEndian.Uint16(hhea[4:6])), int16(binary.BigEndian.Uint16(hhea[6:8]))) - changed = true - } - for _, tag := range []string{"cvt ", "fpgm", "prep"} { - if data, ok := tables[tag]; ok && len(data) == 0 { - delete(tables, tag) - changed = true - } - } - if !changed && !pdfSFNTMissingPadding(program) { - return program, repairedLimit, nil - } - result, err := serializeOTF(tables) - return result, repairedLimit, err -} - -// pdfFontIdentity 返回字体名称或由原始程序生成的稳定封装标识 -// 入参: source PDF字体 -// 返回: string 字体标识 -func pdfFontIdentity(source *pdfgo.Font) string { - if source.Name != "" { - return source.Name - } - checksum := sha256.Sum256(source.Program) - return fmt.Sprintf("PDF-%x", checksum[:28]) -} - -// pdfCmapOverlaps 检查格式4字符映射是否包含交叠区段 -// 入参: data cmap表数据 -// 返回: bool 是否存在交叠 -func pdfCmapOverlaps(data []byte) bool { - if len(data) < 4 { - return false - } - count := int(binary.BigEndian.Uint16(data[2:])) - if count > (len(data)-4)/8 { - return false - } - for index := range count { - offset := uint64(binary.BigEndian.Uint32(data[8+8*index:])) - if offset > uint64(len(data)) || uint64(len(data))-offset < 16 { - continue - } - sub := data[int(offset):] - if binary.BigEndian.Uint16(sub) != 4 { - continue - } - length := int(binary.BigEndian.Uint16(sub[2:])) - segments := int(binary.BigEndian.Uint16(sub[6:])) / 2 - if length > len(sub) || 16+8*segments > length { - continue - } - for i := 1; i < segments; i++ { - previous := binary.BigEndian.Uint16(sub[14+2*(i-1):]) - start := binary.BigEndian.Uint16(sub[16+2*segments+2*i:]) - if start <= previous { - return true - } - } - } - return false -} - -// pdfSFNTMissingPadding 判断字体表目录是否引用了未写入的末尾对齐字节 -// 入参: program OpenType字体数据 -// 返回: bool 是否需要重新封装 -func pdfSFNTMissingPadding(program []byte) bool { - if len(program) < 12 { - return true - } - count := int(binary.BigEndian.Uint16(program[4:6])) - if count > (len(program)-12)/16 { - return true - } - for index := range count { - record := program[12+16*index:] - offset := int(binary.BigEndian.Uint32(record[8:12])) - length := int(binary.BigEndian.Uint32(record[12:16])) - if offset > len(program) || length > len(program)-offset || (4-length%4)%4 > len(program)-offset-length { - return true - } - } - return false -} - -// pdfMissingLeftBearings 从轮廓边界恢复缺失的尾部左侧边距 -// 入参: tables 字体表, start 首个缺失字形, count 字形总数 -// 返回: []byte 补齐的hmtx数据, error 无法读取轮廓 -func pdfMissingLeftBearings(tables map[string][]byte, start, count int) ([]byte, error) { - head, loca, glyf := tables["head"], tables["loca"], tables["glyf"] - if len(head) < 54 || len(glyf) == 0 { - return nil, fmt.Errorf("embedded PDF font lacks glyph outlines for missing metrics") - } - format := int(int16(binary.BigEndian.Uint16(head[50:52]))) - entrySize := 2 - if format == 1 { - entrySize = 4 - } else if format != 0 { - return nil, fmt.Errorf("invalid embedded PDF font location format") - } - if len(loca) < (count+1)*entrySize { - return nil, fmt.Errorf("incomplete embedded PDF font glyph locations") - } - location := func(index int) int { - if format == 0 { - return int(binary.BigEndian.Uint16(loca[index*2:])) * 2 - } - return int(binary.BigEndian.Uint32(loca[index*4:])) - } - result := make([]byte, 2*(count-start)) - for index := start; index < count; index++ { - from, to := location(index), location(index+1) - if from > to || to > len(glyf) { - return nil, fmt.Errorf("invalid embedded PDF font glyph location") - } - if from == to { - continue - } - if to-from < 10 { - return nil, fmt.Errorf("incomplete embedded PDF font glyph outline") - } - copy(result[(index-start)*2:], glyf[from+2:from+4]) - } - return result, nil -} diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index 16d876e..b6defb4 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -15,11 +15,29 @@ package ofdgo import ( + "fmt" + "image" "math" "github.com/xiaoqidun/pdfgo" ) +// pdfShadingPixel 保存抗锯齿覆盖率与混合空间分量 +type pdfShadingPixel struct { + values [4]float64 + shape float64 +} + +// pdfGradientKey 区分渐变画刷及其输出混合空间 +type pdfGradientKey struct { + axial *pdfgo.AxialGradient + radial *pdfgo.RadialGradient + function *pdfgo.FunctionGradient + space *pdfgo.ColorSpace + geometry pdfCompositeKey + conversion pdfgo.ColorConversion +} + // gradientSegments 保留线性源颜色的设备分量,校准颜色经源空间转换 // 入参: stops 源颜色分段, space 源颜色空间 // 返回: []ShdSegment OFD颜色分段, error 不可线性表达的变换 @@ -63,9 +81,12 @@ func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) // 入参: paint 画刷 // 返回: error 不可等价表达的渐变 func pdfGradientError(paint pdfgo.Paint) error { + if paint.Function != nil { + return &pdfgo.UnsupportedError{Feature: "function shading conversion"} + } if paint.Mesh != nil { mesh := paint.Mesh - if len(mesh.Patches) != 0 || len(mesh.Triangles) == 0 || mesh.UsesFunction() { + if len(mesh.Patches) != 0 || len(mesh.Triangles) == 0 || mesh.UsesFunction() || mesh.Background != nil || mesh.Bounds != nil { return &pdfgo.UnsupportedError{Feature: "nonlinear mesh conversion"} } if mesh.Space.Calibrated() && !mesh.Space.SRGBEquivalent() { @@ -73,9 +94,15 @@ func pdfGradientError(paint pdfgo.Paint) error { } } if paint.Axial != nil { + if paint.Axial.Background != nil || paint.Axial.Bounds != nil { + return &pdfgo.UnsupportedError{Feature: "bounded axial pattern conversion"} + } return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space) } if paint.Radial != nil { + if paint.Radial.Background != nil || paint.Radial.Bounds != nil { + return &pdfgo.UnsupportedError{Feature: "bounded radial pattern conversion"} + } return pdfGradientStopsError(paint.Radial.Stops, paint.Radial.Space) } return nil @@ -96,6 +123,8 @@ func (p *pdfImporter) gradientPath(mark pdfgo.PathMark) error { space = paint.Axial.Space case paint.Radial != nil: space = paint.Radial.Space + case paint.Function != nil: + space = paint.Function.Space case paint.Mesh != nil: space = paint.Mesh.Space } @@ -111,6 +140,218 @@ func (p *pdfImporter) gradientPath(mark pdfgo.PathMark) error { return p.compositeRegion(nil, pdfCompositeNode{path: &mark}, space, false) } +// gradient 合成二维函数着色及带区域的渐变,保留边缘覆盖和对象透明度 +// 入参: paint 渐变画刷, space 混合空间, node 几何图元,可为空, outline 是否描边 +// 返回: []pdfShadingPixel 颜色及覆盖, error 求值或变换错误 +func (c *pdfCompositor) gradient(paint pdfgo.Paint, space *pdfgo.ColorSpace, node *pdfCompositeNode, outline bool) ([]pdfShadingPixel, error) { + if paint.Axial != nil && paint.Axial.Background == nil && paint.Axial.Bounds == nil || paint.Radial != nil && paint.Radial.Background == nil && paint.Radial.Bounds == nil { + return nil, nil + } + key := pdfGradientKey{axial: paint.Axial, radial: paint.Radial, function: paint.Function, space: space} + var conversion pdfgo.ColorConversion + if node != nil { + key.geometry = pdfCompositeKey{path: node.path, text: node.text, image: node.image, stroke: outline} + style, _, _ := node.style() + conversion = style.ColorConversion + key.conversion = conversion + } + if pixels, ok := c.gradients[key]; ok { + return pixels, nil + } + var background *[4]float64 + var bounds *pdfgo.Rectangle + var matrix pdfgo.Matrix + var source *pdfgo.ColorSpace + var intent pdfgo.Name + if paint.Axial != nil { + g := *paint.Axial + background, bounds, matrix, source, intent = g.Background, g.Bounds, g.Matrix, g.Space, g.Intent + g.Background, g.Bounds = nil, nil + paint.Axial = &g + } else if paint.Radial != nil { + g := *paint.Radial + background, bounds, matrix, source, intent = g.Background, g.Bounds, g.Matrix, g.Space, g.Intent + g.Background, g.Bounds = nil, nil + g.Matrix = pdfgo.Matrix{} + paint.Radial = &g + } else { + g := *paint.Function + background, bounds, matrix, source, intent = g.Background, g.Bounds, g.PatternMatrix, g.Space, g.Intent + g.Background, g.Bounds = nil, nil + paint.Function = &g + } + var backgroundValues [4]float64 + if background != nil { + var err error + backgroundValues, err = space.ConvertWith(background[:source.Components()], source, intent, conversion) + if err != nil { + return nil, err + } + } + inverse := pdfgo.Identity() + if matrix != (pdfgo.Matrix{}) && (bounds != nil || paint.Radial != nil) { + var ok bool + inverse, ok = matrix.Inverse() + if !ok { + return nil, fmt.Errorf("singular shading transform") + } + } + var functionInverse pdfgo.Matrix + if paint.Function != nil { + var ok bool + functionInverse, ok = paint.Function.Matrix.Inverse() + if !ok { + return nil, fmt.Errorf("singular function shading transform") + } + } + const samples = 4 + var coverage image.Image + domainClipped := node != nil && paint.Function != nil && background == nil + if node != nil { + local, importer := *c, *c.importer + importer.rasterDPI *= samples + local.importer = &importer + var err error + if domainClipped { + geometry, ok := c.cache.shadings[key] + if !ok { + g := paint.Function + n := pdfShadingClippedNode(*node, g.Domain, g.Matrix) + geometry.scene, geometry.box, err = c.importer.compileGroup(func(p *pdfImporter) error { return n.coverage(p, outline) }) + if err != nil { + return nil, err + } + if c.cache.shadings == nil { + c.cache.shadings = map[pdfGradientKey]pdfCompositeGeometry{} + } + c.cache.shadings[key] = geometry + } + if geometry.scene != nil { + coverage, err = importer.renderGroupScene(geometry.scene, c.box) + } + } else { + coverage, err = local.coverage(*node, outline) + } + if err != nil { + return nil, err + } + if coverage == nil { + return make([]pdfShadingPixel, c.width*c.height), nil + } + } + step := 25.4 / c.importer.rasterDPI + pixels := make([]pdfShadingPixel, c.width*c.height) + for y := range c.height { + if err := c.importer.ctx.Err(); err != nil { + return nil, err + } + for x := range c.width { + pixel := &pixels[y*c.width+x] + for sy := range samples { + for sx := range samples { + weight := 1.0 + if coverage != nil { + _, _, _, a := coverage.At(x*samples+sx, y*samples+sy).RGBA() + weight = float64(a) / 65535 + if weight == 0 { + continue + } + } + pagePoint := c.inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+(float64(sx)+.5)/samples)*step, Y: c.box.Y + (float64(y)+(float64(sy)+.5)/samples)*step}) + point := pagePoint + local := point + if bounds != nil || paint.Radial != nil { + local = inverse.Apply(point) + } + if bounds != nil { + if local.X < bounds.XMin || local.X > bounds.XMax || local.Y < bounds.YMin || local.Y > bounds.YMax { + continue + } + } + if paint.Radial != nil { + point = local + } + var values [4]float64 + var visible bool + var err error + if g := paint.Function; g != nil { + local = functionInverse.Apply(point) + visible = domainClipped || local.X >= g.Domain.XMin && local.X <= g.Domain.XMax && local.Y >= g.Domain.YMin && local.Y <= g.Domain.YMax + if visible { + values, err = g.ValuesAt(local) + if err == nil { + values, err = space.ConvertWith(values[:source.Components()], source, intent, conversion) + } + } + } else { + values, visible, err = pdfCompositeColor(paint, point, space, intent, conversion) + } + if err != nil { + return nil, err + } + if !visible { + if background == nil { + continue + } + values = backgroundValues + } + if node != nil && node.image != nil { + _, alpha, err := c.imageColor(node.image, pagePoint, space) + if err != nil { + return nil, err + } + weight *= alpha + } + for i, value := range values { + pixel.values[i] += value * weight + } + pixel.shape += weight + } + } + if pixel.shape != 0 { + for i := range pixel.values { + pixel.values[i] /= pixel.shape + } + pixel.shape /= samples * samples + } + } + } + if c.gradients == nil { + c.gradients = map[pdfGradientKey][]pdfShadingPixel{} + } + c.gradients[key] = pixels + return pixels, nil +} + +// pdfShadingClippedNode 将函数定义域作为几何裁剪,避免重合边界的覆盖率重复相乘 +// 入参: node 原图元, box 定义域矩形, matrix 定义域到页面的变换 +// 返回: pdfCompositeNode 带独立裁剪的图元 +func pdfShadingClippedNode(node pdfCompositeNode, box pdfgo.Rectangle, matrix pdfgo.Matrix) pdfCompositeNode { + clip := pdfgo.Path{Segments: []pdfgo.Segment{ + {Operator: "M", Points: []pdfgo.Point{matrix.Apply(pdfgo.Point{X: box.XMin, Y: box.YMin})}}, + {Operator: "L", Points: []pdfgo.Point{matrix.Apply(pdfgo.Point{X: box.XMax, Y: box.YMin})}}, + {Operator: "L", Points: []pdfgo.Point{matrix.Apply(pdfgo.Point{X: box.XMax, Y: box.YMax})}}, + {Operator: "L", Points: []pdfgo.Point{matrix.Apply(pdfgo.Point{X: box.XMin, Y: box.YMax})}}, + {Operator: "C"}, + }} + var style *pdfgo.Style + if node.path != nil { + mark := *node.path + node.path = &mark + style = &mark.Style + } else if node.text != nil { + mark := *node.text + node.text = &mark + style = &mark.Style + } else { + mark := *node.image + node.image = &mark + style = &mark.Style + } + style.Clips = append(append([]pdfgo.Path(nil), style.Clips...), clip) + return node +} + // pdfShadingPosition 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色 // 入参: paint 渐变画刷, point 页面坐标 // 返回: float64 归一化参数, bool 是否着色 diff --git a/ofdgo_pdf_text_group.go b/ofdgo_pdf_text_group.go new file mode 100644 index 0000000..31e9aa8 --- /dev/null +++ b/ofdgo_pdf_text_group.go @@ -0,0 +1,115 @@ +// 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 "github.com/xiaoqidun/pdfgo" + +// disjointText 检查实际字形轮廓是否互不相交,允许无重叠挖空文字直接保留 +// 入参: node 隐式文字组 +// 返回: bool 是否互不相交, error 轮廓编译错误 +func (p *pdfImporter) disjointText(node pdfCompositeNode) (bool, error) { + cache := p.compositingCache() + if result, found := cache.textDisjoint[node.group]; found { + return result, nil + } + geometry, err := p.renderer.Geometry() + if err != nil { + return false, err + } + var inverse pdfgo.Matrix + var boxes []Box + for _, child := range node.children { + mark := child.text + if mark == nil { + mark = child.glyph + } + if mark == nil || len(mark.Glyphs) != 1 { + return false, nil + } + if inverse == (pdfgo.Matrix{}) { + var ok bool + inverse, ok = p.matrix.Mul(mark.Matrix).Inverse() + if !ok { + return false, nil + } + } + scene, _, err := p.compileGroup(func(local *pdfImporter) error { + if child.group != nil { + return child.coverage(local, false) + } + _, fill, stroke := child.style() + if fill { + if err := child.coverage(local, false); err != nil { + return err + } + } + if stroke { + return child.coverage(local, true) + } + return nil + }) + if err != nil { + return false, err + } + if scene == nil { + continue + } + var box Box + for _, command := range scene.Commands { + path := geometryFromRaster(command.Path) + if command.Image != nil { + b := command.Image.Bounds() + path = geometryRectangle(Box{X: float64(b.Min.X), Y: float64(b.Min.Y), W: float64(b.Dx()), H: float64(b.Dy())}) + } + path, err = geometry.Transform(path, MatrixFromValues([6]float64(command.Transform))) + if err != nil { + return false, err + } + if command.Stroke != nil { + path, err = geometry.Stroke(path, *command.Stroke) + if err != nil { + return false, err + } + } + path, err = geometry.Transform(path, MatrixFromValues([6]float64(inverse))) + if err != nil { + return false, err + } + bounds, err := geometry.Bounds(path) + if err != nil { + return false, err + } + box = unionTextBox(box, bounds) + } + if box.W <= 0 || box.H <= 0 { + continue + } + for _, previous := range boxes { + if box.X < previous.X+previous.W && previous.X < box.X+box.W && box.Y < previous.Y+previous.H && previous.Y < box.Y+box.H { + if cache.textDisjoint == nil { + cache.textDisjoint = make(map[*pdfgo.GroupMark]bool) + } + cache.textDisjoint[node.group] = false + return false, nil + } + } + boxes = append(boxes, box) + } + if cache.textDisjoint == nil { + cache.textDisjoint = make(map[*pdfgo.GroupMark]bool) + } + cache.textDisjoint[node.group] = true + return true, nil +} diff --git a/ofdgo_pdf_text_path.go b/ofdgo_pdf_text_path.go new file mode 100644 index 0000000..07eb060 --- /dev/null +++ b/ofdgo_pdf_text_path.go @@ -0,0 +1,53 @@ +// 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 ( + "fmt" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfGlyphPath 将字形轮廓变换到PDF页面坐标,二次曲线精确转换为三次曲线 +// 入参: outline 字形轮廓, matrix 字形到页面的变换 +// 返回: pdfgo.Path 页面路径, error 非字体轮廓指令 +func pdfGlyphPath(outline GeometryPath, matrix pdfgo.Matrix) (pdfgo.Path, error) { + path := pdfgo.Path{Segments: make([]pdfgo.Segment, 0, len(outline))} + var current, start Point + point := func(p Point) pdfgo.Point { return matrix.Apply(pdfgo.Point{X: p.X, Y: p.Y}) } + for _, segment := range outline { + switch segment.Verb { + case GeometryMove: + start = segment.End + path.Segments = append(path.Segments, pdfgo.Segment{Operator: "M", Points: []pdfgo.Point{point(segment.End)}}) + case GeometryLine: + path.Segments = append(path.Segments, pdfgo.Segment{Operator: "L", Points: []pdfgo.Point{point(segment.End)}}) + case GeometryQuad: + c1 := Point{current.X + (segment.Control1.X-current.X)*2/3, current.Y + (segment.Control1.Y-current.Y)*2/3} + c2 := Point{segment.End.X + (segment.Control1.X-segment.End.X)*2/3, segment.End.Y + (segment.Control1.Y-segment.End.Y)*2/3} + path.Segments = append(path.Segments, pdfgo.Segment{Operator: "B", Points: []pdfgo.Point{point(c1), point(c2), point(segment.End)}}) + case GeometryCubic: + path.Segments = append(path.Segments, pdfgo.Segment{Operator: "B", Points: []pdfgo.Point{point(segment.Control1), point(segment.Control2), point(segment.End)}}) + case GeometryClose: + path.Segments = append(path.Segments, pdfgo.Segment{Operator: "C"}) + current = start + continue + default: + return pdfgo.Path{}, fmt.Errorf("unsupported PDF glyph geometry verb %d", segment.Verb) + } + current = segment.End + } + return path, nil +} diff --git a/ofdgo_pdf_transfer.go b/ofdgo_pdf_transfer.go new file mode 100644 index 0000000..ff22450 --- /dev/null +++ b/ofdgo_pdf_transfer.go @@ -0,0 +1,163 @@ +// 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 "github.com/xiaoqidun/pdfgo" + +// pdfTransferKey 区分图形状态与网屏覆盖函数 +type pdfTransferKey struct { + transfer *pdfgo.TransferFunction + halftone *pdfgo.Halftone +} + +// resolveTransfer 在页面内复用连续RGB设备的有效传递函数 +// 入参: style 原始图形状态与有效输出状态 +// 返回: error 网屏传递函数错误 +func (p *pdfImporter) resolveTransfer(style *pdfgo.Style) error { + if style.Halftone == nil { + return nil + } + cache := p.compositingCache() + if cache.transfers == nil { + cache.transfers = make(map[pdfTransferKey]*pdfgo.TransferFunction) + } + key := pdfTransferKey{style.Transfer, style.Halftone} + transfer, found := cache.transfers[key] + if !found { + var err error + transfer, err = p.reader.DeviceTransfer(style.Transfer, style.Halftone, "DeviceRGB") + if err != nil { + return err + } + cache.transfers[key] = transfer + } + style.Transfer = transfer + return nil +} + +// pdfCompositeHasTransfer 检查页面是否需要保留最终设备传递函数 +// 入参: nodes 原始图元及透明组 +// 返回: bool 是否存在非恒等函数 +func pdfCompositeHasTransfer(nodes []pdfCompositeNode) bool { + for _, node := range nodes { + if node.group != nil { + if pdfCompositeHasTransfer(node.children) { + return true + } + } else { + style, _, _ := node.style() + if style.Transfer != nil { + return true + } + } + } + return false +} + +// selectTransfer 按最上层基本图元及祖先的不透明状态选择最终设备函数 +// 入参: node 基本图元, outline 是否描边 +// 返回: *pdfgo.TransferFunction 当前函数或设备默认值, error 图案解析错误 +func (c *pdfCompositor) selectTransfer(node pdfCompositeNode, outline bool) (*pdfgo.TransferFunction, error) { + style, _, _ := node.style() + paint := style.Fill + if outline { + paint = style.Stroke + } + if c.transferBlocked || paint.Alpha != 1 || style.SoftMask != nil || !pdfNormalBlend(style.BlendMode) { + return nil, nil + } + if style.Transfer == nil { + return nil, nil + } + if node.image != nil { + soft, err := node.image.Image.HasSoftMask() + if err != nil || soft { + return nil, err + } + } + if paint.Tiling != nil && (node.image == nil || node.image.Image.ImageMask) { + pattern, err := c.importer.compositePattern(paint) + if err != nil { + return nil, err + } + opaque, err := pattern.opaqueTransfer() + if err != nil || !opaque { + return nil, err + } + } + return style.Transfer, nil +} + +// opaqueTransfer 复用图案不透明性检查,图案内部函数不提前作用于源颜色 +// 返回: bool 是否允许使用外层设备函数, error 单元解析错误 +func (p *pdfCompositePattern) opaqueTransfer() (bool, error) { + if !p.transferChecked { + for _, child := range p.nodes { + opaque, err := p.local.transferOpaque(child) + if err != nil { + return false, err + } + if !opaque { + p.transferChecked = true + return false, nil + } + } + p.transferChecked, p.transferOpaque = true, true + } + return p.transferOpaque, nil +} + +// transferOpaque 检查全部图案内容的不透明条件,不限制混合颜色空间 +// 入参: node 单元图元或透明组 +// 返回: bool 是否不透明, error 图案或图片遮罩引用错误 +func (p *pdfImporter) transferOpaque(node pdfCompositeNode) (bool, error) { + if group := node.group; group != nil { + if group.Alpha != 1 || group.SoftMask != nil || !pdfNormalBlend(group.BlendMode) { + return false, nil + } + for _, child := range node.children { + if opaque, err := p.transferOpaque(child); err != nil || !opaque { + return opaque, err + } + } + return true, nil + } + style, fill, stroke := node.style() + if style.SoftMask != nil || !pdfNormalBlend(style.BlendMode) || fill && style.Fill.Alpha != 1 || stroke && style.Stroke.Alpha != 1 { + return false, nil + } + if node.image != nil { + soft, err := node.image.Image.HasSoftMask() + if err != nil || soft { + return false, err + } + if !node.image.Image.ImageMask { + return true, nil + } + } + for i, paint := range [2]pdfgo.Paint{style.Fill, style.Stroke} { + if i == 0 && !fill || i == 1 && !stroke || paint.Tiling == nil { + continue + } + pattern, err := p.compositePattern(paint) + if err != nil { + return false, err + } + if opaque, err := pattern.opaqueTransfer(); err != nil || !opaque { + return opaque, err + } + } + return true, nil +} diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index a8c67c9..ec9851d 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -30,6 +30,8 @@ type semanticCompiler struct { measurement ObjectMeasurement measure, contours, textOnly, pattern bool patterns *renderCache[patternCellKey, []RasterCommand] + patternReferences map[*Pattern]bool + patternPage *Matrix } // semanticStyle 保存已继承的路径颜色和描边参数 @@ -466,11 +468,16 @@ func (c *semanticCompiler) fillPattern(clip GeometryPath, pattern *PatternPaint, matrix := NewMatrix(pattern.CTM) if pattern.RelativeTo != "Page" { matrix = object.Multiply(matrix) + } else if c.patternPage != nil { + matrix = c.patternPage.Multiply(matrix) } inverse, ok := matrix.Invert() if !ok { return fmt.Errorf("invalid pattern transform") } + if pattern.CellContent.empty() { + return nil + } box, err := c.geometry.Bounds(clip) if err != nil { return err diff --git a/ofdgo_render_compile_image.go b/ofdgo_render_compile_image.go index 30d1db7..0b4f040 100644 --- a/ofdgo_render_compile_image.go +++ b/ofdgo_render_compile_image.go @@ -129,6 +129,15 @@ func (c *semanticCompiler) addImage(img image.Image, matrix Matrix, clip *Geomet if box.X <= visible.X && box.Y <= visible.Y && visible.X+visible.W <= box.X+box.W && visible.Y+visible.H <= box.Y+box.H { clip = nil } + } else { + bounds := img.Bounds() + outline, err := geometryRectangle(Box{X: float64(bounds.Min.X), Y: float64(bounds.Min.Y), W: float64(bounds.Dx()), H: float64(bounds.Dy())}).Transform(matrix) + if err != nil { + return err + } + if geometryConvexContains(*clip, outline) { + clip = nil + } } } if clip != nil { diff --git a/ofdgo_render_pattern.go b/ofdgo_render_pattern.go index dc4dae7..65793b5 100644 --- a/ofdgo_render_pattern.go +++ b/ofdgo_render_pattern.go @@ -16,6 +16,7 @@ package ofdgo import ( "crypto/sha256" + "fmt" "slices" ) @@ -101,6 +102,9 @@ func (c *semanticCompiler) sharedStamp(stamp Stamp, compile func(*semanticCompil // 入参: pattern 底纹画刷, matrix 单元到页面矩阵 // 返回: []RasterCommand 只读单元指令, error 编译错误 func (c *semanticCompiler) patternCell(pattern *PatternPaint, matrix Matrix) ([]RasterCommand, error) { + if c.patternReferences[pattern.Pattern] { + return nil, fmt.Errorf("cyclic pattern paint") + } x, y := 0.0, 0.0 if patternCellTranslatable(pattern) { x, y = matrix.e, matrix.f @@ -119,9 +123,14 @@ func (c *semanticCompiler) patternCell(pattern *PatternPaint, matrix Matrix) ([] if commands, ok := c.patterns.get(key); ok { return translatePatternCommands(commands, x, y), nil } - child := &semanticCompiler{renderer: c.renderer, geometry: c.geometry, page: &RasterPage{Width: c.page.Width, Height: c.page.Height, DPI: c.page.DPI}, pattern: true, patterns: c.patterns} + if c.patternReferences == nil { + c.patternReferences = make(map[*Pattern]bool) + } + c.patternReferences[pattern.Pattern] = true + defer delete(c.patternReferences, pattern.Pattern) + child := &semanticCompiler{renderer: c.renderer, geometry: c.geometry, page: &RasterPage{Width: c.page.Width, Height: c.page.Height, DPI: c.page.DPI}, pattern: true, patterns: c.patterns, patternReferences: c.patternReferences, patternPage: &matrix} defaults := &DrawParam{FillColor: &pattern.Color, StrokeColor: (*StrokeColor)(&pattern.Color)} - for _, object := range pattern.CellContent.Objects { + for object := range pattern.CellContent.objects() { object = mergeGraphicObjectAlpha(object, pattern.Alpha) if err := c.renderer.WalkObject(&object, RenderState{Defaults: defaults, Parent: &matrix, BoundaryInCTM: true}, child); err != nil { return nil, err @@ -142,7 +151,7 @@ func patternCellTranslatable(pattern *PatternPaint) bool { if pattern.Color.Pattern != nil { return false } - for _, object := range pattern.CellContent.Objects { + for object := range pattern.CellContent.objects() { var fill, stroke *FillColor switch object.Type { case "PathObject": diff --git a/ofdgo_render_text.go b/ofdgo_render_text.go index b413490..f69284f 100644 --- a/ofdgo_render_text.go +++ b/ofdgo_render_text.go @@ -368,12 +368,27 @@ func renderObjectMatrix(boundary string, local Matrix, state RenderState) (Matri return matrix, linear } -// objectGeometryClip 解析对象裁剪并合并父裁剪 +// objectGeometryClip 合并对象外接矩形、显式裁剪和父裁剪 // 入参: clips 对象裁剪, boundary 边界, local 局部矩阵, state 继承状态 // 返回: *GeometryPath 页面裁剪, error 几何错误 func (r *Renderer) objectGeometryClip(clips *Clips, boundary string, local Matrix, state RenderState) (*GeometryPath, error) { + clip := state.Clip + if boundary != "" { + box, err := ParseBox(boundary) + if err != nil { + return nil, err + } + if box.W <= 0 || box.H <= 0 { + return new(GeometryPath), nil + } + matrix, _ := renderObjectMatrix(boundary, IdentityMatrix, state) + clip, err = r.geometryExtentClip(box.W, box.H, matrix, clip) + if err != nil { + return nil, err + } + } if clips == nil { - return state.Clip, nil + return clip, nil } matrix, _ := renderObjectMatrix(boundary, local, state) if clips.TransFlag != nil && !*clips.TransFlag { @@ -390,17 +405,7 @@ func (r *Renderer) objectGeometryClip(clips *Clips, boundary string, local Matri if err != nil { return nil, err } - return geometry.Clip(r, clips, matrix, state.Clip) -} - -// clipGeometry 将非零填充路径与页面裁剪相交 -// 入参: geometry 几何后端, path 页面路径, clip 页面裁剪 -// 返回: GeometryPath 裁剪路径, error 几何错误 -func clipGeometry(geometry GeometryBackend, path GeometryPath, clip *GeometryPath) (GeometryPath, error) { - if clip == nil { - return path, nil - } - return geometry.Combine(path, *clip, GeometryIntersect) + return geometry.Clip(r, clips, matrix, clip) } // addGeometrySpan 以公共几何语义收集文字选择区域,保留旋转和斜切 diff --git a/ofdgo_render_walk.go b/ofdgo_render_walk.go index 27e0a7e..ba378f0 100644 --- a/ofdgo_render_walk.go +++ b/ofdgo_render_walk.go @@ -198,6 +198,17 @@ func (r *Renderer) walkComposite(object CompositeGraphicUnit, state RenderState, boundary = state.Parent.Multiply(boundary) } matrix := boundary.Multiply(NewMatrix(object.CTM)) + var err error + if object.Boundary != "" { + state.Clip, err = r.geometryExtentClip(box.W, box.H, boundary, state.Clip) + if err != nil { + return err + } + } + state.Clip, err = r.compositeExtentClip(object, matrix, state.Clip) + if err != nil { + return err + } clips, clipMatrix := object.Clips, matrix if clips != nil && clips.TransFlag != nil && !*clips.TransFlag { copy := *clips @@ -236,38 +247,48 @@ func (r *Renderer) walkComposite(object CompositeGraphicUnit, state RenderState, } } } - if len(object.Objects) != 0 { - for _, member := range object.Objects { - member = mergeGraphicObjectAlpha(member, object.Alpha) - if err := r.walkObject(&member, state, visitor, references); err != nil { - return err - } - } - return nil - } - for _, member := range object.ImageObject { - member.Alpha = mergeAlpha(member.Alpha, object.Alpha) - if err := visitor.DrawObject(&GraphicObject{Type: "ImageObject", ImageObject: member}, state); err != nil { - return err - } - } - for _, member := range object.PathObject { - member.Alpha = mergeAlpha(member.Alpha, object.Alpha) - if err := visitor.DrawObject(&GraphicObject{Type: "PathObject", PathObject: member}, state); err != nil { - return err - } - } - for _, member := range object.TextObject { - member.Alpha = mergeAlpha(member.Alpha, object.Alpha) - if err := visitor.DrawObject(&GraphicObject{Type: "TextObject", TextObject: member}, state); err != nil { - return err - } - } - for _, member := range object.CompositeGraphicUnit { - member.Alpha = mergeAlpha(member.Alpha, object.Alpha) - if err := r.walkComposite(member, state, visitor, references); err != nil { + for member := range object.objects() { + member = mergeGraphicObjectAlpha(member, object.Alpha) + if err := r.walkObject(&member, state, visitor, references); err != nil { return err } } return nil } + +// compositeExtentClip 按矢量资源尺寸裁剪内容,资源裁剪随实例变换 +// 入参: object 矢量资源, matrix 资源到页面矩阵, parent 父裁剪 +// 返回: *GeometryPath 页面裁剪, error 尺寸或几何错误 +func (r *Renderer) compositeExtentClip(object CompositeGraphicUnit, matrix Matrix, parent *GeometryPath) (*GeometryPath, error) { + if !object.extentSet && object.Width == 0 && object.Height == 0 { + return parent, nil + } + return r.geometryExtentClip(object.Width, object.Height, matrix, parent) +} + +// geometryExtentClip 按给定坐标系限制宽高并与父裁剪求交 +// 入参: width 宽度, height 高度, matrix 到页面矩阵, parent 父裁剪 +// 返回: *GeometryPath 页面裁剪, error 尺寸或几何错误 +func (r *Renderer) geometryExtentClip(width, height float64, matrix Matrix, parent *GeometryPath) (*GeometryPath, error) { + if !finite(width) || !finite(height) || width < 0 || height < 0 { + return nil, fmt.Errorf("invalid clipping dimensions") + } + if width == 0 || height == 0 { + return new(GeometryPath), nil + } + geometry, err := r.Geometry() + if err != nil { + return nil, err + } + clip, err := geometry.Transform(geometryRectangle(Box{W: width, H: height}), matrix) + if err != nil { + return nil, err + } + if parent != nil { + clip, err = clipGeometry(geometry, clip, parent) + if err != nil { + return nil, err + } + } + return &clip, nil +} diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 76c04f3..e4138ec 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -51,9 +51,11 @@ type Renderer struct { preparedFonts map[string]*PreparedFont fontPreparations *renderCache[[32]byte, PreparedFont] fontSourcesCache *fontSourceCache - fontMetrics renderCache[[32]byte, FontMetrics] + fontMetrics *renderCache[[32]byte, FontMetrics] glyphOutlines renderCache[glyphOutlineKey, GeometryPath] renderError error + patternReferences map[*Pattern]bool + patternPage *Matrix TransparentBackground bool } @@ -100,6 +102,7 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer { renderer := NewRenderer(reader, opts...) renderer.sharedBackendStates = r.sharedBackendStates renderer.fontPreparations = r.fontPreparations + renderer.fontMetrics = r.fontMetrics renderer.decodeImages = r.decodeImages renderer.TransparentBackground = r.TransparentBackground return renderer diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index 36a9c02..6eb3215 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -15,7 +15,6 @@ package ofdgo import ( - "fmt" "image" "github.com/tdewolff/canvas" @@ -66,10 +65,10 @@ func (r *clipRenderer) add(path *canvas.Path) { } // buildObjectClipPath 构建对象裁剪路径并应用父级变换 -// 入参: clips 裁剪对象, pageH 页面高度, bx 边界X坐标, by 边界Y坐标, objectCTM 对象CTM, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM +// 入参: clips 裁剪对象, pageH 页面高度, boundary 外接矩形, objectCTM 对象CTM, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM // 返回: *canvas.Path 路径对象 -func (r *Renderer) buildObjectClipPath(clips *Clips, pageH float64, bx, by float64, objectCTM Matrix, parentCTM *Matrix, boundaryInCTM bool) *canvas.Path { - path, err := r.objectGeometryClip(clips, fmt.Sprintf("%g %g 0 0", bx, by), objectCTM, RenderState{Parent: parentCTM, BoundaryInCTM: boundaryInCTM}) +func (r *Renderer) buildObjectClipPath(clips *Clips, pageH float64, boundary string, objectCTM Matrix, parentCTM *Matrix, boundaryInCTM bool) *canvas.Path { + path, err := r.objectGeometryClip(clips, boundary, objectCTM, RenderState{Parent: parentCTM, BoundaryInCTM: boundaryInCTM}) if err != nil { r.renderError = err return nil @@ -116,7 +115,7 @@ func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, ob m := areaCTM.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(ctm) ctx := canvas.NewContext(renderer) ctx.SetView(canvas.Matrix{{m.a, -m.c, bx + m.e}, {-m.b, m.d, pageH - by - m.f}}) - renderer.clip = r.buildObjectClipPath(textObj.Clips, pageH, box.X, box.Y, ctm, &areaCTM, true) + renderer.clip = r.buildObjectClipPath(textObj.Clips, pageH, textObj.Boundary, ctm, &areaCTM, true) if renderer.clip != nil { renderer.clip = renderer.clip.Translate(bx, -by) } @@ -167,6 +166,9 @@ func intersectClipPath(parent, current *canvas.Path) *canvas.Path { if currentOK && currentRect.Contains(parent.FastBounds()) { return parent } + if result, ok := intersectConvexCanvasPaths(parent, current); ok { + return result + } return parent.And(current) } @@ -208,6 +210,9 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path { return pathRect.And(rect).ToPath() } } + if result, ok := intersectConvexCanvasPaths(path, clip); ok { + return result + } return path.And(clip) } diff --git a/ofdgo_renderer_clip_convex_canvas.go b/ofdgo_renderer_clip_convex_canvas.go new file mode 100644 index 0000000..68a8cc6 --- /dev/null +++ b/ofdgo_renderer_clip_convex_canvas.go @@ -0,0 +1,152 @@ +// 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 ( + "math" + + "github.com/tdewolff/canvas" +) + +// intersectConvexCanvasPaths 逐边裁剪单个凸多边形,避免仿射矩形共边进入曲线扫线 +// 入参: left 左侧路径, right 右侧路径 +// 返回: *canvas.Path 交集路径, bool 是否适用凸多边形裁剪 +func intersectConvexCanvasPaths(left, right *canvas.Path) (*canvas.Path, bool) { + a, ok := convexCanvasPolygon(left) + if !ok { + return nil, false + } + b, ok := convexCanvasPolygon(right) + if !ok { + return nil, false + } + for i, start := range b { + end := b[(i+1)%len(b)] + dx, dy := end.X-start.X, end.Y-start.Y + length := math.Hypot(dx, dy) + if length == 0 { + continue + } + dx, dy = dx/length, dy/length + distance := func(p canvas.Point) float64 { return convexCanvasDistance(start, p, dx, dy) } + sign := 1.0 + for _, p := range b { + if d := distance(p); d != 0 { + sign = math.Copysign(1, d) + break + } + } + if len(a) == 0 { + break + } + var clipped []canvas.Point + appendPoint := func(p canvas.Point) { + if len(clipped) == 0 || p != clipped[len(clipped)-1] { + clipped = append(clipped, p) + } + } + previous := a[len(a)-1] + previousDistance := sign * distance(previous) + if !finite(previousDistance) { + return nil, false + } + for _, p := range a { + d := sign * distance(p) + if !finite(d) { + return nil, false + } + if (d >= 0) != (previousDistance >= 0) { + scale := math.Max(math.Abs(d), math.Abs(previousDistance)) + fraction := (previousDistance / scale) / (previousDistance/scale - d/scale) + intersection := geometryLerp(Point{previous.X, previous.Y}, Point{p.X, p.Y}, fraction) + appendPoint(canvas.Point{X: intersection.X, Y: intersection.Y}) + } + if d >= 0 { + appendPoint(p) + } + previous, previousDistance = p, d + } + if len(clipped) > 1 && clipped[0] == clipped[len(clipped)-1] { + clipped = clipped[:len(clipped)-1] + } + a = clipped + } + result := &canvas.Path{} + if len(a) >= 3 { + result.MoveTo(a[0].X, a[0].Y) + for _, p := range a[1:] { + result.LineTo(p.X, p.Y) + } + result.Close() + } + return result, true +} + +// convexCanvasPolygon 检查单个闭合直线轮廓的所有顶点位于各边的同一侧 +// 入参: path 路径 +// 返回: []canvas.Point 独立顶点, bool 是否为非退化凸多边形 +func convexCanvasPolygon(path *canvas.Path) ([]canvas.Point, bool) { + if path == nil || path.HasSubpaths() || !path.Closed() { + 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 + } + } + points := path.Coords() + if len(points) < 4 || points[0] != points[len(points)-1] { + return nil, false + } + points = points[:len(points)-1] + positive := false + for i, start := range points { + end := points[(i+1)%len(points)] + dx, dy := end.X-start.X, end.Y-start.Y + length := math.Hypot(dx, dy) + if !finite(length) { + return nil, false + } + if length == 0 { + continue + } + dx, dy = dx/length, dy/length + sign := 0.0 + for _, p := range points { + d := convexCanvasDistance(start, p, dx, dy) + if !finite(d) || sign*d < 0 { + return nil, false + } + if d != 0 { + sign, positive = math.Copysign(1, d), true + } + } + } + return points, positive +} + +// convexCanvasDistance 按浮点乘法误差界识别共线点,不使用固定坐标容差 +// 入参: start 边起点, point 待测顶点, dx 横向单位分量, dy 纵向单位分量 +// 返回: float64 顶点到边的有向距离 +func convexCanvasDistance(start, point canvas.Point, dx, dy float64) float64 { + x, y := point.X-start.X, point.Y-start.Y + distance := math.FMA(dx, y, -dy*x) + errorBound := (math.Abs(dx*y) + math.Abs(dy*x)) * 0x1p-49 + if finite(errorBound) && math.Abs(distance) <= errorBound { + return 0 + } + return distance +} diff --git a/ofdgo_renderer_image_canvas.go b/ofdgo_renderer_image_canvas.go index 7bb2560..6a31160 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, box.X, box.Y, localCTM, parentCTM, boundaryInCTM)) + clipPath := intersectClipPath(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) { @@ -171,6 +171,9 @@ func imageClipContains(img image.Image, clipPath *canvas.Path, m canvas.Matrix) if rect, ok := rectangularPath(clipPath); ok { return rect.Contains(outline.FastBounds()) } + if geometryConvexContains(*geometryFromCanvasPath(clipPath), *geometryFromCanvasPath(outline)) { + return true + } return clipPath.Contains(outline) } @@ -190,6 +193,7 @@ func (r *Renderer) imageWithClip(img image.Image, clipPath *canvas.Path, m canva if imageClipContains(img, clipPath, m) { return img } + original := img source, err := imagePixelData(img) if err != nil { r.renderError = err @@ -231,6 +235,9 @@ func (r *Renderer) imageWithClip(img image.Image, clipPath *canvas.Path, m canva draw.Draw(mask, mask.Bounds(), result, result.Bounds().Min, draw.Src) } if mask.Opaque() { + if scale <= 1 { + return original + } return img } source = img diff --git a/ofdgo_renderer_measure_canvas.go b/ofdgo_renderer_measure_canvas.go index fb76347..ccfefb7 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, box.X, box.Y, ctm, parentCTM, boundaryInCTM)) + clip := intersectClipPath(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 b368a94..2b3142b 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -15,6 +15,7 @@ package ofdgo import ( + "fmt" "image/color" "math" "slices" @@ -234,7 +235,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, bx, by, localCTM, parentCTM, boundaryInCTM)) + clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) shouldFill := false if obj.Fill != nil { shouldFill = *obj.Fill @@ -331,9 +332,22 @@ func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *PatternPaint, pag r.pageText = nil defer func() { r.pageText = text }() } - if clip == nil || clip.Empty() || len(pattern.CellContent.Objects) == 0 { + if clip == nil || clip.Empty() || pattern.CellContent.empty() { return } + if r.patternReferences[pattern.Pattern] { + r.renderError = fmt.Errorf("cyclic pattern paint") + return + } + if r.patternReferences == nil { + r.patternReferences = make(map[*Pattern]bool) + } + r.patternReferences[pattern.Pattern] = true + parentPage := r.patternPage + defer func() { + delete(r.patternReferences, pattern.Pattern) + r.patternPage = parentPage + }() xStep, yStep := pattern.XStep, pattern.YStep if xStep < pattern.Width { xStep = pattern.Width @@ -347,6 +361,8 @@ func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *PatternPaint, pag patternCTM := NewMatrix(pattern.CTM) if pattern.RelativeTo != "Page" { patternCTM = objectCTM.Multiply(patternCTM) + } else if parentPage != nil { + patternCTM = parentPage.Multiply(patternCTM) } invCTM, ok := patternCTM.Invert() if !ok { @@ -387,9 +403,13 @@ func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *PatternPaint, pag if iy%2 != 0 && (pattern.ReflectMethod == "Row" || pattern.ReflectMethod == "RowAndColumn") { tileCTM = tileCTM.Multiply(Matrix{a: 1, d: -1, f: pattern.Height}) } - for _, obj := range pattern.CellContent.Objects { + r.patternPage = &tileCTM + for obj := range pattern.CellContent.objects() { obj = mergeGraphicObjectAlpha(obj, pattern.Alpha) r.renderObject(ctx, &obj, pageH, defaults, &tileCTM, true, clip) + if r.renderError != nil { + return + } } } } diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index dd70ac2..c66b26d 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -49,7 +49,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, bx, by, localCTM, parentCTM, boundaryInCTM)) + clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, obj.Boundary, localCTM, parentCTM, boundaryInCTM)) var dp *DrawParam if obj.DrawParam != "" { dp = r.getDrawParam(obj.DrawParam, nil) @@ -325,7 +325,25 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if fillPaint != nil || fillPattern != nil { ctx.SetFill(fillPaint) ctx.SetStrokeColor(canvas.Transparent) - if fillClip != nil || shadedFill || fillPattern != nil { + glyphClip := fillClip + if fillClip != nil && !embeddedFont && face.FauxBold == 0 && !textCodePositioned(tc, xs, ys) && !shadedFill && fillPattern == nil && glyph.GlyphID < 0 && !hasUnderline { + if rect, ok := rectangularPath(fillClip); ok { + transform := canvas.Identity.Translate(canvasX, canvasY) + if useTextMatrix { + transform = transform.Mul(glyphMatrix) + } + if rect.Contains(glyphPath.Copy().Transform(transform.Scale(hScale, 1)).Bounds()) { + available := true + for _, char := range str { + available = available && face.Font.GlyphIndex(char) != 0 + } + if available { + glyphClip, drawAsGlyphPath = nil, false + } + } + } + } + if glyphClip != nil || shadedFill || fillPattern != nil { scaleX := hScale if advanceLimit > 0 && glyphWidth*scaleX > advanceLimit { scaleX = advanceLimit / glyphWidth @@ -335,7 +353,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if useTextMatrix { textTransform = textTransform.Mul(glyphMatrix) } - glyphPath = applyClipPath(glyphPath.Copy().Transform(textTransform.Scale(scaleX, 1)), fillClip) + glyphPath = applyClipPath(glyphPath.Copy().Transform(textTransform.Scale(scaleX, 1)), glyphClip) if fillPattern != nil { r.renderPattern(ctx, fillPattern, pageH, glyphPath, objectCTM) } else { diff --git a/ofdgo_res.go b/ofdgo_res.go index 82621f8..0bd1a15 100644 --- a/ofdgo_res.go +++ b/ofdgo_res.go @@ -78,24 +78,24 @@ type DrawParams struct { // DrawParam 绘制参数 type DrawParam struct { - ID string `xml:"ID,attr"` - Relative string `xml:"Relative,attr"` - ResourceID string `xml:"ResourceID,attr"` - BaseLoc string `xml:"BaseLoc,attr"` - Link string `xml:"Link,attr"` - LineWidth float64 `xml:"LineWidth,attr"` - Join string `xml:"Join,attr"` - Cap string `xml:"Cap,attr"` - DashOffset *float64 `xml:"DashOffset,attr"` - DashPattern string `xml:"DashPattern,attr"` - MiterLimit float64 `xml:"MiterLimit,attr"` - Font string `xml:"Font,attr"` - Size float64 `xml:"Size,attr"` - Weight int `xml:"Weight,attr"` - Italic bool `xml:"Italic,attr"` - FillColor *FillColor `xml:"FillColor"` - StrokeColor *StrokeColor `xml:"StrokeColor"` - LineWidthSet bool `xml:"-"` + ID string `xml:"ID,attr"` + Relative string `xml:"Relative,attr"` + ResourceID string `xml:"ResourceID,attr"` + BaseLoc string `xml:"BaseLoc,attr"` + Link string `xml:"Link,attr"` + LineWidth float64 `xml:"LineWidth,attr"` + Join string `xml:"Join,attr"` + Cap string `xml:"Cap,attr"` + DashOffset *float64 `xml:"DashOffset,attr"` + DashPattern string `xml:"DashPattern,attr"` + MiterLimit float64 `xml:"MiterLimit,attr"` + Font string `xml:"Font,attr"` + Size float64 `xml:"Size,attr"` + Weight int `xml:"Weight,attr"` + Italic bool `xml:"Italic,attr"` + FillColor *FillColor `xml:"FillColor"` + StrokeColor *StrokeColor `xml:"StrokeColor"` + LineWidthSet bool `xml:"-"` dashPatternSet bool } @@ -105,6 +105,7 @@ type CompositeGraphicUnits struct { } // CompositeGraphicUnit 复合图元 +// Width、Height、Thumbnail和Substitution用于矢量资源定义 type CompositeGraphicUnit struct { ID string `xml:"ID,attr"` BaseLoc string `xml:"BaseLoc,attr"` @@ -114,6 +115,10 @@ type CompositeGraphicUnit struct { DrawParam string `xml:"DrawParam,attr"` Alpha *int `xml:"Alpha,attr"` Visible *bool `xml:"Visible,attr"` + Width float64 `xml:"Width,attr"` + Height float64 `xml:"Height,attr"` + Thumbnail string `xml:"Thumbnail"` + Substitution string `xml:"Substitution"` Objects []GraphicObject `xml:"-"` TextObject []TextObject `xml:"TextObject"` PathObject []PathObject `xml:"PathObject"` @@ -122,4 +127,5 @@ type CompositeGraphicUnit struct { Clips *Clips `xml:"Clips"` Actions []Action `xml:"Actions>Action"` states map[string]editorCompositeState + extentSet bool } diff --git a/ofdgo_sign_crl.go b/ofdgo_sign_crl.go new file mode 100644 index 0000000..22c8489 --- /dev/null +++ b/ofdgo_sign_crl.go @@ -0,0 +1,242 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/asn1" + "fmt" + "math/big" + "sort" + "time" + + "github.com/emmansun/gmsm/smx509" +) + +// signatureCRL 已验证的直接CRL及目标条目 +type signatureCRL struct { + list *smx509.RevocationList + baseNumber *big.Int + authority []byte + entry *smx509.RevocationListEntry +} + +// verifySignatureCRLs 验证完整CRL及增量合并后的目标状态 +// 入参: raws CRL字节, cert 证书, issuer 颁发者, at 验证时间, maxAge 最大年龄 +// 返回: SignatureRevocationReport 吊销结果 +func verifySignatureCRLs(raws [][]byte, cert, issuer *smx509.Certificate, at time.Time, maxAge time.Duration) SignatureRevocationReport { + result := SignatureRevocationReport{Certificate: signatureCertInfo(cert.Raw), Source: "CRL", Status: "unknown"} + lists := make([]signatureCRL, 0, len(raws)) + for _, raw := range raws { + item, err := readSignatureCRL(raw, cert, issuer, at) + if err != nil { + result.Error = err.Error() + continue + } + lists = append(lists, item) + } + sort.Slice(lists, func(i, j int) bool { + if !lists[i].list.ThisUpdate.Equal(lists[j].list.ThisUpdate) { + return lists[i].list.ThisUpdate.Before(lists[j].list.ThisUpdate) + } + return lists[i].list.Number.Cmp(lists[j].list.Number) < 0 + }) + versions := make(map[struct { + number string + delta bool + }]*smx509.RevocationList, len(lists)) + bases := make(map[string][]*signatureCRL) + for n := range lists { + item := &lists[n] + if n != 0 { + previous := lists[n-1].list + order := item.list.Number.Cmp(previous.Number) + if order < 0 || order == 0 && !item.list.ThisUpdate.Equal(previous.ThisUpdate) { + result.Error = "inconsistent CRL numbering" + return result + } + } + key := struct { + number string + delta bool + }{item.list.Number.String(), item.baseNumber != nil} + if previous := versions[key]; previous != nil && !bytes.Equal(item.list.RawTBSRevocationList, previous.RawTBSRevocationList) { + result.Error = "conflicting CRL version" + return result + } + versions[key] = item.list + if item.baseNumber == nil { + id := string(item.authority) + bases[id] = append(bases[id], item) + } + } + var selected *signatureCRL + var selectedEntry *smx509.RevocationListEntry + for n := range lists { + item := &lists[n] + if !signatureEvidenceFresh(item.list.ThisUpdate, item.list.NextUpdate, at, maxAge) { + result.Error = "stale or future CRL" + continue + } + entry := item.entry + if item.baseNumber != nil { + candidates := bases[string(item.authority)] + index := sort.Search(len(candidates), func(n int) bool { + return candidates[n].list.Number.Cmp(item.list.Number) >= 0 + }) + if index == 0 || candidates[index-1].list.Number.Cmp(item.baseNumber) < 0 { + result.Error = "matching complete CRL not found" + continue + } + if entry == nil { + entry = candidates[index-1].entry + } + } + if selected != nil && item.list.Number.Cmp(selected.list.Number) == 0 && !signatureCRLStatusEqual(entry, selectedEntry) { + result.Error = "conflicting complete and delta CRL status" + return result + } + selected, selectedEntry = item, entry + } + if selected == nil { + if result.Error == "" { + result.Error = "usable CRL not found" + } + return result + } + result.ThisUpdate, result.NextUpdate = selected.list.ThisUpdate, selected.list.NextUpdate + result.Checked, result.OK, result.Status, result.Error = true, true, "good", "" + if selectedEntry != nil && selectedEntry.ReasonCode != 8 { + result.OK, result.Status, result.RevokedAt = false, "revoked", selectedEntry.RevocationTime + } + return result +} + +// readSignatureCRL 校验直接CRL签名、扩展及目标条目 +// 入参: raw CRL字节, cert 证书, issuer 颁发者, at 验证时间 +// 返回: signatureCRL 已验证列表, error 校验错误 +func readSignatureCRL(raw []byte, cert, issuer *smx509.Certificate, at time.Time) (signatureCRL, error) { + crl, err := smx509.ParseRevocationList(raw) + if err != nil { + return signatureCRL{}, err + } + if len(crl.Raw) != len(raw) { + return signatureCRL{}, fmt.Errorf("trailing CRL data") + } + if !bytes.Equal(crl.RawIssuer, issuer.RawSubject) { + return signatureCRL{}, fmt.Errorf("CRL issuer mismatch") + } + if len(crl.AuthorityKeyId) != 0 && !bytes.Equal(crl.AuthorityKeyId, issuer.SubjectKeyId) { + return signatureCRL{}, fmt.Errorf("CRL authority key mismatch") + } + if err := crl.CheckSignatureFrom(issuer); err != nil { + return signatureCRL{}, err + } + if crl.ThisUpdate.IsZero() || crl.ThisUpdate.After(at) || !crl.NextUpdate.IsZero() && !crl.NextUpdate.After(crl.ThisUpdate) { + return signatureCRL{}, fmt.Errorf("invalid CRL time window") + } + item := signatureCRL{list: crl} + for n, ext := range crl.Extensions { + for _, previous := range crl.Extensions[:n] { + if ext.Id.Equal(previous.Id) { + return signatureCRL{}, fmt.Errorf("duplicate CRL extension") + } + } + switch ext.Id.String() { + case "2.5.29.35": + if ext.Critical { + return signatureCRL{}, fmt.Errorf("invalid CRL authority key extension") + } + item.authority = ext.Value + case "2.5.29.20": + number, err := signatureCRLNumber(ext.Value) + if err != nil || ext.Critical { + return signatureCRL{}, fmt.Errorf("invalid CRL number") + } + crl.Number = number + case "2.5.29.27": + number, err := signatureCRLNumber(ext.Value) + if err != nil || !ext.Critical { + return signatureCRL{}, fmt.Errorf("invalid delta CRL indicator") + } + item.baseNumber = number + case "2.5.29.28": + return signatureCRL{}, fmt.Errorf("scoped CRL not supported") + default: + if ext.Critical { + return signatureCRL{}, fmt.Errorf("unsupported critical CRL extension") + } + } + } + if crl.Number == nil || item.baseNumber != nil && item.baseNumber.Cmp(crl.Number) >= 0 { + return signatureCRL{}, fmt.Errorf("invalid CRL numbering") + } + for n := range crl.RevokedCertificateEntries { + entry := &crl.RevokedCertificateEntries[n] + for m, ext := range entry.Extensions { + for _, previous := range entry.Extensions[:m] { + if ext.Id.Equal(previous.Id) { + return signatureCRL{}, fmt.Errorf("duplicate CRL entry extension") + } + } + if ext.Critical || ext.Id.String() == "2.5.29.29" { + return signatureCRL{}, fmt.Errorf("unsupported CRL entry extension") + } + if ext.Id.String() == "2.5.29.21" { + var reason asn1.Enumerated + rest, err := asn1.Unmarshal(ext.Value, &reason) + if err != nil || len(rest) != 0 || reason < 0 || reason > 10 || reason == 7 { + return signatureCRL{}, fmt.Errorf("invalid CRL reason code") + } + } + } + if entry.ReasonCode == 8 && item.baseNumber == nil { + return signatureCRL{}, fmt.Errorf("removeFromCRL requires delta processing") + } + if entry.RevocationTime.After(crl.ThisUpdate) { + return signatureCRL{}, fmt.Errorf("CRL revocation time is in the future") + } + if entry.SerialNumber.Cmp(cert.SerialNumber) == 0 { + if item.entry != nil { + return signatureCRL{}, fmt.Errorf("duplicate CRL certificate entry") + } + item.entry = entry + } + } + return item, nil +} + +// signatureCRLNumber 解析非负CRL编号 +// 入参: raw ASN.1整数 +// 返回: *big.Int 编号, error 编码错误 +func signatureCRLNumber(raw []byte) (*big.Int, error) { + var number *big.Int + rest, err := asn1.Unmarshal(raw, &number) + if err != nil || len(rest) != 0 || number.Sign() < 0 { + return nil, fmt.Errorf("invalid CRL number encoding") + } + return number, nil +} + +// signatureCRLStatusEqual 比较完整及增量列表的目标状态 +// 入参: a 首个条目, b 第二个条目 +// 返回: bool 状态是否一致 +func signatureCRLStatusEqual(a, b *smx509.RevocationListEntry) bool { + aGood, bGood := a == nil || a.ReasonCode == 8, b == nil || b.ReasonCode == 8 + if aGood || bGood { + return aGood == bGood + } + return a.ReasonCode == b.ReasonCode && a.RevocationTime.Equal(b.RevocationTime) +} diff --git a/ofdgo_sign_revocation.go b/ofdgo_sign_revocation.go index 0133423..49e56b5 100644 --- a/ofdgo_sign_revocation.go +++ b/ofdgo_sign_revocation.go @@ -42,6 +42,7 @@ type SignatureRevocationRequest struct { // SignatureRevocationOptions 离线吊销检查选项 // 检查签署者及制章者证书, 不代表整条证书链或TSA的吊销状态 +// CRL支持直接完整列表及增量合并, 尚不支持间接或带作用域列表 // Fetch仅提供DER证据, 本库不主动联网, 缺少NextUpdate时必须显式设置MaxAge // VerifyTime缺省取验签指定时间或当前时间, 不采用签名自报时间 type SignatureRevocationOptions struct { @@ -120,8 +121,8 @@ func VerifySignatureRevocation(cert, issuer []byte, options SignatureRevocationO evidence.OCSPResponses = append(cloneSignatureEvidence(evidence.OCSPResponses), fetched.OCSPResponses...) } var good *SignatureRevocationReport - for _, raw := range evidence.CRLs { - item := verifySignatureCRL(raw, cert, issuer, at, options.MaxAge) + if len(evidence.CRLs) != 0 { + item := verifySignatureCRLs(evidence.CRLs, c, i, at, options.MaxAge) if item.Checked && item.Status == "revoked" { return item } @@ -172,71 +173,6 @@ func signatureEvidenceFresh(thisUpdate, nextUpdate, at time.Time, maxAge time.Du return nextUpdate.After(thisUpdate) && at.Before(nextUpdate) } -// verifySignatureCRL 验证直接完整CRL及目标序列号 -// 入参: raw CRL字节, cert 证书, issuer 颁发者, at 验证时间, maxAge 最大年龄 -// 返回: SignatureRevocationReport 吊销结果 -func verifySignatureCRL(raw, cert, issuer []byte, at time.Time, maxAge time.Duration) (result SignatureRevocationReport) { - result = SignatureRevocationReport{Certificate: signatureCertInfo(cert), Source: "CRL", Status: "unknown"} - err := func() error { - crl, err := smx509.ParseRevocationList(raw) - if err != nil { - return err - } - parent, err := parseSignatureCertificate(issuer) - if err != nil { - return err - } - if !bytes.Equal(crl.RawIssuer, parent.RawSubject) { - return fmt.Errorf("CRL issuer mismatch") - } - if len(crl.AuthorityKeyId) != 0 && !bytes.Equal(crl.AuthorityKeyId, parent.SubjectKeyId) { - return fmt.Errorf("CRL authority key mismatch") - } - if err := crl.CheckSignatureFrom(parent); err != nil { - return err - } - for _, ext := range crl.Extensions { - if ext.Id.String() == "2.5.29.27" || ext.Id.String() == "2.5.29.28" { - return fmt.Errorf("delta or scoped CRL not supported") - } - if ext.Critical { - return fmt.Errorf("unsupported critical CRL extension") - } - } - result.ThisUpdate, result.NextUpdate = crl.ThisUpdate, crl.NextUpdate - if !signatureEvidenceFresh(crl.ThisUpdate, crl.NextUpdate, at, maxAge) { - return fmt.Errorf("stale or future CRL") - } - c, err := parseSignatureCertificate(cert) - if err != nil { - return err - } - for _, entry := range crl.RevokedCertificateEntries { - for _, ext := range entry.Extensions { - if ext.Critical || ext.Id.String() == "2.5.29.29" { - return fmt.Errorf("unsupported CRL entry extension") - } - } - if entry.SerialNumber.Cmp(c.SerialNumber) == 0 { - if entry.ReasonCode == 8 { - return fmt.Errorf("removeFromCRL requires delta processing") - } - if entry.RevocationTime.After(crl.ThisUpdate) { - return fmt.Errorf("CRL revocation time is in the future") - } - result.Checked, result.Status, result.RevokedAt = true, "revoked", entry.RevocationTime - return nil - } - } - result.Checked, result.OK, result.Status = true, true, "good" - return nil - }() - if err != nil { - result.Error = err.Error() - } - return result -} - // verifySignatureOCSP 验证OCSP签名、授权、颁发者绑定及状态 // 入参: raw 响应DER, cert 证书, issuer 颁发者, at 验证时间, maxAge 最大年龄 // 返回: SignatureRevocationReport 吊销结果 diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 2568b32..4f63d74 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -989,8 +989,10 @@ func (x *ofdXML) pattern(pattern *Pattern) { attrs.add("RelativeTo", pattern.RelativeTo) attrs.add("CTM", pattern.CTM) x.start("Pattern", attrs) - x.start("CellContent", nil) - for _, object := range pattern.CellContent.Objects { + var content ofdAttrs + content.add("Thumbnail", pattern.CellContent.Thumbnail) + x.start("CellContent", content) + for object := range pattern.CellContent.objects() { x.object(object, false) } x.end("CellContent")