mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
This commit is contained in:
1 parent
f44d0d8a60
commit
580e7043c4
31 files changed
+1488
-204
No files matched your search
+1
-1
@@ -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
@@ -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 解析创建接口的有限十进制数值序列
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}})
|
||||
|
||||
@@ -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
@@ -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 统一处理内嵌字体包装和索引映射,供各编译器复用
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 显式闭合路径
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 返回字体设计单位
|
||||
|
||||
@@ -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表示不裁剪
|
||||
|
||||
@@ -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
@@ -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 非预乘颜色
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user