feat(渲染转换): 完善PDF转换及点线绘制
build.yaml / build (push) Successful in 43s

This commit is contained in:
xiaoqidun committed 2026-09-27 15:26:10 +08:00
1 parent e78ea6f919
commit 3b9f88cbba
16 files changed
+383 -43

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+122
View File
@@ -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
}
+15 -2
View File
@@ -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
}
+9
View File
@@ -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"]
+25
View File
@@ -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
+33 -19
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+31
View File
@@ -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))
}
+84 -4
View File
@@ -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 颜色错误
+18 -1
View File
@@ -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 {
+9
View File
@@ -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]}}
+3 -3
View File
@@ -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
+20 -3
View File
@@ -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
}
+9 -3
View File
@@ -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)
+1 -4
View File
@@ -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)