diff --git a/ofdgo_backend.go b/ofdgo_backend.go index fb0896e..2da2859 100644 --- a/ofdgo_backend.go +++ b/ofdgo_backend.go @@ -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) } diff --git a/ofdgo_editor.go b/ofdgo_editor.go index e4231b0..7e88ccb 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -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 解析创建接口的有限十进制数值序列 diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go index 44b534d..03a750f 100644 --- a/ofdgo_editor_composite_text.go +++ b/ofdgo_editor_composite_text.go @@ -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 { diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 8d30b9b..005c24a 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -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", }}) diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 0755b92..210aae0 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -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 diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go index ddbe3ff..fcf3b14 100644 --- a/ofdgo_font_backend.go +++ b/ofdgo_font_backend.go @@ -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 统一处理内嵌字体包装和索引映射,供各编译器复用 diff --git a/ofdgo_geom_arc.go b/ofdgo_geom_arc.go new file mode 100644 index 0000000..3b6a969 --- /dev/null +++ b/ofdgo_geom_arc.go @@ -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 +} diff --git a/ofdgo_geom_backend.go b/ofdgo_geom_backend.go index f59f43b..586521a 100644 --- a/ofdgo_geom_backend.go +++ b/ofdgo_geom_backend.go @@ -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 { diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go index a59d715..dc79456 100644 --- a/ofdgo_geom_path.go +++ b/ofdgo_geom_path.go @@ -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 显式闭合路径 diff --git a/ofdgo_page.go b/ofdgo_page.go index d4c64de..d250d20 100644 --- a/ofdgo_page.go +++ b/ofdgo_page.go @@ -156,6 +156,7 @@ type TextCode struct { } // CGTransform 字符到字形的映射 +// CodePosition在内存中为整个TextObject的字符索引,保存时转换为所在TextCode的局部索引 type CGTransform struct { CodePosition int `xml:"CodePosition,attr"` CodeCount int `xml:"CodeCount,attr"` diff --git a/ofdgo_raster.go b/ofdgo_raster.go index 75897e5..7539768 100644 --- a/ofdgo_raster.go +++ b/ofdgo_raster.go @@ -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 diff --git a/ofdgo_raster_clip.go b/ofdgo_raster_clip.go new file mode 100644 index 0000000..19987b0 --- /dev/null +++ b/ofdgo_raster_clip.go @@ -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 +} diff --git a/ofdgo_raster_image.go b/ofdgo_raster_image.go index f61acfe..b5aaca5 100644 --- a/ofdgo_raster_image.go +++ b/ofdgo_raster_image.go @@ -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 } diff --git a/ofdgo_raster_scene.go b/ofdgo_raster_scene.go new file mode 100644 index 0000000..583d230 --- /dev/null +++ b/ofdgo_raster_scene.go @@ -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 +} diff --git a/ofdgo_render_cache.go b/ofdgo_render_cache.go new file mode 100644 index 0000000..40b4b00 --- /dev/null +++ b/ofdgo_render_cache.go @@ -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 +} diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index 6d4c467..d931030 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -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 } } } diff --git a/ofdgo_render_compile_image.go b/ofdgo_render_compile_image.go index 4f39c05..87a0e88 100644 --- a/ofdgo_render_compile_image.go +++ b/ofdgo_render_compile_image.go @@ -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 { diff --git a/ofdgo_render_pattern.go b/ofdgo_render_pattern.go new file mode 100644 index 0000000..86eb0fb --- /dev/null +++ b/ofdgo_render_pattern.go @@ -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 +} diff --git a/ofdgo_render_text.go b/ofdgo_render_text.go index d984149..afafa7e 100644 --- a/ofdgo_render_text.go +++ b/ofdgo_render_text.go @@ -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 } diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 30a365f..1293d10 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -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 } diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index abb9026..05243ed 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -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 } diff --git a/ofdgo_renderer_font_gg.go b/ofdgo_renderer_font_gg.go index a3c084a..e332fb8 100644 --- a/ofdgo_renderer_font_gg.go +++ b/ofdgo_renderer_font_gg.go @@ -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 返回字体设计单位 diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index b5a1ef5..416eec4 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -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表示不裁剪 diff --git a/ofdgo_renderer_geom_gg.go b/ofdgo_renderer_geom_gg.go index 7c9a20d..bb6e898 100644 --- a/ofdgo_renderer_geom_gg.go +++ b/ofdgo_renderer_geom_gg.go @@ -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计算贝塞尔曲线极值,椭圆弧交给配置的几何后端 diff --git a/ofdgo_renderer_gg.go b/ofdgo_renderer_gg.go index c33995e..0cc2bfe 100644 --- a/ofdgo_renderer_gg.go +++ b/ofdgo_renderer_gg.go @@ -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 非预乘颜色 diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index 0463e10..fc9d139 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -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() diff --git a/ofdgo_renderer_stroke_gg.go b/ofdgo_renderer_stroke_gg.go new file mode 100644 index 0000000..e4aa858 --- /dev/null +++ b/ofdgo_renderer_stroke_gg.go @@ -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 +} diff --git a/ofdgo_text_layout.go b/ofdgo_text_layout.go index 603407b..1585ed8 100644 --- a/ofdgo_text_layout.go +++ b/ofdgo_text_layout.go @@ -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} diff --git a/ofdgo_text_shape.go b/ofdgo_text_shape.go new file mode 100644 index 0000000..88720a1 --- /dev/null +++ b/ofdgo_text_shape.go @@ -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 +} diff --git a/ofdgo_text_xml.go b/ofdgo_text_xml.go new file mode 100644 index 0000000..2e33753 --- /dev/null +++ b/ofdgo_text_xml.go @@ -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 +} diff --git a/ofdgo_writer.go b/ofdgo_writer.go index eb8fbf4..0be8703 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -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)