From 3b9f88cbba501bdf4818ba934b4a8e9f1ebcb9c3 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 27 Sep 2026 15:26:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B2=E6=9F=93=E8=BD=AC=E6=8D=A2):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84PDF=E8=BD=AC=E6=8D=A2=E5=8F=8A=E7=82=B9?= =?UTF-8?q?=E7=BA=BF=E7=BB=98=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_font_cmap.go | 122 +++++++++++++++++++++++++++++++ ofdgo_font_file.go | 17 ++++- ofdgo_font_ttf.go | 9 +++ ofdgo_geom_path.go | 25 +++++++ ofdgo_geom_stroke.go | 52 ++++++++----- ofdgo_paint.go | 2 +- ofdgo_pdf_composite.go | 31 ++++++++ ofdgo_pdf_composite_draw.go | 88 +++++++++++++++++++++- ofdgo_pdf_object.go | 19 ++++- ofdgo_renderer_canvas.go | 9 +++ ofdgo_renderer_compile_canvas.go | 6 +- ofdgo_renderer_geom_canvas.go | 23 +++++- ofdgo_renderer_path_canvas.go | 12 ++- ofdgo_renderer_text_canvas.go | 5 +- 16 files changed, 383 insertions(+), 43 deletions(-) create mode 100644 ofdgo_font_cmap.go diff --git a/go.mod b/go.mod index 7510da9..38a2f36 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-20260927050705-f09ba3f6aa16 + github.com/xiaoqidun/pdfgo v0.0.0-20260927070002-9eedb7bf8e13 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 52a372c..dccf3a0 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-20260927050705-f09ba3f6aa16 h1:yzUX5/pLl38Nczw+yQXck/QZ7sPblNvKu5+55cH6ONM= -github.com/xiaoqidun/pdfgo v0.0.0-20260927050705-f09ba3f6aa16/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260927070002-9eedb7bf8e13 h1:P3UEKffubpK6Cqh1ulQysufcw3c+zhEsUv4z+EhqCDA= +github.com/xiaoqidun/pdfgo v0.0.0-20260927070002-9eedb7bf8e13/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_font_cmap.go b/ofdgo_font_cmap.go new file mode 100644 index 0000000..1ad010d --- /dev/null +++ b/ofdgo_font_cmap.go @@ -0,0 +1,122 @@ +// 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/binary" + "fmt" +) + +// normalizeCmap 转换旧式双字节子表,保留各编码记录及原字形编号 +// 入参: data 字符映射表 +// 返回: []byte 等价字符映射表, error 子表错误 +func normalizeCmap(data []byte) ([]byte, error) { + if len(data) < 4 { + return data, nil + } + count := int(binary.BigEndian.Uint16(data[2:])) + if count > (len(data)-4)/8 { + return nil, fmt.Errorf("invalid cmap encoding records") + } + var out []byte + converted := make(map[uint32]uint32) + for n := 0; n < count; n++ { + record := 8 + 8*n + offset := binary.BigEndian.Uint32(data[record:]) + if uint64(offset)+2 > uint64(len(data)) { + return nil, fmt.Errorf("invalid cmap subtable offset") + } + if binary.BigEndian.Uint16(data[offset:]) != 2 { + continue + } + if out == nil { + out = bytes.Clone(data) + } + next, ok := converted[offset] + if !ok { + mapping, err := parseCmapFormat2(data[offset:]) + if err != nil { + return nil, err + } + table := buildCmapTable(0, mapping) + subtable := table[binary.BigEndian.Uint32(table[8:]):] + language := binary.BigEndian.Uint16(data[offset+4:]) + if binary.BigEndian.Uint16(subtable) == 12 { + binary.BigEndian.PutUint32(subtable[8:], uint32(language)) + } else { + binary.BigEndian.PutUint16(subtable[4:], language) + } + next = uint32(len(out)) + converted[offset] = next + out = append(out, subtable...) + } + binary.BigEndian.PutUint32(out[record:], next) + } + if out == nil { + return data, nil + } + return out, nil +} + +// parseCmapFormat2 解析混合单字节与双字节字符的子头映射 +// 入参: data format 2子表 +// 返回: map[rune]uint16 编码值到字形编号, error 子头或字形数组越界 +func parseCmapFormat2(data []byte) (map[rune]uint16, error) { + if len(data) < 526 { + return nil, fmt.Errorf("truncated cmap format 2") + } + length := int(binary.BigEndian.Uint16(data[2:])) + if length < 526 || length > len(data) { + return nil, fmt.Errorf("invalid cmap format 2 length") + } + data = data[:length] + maxKey := 0 + for n := 0; n < 256; n++ { + key := int(binary.BigEndian.Uint16(data[6+2*n:])) + if key%8 != 0 || 518+key+8 > len(data) { + return nil, fmt.Errorf("invalid cmap format 2 subheader") + } + maxKey = max(maxKey, key) + } + mapping := make(map[rune]uint16) + for high := 0; high < 256; high++ { + key := 0 + if high != 0 { + key = int(binary.BigEndian.Uint16(data[6+2*high:])) + if key == 0 { + continue + } + } + pos := 518 + key + first, count := int(binary.BigEndian.Uint16(data[pos:])), int(binary.BigEndian.Uint16(data[pos+2:])) + delta := binary.BigEndian.Uint16(data[pos+4:]) + start := pos + 6 + int(binary.BigEndian.Uint16(data[pos+6:])) + if first+count > 256 || count > 0 && (start < 526+maxKey || start+count*2 > len(data)) { + return nil, fmt.Errorf("invalid cmap format 2 glyph array") + } + for n := 0; n < count; n++ { + low := first + n + if high == 0 && binary.BigEndian.Uint16(data[6+2*low:]) != 0 { + continue + } + glyph := binary.BigEndian.Uint16(data[start+2*n:]) + if glyph != 0 { + mapping[rune(high*256+low)] = glyph + delta + } + } + } + return mapping, nil +} diff --git a/ofdgo_font_file.go b/ofdgo_font_file.go index 4dd69e1..dd59835 100644 --- a/ofdgo_font_file.go +++ b/ofdgo_font_file.go @@ -89,11 +89,24 @@ func (f FontFile) Face(index int) ([]byte, error) { return nil, err } if bytes.HasPrefix(data, []byte("ttcf")) { - return extractCollectionFont(data, index) + data, err = extractCollectionFont(data, index) + if err != nil { + return nil, err + } + index = 0 } - if _, err := fontFileTables(data, index); err != nil { + tables, err := fontFileTables(data, index) + if err != nil { return nil, err } + cmap, err := normalizeCmap(tables["cmap"]) + if err != nil { + return nil, err + } + if !bytes.Equal(cmap, tables["cmap"]) { + tables["cmap"] = cmap + return serializeOTF(tables) + } return bytes.Clone(data), nil } diff --git a/ofdgo_font_ttf.go b/ofdgo_font_ttf.go index b075b01..a7bc219 100644 --- a/ofdgo_font_ttf.go +++ b/ofdgo_font_ttf.go @@ -15,6 +15,7 @@ package ofdgo import ( + "bytes" "encoding/binary" ) @@ -53,6 +54,14 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin if existingTables["CFF "] != nil { isCFFSfnt = true } + cmap, err := normalizeCmap(existingTables["cmap"]) + if err != nil { + return false, data, nil, false, err + } + if !bytes.Equal(cmap, existingTables["cmap"]) { + existingTables["cmap"] = cmap + malformedDirectory = true + } missingHead := existingTables["head"] == nil if !missingHead { head := existingTables["head"] diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go index 2cb96c7..5713915 100644 --- a/ofdgo_geom_path.go +++ b/ofdgo_geom_path.go @@ -24,6 +24,31 @@ import ( // 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点 type GeometryPath []GeometrySegment +// stationaryEndpoint 判断贝塞尔曲线端点是否具有零一阶导数 +// 返回: bool 是否存在驻点端点 +func (p GeometryPath) stationaryEndpoint() bool { + var current, start Point + for _, segment := range p { + switch segment.Verb { + case GeometryMove: + start = segment.End + case GeometryCubic: + if segment.Control1 == current || segment.Control2 == segment.End { + return true + } + case GeometryQuad: + if segment.Control1 == current || segment.Control1 == segment.End { + return true + } + case GeometryClose: + current = start + continue + } + current = segment.End + } + return false +} + // GeometryVerb 表示几何路径指令 type GeometryVerb uint8 diff --git a/ofdgo_geom_stroke.go b/ofdgo_geom_stroke.go index f361328..15ebf79 100644 --- a/ofdgo_geom_stroke.go +++ b/ofdgo_geom_stroke.go @@ -21,15 +21,16 @@ import ( // geometryPolyline 保存展开后的子路径,独立记录闭合状态 type geometryPolyline struct { - points []Point - closed bool + points []Point + closed bool + direction Point } -// Stroke 将曲线、正长度虚线及线帽连接转换为非零填充轮廓 +// Stroke 将曲线、虚线及线帽连接转换为非零填充轮廓 // 曲线按Tolerance展开,默认误差为0.001毫米,重叠轮廓保持同向绕数 // 结果未做布尔整理,按非零规则填充,区域运算需能处理重叠子路径的后端 // 入参: options 描边样式 -// 返回: GeometryPath 描边轮廓, error 无效参数、不支持的零长度语义或精度限制 +// 返回: GeometryPath 描边轮廓, error 无效参数或精度限制 func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) { if err := validateStroke(options); err != nil { return nil, err @@ -51,11 +52,6 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) { if total == 0 { dashes = nil } else { - for _, d := range dashes { - if d == 0 { - return nil, fmt.Errorf("zero-length stroke dash: %w", ErrBackendUnavailable) - } - } if len(dashes)%2 != 0 { dashes = append(dashes, dashes...) total *= 2 @@ -199,7 +195,7 @@ func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, er } // dash 按子路径重置相位并合并跨闭合接缝的连续虚线 -// 入参: pattern 正长度偶数虚线数组, total 周期, offset 偏移 +// 入参: pattern 非负偶数虚线数组, total 周期, offset 偏移 // 返回: []geometryPolyline 虚线段, error 长度或细分限制 func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]geometryPolyline, error) { phase := math.Mod(offset, total) @@ -207,7 +203,7 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g phase += total } index := 0 - for phase >= pattern[index] { + for phase > 0 && phase >= pattern[index] { phase -= pattern[index] index = (index + 1) % len(pattern) } @@ -233,7 +229,24 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g if !finite(length) { return nil, fmt.Errorf("stroke dash length overflow") } - for at := 0.0; at < length; { + for at := 0.0; ; { + for remaining == 0 { + steps++ + if steps > 1<<18 { + return nil, fmt.Errorf("stroke dash subdivision limit exceeded") + } + if pattern[index] == 0 && index%2 == 0 && len(run) == 0 { + result = append(result, geometryPolyline{points: []Point{geometryLerp(a, b, at/length)}, direction: Point{(b.X - a.X) / length, (b.Y - a.Y) / length}}) + } + index = (index + 1) % len(pattern) + remaining = pattern[index] + if index%2 == 1 && remaining > 0 { + flush() + } + } + if at == length { + break + } step := math.Min(remaining, length-at) if at+step == at || steps > 1<<18 { return nil, fmt.Errorf("stroke dash subdivision limit exceeded") @@ -247,11 +260,6 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g } at += step remaining -= step - if remaining == 0 { - flush() - index = (index + 1) % len(pattern) - remaining = pattern[index] - } } } flush() @@ -259,7 +267,7 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g first, last := &result[0], &result[len(result)-1] if first.points[0] == line.points[0] && last.points[len(last.points)-1] == line.points[0] { if len(result) == 1 { - first.closed = true + first.closed = len(first.points) > 1 } else { first.points = append(last.points[:len(last.points)-1], first.points...) result = result[:len(result)-1] @@ -316,7 +324,13 @@ func (line geometryPolyline) stroke(options StrokeOptions) (GeometryPath, error) if options.Cap == "Round" { disk(points[0]) } else if options.Cap == "Square" { - return nil, fmt.Errorf("directionless square stroke cap: %w", ErrBackendUnavailable) + d := line.direction + if d == (Point{}) { + return nil, fmt.Errorf("directionless square stroke cap: %w", ErrBackendUnavailable) + } + p := points[0] + u, v := Point{d.X * half, d.Y * half}, Point{-d.Y * half, d.X * half} + polygon(Point{p.X - u.X - v.X, p.Y - u.Y - v.Y}, Point{p.X + u.X - v.X, p.Y + u.Y - v.Y}, Point{p.X + u.X + v.X, p.Y + u.Y + v.Y}, Point{p.X - u.X + v.X, p.Y - u.Y + v.Y}) } return result, nil } diff --git a/ofdgo_paint.go b/ofdgo_paint.go index fed170c..e18f91b 100644 --- a/ofdgo_paint.go +++ b/ofdgo_paint.go @@ -182,7 +182,7 @@ func (r *Renderer) parseFillColor(fillColor *FillColor) color.Color { if fillColor == nil { return nil } - if fillColor.Pattern != nil { + if fillColor.Pattern != nil || fillColor.GouraudShd != nil || fillColor.LaGouraudShd != nil { return nil } if fillColor.AxialShd != nil { diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 08a4b16..966bc27 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -173,6 +173,31 @@ func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Vis } if space == nil { space = &pdfgo.ColorSpace{Model: "DeviceRGB"} + var needsProcess func([]pdfCompositeNode) (bool, error) + needsProcess = func(items []pdfCompositeNode) (bool, error) { + for _, item := range items { + if item.group != nil { + found, err := needsProcess(item.children) + if err != nil || found { + return found, err + } + } + if item.image != nil && item.image.Style.FillOverprint { + _, found, err := pdfImageProcessColorants(item.image.Image) + if err != nil || found { + return found, err + } + } + } + return false, nil + } + found, err := needsProcess(nodes) + if err != nil { + return err + } + if found { + space = &pdfgo.ColorSpace{Model: "DeviceCMYK"} + } } for i, node := range nodes { if node.opaque(space) || space.SRGBEquivalent() && node.direct() { @@ -211,6 +236,9 @@ func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool { return true } style, fill, stroke := n.style() + if n.image != nil && style.FillOverprint { + return false + } if style.SoftMask != nil || !pdfNormalBlend(style.BlendMode) || fill && style.Fill.Alpha != 1 || stroke && style.Stroke.Alpha != 1 { return false } @@ -241,6 +269,9 @@ func (n pdfCompositeNode) direct() bool { return true } s, fill, stroke := n.style() + if n.image != nil && s.FillOverprint { + return false + } return pdfNormalBlend(s.BlendMode) && !(fill && s.FillOverprint && pdfOverprintNeedsSeparation(s.Fill) || stroke && s.StrokeOverprint && pdfOverprintNeedsSeparation(s.Stroke)) } diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index 76c9f91..18bdfae 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -98,6 +98,14 @@ 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) + if err != nil { + return err + } + } 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 @@ -156,12 +164,28 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf } if initial != nil { pixel := initial[index] + if processOverprint { + for c := range values { + if !processMask[c] { + values[c] = pixel.values[c] * pixel.alpha + } + } + } if err := pdfCompositePaintOver(&pixel, values, opacity, space, mode, compatible); err != nil { return err } pdfCompositeInterpolate(&pixels[index], pixel, shape) - } else if err := pdfCompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible); err != nil { - return err + } else { + if processOverprint { + for c := range values { + if !processMask[c] { + values[c] = pixels[index].values[c] * pixels[index].alpha + } + } + } + if err := pdfCompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible); err != nil { + return err + } } pixels[index].shape += shape * (1 - pixels[index].shape) } @@ -359,8 +383,33 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac return [4]float64{}, 0, nil } x, y := point.X*float64(source.Rect.Dx())-.5, (1-point.Y)*float64(source.Rect.Dy())-.5 + process := mark.Style.FillOverprint && space.Model == "DeviceCMYK" && !space.Calibrated() && len(source.Colorants) > 0 + var indices [4]int + if process { + for i, name := range source.Colorants { + index := -1 + for j, known := range []pdfgo.Name{"Cyan", "Magenta", "Yellow", "Black"} { + if name == known { + index = j + } + } + if index < 0 { + process = false + break + } + indices[i] = index + } + } sample := func(x, y int) ([4]float64, float64) { - return source.ValuesAt(max(0, min(source.Rect.Dx()-1, x)), max(0, min(source.Rect.Dy()-1, y))) + 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 { + values = [4]float64{} + for j, tint := range source.TintsAt(x, y) { + values[indices[j]] = float64(tint) / 65535 + } + } + return values, alpha } values, alpha := sample(int(math.Floor(x+.5)), int(math.Floor(y+.5))) if mark.Image.Interpolate { @@ -381,7 +430,7 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac } v, a := sample(ix+i, iy+j) alpha += a * weight - for k := 0; k < source.Space.Components(); k++ { + for k := range values { values[k] += v[k] * a * weight } } @@ -399,10 +448,41 @@ func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, spac } return color, alpha * (1 - values[0]), err } + if process { + return values, alpha, nil + } values, err := space.Convert(values[:source.Space.Components()], source.Space, mark.Style.RenderingIntent) return values, alpha, err } +// pdfImageProcessColorants 识别可在四色设备上直接绘制的专色色料 +// 入参: image PDF图像 +// 返回: [4]bool 参与绘制的四色通道, bool 是否全部属于印刷原色, error 色空间错误 +func pdfImageProcessColorants(image *pdfgo.Image) ([4]bool, bool, error) { + var mask [4]bool + names, err := image.Colorants() + if err != nil { + return mask, false, err + } + if len(names) == 0 { + return mask, false, nil + } + for _, name := range names { + found := false + for j, known := range []pdfgo.Name{"Cyan", "Magenta", "Yellow", "Black"} { + if name == known { + mask[j] = true + found = true + break + } + } + if !found { + return [4]bool{}, false, nil + } + } + return mask, true, nil +} + // pdfCompositeColor 将原始画刷变换到混合空间,渐变在源空间求值 // 入参: paint 画刷, point 页面坐标, space 混合空间, intent 渲染意图 // 返回: [4]float64 混合分量, bool 是否着色, error 颜色错误 diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 86d65e3..1a0f437 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -473,7 +473,21 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er tables["hmtx"] = tables["hmtx"][:metricLength] changed = true } - if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && pdfCmapOverlaps(tables["cmap"]) { + 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)) @@ -484,6 +498,9 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er 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 { diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index ed1bfc1..faca27d 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -19,6 +19,7 @@ import ( "image" "image/color" "image/draw" + "slices" "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/renderers/rasterizer" @@ -148,6 +149,14 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) { style.Dashes[i] = dash / options.Width } } + if slices.Contains(options.Dashes, 0) { + var err error + p, err = canvasStrokePath(b, p, options) + if err != nil { + return nil, err + } + style = canvas.Style{Fill: paint, FillRule: canvas.NonZero} + } m = RasterMatrix{1, 0, 0, 1, 0, 0} } matrix := canvas.Matrix{{m[0], m[2], m[4]}, {-m[1], -m[3], page.Height - m[5]}} diff --git a/ofdgo_renderer_compile_canvas.go b/ofdgo_renderer_compile_canvas.go index 055faf5..76c16e5 100644 --- a/ofdgo_renderer_compile_canvas.go +++ b/ofdgo_renderer_compile_canvas.go @@ -70,12 +70,12 @@ func (c *canvasPageCompiler) RenderPath(path *canvas.Path, style canvas.Style, m } if style.HasStroke() { stroke := path + options := canvasStrokeOptions(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.PixelTolerance/(c.page.DPI/25.4)) if len(style.Dashes) > 0 { - offset, dashes := canvas.ScaleDash(style.StrokeWidth, style.DashOffset, style.Dashes) - stroke = stroke.Dash(offset, dashes...) + options.DashOffset, options.Dashes = canvas.ScaleDash(style.StrokeWidth, style.DashOffset, style.Dashes) } var err error - stroke, err = canvasStrokePath(c.geometry, stroke, canvasStrokeOptions(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.PixelTolerance/(c.page.DPI/25.4))) + stroke, err = canvasStrokePath(c.geometry, stroke, options) if err != nil { c.err = err return diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 2102405..016b26b 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "slices" "strconv" "github.com/tdewolff/canvas" @@ -77,12 +78,21 @@ func canvasStrokePath(geometry GeometryBackend, path *canvas.Path, options Strok // 入参: path Canvas路径, width 描边宽度, cap 线帽, join 连接 // 返回: *canvas.Path 描边轮廓 func (r *Renderer) strokeCanvasPath(path *canvas.Path, width float64, cap canvas.Capper, join canvas.Joiner) *canvas.Path { + return r.strokeDashedCanvasPath(path, width, cap, join, 0, nil) +} + +// strokeDashedCanvasPath 通过公共几何后端展开虚线和线帽,保留零长度绘制段 +// 入参: path 路径, width 线宽, cap 线帽, join 连接, offset 虚线偏移, dashes 虚线数组 +// 返回: *canvas.Path 描边填充轮廓 +func (r *Renderer) strokeDashedCanvasPath(path *canvas.Path, width float64, cap canvas.Capper, join canvas.Joiner, offset float64, dashes []float64) *canvas.Path { geometry, err := r.Geometry() if err != nil { r.renderError = err return &canvas.Path{} } - result, err := canvasStrokePath(geometry, path, canvasStrokeOptions(width, cap, join, canvas.Tolerance)) + options := canvasStrokeOptions(width, cap, join, canvas.Tolerance) + options.DashOffset, options.Dashes = offset, dashes + result, err := canvasStrokePath(geometry, path, options) if err != nil { r.renderError = err return &canvas.Path{} @@ -191,13 +201,16 @@ func (CanvasBackend) Combine(left, right GeometryPath, operation GeometryOperati return *geometryFromCanvasPath(result), nil } -// Stroke 将绝对长度虚线与描边转换为填充区域 +// Stroke 将绝对长度虚线与描边转换为填充区域,驻点曲线按指定精度展开 // 入参: path 页面路径, options 描边样式 // 返回: GeometryPath 描边区域, error 样式或路径错误 func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryPath, error) { if err := validateStroke(options); err != nil { return nil, err } + if slices.Contains(options.Dashes, 0) { + return path.Stroke(options) + } p, err := geometryToCanvasPath(&path) if err != nil { return nil, err @@ -209,7 +222,11 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryP style := pathStyle{lineJoin: canvas.MiterJoin, lineCap: canvas.ButtCap, miterLimit: defaultMiterLimit} style.applyLineJoin(options.Join, options.MiterLimit) style.lineCap = pathLineCap(options.Cap, style.lineCap) - p = p.Dash(options.DashOffset, options.Dashes...).Stroke(options.Width, style.lineCap, style.lineJoin, tolerance) + p = p.Dash(options.DashOffset, options.Dashes...) + if path.stationaryEndpoint() { + p = p.Flatten(tolerance) + } + p = p.Stroke(options.Width, style.lineCap, style.lineJoin, tolerance) return *geometryFromCanvasPath(p), nil } diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index 180247e..54a0595 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -17,6 +17,7 @@ package ofdgo import ( "image/color" "math" + "slices" "github.com/tdewolff/canvas" ) @@ -291,15 +292,20 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 ctx.SetStrokeCapper(style.lineCap) ctx.SetStrokeJoiner(style.lineJoin) sp := p - if len(style.dashPattern) > 0 { + zeroDash := slices.Contains(style.dashPattern, 0) + if len(style.dashPattern) > 0 && !zeroDash { sp = sp.Dash(style.dashOffset, style.dashPattern...) ctx.SetDashes(0) } strokeClip := intersectClipPath(clipPath, shadingClip) _, repeat := strokePaint.(*repeatAxialGradient) - if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !canvasNativeStroke(r.backends.Geometry) { + if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !canvasNativeStroke(r.backends.Geometry) || zeroDash { sp = sp.Copy() - sp = r.strokeCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin) + if zeroDash { + sp = r.strokeDashedCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin, style.dashOffset, style.dashPattern) + } else { + sp = r.strokeCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin) + } sp = applyClipPath(sp, strokeClip) if style.strokePattern != nil { r.renderPattern(ctx, style.strokePattern, pageH, sp, objectCTM) diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index f1810fb..dd70ac2 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -295,10 +295,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 ctx.SetStrokeColor(canvas.Transparent) r.drawShdPath(ctx, applyClipPath(path.Copy(), fillClip), fillView) } - if len(strokeStyle.dashPattern) > 0 { - path = path.Dash(strokeStyle.dashOffset, strokeStyle.dashPattern...) - } - path = r.strokeCanvasPath(path, strokeStyle.lineWidth, strokeStyle.lineCap, strokeStyle.lineJoin) + path = r.strokeDashedCanvasPath(path, strokeStyle.lineWidth, strokeStyle.lineCap, strokeStyle.lineJoin, strokeStyle.dashOffset, strokeStyle.dashPattern) path = applyClipPath(path, strokeClip) if strokeStyle.strokePattern != nil { r.renderPattern(ctx, strokeStyle.strokePattern, pageH, path, objectCTM)