feat(后端架构): 优化多后端渲染,修复描边与字形校验
build.yaml / build (push) Successful in 40s

This commit is contained in:
xiaoqidun committed 2026-09-23 20:04:48 +08:00
1 parent f44d0d8a60
commit 580e7043c4
31 files changed
+1488 -204

No files matched your search

+1 -1
View File
@@ -136,7 +136,7 @@ func NewRenderBackends(name string) (RenderBackends, error) {
backends.Fonts = GGBackend{}
backends.Geometry = GGGeometryBackend{GeometryBackend: backends.Geometry}
backends.Compiler = GGBackend{}
backends.Raster = GGBackend{}
backends.Raster = GGBackend{StrokeGeometry: backends.Geometry}
default:
return RenderBackends{}, fmt.Errorf("unknown render backend %q", name)
}
+17 -37
View File
@@ -949,7 +949,7 @@ func (e *Editor) AddText(page int, box Box, value, fontID string, size float64)
}
// UpdateText 按现有段落选项重排横向文字,保留对象标识、顺序、边界及绘制属性
// 自定义文字定位使用UpdateObject,不进行复杂文字塑形
// 自定义文字定位使用UpdateObject,字体塑形沿用既有段落选项
// 入参: page 页面索引, id 文字对象标识, value 原文, fontID 字体资源标识, size 字号
// 返回: error 错误信息
func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) error {
@@ -1115,8 +1115,11 @@ func (e *Editor) prepareText(obj *TextObject) error {
if len(obj.TextCode) == 0 {
return fmt.Errorf("text codes are empty")
}
if obj.VScale != 0 || obj.Decoration != "" || len(obj.CGTransform) != 0 {
return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("text extensions and glyph transforms are not supported for creation")}
if obj.VScale != 0 || obj.Decoration != "" {
return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("text extensions are not supported for creation")}
}
if err := validateTextGlyphs(*obj, sfnt.NumGlyphs()); err != nil {
return err
}
if !finite(obj.HScale) || obj.HScale < 0 || !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 {
return fmt.Errorf("invalid text dimensions")
@@ -1131,6 +1134,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
}
obj.TextCode = append([]TextCode(nil), obj.TextCode...)
var missing []rune
position, transform := 0, 0
x, y := "", ""
for i := range obj.TextCode {
code := &obj.TextCode[i]
@@ -1151,9 +1155,14 @@ func (e *Editor) prepareText(obj *TextObject) error {
code.X, code.Y = x, y
runes := textCodeRunes(code.Value)
for _, char := range runes {
if sfnt.GlyphIndex(char) == 0 {
for transform < len(obj.CGTransform) && position >= obj.CGTransform[transform].CodePosition+obj.CGTransform[transform].CodeCount {
transform++
}
mapped := transform < len(obj.CGTransform) && position >= obj.CGTransform[transform].CodePosition
if !mapped && sfnt.GlyphIndex(char) == 0 {
missing = append(missing, char)
}
position++
}
for _, delta := range []string{code.DeltaX, code.DeltaY} {
if delta != "" {
@@ -1200,40 +1209,11 @@ func creationDeltas(value string) error {
// 入参: value 路径数据
// 返回: error 错误信息
func creationPath(value string) error {
tokens := strings.Fields(value)
if len(tokens) == 0 || (tokens[0] != "M" && tokens[0] != "S") {
return fmt.Errorf("path must start with M or S")
path, err := ParseGeometryPath(value)
if err == nil && len(path) == 0 {
return fmt.Errorf("empty path")
}
for i := 0; i < len(tokens); {
command := tokens[i]
i++
count := 0
switch command {
case "M", "S", "L":
count = 2
case "Q":
count = 4
case "B":
count = 6
case "A":
count = 7
case "C":
default:
return fmt.Errorf("invalid path command %q", command)
}
if len(tokens)-i < count {
return fmt.Errorf("missing arguments for path command %s", command)
}
values, err := creationNumbers(strings.Join(tokens[i:i+count], " "), count)
if err != nil {
return err
}
if command == "A" && (values[0] < 0 || values[1] < 0 || (values[3] != 0 && values[3] != 1) || (values[4] != 0 && values[4] != 1)) {
return fmt.Errorf("invalid arc radii or flags")
}
i += count
}
return nil
return err
}
// creationNumbers 解析创建接口的有限十进制数值序列
+2 -1
View File
@@ -105,7 +105,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
return err
}
object.TextObject.TextCode = text.TextCode
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size {
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && state.layout.options.Shape && style.Font != "" && style.Font != object.TextObject.Font {
if style.Size != 0 {
text.Size = style.Size
}
@@ -123,6 +123,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
return err
}
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
object.TextObject.CGTransform = text.CGTransform
state.layout = &textLayout{value: content, options: layout}
}
if style.Font != "" && style.Font != object.TextObject.Font {
+41 -5
View File
@@ -180,6 +180,7 @@ func subsetSourceFont(data []byte, usage *editorFontUsage) []byte {
type editorFontSubset struct {
glyphs []uint16
data []byte
mapped bool
}
// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,保留完整编辑字体
@@ -187,6 +188,7 @@ type editorFontSubset struct {
// 返回: map[string][]byte 包内字体子集, error 错误信息
func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) {
used := make(map[string]map[uint16]bool)
mapped := make(map[string]bool)
for _, resource := range e.resources {
if resource.font == nil {
continue
@@ -248,6 +250,17 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
glyphs[e.fonts[obj.Font].GlyphIndex(char)] = true
}
}
for _, transform := range obj.CGTransform {
mapped[obj.Font] = true
for _, value := range strings.Fields(transform.Glyphs) {
id, err := strconv.ParseUint(value, 10, 16)
if err != nil || id >= uint64(e.fonts[obj.Font].NumGlyphs()) {
delete(used, obj.Font)
break
}
glyphs[uint16(id)] = true
}
}
}
}
}
@@ -295,15 +308,15 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
ids = append(ids, id)
}
slices.Sort(ids)
if resource.subset == nil || !slices.Equal(resource.subset.glyphs, ids) {
data, err := subsetEditorFont(resource.data, ids)
if resource.subset == nil || resource.subset.mapped != mapped[resource.font.ID] || !slices.Equal(resource.subset.glyphs, ids) {
data, err := subsetEditorFont(resource.data, ids, mapped[resource.font.ID])
if err != nil {
return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err)
}
if len(data) >= len(resource.data) {
data = nil
}
resource.subset = &editorFontSubset{glyphs: ids, data: data}
resource.subset = &editorFontSubset{glyphs: ids, data: data, mapped: mapped[resource.font.ID]}
}
if resource.subset.data != nil {
result[resource.name] = resource.subset.data
@@ -316,13 +329,36 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
}
// subsetEditorFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令
// 入参: data 字体数据, glyphs 已排序的字形编号,包含0
// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号
// 返回: []byte 字体子集, error 错误信息
func subsetEditorFont(data []byte, glyphs []uint16) ([]byte, error) {
func subsetEditorFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) {
sfnt, err := font.ParseSFNT(bytes.Clone(data), 0)
if err != nil {
return nil, err
}
if mapped {
usage := newEditorFontUsage()
for _, id := range glyphs {
usage.glyphs[id] = true
for _, char := range sfnt.Cmap.ToUnicode(id) {
usage.chars[char] = true
}
}
tables := make(map[string][]byte)
for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} {
if table := sfnt.Tables[tag]; table != nil {
tables[tag] = table
}
}
fixed, err := serializeOTF(tables)
if err != nil {
return nil, err
}
if subset := subsetSourceFont(fixed, usage); subset != nil {
return subset, nil
}
return fixed, nil
}
subset, err := sfnt.Subset(glyphs, font.SubsetOptions{Tables: []string{
"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp",
}})
+2
View File
@@ -63,7 +63,9 @@ func missingGlyphError(id string, characters []rune) error {
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
// 零值保持显式换行和左对齐
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
type TextLayout struct {
Shape bool
Wrap bool
Align string
LineHeight float64
+17 -2
View File
@@ -35,6 +35,20 @@ type FontOutlines interface {
GlyphOutline(glyph uint16, size float64) (GeometryPath, error)
}
// FontShaper 提供可选的横向文字塑形,仅用于显式新建或重排
// 字形按逻辑簇顺序排列,Cluster使用原文的符文索引,坐标为毫米且纵轴向下
type FontShaper interface {
FontMetrics
ShapeText(value string, size float64) ([]ShapedGlyph, error)
}
// ShapedGlyph 保存塑形后的字形、原文簇和基线定位
type ShapedGlyph struct {
Glyph uint16
Cluster int
X, Y, Advance float64
}
// ResolvedFont 保存字体来源和只读字体数据,不将外部字体写入文档
type ResolvedFont struct {
Data []byte
@@ -78,8 +92,8 @@ func (r *Renderer) resetFontBackendCache() {
r.fontSourcesCache.fallback = ResolvedFont{}
r.fontSourcesCache.fallbackRead = false
}
r.fontMetrics = make(map[*PreparedFont]FontMetrics)
r.glyphOutlines = make(map[glyphOutlineKey]GeometryPath)
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 {
fonts.resetFonts()
@@ -110,6 +124,7 @@ type PreparedFont struct {
ResolvedFont
Glyphs map[uint16]rune
CIDs map[uint16]rune
digest [32]byte
}
// PrepareFont 统一处理内嵌字体包装和索引映射,供各编译器复用
+117
View File
@@ -0,0 +1,117 @@
// 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"
)
// Curves 按毫米误差上限将端点式椭圆弧转换为三次贝塞尔曲线,其他指令保持原样
// 入参: tolerance 正有限误差上限
// 返回: GeometryPath 独立路径, error 参数错误
func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
if !rasterPositive(tolerance) {
return nil, fmt.Errorf("invalid curve tolerance")
}
result := make(GeometryPath, 0, len(p))
var current, start Point
for _, s := range p {
switch s.Verb {
case GeometryMove:
start = s.End
case GeometryArc:
curves, err := arcCurves(current, s, tolerance)
if err != nil {
return nil, err
}
result = append(result, curves...)
current = s.End
continue
case GeometryClose:
result = append(result, s)
current = start
continue
case GeometryLine, GeometryQuad, GeometryCubic:
default:
return nil, fmt.Errorf("unsupported geometry command %d", s.Verb)
}
result = append(result, s)
current = s.End
}
return result, nil
}
// arcCurves 按标准端点参数求椭圆中心,只在绘制边界近似弧线
// 入参: start 起点, arc 弧线, tolerance 毫米误差上限
// 返回: GeometryPath 贝塞尔片段, error 无效弧线
func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPath, error) {
rx, ry := arc.RadiusX, arc.RadiusY
if !finite(rx) || !finite(ry) || rx < 0 || ry < 0 || !finite(arc.Rotation) ||
!finite(start.X) || !finite(start.Y) || !finite(arc.End.X) || !finite(arc.End.Y) {
return nil, fmt.Errorf("invalid arc parameters")
}
if start == arc.End {
return nil, nil
}
if rx == 0 || ry == 0 {
return GeometryPath{{Verb: GeometryLine, End: arc.End}}, nil
}
angle := math.Mod(arc.Rotation, 360) * math.Pi / 180
sine, cosine := math.Sincos(angle)
dx, dy := (start.X-arc.End.X)/2, (start.Y-arc.End.Y)/2
x, y := cosine*dx+sine*dy, -sine*dx+cosine*dy
scale := math.Hypot(x/rx, y/ry)
if scale > 1 {
rx *= scale
ry *= scale
}
u, v := x/rx, y/ry
square := u*u + v*v
factor := math.Sqrt(math.Max(0, (1-square)/square))
if arc.Large == arc.Sweep {
factor = -factor
}
cx, cy := factor*rx*v, -factor*ry*u
theta := math.Atan2((y-cy)/ry, (x-cx)/rx)
end := math.Atan2((-y-cy)/ry, (-x-cx)/rx)
delta := math.Mod(end-theta, 2*math.Pi)
if arc.Sweep && delta < 0 {
delta += 2 * math.Pi
}
if !arc.Sweep && delta > 0 {
delta -= 2 * math.Pi
}
center := Point{cosine*cx - sine*cy + (start.X+arc.End.X)/2, sine*cx + cosine*cy + (start.Y+arc.End.Y)/2}
step := math.Min(math.Pi/2, math.Pow(tolerance/math.Max(rx, ry)*40000, 1.0/6))
count := int(math.Ceil(math.Abs(delta) / step))
if count < 1 || !finite(center.X) || !finite(center.Y) {
return nil, fmt.Errorf("invalid arc extent")
}
step = delta / float64(count)
point := func(x, y float64) Point {
return Point{center.X + rx*cosine*x - ry*sine*y, center.Y + rx*sine*x + ry*cosine*y}
}
result := make(GeometryPath, 0, count)
for i := 0; i < count; i++ {
a, b := theta+float64(i)*step, theta+float64(i+1)*step
sa, ca := math.Sincos(a)
sb, cb := math.Sincos(b)
k := 4.0 / 3 * math.Tan(step/4)
result = append(result, GeometrySegment{Verb: GeometryCubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
}
result[len(result)-1].End = arc.End
return result, nil
}
+21
View File
@@ -35,6 +35,27 @@ type StrokeOptions struct {
Dashes []float64
}
// validateStroke 校验各后端共用的描边参数
// 入参: options 描边样式
// 返回: error 无效样式
func validateStroke(options StrokeOptions) error {
if !finite(options.Width) || options.Width <= 0 || !finite(options.Tolerance) || options.Tolerance < 0 || !finite(options.DashOffset) || !finite(options.MiterLimit) || options.MiterLimit < 0 {
return fmt.Errorf("invalid stroke width or tolerance")
}
if options.Cap != "" && options.Cap != "Butt" && options.Cap != "Round" && options.Cap != "Square" {
return fmt.Errorf("invalid stroke cap %q", options.Cap)
}
if options.Join != "" && options.Join != "Miter" && options.Join != "Round" && options.Join != "Bevel" {
return fmt.Errorf("invalid stroke join %q", options.Join)
}
for _, dash := range options.Dashes {
if !finite(dash) || dash < 0 {
return fmt.Errorf("invalid stroke dash")
}
}
return nil
}
// GeometryBackend 操作库自有路径,坐标以页面左上角为原点,不修改任何输入
// Clip返回nil表示不裁剪,空路径指针表示完全裁去,矩阵映射到页面坐标
type GeometryBackend interface {
+91
View File
@@ -16,6 +16,7 @@ package ofdgo
import (
"fmt"
"strconv"
"strings"
)
@@ -23,6 +24,96 @@ import (
// 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点
type GeometryPath []GeometrySegment
// ParseGeometryPath 解析标准OFD紧缩路径,保留弧线和原始数值精度
// 入参: data 紧缩路径
// 返回: GeometryPath 独立路径, error 指令或参数错误
func ParseGeometryPath(data string) (GeometryPath, error) {
tokens := strings.Fields(data)
if len(tokens) == 0 {
return nil, nil
}
if tokens[0] != "M" && tokens[0] != "S" {
return nil, fmt.Errorf("path must start with M or S")
}
var result GeometryPath
for i := 0; i < len(tokens); {
command := tokens[i]
i++
count := 0
switch command {
case "M", "S", "L":
count = 2
case "Q":
count = 4
case "B":
count = 6
case "A":
count = 7
case "C":
default:
return nil, fmt.Errorf("invalid path command %q", command)
}
if len(tokens)-i < count {
return nil, fmt.Errorf("missing arguments for path command %s", command)
}
var values [7]float64
for j, token := range tokens[i : i+count] {
if command == "A" && (j == 3 || j == 4) && (token == "true" || token == "false") {
if token == "true" {
values[j] = 1
}
continue
}
value, err := strconv.ParseFloat(token, 64)
if err != nil || !finite(value) || strings.ContainsAny(token, "xX_") {
return nil, fmt.Errorf("invalid number %q", token)
}
values[j] = value
}
i += count
segment := GeometrySegment{}
switch command {
case "M", "S":
segment.Verb, segment.End = GeometryMove, Point{values[0], values[1]}
case "L":
segment.Verb, segment.End = GeometryLine, Point{values[0], values[1]}
case "Q":
segment.Verb, segment.Control1, segment.End = GeometryQuad, Point{values[0], values[1]}, Point{values[2], values[3]}
case "B":
segment.Verb, segment.Control1, segment.Control2, segment.End = GeometryCubic, Point{values[0], values[1]}, Point{values[2], values[3]}, Point{values[4], values[5]}
case "A":
if values[0] < 0 || values[1] < 0 || values[3] != 0 && values[3] != 1 || values[4] != 0 && values[4] != 1 {
return nil, fmt.Errorf("invalid arc radii or flags")
}
segment = GeometrySegment{Verb: GeometryArc, RadiusX: values[0], RadiusY: values[1], Rotation: values[2], Large: values[3] != 0, Sweep: values[4] != 0, End: Point{values[5], values[6]}}
case "C":
segment.Verb = GeometryClose
}
result = append(result, segment)
}
return result, nil
}
// objectGeometryPath 解析对象路径并由当前几何实现应用页面变换
// 入参: geometry 几何后端, object 路径对象
// 返回: GeometryPath 页面路径, error 参数或变换错误
func objectGeometryPath(geometry GeometryBackend, object PathObject) (GeometryPath, error) {
path, err := ParseGeometryPath(object.AbbreviatedData)
if err != nil {
return nil, err
}
box, err := creationBox(object.Boundary)
if err != nil {
return nil, err
}
if object.CTM != "" {
if _, err := creationNumbers(object.CTM, 6); err != nil {
return nil, err
}
}
return geometry.Transform(path, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CTM)))
}
// closedGeometry 为填充轮廓补齐隐式闭合边,不改变源路径
// 入参: path 填充路径
// 返回: GeometryPath 显式闭合路径
+1
View File
@@ -156,6 +156,7 @@ type TextCode struct {
}
// CGTransform 字符到字形的映射
// CodePosition在内存中为整个TextObject的字符索引,保存时转换为所在TextCode的局部索引
type CGTransform struct {
CodePosition int `xml:"CodePosition,attr"`
CodeCount int `xml:"CodeCount,attr"`
+4 -1
View File
@@ -56,7 +56,9 @@ type RasterSegment struct {
// RasterCommand 填充路径或绘制图像,Image非空时为图像指令
// Transform将局部坐标映射到页面,图像局部坐标与image.Image的像素坐标一致
// 字形和描边由页面编译器生成轮廓,Clip为页面坐标图像裁剪,nil表示不裁剪
// Stroke非空时为描边,宽度与虚线使用页面毫米,不随Transform再次缩放
// EvenOdd仅控制填充规则,不影响描边
// Clip为页面坐标裁剪,适用于路径和图像,nil表示不裁剪,空切片表示全部裁去
type RasterCommand struct {
Path []RasterSegment
Paint RasterPaint
@@ -64,6 +66,7 @@ type RasterCommand struct {
Transform RasterMatrix
Image image.Image
Clip []RasterSegment
Stroke *StrokeOptions
}
// RasterPaint 预乘RGBA纯色或渐变,Gradient非空时忽略Color
+113
View File
@@ -0,0 +1,113 @@
// 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 (
"crypto/sha256"
"encoding/binary"
"fmt"
"image"
"image/color"
"math"
)
// rasterClipKey 区分页面尺寸、分辨率和裁剪路径
// 入参: page 页面, path 页面坐标路径
// 返回: [32]byte 缓存键
func rasterClipKey(page *RasterPage, path []RasterSegment) [32]byte {
hash := sha256.New()
var data [8]byte
number := func(v float64) {
binary.LittleEndian.PutUint64(data[:], math.Float64bits(v))
hash.Write(data[:])
}
number(page.Width)
number(page.Height)
number(page.DPI)
for _, s := range path {
hash.Write([]byte{byte(s.Verb)})
for _, p := range []RasterPoint{s.End, s.Control1, s.Control2} {
number(p.X)
number(p.Y)
}
}
var key [32]byte
copy(key[:], hash.Sum(nil))
return key
}
// rasterClipMask 使用所选后端生成区域化透明度蒙版,同页相同裁剪可复用
// 入参: backend 光栅后端, page 页面, path 页面坐标裁剪, cache 蒙版缓存
// 返回: *image.Alpha 页面像素坐标蒙版, error 绘制错误
func rasterClipMask(backend RasterBackend, page *RasterPage, path []RasterSegment, cache *renderCache[[32]byte, *image.Alpha]) (*image.Alpha, error) {
key := rasterClipKey(page, path)
if mask, ok := cache.get(key); ok {
return mask, nil
}
w, h, err := page.PixelSize()
if err != nil {
return nil, err
}
matrix := page.PixelTransform(RasterMatrix{1, 0, 0, 1, 0, 0}, h)
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
include := func(p RasterPoint) {
p = matrix.Apply(p)
minX = math.Min(minX, p.X)
minY = math.Min(minY, p.Y)
maxX = math.Max(maxX, p.X)
maxY = math.Max(maxY, p.Y)
}
for _, s := range path {
switch s.Verb {
case RasterMove, RasterLine:
include(s.End)
case RasterQuad:
include(s.End)
include(s.Control1)
case RasterCubic:
include(s.End)
include(s.Control1)
include(s.Control2)
case RasterClose:
default:
return nil, fmt.Errorf("invalid clip path command %d", s.Verb)
}
}
if len(path) == 0 || minX > maxX || minY > maxY {
return image.NewAlpha(image.Rectangle{}), nil
}
if !finite(minX) || !finite(minY) || !finite(maxX) || !finite(maxY) {
return nil, fmt.Errorf("invalid clip coordinates")
}
bounds := image.Rect(int(math.Max(0, math.Min(float64(w), math.Floor(minX)-1))), int(math.Max(0, math.Min(float64(h), math.Floor(minY)-1))), int(math.Max(0, math.Min(float64(w), math.Ceil(maxX)+1))), int(math.Max(0, math.Min(float64(h), math.Ceil(maxY)+1))))
mask := image.NewAlpha(bounds)
if !bounds.Empty() {
scale := page.DPI / 25.4
local := &RasterPage{Width: float64(bounds.Dx()) / scale, Height: float64(bounds.Dy()) / scale, DPI: page.DPI}
local.Commands = []RasterCommand{{Path: path, Paint: RasterPaint{Color: color.RGBA{255, 255, 255, 255}}, Transform: RasterMatrix{1, 0, 0, 1, -float64(bounds.Min.X) / scale, (matrix[5] - float64(bounds.Min.Y)) / scale}}}
pixels, err := backend.Render(local)
if err != nil {
return nil, err
}
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
_, _, _, alpha := pixels.At(x-bounds.Min.X, y-bounds.Min.Y).RGBA()
mask.SetAlpha(x, y, color.Alpha{uint8(alpha >> 8)})
}
}
}
cache.put(key, mask, len(mask.Pix)+256)
return mask, nil
}
+6 -4
View File
@@ -16,7 +16,6 @@ package ofdgo
import (
"image"
"image/color"
"golang.org/x/image/draw"
"golang.org/x/image/math/f64"
@@ -29,18 +28,21 @@ func drawRasterImage(dst draw.Image, src image.Image, m RasterMatrix) {
}
// drawRasterImageCommand 使用当前后端生成裁剪蒙版,共用高质量图像采样
// 入参: backend 当前后端, page 页面, dst 目标图像, src 原始图像, command 图像指令
// 入参: backend 当前后端, page 页面, dst 目标图像, src 原始图像, command 图像指令, cache 蒙版缓存
// 返回: error 裁剪绘制错误
func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Image, src image.Image, command RasterCommand) error {
func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Image, src image.Image, command RasterCommand, cache *renderCache[[32]byte, *image.Alpha]) error {
m := page.PixelTransform(command.Transform, dst.Bounds().Dy())
if command.Clip == nil {
drawRasterImage(dst, src, m)
return nil
}
mask, err := backend.Render(&RasterPage{Width: page.Width, Height: page.Height, DPI: page.DPI, Commands: []RasterCommand{{Path: command.Clip, Paint: RasterPaint{Color: color.RGBA{255, 255, 255, 255}}, Transform: RasterMatrix{1, 0, 0, 1, 0, 0}}}})
mask, err := rasterClipMask(backend, page, command.Clip, cache)
if err != nil {
return err
}
if mask.Rect.Empty() {
return nil
}
draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, imagePixelSource(src), src.Bounds(), draw.Over, &draw.Options{DstMask: mask})
return nil
}
+100
View File
@@ -0,0 +1,100 @@
// 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"
"image"
"slices"
)
// RasterScene 固定尺寸、DPI、字体和资源的可重复绘制场景
// 每次Render返回独立图像;源图像只读借用,修改页面或配置后应重新准备
type RasterScene interface {
Render() (image.Image, error)
}
// RasterSceneBackend 可选的光栅预编译能力,不改变后端选择
type RasterSceneBackend interface {
RasterBackend
Prepare(page *RasterPage) (RasterScene, error)
}
// PreparePage 编译并准备当前配置的页面场景,不自动跟踪后续编辑
// 入参: page 源页面
// 返回: RasterScene 可重复绘制场景, error 编译或准备错误
func (r *Renderer) PreparePage(page *PageContent) (RasterScene, error) {
if r.backends.Raster == nil {
return nil, fmt.Errorf("raster scene: %w", ErrBackendUnavailable)
}
compiled, err := r.CompilePage(page)
if err != nil {
return nil, err
}
return PrepareRasterScene(r.backends.Raster, compiled)
}
// PrepareRasterScene 准备绘制快照,未实现预编译的后端复用公共指令
// 入参: backend 光栅后端, page 绘制页面
// 返回: RasterScene 可重复绘制场景, error 准备错误
func PrepareRasterScene(backend RasterBackend, page *RasterPage) (RasterScene, error) {
if backend == nil {
return nil, fmt.Errorf("raster scene: %w", ErrBackendUnavailable)
}
if _, _, err := page.PixelSize(); err != nil {
return nil, err
}
if provider, ok := backend.(RasterSceneBackend); ok {
return provider.Prepare(page)
}
return &rasterScene{backend: backend, page: cloneRasterPage(page)}, nil
}
// rasterScene 复用后端无关指令,图片保持只读引用
type rasterScene struct {
backend RasterBackend
page *RasterPage
}
// Render 绘制固定快照
// 返回: image.Image 独立图像, error 绘制错误
func (s *rasterScene) Render() (image.Image, error) { return s.backend.Render(s.page) }
// cloneRasterPage 隔离可变指令,源图片按接口约定只读借用
// 入参: page 绘制页面
// 返回: *RasterPage 指令快照
func cloneRasterPage(page *RasterPage) *RasterPage {
result := *page
result.Commands = slices.Clone(page.Commands)
for i := range result.Commands {
cmd := &result.Commands[i]
cmd.Path, cmd.Clip = slices.Clone(cmd.Path), slices.Clone(cmd.Clip)
if cmd.Stroke != nil {
stroke := *cmd.Stroke
stroke.Dashes = slices.Clone(stroke.Dashes)
cmd.Stroke = &stroke
}
if cmd.Paint.Gradient != nil {
gradient := *cmd.Paint.Gradient
gradient.Stops = slices.Clone(gradient.Stops)
if gradient.Spread != nil {
spread := *gradient.Spread
gradient.Spread = &spread
}
cmd.Paint.Gradient = &gradient
}
}
return &result
}
+68
View File
@@ -0,0 +1,68 @@
// 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 "container/list"
// renderCache 按估算字节数限制派生渲染数据,淘汰不修改源资源
type renderCache[K comparable, V any] struct {
limit, used int
entries map[K]*list.Element
order list.List
}
// renderCacheEntry 保存缓存值及内存成本
type renderCacheEntry[K comparable, V any] struct {
key K
value V
cost int
}
// get 读取缓存并更新使用顺序
// 入参: key 缓存键
// 返回: V 缓存值, bool 是否命中
func (c *renderCache[K, V]) get(key K) (V, bool) {
if item := c.entries[key]; item != nil {
c.order.MoveToFront(item)
return item.Value.(renderCacheEntry[K, V]).value, true
}
var zero V
return zero, false
}
// put 缓存预算内的派生数据,超大单项不进入缓存
// 入参: key 缓存键, value 缓存值, cost 字节成本
func (c *renderCache[K, V]) put(key K, value V, cost int) {
if item := c.entries[key]; item != nil {
c.used -= item.Value.(renderCacheEntry[K, V]).cost
c.order.Remove(item)
delete(c.entries, key)
}
if cost <= 0 || cost > c.limit {
return
}
if c.entries == nil {
c.entries = make(map[K]*list.Element)
}
for c.used+cost > c.limit {
item := c.order.Back()
entry := item.Value.(renderCacheEntry[K, V])
c.used -= entry.cost
delete(c.entries, entry.key)
c.order.Remove(item)
}
c.entries[key] = c.order.PushFront(renderCacheEntry[K, V]{key, value, cost})
c.used += cost
}
+49 -20
View File
@@ -29,6 +29,7 @@ type semanticCompiler struct {
text PageText
measurement ObjectMeasurement
measure, contours, textOnly, pattern bool
patterns *renderCache[patternCellKey, []RasterCommand]
}
// newSemanticCompiler 绑定当前字体和几何能力
@@ -39,7 +40,7 @@ func newSemanticCompiler(r *Renderer) (*semanticCompiler, error) {
if err != nil {
return nil, err
}
return &semanticCompiler{renderer: r, geometry: geometry}, nil
return &semanticCompiler{renderer: r, geometry: geometry, patterns: &renderCache[patternCellKey, []RasterCommand]{limit: 8 << 20}}, nil
}
// DrawObject 解释叶子对象,不调用任何具体页面编译器
@@ -132,6 +133,9 @@ func (c *semanticCompiler) fill(path GeometryPath, paint Paint, evenOdd bool, cl
if len(path) == 0 || paint.Kind == PaintNone || paint.Kind == PaintSolid && paint.Color.A == 0 {
return nil
}
if !c.measure && paint.Kind != PaintPattern {
return c.command(path, paint, evenOdd, clip, shadingMatrix, nil)
}
var err error
if clip != nil || paint.Kind == PaintPattern || paint.Gradient != nil {
path = closedGeometry(path)
@@ -159,7 +163,22 @@ func (c *semanticCompiler) fill(path GeometryPath, paint Paint, evenOdd bool, cl
if paint.Kind == PaintPattern {
return c.fillPattern(path, paint.Pattern, matrix)
}
return nil
}
// command 保留路径、画刷与绘制裁剪,不提前求几何交集或展开描边
// 入参: path 页面路径, paint 画刷, evenOdd 奇偶规则, clip 绘制裁剪, shadingMatrix 渐变矩阵, stroke 页面描边样式
// 返回: error 路径或矩阵错误
func (c *semanticCompiler) command(path GeometryPath, paint Paint, evenOdd bool, clip *GeometryPath, shadingMatrix Matrix, stroke *StrokeOptions) error {
var err error
command := RasterCommand{Paint: RasterPaint{Color: paint.Color}, EvenOdd: evenOdd, Transform: RasterMatrix{1, 0, 0, 1, 0, 0}}
command.Stroke = stroke
if clip != nil {
command.Clip, err = c.segments(*clip)
if err != nil {
return err
}
}
if paint.Gradient != nil {
gradient, view := semanticGradient(paint, shadingMatrix)
inverse, ok := view.Invert()
@@ -181,6 +200,23 @@ func (c *semanticCompiler) fill(path GeometryPath, paint Paint, evenOdd bool, cl
return nil
}
// stroke 将普通描边交给绘制后端,度量和底纹仍使用精确几何轮廓
// 入参: path 页面路径, paint 画刷, options 页面描边样式, clip 对象裁剪, matrix 图案矩阵, shadingMatrix 渐变矩阵
// 返回: error 描边或编译错误
func (c *semanticCompiler) stroke(path GeometryPath, paint Paint, options StrokeOptions, clip *GeometryPath, matrix, shadingMatrix Matrix) error {
if len(path) == 0 || paint.Kind == PaintNone || paint.Kind == PaintSolid && paint.Color.A == 0 {
return nil
}
if !c.measure && paint.Kind != PaintPattern {
return c.command(path, paint, false, clip, shadingMatrix, &options)
}
outline, err := c.geometry.Stroke(path, options)
if err != nil {
return err
}
return c.fill(outline, paint, false, clip, matrix, shadingMatrix)
}
// semanticGradient 保留渐变周期、延伸与椭圆坐标
// 入参: paint 渐变画刷, matrix 渐变父矩阵
// 返回: *RasterGradient 光栅渐变, Matrix 局部到页面矩阵
@@ -324,11 +360,7 @@ func (c *semanticCompiler) pathObject(object PathObject, state RenderState) erro
}
}
if object.Stroke == nil || *object.Stroke {
stroke, err := c.geometry.Stroke(path, style.options)
if err != nil {
return err
}
return c.fill(stroke, c.renderer.ResolvePaint(style.stroke), false, clip, matrix, shading)
return c.stroke(path, c.renderer.ResolvePaint(style.stroke), style.options, clip, matrix, shading)
}
return nil
}
@@ -369,11 +401,7 @@ func (c *semanticCompiler) textObject(object TextObject, state RenderState) erro
}
}
if object.Stroke != nil && *object.Stroke && len(glyph.Path) > 0 {
path, err := c.geometry.Stroke(glyph.Path, style.options)
if err != nil {
return err
}
if err := c.fill(path, c.renderer.ResolvePaint(style.stroke), false, positioned.Clip, positioned.Matrix, shading); err != nil {
if err := c.stroke(glyph.Path, c.renderer.ResolvePaint(style.stroke), style.options, positioned.Clip, positioned.Matrix, shading); err != nil {
return err
}
}
@@ -405,10 +433,10 @@ func (c *semanticCompiler) fillPattern(clip GeometryPath, pattern *PatternPaint,
return err
}
box = inverse.TransformBox(box)
prior := c.pattern
c.pattern = true
defer func() { c.pattern = prior }()
defaults := &DrawParam{FillColor: &pattern.Color, StrokeColor: (*StrokeColor)(&pattern.Color)}
segments, err := c.segments(clip)
if err != nil {
return err
}
for x, end := int(math.Floor(box.X/xstep))-1, int(math.Ceil((box.X+box.W)/xstep))+1; x <= end; x++ {
for y, end := int(math.Floor(box.Y/ystep))-1, int(math.Ceil((box.Y+box.H)/ystep))+1; y <= end; y++ {
tile := matrix.Multiply(TranslationMatrix(float64(x)*xstep, float64(y)*ystep))
@@ -418,11 +446,12 @@ func (c *semanticCompiler) fillPattern(clip GeometryPath, pattern *PatternPaint,
if y%2 != 0 && (pattern.ReflectMethod == "Row" || pattern.ReflectMethod == "RowAndColumn") {
tile = tile.Multiply(Matrix{a: 1, d: -1, f: pattern.Height})
}
for _, object := range pattern.CellContent.Objects {
object = mergeGraphicObjectAlpha(object, pattern.Alpha)
if err := c.renderer.WalkObject(&object, RenderState{Defaults: defaults, Parent: &tile, BoundaryInCTM: true, Clip: &clip}, c); err != nil {
return err
}
commands, err := c.patternCell(pattern, tile)
if err != nil {
return err
}
if err := c.appendPatternCell(commands, clip, segments); err != nil {
return err
}
}
}
+12
View File
@@ -18,6 +18,7 @@ import (
"bytes"
"fmt"
"image"
"math"
"slices"
)
@@ -157,6 +158,17 @@ func (c *semanticCompiler) DrawStamp(stamp Stamp) error {
matrix := TranslationMatrix(stamp.Box.X, stamp.Box.Y).Multiply(Matrix{a: stamp.Box.W / page.Width, d: stamp.Box.H / page.Height})
for _, command := range page.Commands {
command.Transform = RasterMatrix(matrix.Multiply(MatrixFromValues([6]float64(command.Transform))).Values())
if command.Stroke != nil {
stroke := *command.Stroke
scale := math.Sqrt(math.Abs(matrix.a*matrix.d - matrix.b*matrix.c))
stroke.Width *= scale
stroke.DashOffset *= scale
stroke.Dashes = slices.Clone(stroke.Dashes)
for i := range stroke.Dashes {
stroke.Dashes[i] *= scale
}
command.Stroke = &stroke
}
if command.Clip != nil {
command.Clip = slices.Clone(command.Clip)
for i := range command.Clip {
+94
View File
@@ -0,0 +1,94 @@
// 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
// patternCellKey 区分共享底纹、颜色、透明度和实际页面变换
type patternCellKey struct {
pattern *Pattern
color, space string
index, alpha int
matrix [6]float64
}
// patternCell 复用同次编译中相同底纹单元,不缓存外部对象的裁剪
// 入参: pattern 底纹画刷, matrix 单元到页面矩阵
// 返回: []RasterCommand 只读单元指令, error 编译错误
func (c *semanticCompiler) patternCell(pattern *PatternPaint, matrix Matrix) ([]RasterCommand, error) {
key := patternCellKey{pattern: pattern.Pattern, color: pattern.Color.Value, space: pattern.Color.ColorSpace, index: -1, alpha: 255, matrix: matrix.Values()}
if pattern.Color.Index != nil {
key.index = *pattern.Color.Index
}
if pattern.Alpha != nil {
key.alpha = *pattern.Alpha
}
if commands, ok := c.patterns.get(key); ok {
return commands, 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}
defaults := &DrawParam{FillColor: &pattern.Color, StrokeColor: (*StrokeColor)(&pattern.Color)}
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
}
}
cost := 0
for _, cmd := range child.page.Commands {
cost += 256 + (len(cmd.Path)+len(cmd.Clip))*104
if cmd.Paint.Gradient != nil {
cost += 128 + len(cmd.Paint.Gradient.Stops)*16
}
}
c.patterns.put(key, child.page.Commands, cost)
return child.page.Commands, nil
}
// appendPatternCell 将缓存指令放入当前裁剪,不修改单元数据
// 入参: commands 单元指令, clip 页面裁剪, segments 已转换裁剪
// 返回: error 裁剪合并错误
func (c *semanticCompiler) appendPatternCell(commands []RasterCommand, clip GeometryPath, segments []RasterSegment) error {
for _, command := range commands {
commandClip := segments
if command.Clip != nil {
path := geometryFromRaster(command.Clip)
combined, err := c.geometry.Combine(path, clip, GeometryIntersect)
if err != nil {
return err
}
commandClip, err = c.segments(combined)
if err != nil {
return err
}
}
command.Clip = commandClip
c.page.Commands = append(c.page.Commands, command)
}
return nil
}
// geometryFromRaster 将只含贝塞尔曲线的光栅路径还原为库路径
// 入参: path 光栅路径
// 返回: GeometryPath 库路径
func geometryFromRaster(path []RasterSegment) GeometryPath {
result := make(GeometryPath, 0, len(path))
for _, s := range path {
verb := GeometryVerb(s.Verb)
if s.Verb == RasterClose {
verb = GeometryClose
}
result = append(result, GeometrySegment{Verb: verb, End: Point{s.End.X, s.End.Y}, Control1: Point{s.Control1.X, s.Control1.Y}, Control2: Point{s.Control2.X, s.Control2.Y}})
}
return result
}
+9 -4
View File
@@ -15,6 +15,7 @@
package ofdgo
import (
"crypto/sha256"
"fmt"
"math"
"strings"
@@ -47,14 +48,18 @@ type glyphOutlineKey struct {
// 入参: prepared 已包装字体
// 返回: FontMetrics 字体度量, error 字体解析错误
func (r *Renderer) preparedMetrics(prepared *PreparedFont) (FontMetrics, error) {
if metrics := r.fontMetrics[prepared]; metrics != nil {
if prepared.digest == ([32]byte{}) {
prepared.digest = sha256.Sum256(prepared.Data)
}
key := prepared.digest
if metrics, ok := r.fontMetrics.get(key); ok {
return metrics, nil
}
metrics, err := r.backends.Fonts.OpenFont(prepared.Data)
if err != nil {
return nil, err
}
r.fontMetrics[prepared] = metrics
r.fontMetrics.put(key, metrics, len(prepared.Data)*2+256)
return metrics, nil
}
@@ -63,7 +68,7 @@ func (r *Renderer) preparedMetrics(prepared *PreparedFont) (FontMetrics, error)
// 返回: GeometryPath 字形路径, error 能力或解析错误
func (r *Renderer) preparedOutline(prepared *PreparedFont, metrics FontMetrics, glyph uint16, size float64) (GeometryPath, error) {
key := glyphOutlineKey{prepared, glyph, size}
if path, ok := r.glyphOutlines[key]; ok {
if path, ok := r.glyphOutlines.get(key); ok {
return path, nil
}
provider, ok := metrics.(FontOutlines)
@@ -72,7 +77,7 @@ func (r *Renderer) preparedOutline(prepared *PreparedFont, metrics FontMetrics,
}
path, err := provider.GlyphOutline(glyph, size)
if err == nil {
r.glyphOutlines[key] = path
r.glyphOutlines.put(key, path, len(path)*96+128)
}
return path, err
}
+2 -2
View File
@@ -44,8 +44,8 @@ type Renderer struct {
resolvedFonts map[resolvedFontKey]resolvedFontResult
preparedFonts map[string]*PreparedFont
fontSourcesCache *fontSourceCache
fontMetrics map[*PreparedFont]FontMetrics
glyphOutlines map[glyphOutlineKey]GeometryPath
fontMetrics renderCache[[32]byte, FontMetrics]
glyphOutlines renderCache[glyphOutlineKey, GeometryPath]
renderError error
TransparentBackground bool
}
+68 -2
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"image"
"image/color"
"image/draw"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
@@ -46,6 +47,7 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) {
space = canvas.DefaultColorSpace
}
r := rasterizer.FromImage(img, canvas.DPMM(page.DPI/25.4), space)
masks := renderCache[[32]byte, *image.Alpha]{limit: 16 << 20}
for i, cmd := range page.Commands {
if cmd.Image != nil {
source := cmd.Image
@@ -58,7 +60,7 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) {
}
source = converted
}
if err := drawRasterImageCommand(b, page, img, source, cmd); err != nil {
if err := drawRasterImageCommand(b, page, img, source, cmd, &masks); err != nil {
return nil, err
}
continue
@@ -104,12 +106,76 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) {
style.FillRule = canvas.EvenOdd
}
m := cmd.Transform
r.RenderPath(p, style, canvas.Matrix{{m[0], m[2], m[4]}, {-m[1], -m[3], page.Height - m[5]}})
if cmd.Stroke != nil {
options := *cmd.Stroke
if err := validateStroke(options); err != nil {
return nil, err
}
p = p.Transform(canvas.Matrix{{m[0], m[2], m[4]}, {m[1], m[3], m[5]}})
if cmd.Paint.Gradient != nil {
inverse, err := m.Inverse()
if err != nil {
return nil, err
}
paint = canvas.Paint{Gradient: rasterStrokeGradientCanvas{rasterGradientCanvas{cmd.Paint.Gradient}, inverse}}
}
line := pathStyle{lineJoin: canvas.MiterJoin, miterLimit: defaultMiterLimit}
line.applyLineJoin(options.Join, options.MiterLimit)
style = canvas.Style{Stroke: paint, StrokeWidth: options.Width, StrokeCapper: pathLineCap(options.Cap, canvas.ButtCap), StrokeJoiner: line.lineJoin, FillRule: canvas.NonZero}
if len(options.Dashes) > 0 {
style.DashOffset = options.DashOffset / options.Width
style.Dashes = make([]float64, len(options.Dashes))
for i, dash := range options.Dashes {
style.Dashes[i] = dash / options.Width
}
}
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]}}
if cmd.Clip == nil {
r.RenderPath(p, style, matrix)
continue
}
mask, err := rasterClipMask(b, page, cmd.Clip, &masks)
if err != nil {
return nil, err
}
if mask.Rect.Empty() {
continue
}
part := image.NewRGBA(image.Rect(0, 0, mask.Rect.Dx(), mask.Rect.Dy()))
local := rasterizer.FromImage(part, canvas.DPMM(page.DPI/25.4), space)
matrix[0][2] -= float64(mask.Rect.Min.X) / (page.DPI / 25.4)
matrix[1][2] -= float64(h-mask.Rect.Max.Y) / (page.DPI / 25.4)
local.RenderPath(p, style, matrix)
draw.DrawMask(img, mask.Rect, part, image.Point{}, mask, mask.Rect.Min, draw.Over)
}
r.Close()
return img, nil
}
// rasterStrokeGradientCanvas 保留页面坐标描边的局部渐变定位
type rasterStrokeGradientCanvas struct {
rasterGradientCanvas
inverse RasterMatrix
}
// At 返回原局部坐标的描边渐变颜色
// 入参: x 页面横坐标, y 页面纵坐标
// 返回: color.RGBA 预乘颜色
func (g rasterStrokeGradientCanvas) At(x, y float64) color.RGBA {
p := g.inverse.Apply(RasterPoint{x, y})
return g.gradient.At(p.X, p.Y)
}
// SetColorSpace 转换描边渐变颜色,不修改共享分段
// 入参: space 颜色空间
// 返回: canvas.Gradient 独立渐变
func (g rasterStrokeGradientCanvas) SetColorSpace(space canvas.ColorSpace) canvas.Gradient {
g.rasterGradientCanvas = g.rasterGradientCanvas.SetColorSpace(space).(rasterGradientCanvas)
return g
}
// rasterGradientCanvas 将公共渐变交给Canvas采样,不改变OFD周期语义
type rasterGradientCanvas struct{ gradient *RasterGradient }
+31 -3
View File
@@ -18,6 +18,8 @@ import (
"bytes"
"fmt"
"math"
"unicode"
"unicode/utf8"
ggtext "github.com/gogpu/gg/text"
)
@@ -38,13 +40,39 @@ func (GGBackend) OpenFont(data []byte) (FontMetrics, error) {
if err != nil {
return nil, err
}
return &ggFontMetrics{font: source.Parsed(), data: data}, nil
return &ggFontMetrics{font: source.Parsed(), data: data, source: source, shaper: ggtext.NewOwnShaper()}, nil
}
// ggFontMetrics 适配GG设计单位度量与字形轮廓
type ggFontMetrics struct {
font ggtext.ParsedFont
data []byte
font ggtext.ParsedFont
data []byte
source *ggtext.FontSource
shaper *ggtext.OwnShaper
}
// ShapeText 使用GG执行横向字偶距与连字塑形,不改动原文定位
// 当前支持拉丁、希腊、西里尔和东亚文字,复杂文字需提供其他FontShaper
// 入参: value 单行原文, size 毫米字号
// 返回: []ShapedGlyph 定位字形, error 字符或能力错误
func (f *ggFontMetrics) ShapeText(value string, size float64) ([]ShapedGlyph, error) {
if !rasterPositive(size) || !utf8.ValidString(value) {
return nil, fmt.Errorf("invalid shaping text or size")
}
for _, char := range value {
if unicode.IsControl(char) {
return nil, fmt.Errorf("shaping requires a single text line")
}
if !unicode.In(char, unicode.Latin, unicode.Greek, unicode.Cyrillic, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul, unicode.Common, unicode.Inherited) {
return nil, fmt.Errorf("GG shaping for U+%04X: %w", char, ErrBackendUnavailable)
}
}
glyphs := f.shaper.Shape(value, f.source.Face(size))
result := make([]ShapedGlyph, len(glyphs))
for i, glyph := range glyphs {
result[i] = ShapedGlyph{Glyph: uint16(glyph.GID), Cluster: glyph.Cluster, X: glyph.X, Y: -glyph.Y, Advance: glyph.XAdvance}
}
return result, nil
}
// UnitsPerEm 返回字体设计单位
+43 -35
View File
@@ -96,20 +96,8 @@ func (r *Renderer) strokeCanvasPath(path *canvas.Path, width float64, cap canvas
// Path 解析对象的标准紧缩路径并转换到页面坐标
// 入参: object 路径对象
// 返回: GeometryPath 页面路径, error 无效路径错误
func (CanvasBackend) Path(object PathObject) (GeometryPath, error) {
if err := creationPath(object.AbbreviatedData); err != nil {
return nil, err
}
if _, err := creationBox(object.Boundary); err != nil {
return nil, err
}
if object.CTM != "" {
if _, err := creationNumbers(object.CTM, 6); err != nil {
return nil, err
}
}
p := (&Renderer{}).buildPath(object, 0, NewMatrix(object.CTM), false)
return *geometryFromCanvasPath(p), nil
func (b CanvasBackend) Path(object PathObject) (GeometryPath, error) {
return objectGeometryPath(b, object)
}
// Region 解析动作区域并保持椭圆弧及退化变换的既有处理
@@ -210,19 +198,8 @@ func (CanvasBackend) Combine(left, right GeometryPath, operation GeometryOperati
// 入参: path 页面路径, options 描边样式
// 返回: GeometryPath 描边区域, error 样式或路径错误
func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryPath, error) {
if !finite(options.Width) || options.Width <= 0 || !finite(options.Tolerance) || options.Tolerance < 0 || !finite(options.DashOffset) || !finite(options.MiterLimit) || options.MiterLimit < 0 {
return nil, fmt.Errorf("invalid stroke width or tolerance")
}
if options.Cap != "" && options.Cap != "Butt" && options.Cap != "Round" && options.Cap != "Square" {
return nil, fmt.Errorf("invalid stroke cap %q", options.Cap)
}
if options.Join != "" && options.Join != "Miter" && options.Join != "Round" && options.Join != "Bevel" {
return nil, fmt.Errorf("invalid stroke join %q", options.Join)
}
for _, dash := range options.Dashes {
if !finite(dash) || dash < 0 {
return nil, fmt.Errorf("invalid stroke dash")
}
if err := validateStroke(options); err != nil {
return nil, err
}
p, err := geometryToCanvasPath(&path)
if err != nil {
@@ -264,27 +241,58 @@ func geometryToCanvasPath(path *GeometryPath) (*canvas.Path, error) {
return nil, nil
}
p := &canvas.Path{}
for _, segment := range *path {
switch segment.Verb {
for _, s := range *path {
switch s.Verb {
case GeometryMove:
p.MoveTo(segment.End.X, -segment.End.Y)
p.MoveTo(s.End.X, -s.End.Y)
case GeometryLine:
p.LineTo(segment.End.X, -segment.End.Y)
p.LineTo(s.End.X, -s.End.Y)
case GeometryQuad:
p.QuadTo(segment.Control1.X, -segment.Control1.Y, segment.End.X, -segment.End.Y)
p.QuadTo(s.Control1.X, -s.Control1.Y, s.End.X, -s.End.Y)
case GeometryCubic:
p.CubeTo(segment.Control1.X, -segment.Control1.Y, segment.Control2.X, -segment.Control2.Y, segment.End.X, -segment.End.Y)
p.CubeTo(s.Control1.X, -s.Control1.Y, s.Control2.X, -s.Control2.Y, s.End.X, -s.End.Y)
case GeometryArc:
p.ArcTo(segment.RadiusX, segment.RadiusY, -segment.Rotation, segment.Large, !segment.Sweep, segment.End.X, -segment.End.Y)
p.ArcTo(s.RadiusX, s.RadiusY, -s.Rotation, s.Large, !s.Sweep, s.End.X, -s.End.Y)
case GeometryClose:
p.Close()
default:
return nil, fmt.Errorf("unsupported geometry command %d", segment.Verb)
return nil, fmt.Errorf("unsupported geometry command %d", s.Verb)
}
}
return p, nil
}
// canvasObjectPath 建立对象局部路径,保留折返线和退化贝塞尔的原始端点
// 入参: path 独立路径
// 返回: *canvas.Path 适配路径, error 不支持的指令
func canvasObjectPath(path GeometryPath) (*canvas.Path, error) {
var data []float64
for _, s := range path {
end := s.End
switch s.Verb {
case GeometryMove:
data = append(data, canvas.MoveToCmd, end.X, end.Y, canvas.MoveToCmd)
case GeometryLine:
data = append(data, canvas.LineToCmd, end.X, end.Y, canvas.LineToCmd)
case GeometryQuad:
data = append(data, canvas.QuadToCmd, s.Control1.X, s.Control1.Y, end.X, end.Y, canvas.QuadToCmd)
case GeometryCubic:
data = append(data, canvas.CubeToCmd, s.Control1.X, s.Control1.Y, s.Control2.X, s.Control2.Y, end.X, end.Y, canvas.CubeToCmd)
case GeometryArc:
p := canvas.NewPathFromData(data)
p.ArcTo(s.RadiusX, s.RadiusY, s.Rotation, s.Large, s.Sweep, end.X, end.Y)
data = p.Data()
case GeometryClose:
p := canvas.NewPathFromData(data)
p.Close()
data = p.Data()
default:
return nil, fmt.Errorf("unsupported geometry command %d", s.Verb)
}
}
return canvas.NewPathFromData(data), nil
}
// geometryFromCanvasPath 将默认引擎路径转换为独立的页面坐标路径,保留曲线和椭圆弧
// 入参: path 默认引擎路径
// 返回: *GeometryPath 独立路径,nil表示不裁剪
+9 -6
View File
@@ -24,19 +24,22 @@ import (
// GeometryBackend不可为空,椭圆弧保持精确表示,不为接入GG而预先离散化
type GGGeometryBackend struct{ GeometryBackend }
// Path 解析库自有路径并使用GG几何变换,不经过Canvas路径构建器
// 入参: object 路径对象
// 返回: GeometryPath 页面路径, error 参数或变换错误
func (b GGGeometryBackend) Path(object PathObject) (GeometryPath, error) {
return objectGeometryPath(b, object)
}
// Name 返回实际几何提供者组合
// 返回: string 后端组合标识
func (b GGGeometryBackend) Name() string { return "gg+" + backendName(b.GeometryBackend) }
// Curves 通过显式配置的曲线适配器展开椭圆弧
// Curves 使用公共弧线转换,不调用组合几何后端
// 入参: path 页面路径
// 返回: GeometryPath 贝塞尔路径, error 能力或路径错误
func (b GGGeometryBackend) Curves(path GeometryPath) (GeometryPath, error) {
curves, ok := b.GeometryBackend.(GeometryCurves)
if !ok {
return nil, fmt.Errorf("geometry curves: %w", ErrBackendUnavailable)
}
return curves.Curves(path)
return path.Curves(.0001)
}
// Bounds 使用GG计算贝塞尔曲线极值,椭圆弧交给配置的几何后端
+120 -12
View File
@@ -18,34 +18,39 @@ import (
"fmt"
"image"
"image/color"
"sync"
"github.com/gogpu/gg"
)
// GGBackend 提供字体、页面编译和CPU绘制,不注册全局GPU设备或隐式切换后端
type GGBackend struct{}
// StrokeGeometry用于GG暂不能准确处理的尖角连接、闭合或曲线虚线及亚像素渐变描边,未配置时返回能力错误
type GGBackend struct{ StrokeGeometry GeometryBackend }
// Name 返回后端标识
// 返回: string 后端标识
func (GGBackend) Name() string { return "gg" }
// Render 绘制已编译页面,使用自有双圆渐变语义保持原有定位
// Prepare 将路径、样式和裁剪准备为固定DPI的GG场景
// 入参: page 只读页面
// 返回: image.Image 图像, error 绘制错误
func (GGBackend) Render(page *RasterPage) (image.Image, error) {
// 返回: RasterScene 可重复绘制场景, error 准备错误
func (b GGBackend) Prepare(page *RasterPage) (RasterScene, error) {
w, h, err := page.PixelSize()
if err != nil {
return nil, err
}
pixmap := gg.NewPixmap(w, h)
img := pixmap.ImageView()
raster := gg.NewSoftwareRenderer(w, h)
page = cloneRasterPage(page)
scene := &ggRasterScene{page: page, width: w, height: h, masks: renderCache[[32]byte, *image.Alpha]{limit: 16 << 20}}
for i, cmd := range page.Commands {
if cmd.Stroke != nil && !ggNativeStroke(cmd, page.DPI/25.4) {
cmd, err = b.expandStroke(cmd)
if err != nil {
return nil, fmt.Errorf("gg command %d: %w", i, err)
}
}
m := page.PixelTransform(cmd.Transform, h)
if cmd.Image != nil {
if err := drawRasterImageCommand(GGBackend{}, page, img, imagePixelSource(cmd.Image), cmd); err != nil {
return nil, err
}
scene.commands = append(scene.commands, ggRasterCommand{command: cmd})
continue
}
path := gg.NewPath()
@@ -81,16 +86,119 @@ func (GGBackend) Render(page *RasterPage) (image.Image, error) {
return ggRGBA(g.At(p.X, p.Y))
}))
}
if cmd.EvenOdd {
if cmd.EvenOdd && cmd.Stroke == nil {
paint.FillRule = gg.FillRuleEvenOdd
}
if err := raster.Fill(pixmap, path, paint); err != nil {
if cmd.Clip != nil {
mask, err := rasterClipMask(GGBackend{}, page, cmd.Clip, &scene.masks)
if err != nil {
return nil, err
}
if mask.Rect.Empty() {
continue
}
paint.ClipMask, paint.ClipMaskW, paint.ClipMaskH = mask.Pix, mask.Stride, mask.Rect.Dy()
paint.ClipMaskX, paint.ClipMaskY = mask.Rect.Min.X, mask.Rect.Min.Y
}
if cmd.Stroke != nil {
style, err := ggStroke(*cmd.Stroke, page.DPI/25.4)
if err != nil {
return nil, err
}
paint.SetStroke(style)
paint.SetStrokeBrush(paint.FillBrush())
}
scene.commands = append(scene.commands, ggRasterCommand{command: cmd, path: path, paint: paint})
}
return scene, nil
}
// ggRasterCommand 保存已转换的像素路径和样式
type ggRasterCommand struct {
command RasterCommand
path *gg.Path
paint *gg.Paint
}
// ggRasterScene 复用路径和局部裁剪,串行保护掩码缓存
type ggRasterScene struct {
mu sync.Mutex
page *RasterPage
width, height int
commands []ggRasterCommand
masks renderCache[[32]byte, *image.Alpha]
}
// Render 绘制独立像素缓冲,不累积上次内容
// 返回: image.Image 图像, error 绘制错误
func (s *ggRasterScene) Render() (image.Image, error) {
s.mu.Lock()
defer s.mu.Unlock()
pixmap := gg.NewPixmap(s.width, s.height)
img := pixmap.ImageView()
raster := gg.NewSoftwareRenderer(s.width, s.height)
for i, cmd := range s.commands {
var err error
switch {
case cmd.command.Image != nil:
err = drawRasterImageCommand(GGBackend{}, s.page, img, imagePixelSource(cmd.command.Image), cmd.command, &s.masks)
case cmd.command.Stroke != nil:
err = raster.Stroke(pixmap, cmd.path, cmd.paint)
default:
err = raster.Fill(pixmap, cmd.path, cmd.paint)
}
if err != nil {
return nil, fmt.Errorf("gg command %d: %w", i, err)
}
}
return img, nil
}
// Render 准备并绘制页面,重复绘制可使用Prepare复用场景
// 入参: page 只读页面
// 返回: image.Image 图像, error 绘制错误
func (b GGBackend) Render(page *RasterPage) (image.Image, error) {
scene, err := b.Prepare(page)
if err != nil {
return nil, err
}
return scene.Render()
}
// ggStroke 将页面毫米样式转换为GG像素描边,虚线与线宽同步换算
// 入参: options 描边样式, scale 每毫米像素数
// 返回: gg.Stroke 像素样式, error 样式错误
func ggStroke(options StrokeOptions, scale float64) (gg.Stroke, error) {
if err := validateStroke(options); err != nil {
return gg.Stroke{}, err
}
style := gg.DefaultStroke().WithWidth(options.Width * scale)
style.MiterLimit = options.MiterLimit
if style.MiterLimit == 0 {
style.MiterLimit = defaultMiterLimit
}
switch options.Cap {
case "Round":
style.Cap = gg.LineCapRound
case "Square":
style.Cap = gg.LineCapSquare
}
switch options.Join {
case "Round":
style.Join = gg.LineJoinRound
case "Bevel":
style.Join = gg.LineJoinBevel
}
if len(options.Dashes) > 0 {
dashes := make([]float64, len(options.Dashes))
for i, value := range options.Dashes {
dashes[i] = value * scale
}
style = style.WithDashPattern(dashes...).WithDashOffset(options.DashOffset * scale)
}
return style, nil
}
// ggRGBA 将标准库预乘颜色转换为gg的非预乘颜色
// 入参: c 预乘颜色
// 返回: gg.RGBA 非预乘颜色
+15 -68
View File
@@ -17,8 +17,6 @@ package ofdgo
import (
"image/color"
"math"
"strconv"
"strings"
"github.com/tdewolff/canvas"
)
@@ -222,9 +220,9 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64
if boundaryInCTM {
pathCTM = localCTM
}
p := r.buildPath(obj, pageH, pathCTM, false)
if rectPath := r.buildTinyFillRectPath(obj, pageH, pathCTM, bx, by); rectPath != nil {
p = rectPath
p := r.buildTinyFillRectPath(obj, pageH, pathCTM, bx, by)
if p == nil {
p = r.buildPath(obj, pageH, pathCTM, false)
}
if boundaryInCTM && parentCTM != nil {
p = p.Transform(canvas.Matrix{
@@ -396,72 +394,21 @@ func (r *Renderer) renderPattern(ctx *canvas.Context, pattern *PatternPaint, pag
// 入参: obj 路径对象, pageH 页面高度, ctm 变换矩阵, boundaryInCTM 边界是否参与CTM变换
// 返回: *canvas.Path 路径对象
func (r *Renderer) buildPath(obj PathObject, pageH float64, ctm Matrix, boundaryInCTM bool) *canvas.Path {
bx, by := 0.0, 0.0
if obj.Boundary != "" {
if box, err := ParseBox(obj.Boundary); err == nil {
bx, by = box.X, box.Y
}
path, err := ParseGeometryPath(obj.AbbreviatedData)
if err != nil {
r.renderError = err
return &canvas.Path{}
}
p, err := canvasObjectPath(path)
if err != nil {
r.renderError = err
return &canvas.Path{}
}
box, _ := ParseBox(obj.Boundary)
if boundaryInCTM {
ctm = ctm.Multiply(TranslationMatrix(bx, by))
ctm = ctm.Multiply(TranslationMatrix(box.X, box.Y))
} else {
ctm = TranslationMatrix(bx, by).Multiply(ctm)
}
p := &canvas.Path{}
tokens := strings.Fields(obj.AbbreviatedData)
for i := 0; i < len(tokens); {
cmd := tokens[i]
i++
switch cmd {
case "M", "S":
if i+1 < len(tokens) {
x, _ := strconv.ParseFloat(tokens[i], 64)
y, _ := strconv.ParseFloat(tokens[i+1], 64)
p.MoveTo(x, y)
i += 2
}
case "L":
if i+1 < len(tokens) {
x, _ := strconv.ParseFloat(tokens[i], 64)
y, _ := strconv.ParseFloat(tokens[i+1], 64)
p.LineTo(x, y)
i += 2
}
case "B":
if i+5 < len(tokens) {
x1, _ := strconv.ParseFloat(tokens[i], 64)
y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x2, _ := strconv.ParseFloat(tokens[i+2], 64)
y2, _ := strconv.ParseFloat(tokens[i+3], 64)
x3, _ := strconv.ParseFloat(tokens[i+4], 64)
y3, _ := strconv.ParseFloat(tokens[i+5], 64)
p.CubeTo(x1, y1, x2, y2, x3, y3)
i += 6
}
case "Q":
if i+3 < len(tokens) {
x1, _ := strconv.ParseFloat(tokens[i], 64)
y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x2, _ := strconv.ParseFloat(tokens[i+2], 64)
y2, _ := strconv.ParseFloat(tokens[i+3], 64)
p.QuadTo(x1, y1, x2, y2)
i += 4
}
case "A":
if i+6 < len(tokens) {
rx, _ := strconv.ParseFloat(tokens[i], 64)
ry, _ := strconv.ParseFloat(tokens[i+1], 64)
rot, _ := strconv.ParseFloat(tokens[i+2], 64)
large, _ := strconv.ParseBool(tokens[i+3])
sweep, _ := strconv.ParseBool(tokens[i+4])
x, _ := strconv.ParseFloat(tokens[i+5], 64)
y, _ := strconv.ParseFloat(tokens[i+6], 64)
p.ArcTo(rx, ry, rot, large, sweep, x, y)
i += 7
}
case "C":
p.Close()
}
ctm = TranslationMatrix(box.X, box.Y).Multiply(ctm)
}
if canvas.Equal(ctm.a*ctm.d-ctm.b*ctm.c, 0) {
p = p.ReplaceArcs()
+97
View File
@@ -0,0 +1,97 @@
// 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"
// ggNativeStroke 判断GG当前可保真处理的描边范围
// 入参: command 描边指令, scale 每毫米像素数
// 返回: bool 是否可使用原生描边
func ggNativeStroke(command RasterCommand, scale float64) bool {
if command.Paint.Gradient != nil && command.Stroke.Width*scale < 1 {
return false
}
joins := command.Stroke.Join == "" || command.Stroke.Join == "Miter"
dashed := false
for _, value := range command.Stroke.Dashes {
dashed = dashed || value > 0
}
segments := 0
for _, segment := range command.Path {
switch segment.Verb {
case RasterMove:
segments = 0
case RasterLine, RasterQuad, RasterCubic:
segments++
if joins && segments > 1 {
return false
}
if dashed && segment.Verb != RasterLine {
return false
}
case RasterClose:
if dashed || joins {
return false
}
}
}
return true
}
// expandStroke 使用显式几何提供者展开GG尚不可靠的描边,不切换光栅后端
// 入参: command 描边指令
// 返回: RasterCommand 等价填充指令, error 能力或几何错误
func (b GGBackend) expandStroke(command RasterCommand) (RasterCommand, error) {
if b.StrokeGeometry == nil {
return RasterCommand{}, fmt.Errorf("GG stroke geometry: %w", ErrBackendUnavailable)
}
values := command.Transform
matrix := Matrix{a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5]}
path, err := b.StrokeGeometry.Transform(geometryFromRaster(command.Path), matrix)
if err != nil {
return RasterCommand{}, err
}
path, err = b.StrokeGeometry.Stroke(path, *command.Stroke)
if err != nil {
return RasterCommand{}, err
}
path, err = path.Curves(.0001)
if err != nil {
return RasterCommand{}, err
}
if command.Paint.Gradient != nil {
inverse, ok := matrix.Invert()
if !ok {
return RasterCommand{}, fmt.Errorf("invalid stroke gradient transform")
}
path, err = b.StrokeGeometry.Transform(path, inverse)
if err != nil {
return RasterCommand{}, err
}
} else {
command.Transform = RasterMatrix{1, 0, 0, 1, 0, 0}
}
command.Path = make([]RasterSegment, len(path))
for i, s := range path {
verb := RasterVerb(s.Verb)
if s.Verb == GeometryClose {
verb = RasterClose
}
command.Path[i] = RasterSegment{Verb: verb, End: RasterPoint{s.End.X, s.End.Y}, Control1: RasterPoint{s.Control1.X, s.Control1.Y}, Control2: RasterPoint{s.Control2.X, s.Control2.Y}}
}
command.Stroke = nil
command.EvenOdd = false
return command, nil
}
+5 -1
View File
@@ -22,7 +22,7 @@ import (
"unicode/utf8"
)
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,不进行复杂文字塑形
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
// 返回: error 错误信息
@@ -73,6 +73,9 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if lineHeight == 0 {
lineHeight = math.Max(obj.Size, float64(int(ascender)+int(descender)+int(gap))*unit)
}
if options.Shape {
return layoutShapedText(obj, value, options, metrics, width, lineHeight, hScale, float64(ascender)*unit)
}
var codes []TextCode
var missing []rune
for _, paragraph := range strings.Split(value, "\n") {
@@ -114,6 +117,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
return err
}
obj.TextCode = codes
obj.CGTransform = nil
obj.layout = nil
if options != (TextLayout{}) {
obj.layout = &textLayout{value: value, options: options}
+246
View File
@@ -0,0 +1,246 @@
// 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"
"slices"
"strconv"
"strings"
"github.com/go-text/typesetting/segmenter"
)
// layoutShapedText 将显式塑形结果保存为标准文字定位,成功前不修改对象
// 入参: obj 文字对象, value 原文, options 排版选项, metrics 字体度量, width 行宽, lineHeight 行高, hScale 横向比例, baseline 首行基线
// 返回: error 塑形或排版错误
func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics FontMetrics, width, lineHeight, hScale, baseline float64) error {
shaper, ok := metrics.(FontShaper)
if !ok {
return fmt.Errorf("font shaping: %w", ErrBackendUnavailable)
}
var codes []TextCode
var transforms []CGTransform
offset := 0
for _, paragraph := range strings.Split(value, "\n") {
runes := []rune(paragraph)
_, advances, err := shapeTextLine(shaper, runes, obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing, options.FirstLineIndent)
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := 0.0
if i == 0 {
indent = options.FirstLineIndent
}
available := width - indent
glyphs, lineAdvances, err := shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
for options.Wrap && shapedLineWidth(runes[line[0]:line[1]], lineAdvances, options.LetterSpacing) > available {
var seg segmenter.Segmenter
seg.Init(runes[line[0]:line[1]])
previous := 0
for it := seg.GraphemeIterator(); it.Next(); {
previous = it.Grapheme().Offset
}
if previous == 0 {
break
}
oldEnd := line[1]
line[1] = line[0] + previous
if i+1 < len(lines) {
lines[i+1][0] = line[1]
} else {
lines = append(lines, [2]int{line[1], oldEnd})
}
lines[i] = line
glyphs, lineAdvances, err = shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
}
chars := runes[line[0]:line[1]]
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing)
x += options.LeftIndent + indent
y := baseline + float64(len(codes))*lineHeight
if !finite(x) || !finite(y) {
return fmt.Errorf("text position exceeds finite range")
}
adjusted := parseFloats(delta)
extra := make([]float64, len(chars)+1)
raw := make([]float64, len(chars))
for _, glyph := range glyphs {
raw[glyph.Cluster] += glyph.Advance * hScale
}
for j := range chars {
advance := lineAdvances[j]
if j < len(adjusted) {
advance = adjusted[j]
}
extra[j+1] = extra[j] + advance - raw[j]
}
code := TextCode{X: ofdNumber(x), Y: ofdNumber(y), Value: escapeOFDText(string(chars))}
var dx, dy []string
var lastX, lastY float64
for j, glyph := range glyphs {
gx, gy := x+glyph.X*hScale+extra[glyph.Cluster], y+glyph.Y
if !finite(gx) || !finite(gy) {
return fmt.Errorf("shaped position exceeds finite range")
}
if j == 0 {
code.X, code.Y = ofdNumber(gx), ofdNumber(gy)
} else {
dx, dy = append(dx, ofdNumber(gx-lastX)), append(dy, ofdNumber(gy-lastY))
}
lastX, lastY = gx, gy
}
code.DeltaX, code.DeltaY = strings.Join(dx, " "), strings.Join(dy, " ")
for j := 0; j < len(glyphs); {
end := j + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[j].Cluster {
end++
}
next := len(chars)
if end < len(glyphs) {
next = glyphs[end].Cluster
}
ids := make([]string, end-j)
for k := j; k < end; k++ {
ids[k-j] = strconv.Itoa(int(glyphs[k].Glyph))
}
transforms = append(transforms, CGTransform{CodePosition: offset + glyphs[j].Cluster, CodeCount: next - glyphs[j].Cluster, GlyphCount: end - j, Glyphs: strings.Join(ids, " ")})
j = end
}
codes = append(codes, code)
offset += len(chars)
}
}
obj.TextCode, obj.CGTransform = codes, transforms
obj.layout = &textLayout{value: value, options: options}
return nil
}
// shapeTextLine 检查可选后端的簇映射,按原文字符分配塑形步进
// 入参: shaper 字体塑形器, runes 单行字符, font 字体标识, size 字号, scale 横向比例, spacing 字距
// 返回: []ShapedGlyph 字形, []float64 含字距的字符步进, error 塑形错误
func shapeTextLine(shaper FontShaper, runes []rune, font string, size, scale, spacing float64) ([]ShapedGlyph, []float64, error) {
glyphs, err := shaper.ShapeText(string(runes), size)
if err != nil {
return nil, nil, err
}
if len(runes) > 0 && (len(glyphs) == 0 || glyphs[0].Cluster != 0) {
return nil, nil, fmt.Errorf("shaper omitted source text")
}
advances := make([]float64, len(runes))
var missing []rune
for i, glyph := range glyphs {
if glyph.Cluster < 0 || glyph.Cluster >= len(runes) || i > 0 && glyph.Cluster < glyphs[i-1].Cluster || !finite(glyph.X) || !finite(glyph.Y) || !finite(glyph.Advance) {
return nil, nil, fmt.Errorf("invalid shaped glyph")
}
if glyph.Glyph == 0 {
missing = append(missing, runes[glyph.Cluster])
}
advances[glyph.Cluster] += glyph.Advance * scale
}
if err := missingGlyphError(font, missing); err != nil {
return nil, nil, err
}
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
end++
}
next := len(runes)
if end < len(glyphs) {
next = glyphs[end].Cluster
}
start := glyphs[i].Cluster
advance := advances[start] / float64(next-start)
for j := start; j < next; j++ {
advances[j] = advance
}
i = end
}
spaceTextAdvances(runes, advances, spacing)
if slices.ContainsFunc(advances, func(value float64) bool { return !finite(value) }) {
return nil, nil, fmt.Errorf("shaped advance exceeds finite range")
}
return glyphs, advances, nil
}
// shapedLineWidth 计算不含尾随空格和末尾字距的行宽
// 入参: runes 行内字符, advances 字符步进, spacing 字距
// 返回: float64 行宽
func shapedLineWidth(runes []rune, advances []float64, spacing float64) float64 {
end := len(runes)
for end > 0 && runes[end-1] == ' ' {
end--
}
width := 0.0
for _, advance := range advances[:end] {
width += advance
}
if end > 0 {
width -= spacing
}
return width
}
// validateTextGlyphs 校验显式字形编号及原文区间,不允许跨TextCode或重叠映射
// 入参: obj 文字对象, glyphCount 字体字形数量
// 返回: error 无效映射
func validateTextGlyphs(obj TextObject, glyphCount uint16) error {
if len(obj.CGTransform) == 0 {
return nil
}
var ends []int
total := 0
for _, code := range obj.TextCode {
total += len(textCodeRunes(code.Value))
ends = append(ends, total)
}
previous := 0
for _, transform := range obj.CGTransform {
start, count := transform.CodePosition, transform.CodeCount
if start < previous || count <= 0 || start >= total || count > total-start {
return fmt.Errorf("invalid glyph character range")
}
end := start + count
for _, boundary := range ends {
if start < boundary {
if end > boundary {
return fmt.Errorf("glyph mapping crosses text codes")
}
break
}
}
ids := strings.Fields(transform.Glyphs)
if len(ids) == 0 || len(ids) != transform.GlyphCount {
return fmt.Errorf("invalid glyph count")
}
for _, value := range ids {
id, err := strconv.ParseUint(value, 10, 16)
if err != nil || id >= uint64(glyphCount) {
return fmt.Errorf("invalid glyph index %q", value)
}
}
previous = end
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
// 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 "encoding/xml"
// textObjectTokens 记录字形变换所属TextCode,供解码后转换为对象内索引
type textObjectTokens struct {
decoder *xml.Decoder
start *xml.StartElement
depth, code int
transforms []int
}
// Token 转发XML令牌并记录直接子节点顺序
// 返回: xml.Token XML令牌, error 解码错误
func (s *textObjectTokens) Token() (xml.Token, error) {
if s.start != nil {
start := *s.start
s.start = nil
return start, nil
}
token, err := s.decoder.Token()
switch value := token.(type) {
case xml.StartElement:
if s.depth == 0 && value.Name.Local == "CGTransform" {
s.transforms = append(s.transforms, s.code)
}
s.depth++
case xml.EndElement:
s.depth--
if s.depth == 0 && value.Name.Local == "TextCode" {
s.code++
}
}
return token, err
}
// UnmarshalXML 将各TextCode前的局部字形位置统一为对象内索引
// 入参: decoder XML解码器, start 对象起始节点
// 返回: error 解码错误
func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error {
type plain TextObject
stream := &textObjectTokens{decoder: decoder, start: &start}
var value plain
if err := xml.NewTokenDecoder(stream).Decode(&value); err != nil {
return err
}
offsets := make([]int, len(value.TextCode))
for i := 1; i < len(offsets); i++ {
offsets[i] = offsets[i-1] + len(textCodeRunes(value.TextCode[i-1].Value))
}
for i, code := range stream.transforms {
if code < len(offsets) {
value.CGTransform[i].CodePosition += offsets[code]
}
}
*obj = TextObject(value)
return nil
}
+15
View File
@@ -736,13 +736,28 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
x.clips(object.TextObject.Clips)
x.color("FillColor", fill)
x.color("StrokeColor", stroke)
offset, index := 0, 0
for _, code := range object.TextObject.TextCode {
end := offset + len(textCodeRunes(code.Value))
for index < len(object.TextObject.CGTransform) && object.TextObject.CGTransform[index].CodePosition < end {
transform := object.TextObject.CGTransform[index]
attrs := ofdAttrs{
{Name: xml.Name{Local: "CodePosition"}, Value: strconv.Itoa(transform.CodePosition - offset)},
{Name: xml.Name{Local: "CodeCount"}, Value: strconv.Itoa(transform.CodeCount)},
{Name: xml.Name{Local: "GlyphCount"}, Value: strconv.Itoa(transform.GlyphCount)},
}
x.start("CGTransform", attrs)
x.text("Glyphs", transform.Glyphs)
x.end("CGTransform")
index++
}
var attrs ofdAttrs
attrs.add("X", code.X)
attrs.add("Y", code.Y)
attrs.add("DeltaX", code.DeltaX)
attrs.add("DeltaY", code.DeltaY)
x.textCode(code.Value, attrs)
offset = end
}
}
x.end(object.Type)