diff --git a/go.mod b/go.mod index 24607b1..a9b25ad 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/xiaoqidun/jbig2 v1.1.1 - github.com/xiaoqidun/pdfgo v0.0.0-20260924091402-7973f8a3807a + github.com/xiaoqidun/pdfgo v0.0.0-20260924110344-e481bdea25e9 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 e0c9f78..58f7d14 100644 --- a/go.sum +++ b/go.sum @@ -60,8 +60,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-20260924091402-7973f8a3807a h1:+mspTtduelAEpmCAw/Pc6p2azO1vkuF2wIJRDogZHU4= -github.com/xiaoqidun/pdfgo v0.0.0-20260924091402-7973f8a3807a/go.mod h1:/D0XHgfZ7xNhix95swpNaThpuqoJjVR5wIadogtR5pI= +github.com/xiaoqidun/pdfgo v0.0.0-20260924110344-e481bdea25e9 h1:MjDKg9lMG/5AmEr+SV7C2y4dzfhpfXDSYScfu2mZixQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260924110344-e481bdea25e9/go.mod h1:/D0XHgfZ7xNhix95swpNaThpuqoJjVR5wIadogtR5pI= 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/internal/webui/wasm.go b/internal/webui/wasm.go index dff83f9..3742c66 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -3445,12 +3445,13 @@ func setPathStyle(object *ofdgo.PathObject, args []js.Value) error { } if !args[4].IsNull() { width := args[4].Float() - if width <= 0 || math.IsNaN(width) || math.IsInf(width, 0) { - return fmt.Errorf("line width must be positive and finite") + if width < 0 || math.IsNaN(width) || math.IsInf(width, 0) { + return fmt.Errorf("line width must be nonnegative and finite") } if scale := editorPathScale(*object); width != object.LineWidth*scale { object.LineWidth = width / scale } + object.LineWidthSet = width == 0 } if !args[1].IsNull() { if object.FillColor == nil { diff --git a/ofdgo_drawparam.go b/ofdgo_drawparam.go index 439d06b..03dfbb3 100644 --- a/ofdgo_drawparam.go +++ b/ofdgo_drawparam.go @@ -58,8 +58,9 @@ func (r *Renderer) drawParamDefaults(id string, defaults *DrawParam) *DrawParam // 入参: base 基础绘制参数, dp 覆盖绘制参数 // 返回: *DrawParam 合并后的绘制参数 func mergeDrawParam(base DrawParam, dp *DrawParam) *DrawParam { - if dp.LineWidth > 0 { + if dp.LineWidth > 0 || dp.LineWidthSet { base.LineWidth = dp.LineWidth + base.LineWidthSet = dp.LineWidthSet } if dp.Join != "" { base.Join = dp.Join diff --git a/ofdgo_editor.go b/ofdgo_editor.go index c98002b..c806bfa 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -886,7 +886,7 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic layout.options.FirstLineIndent *= scale obj.layout = &layout } - if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke { + if obj.LineWidth == 0 && !obj.LineWidthSet && obj.Stroke != nil && *obj.Stroke { obj.LineWidth = defaultPathLineWidth } obj.LineWidth *= scale diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index ff0d23d..110e306 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -565,14 +565,7 @@ func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64) return GraphicObject{}, err } } - after, err := e.transformObject(object, dx, dy, scale) - if err != nil { - return GraphicObject{}, err - } - if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.node.attr("LineWidth") == "0" && object.Type == "TextObject" && object.TextObject.LineWidth == 0 { - after.TextObject.LineWidth = 0 - } - return after, nil + return e.transformObject(object, dx, dy, scale) }) } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 20ef2c2..090342f 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -610,7 +610,7 @@ func (e *Editor) compositeMemberStyle(node *editorCompositeNode) (GraphicObject, base = &DrawParam{} } object, err := e.resolveEditorStyleDefaults(node.object, base) - if err == nil && object.Type == "PathObject" && object.PathObject.LineWidth == 0 { + if err == nil && object.Type == "PathObject" && object.PathObject.LineWidth == 0 && !object.PathObject.LineWidthSet { object.PathObject.LineWidth = defaultPathLineWidth } return object, err diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index 6202b75..11817b6 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -263,6 +263,9 @@ func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) { attrs.add(pair[0], pair[1]) } attrs.number("LineWidth", draw.LineWidth) + if draw.LineWidthSet && draw.LineWidth == 0 { + attrs.add("LineWidth", "0") + } attrs.number("MiterLimit", draw.MiterLimit) if draw.Relative == "" || draw.dashPatternSet || draw.DashPattern != "" { attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern}) diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 2c89563..2e61caa 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -610,10 +610,12 @@ func editorPreservedObject(before, after GraphicObject) bool { a, b := &before.TextObject, after.TextObject a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth + a.LineWidthSet = b.LineWidthSet case "PathObject": a, b := &before.PathObject, after.PathObject a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth + a.LineWidthSet = b.LineWidthSet a.DashOffset, a.DashPattern, a.Cap, a.Join = b.DashOffset, b.DashPattern, b.Cap, b.Join a.dashPatternSet = b.dashPatternSet case "ImageObject": diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 6e17d01..43e1fad 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -285,11 +285,12 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara style := mergeDrawParam(*base, dp) if object.Type == "PathObject" { obj := &object.PathObject - style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, Join: obj.Join, Cap: obj.Cap, + style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, LineWidthSet: obj.LineWidthSet, Join: obj.Join, Cap: obj.Cap, MiterLimit: obj.MiterLimit, DashPattern: obj.DashPattern, dashPatternSet: obj.dashPatternSet, DashOffset: obj.DashOffset, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor}) obj.DrawParam = "" obj.LineWidth, obj.Join, obj.Cap = style.LineWidth, style.Join, style.Cap + obj.LineWidthSet = style.LineWidthSet obj.MiterLimit, obj.DashPattern, obj.DashOffset = style.MiterLimit, style.DashPattern, style.DashOffset obj.dashPatternSet = style.dashPatternSet obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor @@ -298,12 +299,13 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara if style.Cap != "" && style.Cap != "Butt" || style.DashPattern != "" || style.DashOffset != nil && *style.DashOffset != 0 { return GraphicObject{}, &EditError{Code: EditUnsupportedStyle, Err: fmt.Errorf("text draw parameters require unsupported stroke styles")} } - style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, Join: obj.Join, + style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, LineWidthSet: obj.LineWidthSet, Join: obj.Join, MiterLimit: obj.MiterLimit, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor}) text := mergeDrawParam(*dp, &DrawParam{Font: obj.Font, Size: obj.Size, Weight: obj.Weight, Italic: obj.Italic}) obj.DrawParam = "" obj.Font, obj.Size, obj.Weight, obj.Italic = text.Font, text.Size, text.Weight, text.Italic obj.LineWidth, obj.Join, obj.MiterLimit = style.LineWidth, style.Join, style.MiterLimit + obj.LineWidthSet = style.LineWidthSet obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor } for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { @@ -396,10 +398,11 @@ func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicOb path.StrokeColor = (*StrokeColor)(editorStyleColor((*FillColor)(style.StrokeColor), (*FillColor)(path.StrokeColor))) } if style.LineWidth != nil { - if !finite(*style.LineWidth) || *style.LineWidth <= 0 { - return GraphicObject{}, fmt.Errorf("line width must be positive") + if !finite(*style.LineWidth) || *style.LineWidth < 0 { + return GraphicObject{}, fmt.Errorf("line width must be nonnegative") } path.LineWidth = *style.LineWidth + path.LineWidthSet = path.LineWidth == 0 } if style.DashPattern != nil { path.DashPattern = *style.DashPattern @@ -547,8 +550,9 @@ func copyEditorPathStyle(to, from PathObject, scale float64) PathObject { to.Fill, to.Stroke, to.Alpha = from.Fill, from.Stroke, from.Alpha to.FillColor, to.StrokeColor = from.FillColor, from.StrokeColor to.LineWidth, to.Cap, to.Join, to.MiterLimit = from.LineWidth, from.Cap, from.Join, from.MiterLimit + to.LineWidthSet = from.LineWidthSet to.DashPattern, to.DashOffset, to.dashPatternSet = from.DashPattern, from.DashOffset, true - if to.LineWidth == 0 { + if to.LineWidth == 0 && !to.LineWidthSet { to.LineWidth = defaultPathLineWidth } if to.Cap == "" { diff --git a/ofdgo_object.go b/ofdgo_object.go index 600a1ed..0b7f4c2 100644 --- a/ofdgo_object.go +++ b/ofdgo_object.go @@ -35,7 +35,7 @@ func (o GraphicObject) Actions() []Action { return nil } -// UnmarshalXML 解析路径并区分省略的虚线样式与显式实线 +// UnmarshalXML 解析路径并区分省略样式、显式实线与零线宽 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -48,6 +48,11 @@ func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return err } *p = PathObject(value.plain) + for _, attr := range start.Attr { + if attr.Name.Local == "LineWidth" { + p.LineWidthSet = p.LineWidth == 0 + } + } if value.DashPattern != nil { p.DashPattern = *value.DashPattern p.dashPatternSet = p.DashPattern == "" @@ -55,7 +60,7 @@ func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return nil } -// UnmarshalXML 解析绘制参数并保留显式实线对基础参数的覆盖 +// UnmarshalXML 解析绘制参数并保留显式实线和零线宽对基础参数的覆盖 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 func (p *DrawParam) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -68,6 +73,11 @@ func (p *DrawParam) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return err } *p = DrawParam(value.plain) + for _, attr := range start.Attr { + if attr.Name.Local == "LineWidth" { + p.LineWidthSet = p.LineWidth == 0 + } + } if value.DashPattern != nil { p.DashPattern = *value.DashPattern p.dashPatternSet = p.DashPattern == "" diff --git a/ofdgo_page.go b/ofdgo_page.go index d250d20..084f630 100644 --- a/ofdgo_page.go +++ b/ofdgo_page.go @@ -109,7 +109,8 @@ type TextObject struct { TextCode []TextCode `xml:"TextCode"` Clips *Clips `xml:"Clips"` Actions []Action `xml:"Actions>Action"` - layout *textLayout + LineWidthSet bool `xml:"-"` + layout *textLayout } // FillColor 填充颜色 @@ -166,15 +167,14 @@ type CGTransform struct { // PathObject 路径对象 type PathObject struct { - ID string `xml:"ID,attr"` - Boundary string `xml:"Boundary,attr"` - DrawParam string `xml:"DrawParam,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"` - dashPatternSet bool + ID string `xml:"ID,attr"` + Boundary string `xml:"Boundary,attr"` + DrawParam string `xml:"DrawParam,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"` CTM string `xml:"CTM,attr"` Alpha *int `xml:"Alpha,attr"` @@ -187,6 +187,8 @@ type PathObject struct { AbbreviatedData string `xml:"AbbreviatedData"` Clips *Clips `xml:"Clips"` Actions []Action `xml:"Actions>Action"` + LineWidthSet bool `xml:"-"` + dashPatternSet bool } // StrokeColor 勾边颜色 diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 12e11b4..c297bbd 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "math" + "reflect" "strconv" "strings" @@ -57,6 +58,9 @@ type pdfImporter struct { pages map[pdfgo.Reference]*pdfgo.Page pageIDs map[pdfgo.Reference]string imageIDs map[*pdfgo.Stream]string + maskClips map[*pdfgo.SoftMask]pdfgo.Path + pageBox pdfgo.Rectangle + pendingPath *pdfgo.PathMark } // ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果 @@ -91,7 +95,12 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI matrix, _, _ := pdfPageMatrix(page) importer.matrix = matrix importer.page = index + importer.pageBox = page.CropBox + importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path) visitor := pdfgo.Visitor{Path: importer.path, Text: importer.text, Image: importer.image} + visitor.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { + return importer.group(mark, walk, visitor) + } if !options.Strict { visitor.Warning = func(warning pdfgo.Diagnostic) { warning.Page = index + 1 @@ -101,6 +110,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI if err := reader.WalkPage(ctx, page, visitor); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } + if err := importer.flushPath(); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) + } if err := importer.links(page); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } @@ -249,10 +261,52 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) *Clips { return clips } -// path 添加独立可编辑路径 +// path 合并相同状态下的不透明描边,保留独立子路径与绘制顺序 // 入参: mark PDF路径绘制信息 // 返回: error 错误信息 func (p *pdfImporter) path(mark pdfgo.PathMark) error { + if !mark.Stroke || mark.Fill || mark.Style.Stroke.Alpha != 1 { + if err := p.flushPath(); err != nil { + return err + } + return p.appendPath(mark) + } + if p.pendingPath != nil && !reflect.DeepEqual(p.pendingPath.Style, mark.Style) { + if err := p.flushPath(); err != nil { + return err + } + } + if p.pendingPath == nil { + p.pendingPath = &mark + } else { + p.pendingPath.Path.Segments = append(p.pendingPath.Path.Segments, mark.Path.Segments...) + } + if len(p.pendingPath.Path.Segments) >= 512 { + return p.flushPath() + } + return nil +} + +// flushPath 写入连续描边批次,保持后续图元的叠放位置 +// 返回: error 转换错误 +func (p *pdfImporter) flushPath() error { + if p.pendingPath == nil { + return nil + } + mark := *p.pendingPath + p.pendingPath = nil + return p.appendPath(mark) +} + +// appendPath 添加独立可编辑路径 +// 入参: mark PDF路径绘制信息 +// 返回: error 错误信息 +func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { + var err error + mark.Style, err = p.maskStyle(mark.Style) + if err != nil { + return err + } if mark.Fill && mark.Style.FillOverprint && !pdfOpaqueBlack(mark.Style.Fill) || mark.Stroke && mark.Style.StrokeOverprint && !pdfOpaqueBlack(mark.Style.Stroke) { return &pdfgo.UnsupportedError{Feature: "color separation overprint"} } @@ -271,8 +325,9 @@ func (p *pdfImporter) path(mark pdfgo.PathMark) error { margin := mark.Style.LineWidth * scale / 2 * math.Max(1, mark.Style.MiterLimit) box = Box{box.X - margin, box.Y - margin, box.W + 2*margin, box.H + 2*margin} } - strokeColor := StrokeColor(*p.color(mark.Style.Stroke)) - object := PathObject{Boundary: pdfBoundary(box), AbbreviatedData: p.pathData(mark.Path, box), Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: p.color(mark.Style.Fill), StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: p.clips(mark.Style.Clips, box)} + strokeColor := StrokeColor(*p.pathColor(mark.Style.Stroke, box)) + object := PathObject{Boundary: pdfBoundary(box), AbbreviatedData: p.pathData(mark.Path, box), Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: p.pathColor(mark.Style.Fill, box), StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: p.clips(mark.Style.Clips, box)} + object.LineWidthSet = object.LineWidth == 0 if mark.Path.EvenOdd { object.Rule = "Even-Odd" } @@ -290,6 +345,32 @@ func (p *pdfImporter) path(mark pdfgo.PathMark) error { return nil } +// pathColor 将页面渐变轴转换为对象局部坐标 +// 入参: paint PDF画刷, box 对象边界 +// 返回: *FillColor OFD颜色或渐变 +func (p *pdfImporter) pathColor(paint pdfgo.Paint, box Box) *FillColor { + color := p.color(paint) + if paint.Axial == nil { + return color + } + gradient := paint.Axial + start, end := p.matrix.Apply(gradient.Start), p.matrix.Apply(gradient.End) + extend := 0 + if gradient.Extend[0] { + extend |= 1 + } + if gradient.Extend[1] { + extend |= 2 + } + shading := &AxialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), Extend: strconv.Itoa(extend)} + for _, stop := range gradient.Stops { + shading.Segment = append(shading.Segment, ShdSegment{Position: stop.Position, Color: ShdColor{Value: pdfNumbers(math.Round(stop.RGB[0]*255), math.Round(stop.RGB[1]*255), math.Round(stop.RGB[2]*255))}}) + } + color.Value = "" + color.AxialShd = shading + return color +} + // pdfOpaqueBlack 判断不受底层分色影响的全黑设备色 // 入参: paint PDF颜色与不透明度 // 返回: bool 是否为不透明全黑CMYK颜色 diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go new file mode 100644 index 0000000..06f8b2f --- /dev/null +++ b/ofdgo_pdf_group.go @@ -0,0 +1,316 @@ +// 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" + "math" + "reflect" + + "github.com/xiaoqidun/pdfgo" +) + +// group 将不透明图元的透明组转换为互不重叠的填充区域,保留矢量与组不透明度 +// 入参: mark 组边界状态, walk 组内容访问函数, visitor 页面访问器 +// 返回: error 不支持的组内容或转换错误 +func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error, visitor pdfgo.Visitor) error { + if err := p.flushPath(); err != nil { + return err + } + if mark.SoftMask != nil { + clip, err := p.maskClip(mark.SoftMask) + if err != nil { + return err + } + original := walk + walk = func(v pdfgo.Visitor) error { + return original(pdfClippedVisitor(v, clip)) + } + } + if mark.Alpha == 1 { + if err := walk(visitor); err != nil { + return err + } + return p.flushPath() + } + geometry, err := p.editor.Geometry() + if err != nil { + return err + } + type paintRegion struct { + paint pdfgo.Paint + path GeometryPath + } + var regions []paintRegion + var combined GeometryPath + var paint *pdfgo.Paint + var clips []pdfgo.Path + scale := math.Hypot(p.matrix[0], p.matrix[1]) + collect := pdfgo.Visitor{Warning: visitor.Warning} + collect.Path = func(path pdfgo.PathMark) error { + shape, err := ParseGeometryPath(p.pathData(path.Path, Box{})) + if err != nil { + return err + } + for _, stroke := range []bool{false, true} { + if stroke && !path.Stroke || !stroke && !path.Fill { + continue + } + color, overprint := path.Style.Fill, path.Style.FillOverprint + if stroke { + color, overprint = path.Style.Stroke, path.Style.StrokeOverprint + } + if color.Alpha != 1 || color.Axial != nil || overprint && !pdfOpaqueBlack(color) { + return &pdfgo.UnsupportedError{Feature: "transparent or overprinted group content"} + } + 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"} + } else if !reflect.DeepEqual(*paint, color) { + regions = append(regions, paintRegion{*paint, combined}) + combined = nil + paint = &color + } + outline := shape + evenOdd := path.Path.EvenOdd + if stroke { + dashes := make([]float64, len(path.Style.Dash)) + for i, dash := range path.Style.Dash { + dashes[i] = dash * scale + } + outline, err = geometry.Stroke(shape, StrokeOptions{Width: path.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[path.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[path.Style.Join], MiterLimit: path.Style.MiterLimit, DashOffset: path.Style.DashPhase * scale, Dashes: dashes, Tolerance: 0.001}) + if err != nil { + return err + } + evenOdd = false + } + outline, err = geometry.Normalize(outline, evenOdd) + if err != nil { + return err + } + combined = append(combined, outline...) + } + return nil + } + if err := walk(collect); err != nil { + return err + } + if len(combined) == 0 { + return nil + } + regions = append(regions, paintRegion{*paint, combined}) + var covered GeometryPath + for i := len(regions) - 1; i >= 0; i-- { + region := regions[i] + visible := region.path + if len(covered) > 0 { + visible, err = geometry.Combine(visible, covered, GeometrySubtract) + if err != nil { + return err + } + } + if i > 0 { + covered, err = geometry.Combine(covered, region.path, GeometryUnion) + if err != nil { + return err + } + } + if len(visible) == 0 { + continue + } + box, err := geometry.Bounds(visible) + if err != nil { + return err + } + if box.W == 0 || box.H == 0 { + continue + } + visible, err = geometry.Transform(visible, TranslationMatrix(-box.X, -box.Y)) + if err != nil { + return err + } + data, err := visible.OFD() + if err != nil { + return err + } + region.paint.Alpha = mark.Alpha + fill, stroke := true, false + object := PathObject{Boundary: pdfBoundary(box), AbbreviatedData: data, Fill: &fill, Stroke: &stroke, FillColor: p.color(region.paint), Clips: p.clips(clips, box)} + p.objects = append(p.objects, GraphicObject{Type: "PathObject", PathObject: object}) + p.report.PathObjects++ + } + return nil +} + +// pdfClippedVisitor 为图元及嵌套组附加裁剪,保留未提供的访问能力 +// 入参: visitor 图元访问器, clip 页面坐标裁剪 +// 返回: pdfgo.Visitor 裁剪访问器 +func pdfClippedVisitor(visitor pdfgo.Visitor, clip pdfgo.Path) pdfgo.Visitor { + result := visitor + if visitor.Path != nil { + result.Path = func(mark pdfgo.PathMark) error { + mark.Style.Clips = append(append([]pdfgo.Path(nil), mark.Style.Clips...), clip) + return visitor.Path(mark) + } + } + if visitor.Text != nil { + result.Text = func(mark pdfgo.TextMark) error { + mark.Style.Clips = append(append([]pdfgo.Path(nil), mark.Style.Clips...), clip) + return visitor.Text(mark) + } + } + if visitor.Image != nil { + result.Image = func(mark pdfgo.ImageMark) error { + mark.Style.Clips = append(append([]pdfgo.Path(nil), mark.Style.Clips...), clip) + return visitor.Image(mark) + } + } + if visitor.Group != nil { + result.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { + return visitor.Group(mark, func(v pdfgo.Visitor) error { return walk(pdfClippedVisitor(v, clip)) }) + } + } + return result +} + +// maskClip 将二值亮度蒙版转换为标准矢量裁剪,不栅格化原图元 +// 入参: mask PDF蒙版 +// 返回: pdfgo.Path 页面坐标裁剪路径, error 不可精确转换或几何错误 +func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { + if clip, ok := p.maskClips[mask]; ok { + return clip, nil + } + geometry, err := p.editor.Geometry() + if err != nil { + return pdfgo.Path{}, err + } + opacity := func(v float64) (bool, error) { + v = math.Max(0, math.Min(1, mask.Transfer[0]+v*(mask.Transfer[1]-mask.Transfer[0]))) + if v != 0 && v != 1 { + return false, &pdfgo.UnsupportedError{Feature: "nonbinary luminosity mask"} + } + return v == 1, nil + } + background, err := opacity(mask.Background) + if err != nil { + return pdfgo.Path{}, err + } + box := p.pageBox + var region GeometryPath + if background { + region, err = ParseGeometryPath(pdfNumbersPathBox(box)) + if err != nil { + return pdfgo.Path{}, err + } + region, err = geometry.Normalize(region, false) + if err != nil { + return pdfgo.Path{}, err + } + } + identity := pdfImporter{matrix: pdfgo.Identity()} + var visitor pdfgo.Visitor + visitor.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { + if mark.Alpha != 1 || mark.SoftMask != nil { + return &pdfgo.UnsupportedError{Feature: "transparent mask group"} + } + return walk(visitor) + } + 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.RGB[0] != paint.RGB[1] || paint.RGB[1] != paint.RGB[2] || mark.Style.SoftMask != nil { + return &pdfgo.UnsupportedError{Feature: "nonbinary mask graphic"} + } + visible, err := opacity(paint.RGB[0]) + if err != nil { + return err + } + shape, err := ParseGeometryPath(identity.pathData(mark.Path, Box{})) + if err != nil { + return err + } + shape, err = geometry.Normalize(shape, mark.Path.EvenOdd) + if err != nil { + return err + } + for _, clip := range mark.Style.Clips { + c, err := ParseGeometryPath(identity.pathData(clip, Box{})) + if err != nil { + return err + } + c, err = geometry.Normalize(c, clip.EvenOdd) + if err != nil { + return err + } + shape, err = geometry.Combine(shape, c, GeometryIntersect) + if err != nil { + return err + } + } + operation := GeometrySubtract + if visible { + operation = GeometryUnion + } + region, err = geometry.Combine(region, shape, operation) + return err + } + if err := mask.Walk(visitor); err != nil { + return pdfgo.Path{}, err + } + path := pdfgo.Path{} + for _, segment := range region { + point := func(p Point) pdfgo.Point { return pdfgo.Point{X: p.X, Y: p.Y} } + switch segment.Verb { + case GeometryMove: + 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 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"}) + default: + return pdfgo.Path{}, fmt.Errorf("unsupported mask geometry verb %d", segment.Verb) + } + } + if p.maskClips == nil { + p.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path) + } + p.maskClips[mask] = path + return path, nil +} + +// pdfNumbersPathBox 编码PDF坐标矩形的闭合路径 +// 入参: box 矩形边界 +// 返回: string 紧缩路径 +func pdfNumbersPathBox(box pdfgo.Rectangle) string { + return "M " + pdfNumbers(box.XMin, box.YMin) + " L " + pdfNumbers(box.XMax, box.YMin) + " L " + pdfNumbers(box.XMax, box.YMax) + " L " + pdfNumbers(box.XMin, box.YMax) + " C" +} + +// maskStyle 将独立图元的二值蒙版附加到裁剪链 +// 入参: style 图形状态 +// 返回: pdfgo.Style 转换后的状态, error 蒙版转换错误 +func (p *pdfImporter) maskStyle(style pdfgo.Style) (pdfgo.Style, error) { + if style.SoftMask == nil { + return style, nil + } + clip, err := p.maskClip(style.SoftMask) + if err != nil { + return style, err + } + style.Clips = append(append([]pdfgo.Path(nil), style.Clips...), clip) + style.SoftMask = nil + return style, nil +} diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 266f8f9..708c91b 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -33,6 +33,17 @@ import ( // 入参: mark PDF图像绘制信息 // 返回: error 错误信息 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 { + return &pdfgo.UnsupportedError{Feature: "gradient stencil image"} + } + var err error + mark.Style, err = p.maskStyle(mark.Style) + if err != nil { + return err + } if mark.Style.FillOverprint { return &pdfgo.UnsupportedError{Feature: "image color separation overprint"} } @@ -102,6 +113,17 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { // 入参: mark PDF文字绘制信息 // 返回: error 错误信息 func (p *pdfImporter) text(mark pdfgo.TextMark) error { + if err := p.flushPath(); err != nil { + return err + } + if mark.Style.Fill.Axial != nil || mark.Style.Stroke.Axial != nil { + return &pdfgo.UnsupportedError{Feature: "gradient text paint"} + } + var err error + mark.Style, err = p.maskStyle(mark.Style) + if err != nil { + return err + } if (mark.Mode == 0 || mark.Mode == 2) && mark.Style.FillOverprint && !pdfOpaqueBlack(mark.Style.Fill) || (mark.Mode == 1 || mark.Mode == 2) && mark.Style.StrokeOverprint && !pdfOpaqueBlack(mark.Style.Stroke) { return &pdfgo.UnsupportedError{Feature: "text color separation overprint"} } @@ -192,6 +214,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { color := StrokeColor(*p.color(mark.Style.Stroke)) object.StrokeColor = &color object.LineWidth = mark.Style.LineWidth * unit + object.LineWidthSet = object.LineWidth == 0 object.Join = []string{"Miter", "Round", "Bevel"}[mark.Style.Join] object.MiterLimit = mark.Style.MiterLimit if len(mark.Style.Dash) > 0 { @@ -234,6 +257,18 @@ func pdfFontProgram(source *pdfgo.Font) ([]byte, error) { return nil, fmt.Errorf("embedded PDF font has no glyphs") } changed := false + metrics := int(binary.BigEndian.Uint16(tables["hhea"][34:36])) + if metrics == 0 || metrics > int(count) { + return nil, fmt.Errorf("invalid embedded PDF font metric count") + } + metricLength := 4*metrics + 2*(int(count)-metrics) + if len(tables["hmtx"]) < metricLength { + return nil, fmt.Errorf("incomplete embedded PDF font metrics") + } + if len(tables["hmtx"]) > metricLength { + tables["hmtx"] = tables["hmtx"][:metricLength] + changed = true + } if len(tables["cmap"]) == 0 { mapping := map[rune]uint16{} for code := range source.Unicode { diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index d931030..833d865 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -260,7 +260,7 @@ func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, l if defaults != nil { style.fill = defaults.FillColor style.stroke = (*FillColor)(defaults.StrokeColor) - if defaults.LineWidth > 0 { + if defaults.LineWidth > 0 || defaults.LineWidthSet { style.options.Width = defaults.LineWidth } if defaults.Cap != "" { @@ -283,7 +283,7 @@ func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, l if object.StrokeColor != nil { style.stroke = (*FillColor)(object.StrokeColor) } - if object.LineWidth > 0 { + if object.LineWidth > 0 || object.LineWidthSet { style.options.Width = object.LineWidth } if object.Cap != "" { @@ -313,6 +313,9 @@ func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, l style.options.Dashes[i] *= scale } } + if style.options.Width == 0 { + style.options.Width = 25.4 / c.renderer.DPI + } return style } @@ -389,7 +392,7 @@ func (c *semanticCompiler) textObject(object TextObject, state RenderState) erro return nil } _, linear := renderObjectMatrix(object.Boundary, NewMatrix(object.CTM), state) - style := c.objectStyle(PathObject{DrawParam: object.DrawParam, FillColor: object.FillColor, StrokeColor: object.StrokeColor, Alpha: object.Alpha, LineWidth: object.LineWidth, Join: object.Join, MiterLimit: object.MiterLimit}, state.Defaults, linear) + style := c.objectStyle(PathObject{DrawParam: object.DrawParam, FillColor: object.FillColor, StrokeColor: object.StrokeColor, Alpha: object.Alpha, LineWidth: object.LineWidth, LineWidthSet: object.LineWidthSet, Join: object.Join, MiterLimit: object.MiterLimit}, state.Defaults, linear) if style.fill == nil { style.fill = withFillAlpha(&FillColor{}, object.Alpha) } diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 974e163..2102405 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -175,7 +175,7 @@ func (CanvasBackend) Combine(left, right GeometryPath, operation GeometryOperati var result *canvas.Path switch operation { case GeometryIntersect: - result = a.And(b) + result = applyClipPath(a, b) case GeometryUnion: result = a.Or(b) case GeometrySubtract: diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index fc9d139..feff476 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -108,7 +108,7 @@ func (s *pathStyle) applyLineJoin(join string, limit float64) { // applyDrawParam 应用绘制参数样式 // 入参: r 渲染器, dp 绘制参数, bx 边界X坐标, by 边界Y坐标, pageH 页面高度, alpha 对象透明度 func (s *pathStyle) applyDrawParam(r *Renderer, dp *DrawParam, bx, by, pageH float64, alpha *int) { - if dp.LineWidth > 0 { + if dp.LineWidth > 0 || dp.LineWidthSet { s.lineWidth = dp.LineWidth } if dp.FillColor != nil { @@ -132,7 +132,7 @@ func (s *pathStyle) applyDrawParam(r *Renderer, dp *DrawParam, bx, by, pageH flo // applyPathObject 应用路径对象样式 // 入参: r 渲染器, obj 路径对象, bx 边界X坐标, by 边界Y坐标, pageH 页面高度 func (s *pathStyle) applyPathObject(r *Renderer, obj PathObject, bx, by, pageH float64) { - if obj.LineWidth > 0 { + if obj.LineWidth > 0 || obj.LineWidthSet { s.lineWidth = obj.LineWidth } if obj.FillColor != nil { @@ -210,6 +210,9 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 } style.applyPathObject(r, obj, bx, by, pageH) style.scale(ctm) + if style.lineWidth == 0 { + style.lineWidth = 25.4 / r.DPI + } objectCTM := TranslationMatrix(bx, by).Multiply(ctm) if boundaryInCTM && parentCTM != nil { objectCTM = parentCTM.Multiply(TranslationMatrix(bx, by)).Multiply(localCTM) diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index 6acf567..63c1f50 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -110,7 +110,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if obj.StrokeColor != nil { strokeStyle.applyStrokeColor(r, obj.StrokeColor, bx, by, pageH, obj.Alpha) } - if obj.LineWidth > 0 { + if obj.LineWidth > 0 || obj.LineWidthSet { strokeStyle.lineWidth = obj.LineWidth } strokeStyle.applyLineJoin(obj.Join, obj.MiterLimit) @@ -118,6 +118,9 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 strokeStyle.strokePaint = colorWithAlpha(canvas.Black, obj.Alpha) } strokeStyle.scale(ctm) + if strokeStyle.lineWidth == 0 { + strokeStyle.lineWidth = 25.4 / r.DPI + } transformShdPaint(strokeStyle.strokePaint, parentCTM, bx, by, pageH, boundaryInCTM) strokeStyle.strokePaint, shadingClip, strokeView = resolveShdPaint(ctx, strokeStyle.strokePaint) strokeClip = intersectClipPath(strokeClip, shadingClip) diff --git a/ofdgo_res.go b/ofdgo_res.go index f142f16..82621f8 100644 --- a/ofdgo_res.go +++ b/ofdgo_res.go @@ -78,24 +78,25 @@ 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"` + 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 - 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"` } // CompositeGraphicUnits 复合图元集合 diff --git a/ofdgo_sign_policy.go b/ofdgo_sign_policy.go index 3112f73..d3aa5db 100644 --- a/ofdgo_sign_policy.go +++ b/ofdgo_sign_policy.go @@ -260,7 +260,6 @@ func (r *Reader) applySignatureCoverage(report *SignatureVerifyReport, listPath for name := range r.files { include(name) } - // 可达文件始终必需, 不允许伪装为签名载体后排除 if err := r.signatureCoverageReferences(o.DocRoot, required); err != nil { report.CoverageOK, report.CoverageError = false, err.Error() } diff --git a/ofdgo_sign_ses.go b/ofdgo_sign_ses.go index 61689e4..cddd468 100644 --- a/ofdgo_sign_ses.go +++ b/ofdgo_sign_ses.go @@ -330,7 +330,6 @@ func parseSESSeal(raw asn1.RawValue) (*sesSeal, error) { if info.Version != 5 { return nil, fmt.Errorf("unexpected timestamp in ses seal version %d", info.Version) } - // V5仅保留解析能力, 不将未验证的制章时间戳当作可信时间 timestampRaw = append([]byte(nil), items[4].FullBytes...) } picType, picData, err := parseSESPicture(infoItems[3]) diff --git a/ofdgo_text_shape.go b/ofdgo_text_shape.go index 46d4914..ca8992f 100644 --- a/ofdgo_text_shape.go +++ b/ofdgo_text_shape.go @@ -128,7 +128,6 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics if options.Shaping.Bidi { shift = visualShifts[glyph.Cluster] } else if options.Shaping.Direction == "rtl" { - // 逻辑簇后的间距位于视觉左侧,整行不计末尾字距 shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing } gx, gy := x+glyph.X*hScale+shift, y+glyph.Y diff --git a/ofdgo_text_xml.go b/ofdgo_text_xml.go index 2e33753..2060b37 100644 --- a/ofdgo_text_xml.go +++ b/ofdgo_text_xml.go @@ -68,5 +68,10 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement } } *obj = TextObject(value) + for _, attr := range start.Attr { + if attr.Name.Local == "LineWidth" { + obj.LineWidthSet = obj.LineWidth == 0 + } + } return nil } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index d4c2b51..2ec19e4 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -668,6 +668,9 @@ func (x *ofdXML) object(object GraphicObject, root bool) { attrs.number("ReadDirection", float64(obj.ReadDirection)) attrs.number("CharDirection", float64(obj.CharDirection)) attrs.number("LineWidth", obj.LineWidth) + if obj.LineWidthSet && obj.LineWidth == 0 { + attrs.add("LineWidth", "0") + } attrs.number("MiterLimit", obj.MiterLimit) attrs.add("Join", obj.Join) if obj.Italic { @@ -686,6 +689,9 @@ func (x *ofdXML) object(object GraphicObject, root bool) { attrs.add("Boundary", obj.Boundary) attrs.add("CTM", obj.CTM) attrs.number("LineWidth", obj.LineWidth) + if obj.LineWidthSet && obj.LineWidth == 0 { + attrs.add("LineWidth", "0") + } attrs.number("MiterLimit", obj.MiterLimit) attrs.add("Join", obj.Join) attrs.add("Cap", obj.Cap)