From f44d0d8a608ce59f9aacf371fc916e06ab05b6f2 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 23 Sep 2026 18:43:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E7=AB=AF=E6=9E=B6=E6=9E=84):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84GG=E5=90=8E=E7=AB=AF=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=B8=90=E5=8F=98=E4=B8=8E=E9=80=89=E5=8C=BA=E5=BC=82?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 2 +- ofdgo.go | 1 + ofdgo_backend.go | 22 +- ofdgo_editor.go | 11 +- ofdgo_font_backend.go | 31 +- ofdgo_font_source.go | 344 +++++++++++++ ...s_native.go => ofdgo_font_source_native.go | 21 +- ...anvas_wasm.go => ofdgo_font_source_wasm.go | 14 +- ofdgo_geom_backend.go | 5 + ofdgo_geom_path.go | 24 + ofdgo_raster.go | 35 +- ofdgo_raster_image.go | 18 + ofdgo_render_compile.go | 466 ++++++++++++++++++ ofdgo_render_compile_image.go | 197 ++++++++ ofdgo_render_shading.go | 108 ++++ ofdgo_render_text.go | 373 ++++++++++++++ ofdgo_renderer.go | 3 + ofdgo_renderer_canvas.go | 27 +- ofdgo_renderer_clip_canvas.go | 24 +- ofdgo_renderer_compile_gg.go | 88 ++++ ofdgo_renderer_font_canvas.go | 66 +-- ofdgo_renderer_font_gg.go | 117 +++++ ofdgo_renderer_font_loader_canvas.go | 258 +--------- ofdgo_renderer_geom_canvas.go | 22 + ofdgo_renderer_geom_gg.go | 135 +++++ ofdgo_renderer_gg.go | 6 +- ofdgo_renderer_image_canvas.go | 25 +- ofdgo_renderer_path_canvas.go | 58 +-- ofdgo_renderer_shading_canvas.go | 12 - ofdgo_renderer_state_canvas.go | 25 +- 30 files changed, 2075 insertions(+), 463 deletions(-) create mode 100644 ofdgo_font_source.go rename ofdgo_renderer_font_canvas_native.go => ofdgo_font_source_native.go (56%) rename ofdgo_renderer_font_canvas_wasm.go => ofdgo_font_source_wasm.go (68%) create mode 100644 ofdgo_render_compile.go create mode 100644 ofdgo_render_compile_image.go create mode 100644 ofdgo_render_shading.go create mode 100644 ofdgo_render_text.go create mode 100644 ofdgo_renderer_compile_gg.go create mode 100644 ofdgo_renderer_font_gg.go create mode 100644 ofdgo_renderer_geom_gg.go diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 6177ae5..670d931 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -3134,7 +3134,7 @@ function displayMode() { } function renderBackendLabel(name) { - return { svg: "SVG", canvas: "Canvas", gg: "GoGPU" }[name] || name || "无"; + return { svg: "SVG", canvas: "Canvas", gg: "GoGPU", "gg+canvas": "GoGPU + Canvas" }[name] || name || "无"; } function updateRenderBackend() { diff --git a/ofdgo.go b/ofdgo.go index 2fba2fe..051eb60 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -74,6 +74,7 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { templatePageCache: make(map[string]*PageContent), imageCache: make(map[string]image.Image), backends: defaultRenderBackends(), + backendStates: make(map[any]any), } r.resetFontCache() for _, opt := range opts { diff --git a/ofdgo_backend.go b/ofdgo_backend.go index f42049e..fb0896e 100644 --- a/ofdgo_backend.go +++ b/ofdgo_backend.go @@ -19,6 +19,7 @@ import ( "fmt" "image" "io" + "reflect" ) // ErrBackendUnavailable 表示未配置所需后端,不会自动切换其他实现 @@ -124,7 +125,7 @@ type BackendInfo struct { EPS string `json:"eps"` } -// NewRenderBackends 创建内置后端组合,gg仅替换光栅绘制,其他能力通过Info明确报告 +// NewRenderBackends 创建内置后端组合,各项能力通过Info报告实际提供者 // 入参: name canvas或gg // 返回: RenderBackends 后端组合, error 未知后端错误 func NewRenderBackends(name string) (RenderBackends, error) { @@ -132,6 +133,9 @@ func NewRenderBackends(name string) (RenderBackends, error) { switch name { case "canvas": case "gg": + backends.Fonts = GGBackend{} + backends.Geometry = GGGeometryBackend{GeometryBackend: backends.Geometry} + backends.Compiler = GGBackend{} backends.Raster = GGBackend{} default: return RenderBackends{}, fmt.Errorf("unknown render backend %q", name) @@ -178,11 +182,25 @@ func backendName(backend Backend) string { // 返回: RendererOption 渲染选项 func WithRenderBackends(backends RenderBackends) RendererOption { return func(r *Renderer) { + fontsChanged := !sameBackend(r.backends.Fonts, backends.Fonts) r.backends = backends - r.resetFontCache() + if fontsChanged { + r.resetFontBackendCache() + } } } +// sameBackend 判断可比较值配置是否未变,指针配置重新应用时刷新可变状态 +// 入参: left 原后端, right 新后端 +// 返回: bool 是否为同一配置 +func sameBackend(left, right Backend) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + a, b := reflect.ValueOf(left), reflect.ValueOf(right) + return a.Kind() != reflect.Pointer && b.Kind() != reflect.Pointer && a.Comparable() && b.Comparable() && left == right +} + // Backends 返回当前后端配置副本,可修改后通过WithRenderBackends应用 // 返回: RenderBackends 后端组合 func (r *Renderer) Backends() RenderBackends { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 788d475..e4231b0 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -81,12 +81,17 @@ func (e *Editor) SetPageCompiler(compiler PageCompiler) { } } -// SetRenderBackends 设置完整后端组合并重置字体度量,不改变文档或撤销历史 +// SetRenderBackends 设置完整后端组合,字体能力变化时更新度量,不改变文档或撤销历史 // 入参: backends 后端组合,未配置的能力不会沿用默认实现 func (e *Editor) SetRenderBackends(backends RenderBackends) { + fontsChanged := !sameBackend(e.backends.Fonts, backends.Fonts) e.backends = backends - e.fontRenderer = nil - e.fontMetrics = make(map[string]FontMetrics) + if e.fontRenderer != nil { + WithRenderBackends(backends)(e.fontRenderer) + } + if fontsChanged { + e.fontMetrics = make(map[string]FontMetrics) + } } // Backends 返回编辑器后端配置副本 diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go index fa10d00..ddbe3ff 100644 --- a/ofdgo_font_backend.go +++ b/ofdgo_font_backend.go @@ -28,6 +28,13 @@ type FontMetrics interface { Write() []byte } +// FontOutlines 提供基线原点、纵轴向下的字形轮廓,不绑定字体实现 +// GlyphOutline返回只读路径,字号与路径单位为毫米 +type FontOutlines interface { + FontMetrics + GlyphOutline(glyph uint16, size float64) (GeometryPath, error) +} + // ResolvedFont 保存字体来源和只读字体数据,不将外部字体写入文档 type ResolvedFont struct { Data []byte @@ -57,11 +64,27 @@ type resolvedFontResult struct { err error } -// resetFontCache 重置字体解析及后端私有缓存,不预先创建任何绘图库对象 +// resetFontCache 重置字体解析及字形缓存,保留图片和非字体后端状态 func (r *Renderer) resetFontCache() { + r.fontSourcesCache = newFontSourceCache() + r.resetFontBackendCache() +} + +// resetFontBackendCache 清理后端字体对象,保留字体文件和图片缓存 +func (r *Renderer) resetFontBackendCache() { r.preparedFonts = make(map[string]*PreparedFont) r.resolvedFonts = make(map[resolvedFontKey]resolvedFontResult) - r.backendStates = make(map[any]any) + if r.fontSourcesCache != nil { + r.fontSourcesCache.fallback = ResolvedFont{} + r.fontSourcesCache.fallbackRead = false + } + r.fontMetrics = make(map[*PreparedFont]FontMetrics) + r.glyphOutlines = make(map[glyphOutlineKey]GeometryPath) + for _, state := range r.backendStates { + if fonts, ok := state.(interface{ resetFonts() }); ok { + fonts.resetFonts() + } + } } // ResolveFont 使用配置的字体后端解析资源,保留精确匹配与回退的区别 @@ -121,8 +144,6 @@ func (r *Renderer) PrepareFont(id string) (*PreparedFont, error) { } } } - if definition != nil { - r.preparedFonts[id] = prepared - } + r.preparedFonts[id] = prepared return prepared, nil } diff --git a/ofdgo_font_source.go b/ofdgo_font_source.go new file mode 100644 index 0000000..8d4798f --- /dev/null +++ b/ofdgo_font_source.go @@ -0,0 +1,344 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "runtime" + "sort" + "sync" + + "github.com/tdewolff/font" +) + +// fontSourceKind 字体来源类型 +type fontSourceKind uint8 + +const ( + fontSourceFile fontSourceKind = iota + fontSourceFS + fontSourceSystem +) + +// fontSource 字体来源 +type fontSource struct { + kind fontSourceKind + index int + face int + name string + exact bool + priority int +} + +// fontSourceKey 字体来源去重键 +type fontSourceKey struct { + kind fontSourceKind + index int + face int + name string +} + +// fontSourceCache 保存字体文件候选与原始数据,不包含绘图库对象 +type fontSourceCache struct { + candidates map[string][]fontSource + directories map[string][]fontFileCandidate + filesystems map[int][]fontFileCandidate + data map[fontSourceKey]fontSourceData + fallback ResolvedFont + fallbackRead bool +} + +// fontSourceData 保存读取结果,避免重复打开同一字体文件 +type fontSourceData struct { + data []byte + err error +} + +// newFontSourceCache 创建独立字体来源缓存 +// 返回: *fontSourceCache 字体来源缓存 +func newFontSourceCache() *fontSourceCache { + return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]fontFileCandidate), filesystems: make(map[int][]fontFileCandidate), data: make(map[fontSourceKey]fontSourceData)} +} + +// systemFontIndex 串行初始化系统字体索引,不注册绘图后端 +var systemFontIndex struct { + sync.Mutex + fonts *font.SystemFonts +} + +// readFontSource 读取并拆出独立字体,保留集合中的名称和样式选择 +// 入参: source 字体来源, definition 字体定义 +// 返回: []byte 字体数据, error 读取或集合错误 +func (r *Renderer) readFontSource(source fontSource, definition *Font) ([]byte, error) { + key := fontSourceKey{kind: source.kind, index: source.index, face: source.face, name: source.name} + if source.kind == fontSourceSystem { + systemFontIndex.Lock() + if systemFontIndex.fonts == nil { + systemFontIndex.fonts, _ = font.FindSystemFonts(font.DefaultFontDirs()) + } + weight := 400 + if definition.Bold { + weight = 700 + } + match, ok := systemFontIndex.fonts.Match(source.name, font.ParseStyleCSS(weight, definition.Italic)) + systemFontIndex.Unlock() + if !ok { + return nil, fmt.Errorf("font %q not found", source.name) + } + key = fontSourceKey{kind: fontSourceFile, name: match.Filename} + } + if cached, ok := r.fontSourcesCache.data[key]; ok { + return cached.data, cached.err + } + var data []byte + var err error + if key.kind == fontSourceFS { + data, err = fs.ReadFile(r.fontFS[key.index], key.name) + } else { + data, err = os.ReadFile(key.name) + } + if err == nil && bytes.HasPrefix(data, []byte("ttcf")) { + data, err = extractCollectionFont(data, key.face) + } + r.fontSourcesCache.data[key] = fontSourceData{data, err} + return data, err +} + +// fontSourceMatch 匹配可被当前字体后端解析的来源 +// 入参: backend 字体后端, fontID 字体ID, definition 字体定义, exact 是否禁止无关回退 +// 返回: fontSource 字体来源, FontMetrics 独立字体度量 +func (r *Renderer) fontSourceMatch(backend FontBackend, fontID string, definition *Font, exact bool) (fontSource, FontMetrics) { + for _, source := range r.fontSources(fontID, definition) { + if exact && !source.exact { + continue + } + data, err := r.readFontSource(source, definition) + if err != nil { + continue + } + if metrics, err := backend.OpenFont(data); err == nil { + return source, metrics + } + } + return fontSource{}, nil +} + +// resolveFontSource 按公共匹配规则读取字体,由指定后端验证外部字体 +// 入参: backend 字体后端, id 字体ID, exact 是否禁止无关回退 +// 返回: ResolvedFont 字体及来源, error 解析错误 +func (r *Renderer) resolveFontSource(backend FontBackend, id string, exact bool) (ResolvedFont, error) { + definition := r.Reader.fontCache[id] + if definition == nil && !r.Reader.fontResourcesRead && r.Reader.OFD != nil { + if _, err := r.Reader.Fonts(); err != nil { + return ResolvedFont{}, err + } + definition = r.Reader.fontCache[id] + } + if definition != nil { + if definition.FontFile != "" { + data, err := r.Reader.FontData(id) + if err != nil { + return ResolvedFont{}, err + } + face, err := r.Reader.embeddedFontFace(*definition) + if err != nil { + return ResolvedFont{}, err + } + return ResolvedFont{Data: data, Source: path.Base(definition.FontFile), Face: face, Exact: true}, nil + } + if source, metrics := r.fontSourceMatch(backend, id, definition, exact); metrics != nil { + data := metrics.Write() + faces, err := (FontFile{Data: data}).Faces() + if err != nil { + return ResolvedFont{}, err + } + face := faces[0] + face.Index = source.face + return ResolvedFont{Data: data, Source: source.name, Face: &face, Exact: source.exact}, nil + } + } + if exact { + return ResolvedFont{}, fmt.Errorf("font %q is unavailable", id) + } + if !r.fontSourcesCache.fallbackRead { + r.fontSourcesCache.fallbackRead = true + if canLoadSystemFonts() { + for _, name := range fontDefaultSystemNames() { + data, err := r.readFontSource(fontSource{kind: fontSourceSystem, name: name}, &Font{}) + if err != nil { + continue + } + metrics, err := backend.OpenFont(data) + if err == nil { + r.fontSourcesCache.fallback = ResolvedFont{Data: metrics.Write()} + break + } + } + } + } + return r.fontSourcesCache.fallback, nil +} + +// fontSources 获取字体来源列表 +// 入参: fontID 字体ID, font OFD字体定义 +// 返回: []fontSource 字体来源列表 +func (r *Renderer) fontSources(fontID string, font *Font) []fontSource { + if sources, ok := r.fontSourcesCache.candidates[fontID]; ok { + return sources + } + bold, italic := font.Bold, font.Italic + names := []string{font.FontName, font.FamilyName} + sources := make([]fontSource, 0) + seen := make(map[fontSourceKey]bool) + for _, dir := range r.fontDirs { + for _, match := range r.matchFontFiles(dir, names, bold, italic) { + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) + } + } + for index := range r.fontFS { + for _, match := range r.matchFontFS(index, names, bold, italic) { + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) + } + } + sortFontSources(sources) + systemStart := len(sources) + if canLoadSystemFonts() { + for _, dir := range systemFontDirs() { + for _, match := range r.matchFontFiles(dir, names, bold, italic) { + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) + } + } + for _, name := range fontSystemNames(names...) { + source := fontSource{kind: fontSourceSystem, name: name, exact: true} + for i, requested := range names { + if fontNormalizeName(name) == fontNormalizeName(requested) { + source.priority = (i - len(names)) * 2 + break + } + } + sources = appendFontSource(sources, seen, source) + } + } + sortFontSources(sources[systemStart:]) + for index := range r.fontFS { + for _, match := range r.matchFontFS(index, nil, bold, italic) { + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face}) + } + } + for index, fsys := range r.fontFS { + names, _ := fs.Glob(fsys, "*") + for _, name := range names { + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: name}) + } + } + r.fontSourcesCache.candidates[fontID] = sources + return sources +} + +// sortFontSources 将完整名称匹配排在回退候选之前 +// 入参: sources 字体来源列表 +func sortFontSources(sources []fontSource) { + sort.SliceStable(sources, func(i, j int) bool { + return sources[i].priority < sources[j].priority + }) +} + +// appendFontSource 追加字体来源 +// 入参: sources 字体来源列表, seen 去重映射, source 字体来源 +// 返回: []fontSource 字体来源列表 +func appendFontSource(sources []fontSource, seen map[fontSourceKey]bool, source fontSource) []fontSource { + if source.name == "" { + return sources + } + key := fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face} + if seen[key] { + return sources + } + seen[key] = true + return append(sources, source) +} + +// matchFontFiles 查找字体文件 +// 入参: dir 字体目录, names 字体名称, bold 是否粗体, italic 是否斜体 +// 返回: []fontFileMatch 字体匹配列表 +func (r *Renderer) matchFontFiles(dir string, names []string, bold, italic bool) []fontFileMatch { + candidates, ok := r.fontSourcesCache.directories[dir] + if !ok { + files, _ := filepath.Glob(filepath.Join(dir, "*")) + candidates = fontFileCandidates(files, filepath.Base) + for i, candidate := range candidates { + file, err := os.Open(candidate.name) + if err != nil { + continue + } + candidates = appendFontFileNames(candidates, i, fontFileNames(file)) + file.Close() + } + r.fontSourcesCache.directories[dir] = candidates + } + return fontFileMatches(candidates, names, bold, italic) +} + +// matchFontFS 匹配字体文件系统中的字体名称 +// 入参: index 字体文件系统索引, names 字体名称, bold 是否粗体, italic 是否斜体 +// 返回: []fontFileMatch 字体匹配列表 +func (r *Renderer) matchFontFS(index int, names []string, bold, italic bool) []fontFileMatch { + fsys := r.fontFS[index] + if fonts, ok := fsys.(*FontFS); ok { + return fonts.matchStyle(names, bold, italic) + } + candidates, ok := r.fontSourcesCache.filesystems[index] + if !ok { + files, _ := fs.Glob(fsys, "*") + candidates = fontFileCandidates(files, path.Base) + for i, candidate := range candidates { + file, err := fsys.Open(candidate.name) + if err != nil { + continue + } + reader, ok := file.(io.ReaderAt) + if !ok { + data, _ := io.ReadAll(file) + reader = bytes.NewReader(data) + } + candidates = appendFontFileNames(candidates, i, fontFileNames(reader)) + file.Close() + } + r.fontSourcesCache.filesystems[index] = candidates + } + return fontFileMatches(candidates, names, bold, italic) +} + +// systemFontDirs 获取系统字体目录 +// 返回: []string 字体目录列表 +func systemFontDirs() []string { + switch runtime.GOOS { + case "android": + return []string{"/product/fonts", "/system/fonts"} + case "darwin": + return []string{`/Library/Fonts`} + case "linux": + return []string{`/usr/share/fonts`} + case "windows": + return []string{`C:\Windows\Fonts`} + } + return nil +} diff --git a/ofdgo_renderer_font_canvas_native.go b/ofdgo_font_source_native.go similarity index 56% rename from ofdgo_renderer_font_canvas_native.go rename to ofdgo_font_source_native.go index 5ef1b1e..ef7667c 100644 --- a/ofdgo_renderer_font_canvas_native.go +++ b/ofdgo_font_source_native.go @@ -16,21 +16,6 @@ package ofdgo -import "github.com/tdewolff/canvas" - -// loadDefaultFonts 加载默认字体 -// 返回: bool 是否加载成功 -func (r *Renderer) loadDefaultFonts() bool { - for _, name := range fontDefaultSystemNames() { - if err := r.canvasState().fontFamily.LoadSystemFont(name, canvas.FontRegular); err == nil { - return true - } - } - return false -} - -// canLoadSystemFonts 判断是否可以加载系统字体 -// 返回: bool 是否可以加载系统字体 -func canLoadSystemFonts() bool { - return true -} +// canLoadSystemFonts 判断是否可以读取系统字体 +// 返回: bool 是否可以读取 +func canLoadSystemFonts() bool { return true } diff --git a/ofdgo_renderer_font_canvas_wasm.go b/ofdgo_font_source_wasm.go similarity index 68% rename from ofdgo_renderer_font_canvas_wasm.go rename to ofdgo_font_source_wasm.go index b16ce42..0d2f7eb 100644 --- a/ofdgo_renderer_font_canvas_wasm.go +++ b/ofdgo_font_source_wasm.go @@ -16,14 +16,6 @@ package ofdgo -// loadDefaultFonts 加载默认字体 -// 返回: bool 是否加载成功 -func (r *Renderer) loadDefaultFonts() bool { - return false -} - -// canLoadSystemFonts 判断是否可以加载系统字体 -// 返回: bool 是否可以加载系统字体 -func canLoadSystemFonts() bool { - return false -} +// canLoadSystemFonts 判断是否可以读取系统字体 +// 返回: bool 是否可以读取 +func canLoadSystemFonts() bool { return false } diff --git a/ofdgo_geom_backend.go b/ofdgo_geom_backend.go index 0b3246e..f59f43b 100644 --- a/ofdgo_geom_backend.go +++ b/ofdgo_geom_backend.go @@ -49,6 +49,11 @@ type GeometryBackend interface { Clip(renderer *Renderer, clips *Clips, matrix Matrix, parent *GeometryPath) (*GeometryPath, error) } +// GeometryCurves 将椭圆弧转换为光栅后端可消费的贝塞尔曲线,不改变源路径 +type GeometryCurves interface { + Curves(path GeometryPath) (GeometryPath, error) +} + // Geometry 返回当前几何后端,不隐式恢复默认实现 // 返回: GeometryBackend 几何后端, error 未配置错误 func (r *Renderer) Geometry() (GeometryBackend, error) { diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go index 4672e6a..a59d715 100644 --- a/ofdgo_geom_path.go +++ b/ofdgo_geom_path.go @@ -23,6 +23,30 @@ import ( // 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点 type GeometryPath []GeometrySegment +// closedGeometry 为填充轮廓补齐隐式闭合边,不改变源路径 +// 入参: path 填充路径 +// 返回: GeometryPath 显式闭合路径 +func closedGeometry(path GeometryPath) GeometryPath { + result := make(GeometryPath, 0, len(path)+1) + open := false + for _, segment := range path { + if segment.Verb == GeometryMove && open { + result = append(result, GeometrySegment{Verb: GeometryClose}) + } + result = append(result, segment) + if segment.Verb == GeometryMove { + open = true + } + if segment.Verb == GeometryClose { + open = false + } + } + if open { + result = append(result, GeometrySegment{Verb: GeometryClose}) + } + return result +} + // GeometryVerb 表示几何路径指令 type GeometryVerb uint8 diff --git a/ofdgo_raster.go b/ofdgo_raster.go index ce1c310..75897e5 100644 --- a/ofdgo_raster.go +++ b/ofdgo_raster.go @@ -56,13 +56,14 @@ type RasterSegment struct { // RasterCommand 填充路径或绘制图像,Image非空时为图像指令 // Transform将局部坐标映射到页面,图像局部坐标与image.Image的像素坐标一致 -// 字形和描边由页面编译器生成轮廓,裁剪与底纹在编译时解析 +// 字形和描边由页面编译器生成轮廓,Clip为页面坐标图像裁剪,nil表示不裁剪 type RasterCommand struct { Path []RasterSegment Paint RasterPaint EvenOdd bool Transform RasterMatrix Image image.Image + Clip []RasterSegment } // RasterPaint 预乘RGBA纯色或渐变,Gradient非空时忽略Color @@ -79,12 +80,20 @@ const ( RasterRadial ) -// RasterGradient 局部坐标下的线性或双圆径向渐变,区间外延续边界颜色 +// RasterGradient 局部坐标下的线性或双圆径向渐变,Spread为空时延续边界颜色 type RasterGradient struct { Kind RasterGradientKind Start, End RasterPoint R0, R1 float64 Stops []ColorStop + Spread *RasterSpread +} + +// RasterSpread 保存OFD渐变延伸和周期,nil表示连续延伸 +type RasterSpread struct { + Extend int + MapType string + Period float64 } // PixelSize 检查页面尺寸并计算目标像素大小 @@ -135,9 +144,31 @@ func (g *RasterGradient) At(x, y float64) color.RGBA { dr := g.R1 - g.R0 a, b, c := dx*dx+dy*dy-dr*dr, -2*(px*dx+py*dy+g.R0*dr), px*px+py*py-g.R0*g.R0 t = rasterRadialPosition(a, b, c, g.R0, dr) + if g.Spread != nil && math.Hypot(dx, dy) < math.Abs(dr) { + d := b*b - 4*a*c + if d < 0 { + return color.RGBA{} + } + t = (-b - math.Copysign(math.Sqrt(d), dr)) / (2 * a) + } } else if length := dx*dx + dy*dy; length > 0 { t = (px*dx + py*dy) / length } + if spread := g.Spread; spread != nil { + if t < 0 && spread.Extend&1 == 0 || t > 1 && spread.Extend&2 == 0 { + return color.RGBA{} + } + period := spread.Period + if period <= 0 { + period = 1 + } + switch spread.MapType { + case "Repeat": + t = t/period - math.Floor(t/period) + case "Reflect": + t = reflectShdPosition(t / period) + } + } return g.colorAt(t) } diff --git a/ofdgo_raster_image.go b/ofdgo_raster_image.go index ad6c9a7..f61acfe 100644 --- a/ofdgo_raster_image.go +++ b/ofdgo_raster_image.go @@ -16,6 +16,7 @@ package ofdgo import ( "image" + "image/color" "golang.org/x/image/draw" "golang.org/x/image/math/f64" @@ -27,6 +28,23 @@ func drawRasterImage(dst draw.Image, src image.Image, m RasterMatrix) { draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, src, src.Bounds(), draw.Over, nil) } +// drawRasterImageCommand 使用当前后端生成裁剪蒙版,共用高质量图像采样 +// 入参: backend 当前后端, page 页面, dst 目标图像, src 原始图像, command 图像指令 +// 返回: error 裁剪绘制错误 +func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Image, src image.Image, command RasterCommand) 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}}}}) + if err != nil { + return err + } + 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 +} + // PixelTransform 将页面毫米变换转换为像素变换,保留尺寸取整后的原点位置 // 入参: m 局部到页面变换, height 目标像素高度 // 返回: RasterMatrix 局部到像素变换 diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go new file mode 100644 index 0000000..6d4c467 --- /dev/null +++ b/ofdgo_render_compile.go @@ -0,0 +1,466 @@ +// 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" + "strconv" + "strings" +) + +// semanticCompiler 将公共页面语义编译为路径和原始图片,绘制、搜索和度量共享定位 +type semanticCompiler struct { + renderer *Renderer + geometry GeometryBackend + page *RasterPage + text PageText + measurement ObjectMeasurement + measure, contours, textOnly, pattern bool +} + +// newSemanticCompiler 绑定当前字体和几何能力 +// 入参: r 渲染器 +// 返回: *semanticCompiler 编译器, error 能力错误 +func newSemanticCompiler(r *Renderer) (*semanticCompiler, error) { + geometry, err := r.Geometry() + if err != nil { + return nil, err + } + return &semanticCompiler{renderer: r, geometry: geometry}, nil +} + +// DrawObject 解释叶子对象,不调用任何具体页面编译器 +// 入参: object 源对象, state 继承状态 +// 返回: error 编译错误 +func (c *semanticCompiler) DrawObject(object *GraphicObject, state RenderState) error { + switch object.Type { + case "TextObject": + return c.textObject(object.TextObject, state) + case "PathObject": + if !c.textOnly { + return c.pathObject(object.PathObject, state) + } + case "ImageObject": + if !c.textOnly { + return c.imageObject(object.ImageObject, state) + } + default: + return fmt.Errorf("unsupported graphic object %q", object.Type) + } + return nil +} + +// segments 转换光栅曲线,仅在输出边界展开椭圆弧 +// 入参: path 页面路径 +// 返回: []RasterSegment 光栅路径, error 能力或路径错误 +func (c *semanticCompiler) segments(path GeometryPath) ([]RasterSegment, error) { + for _, segment := range path { + if segment.Verb == GeometryArc { + curves, ok := c.geometry.(GeometryCurves) + if !ok { + return nil, fmt.Errorf("geometry curves: %w", ErrBackendUnavailable) + } + var err error + path, err = curves.Curves(path) + if err != nil { + return nil, err + } + break + } + } + result := make([]RasterSegment, 0, len(path)) + for _, s := range path { + segment := RasterSegment{End: RasterPoint{s.End.X, s.End.Y}, Control1: RasterPoint{s.Control1.X, s.Control1.Y}, Control2: RasterPoint{s.Control2.X, s.Control2.Y}} + switch s.Verb { + case GeometryMove: + segment.Verb = RasterMove + case GeometryLine: + segment.Verb = RasterLine + case GeometryQuad: + segment.Verb = RasterQuad + case GeometryCubic: + segment.Verb = RasterCubic + case GeometryClose: + segment.Verb = RasterClose + default: + return nil, fmt.Errorf("unsupported raster geometry %d", s.Verb) + } + result = append(result, segment) + } + return result, nil +} + +// addBounds 收集精确范围和独立轮廓,不分配页面像素 +// 入参: path 页面路径, evenOdd 奇偶规则 +// 返回: error 几何错误 +func (c *semanticCompiler) addBounds(path GeometryPath, evenOdd bool) error { + if len(path) == 0 { + return nil + } + box, err := c.geometry.Bounds(path) + if err != nil { + return err + } + c.measurement.Bounds = unionTextBox(c.measurement.Bounds, box) + if c.contours { + svg, err := closedGeometry(path).SVG() + if err != nil { + return err + } + c.measurement.Contours = append(c.measurement.Contours, ObjectContour{Path: svg, EvenOdd: evenOdd}) + } + return nil +} + +// fill 应用对象裁剪、底纹和渐变,不改变输入路径 +// 入参: path 页面路径, paint 画刷, evenOdd 奇偶规则, clip 对象裁剪, matrix 图案矩阵, shadingMatrix 渐变矩阵 +// 返回: error 几何或编译错误 +func (c *semanticCompiler) fill(path GeometryPath, paint Paint, evenOdd bool, clip *GeometryPath, matrix, shadingMatrix Matrix) error { + if len(path) == 0 || paint.Kind == PaintNone || paint.Kind == PaintSolid && paint.Color.A == 0 { + return nil + } + var err error + if clip != nil || paint.Kind == PaintPattern || paint.Gradient != nil { + path = closedGeometry(path) + } + if evenOdd && (clip != nil || paint.Kind == PaintPattern || paint.Gradient != nil) { + path, err = c.geometry.Normalize(path, true) + if err != nil { + return err + } + evenOdd = false + } + path, err = clipGeometry(c.geometry, path, clip) + if err != nil || len(path) == 0 { + return err + } + if paint.Gradient != nil { + path, err = c.shadingClip(path, paint, shadingMatrix) + if err != nil || len(path) == 0 { + return err + } + } + if c.measure { + return c.addBounds(path, evenOdd) + } + if paint.Kind == PaintPattern { + return c.fillPattern(path, paint.Pattern, matrix) + } + command := RasterCommand{Paint: RasterPaint{Color: paint.Color}, EvenOdd: evenOdd, Transform: RasterMatrix{1, 0, 0, 1, 0, 0}} + if paint.Gradient != nil { + gradient, view := semanticGradient(paint, shadingMatrix) + inverse, ok := view.Invert() + if !ok { + return fmt.Errorf("invalid shading transform") + } + path, err = c.geometry.Transform(path, inverse) + if err != nil { + return err + } + command.Transform = RasterMatrix(view.Values()) + command.Paint.Gradient = gradient + } + command.Path, err = c.segments(path) + if err != nil { + return err + } + c.page.Commands = append(c.page.Commands, command) + return nil +} + +// semanticGradient 保留渐变周期、延伸与椭圆坐标 +// 入参: paint 渐变画刷, matrix 渐变父矩阵 +// 返回: *RasterGradient 光栅渐变, Matrix 局部到页面矩阵 +func semanticGradient(paint Paint, matrix Matrix) (*RasterGradient, Matrix) { + s := paint.Gradient + extend, _ := strconv.Atoi(s.Extend) + period := 1.0 + length := math.Hypot(s.End.X-s.Start.X, s.End.Y-s.Start.Y) + if s.MapUnit > 0 && length > 0 { + period = s.MapUnit / length + } + g := &RasterGradient{Start: RasterPoint{s.Start.X, s.Start.Y}, End: RasterPoint{s.End.X, s.End.Y}, R0: s.StartRadius, R1: s.EndRadius, Stops: s.Stops, Spread: &RasterSpread{Extend: extend, MapType: s.MapType, Period: period}} + if paint.Kind == PaintRadial { + g.Kind = RasterRadial + if s.Eccentricity > 0 && s.Eccentricity < 1 { + angle := s.Angle * math.Pi / 180 + view := TranslationMatrix(s.Start.X, s.Start.Y).Multiply(Matrix{a: math.Cos(angle), b: math.Sin(angle), c: -math.Sin(angle), d: math.Cos(angle)}).Multiply(Matrix{a: 1, d: math.Sqrt(1 - s.Eccentricity*s.Eccentricity)}).Multiply(TranslationMatrix(-s.Start.X, -s.Start.Y)) + inverse, _ := view.Invert() + g.End.X, g.End.Y = inverse.Transform(g.End.X, g.End.Y) + matrix = matrix.Multiply(view) + } + if math.Hypot(g.End.X-g.Start.X, g.End.Y-g.Start.Y) >= math.Abs(g.R1-g.R0) { + g.Spread = nil + } + } + return g, matrix +} + +// semanticStyle 保存已继承的路径颜色和描边参数 +type semanticStyle struct { + fill, stroke *FillColor + options StrokeOptions +} + +// objectStyle 合并继承与对象样式,不修改共享绘制参数 +// 入参: object 路径样式, defaults 继承样式, linear 组合变换 +// 返回: semanticStyle 解析样式 +func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, linear Matrix) semanticStyle { + defaults = c.renderer.drawParamDefaults(object.DrawParam, defaults) + style := semanticStyle{options: StrokeOptions{Width: defaultPathLineWidth, Cap: "Butt", Join: "Miter", MiterLimit: defaultMiterLimit}} + if defaults != nil { + style.fill = defaults.FillColor + style.stroke = (*FillColor)(defaults.StrokeColor) + if defaults.LineWidth > 0 { + style.options.Width = defaults.LineWidth + } + if defaults.Cap != "" { + style.options.Cap = defaults.Cap + } + if defaults.Join != "" { + style.options.Join = defaults.Join + } + if defaults.MiterLimit > 0 { + style.options.MiterLimit = defaults.MiterLimit + } + style.options.Dashes = parseFloats(defaults.DashPattern) + if defaults.DashOffset != nil { + style.options.DashOffset = *defaults.DashOffset + } + } + if object.FillColor != nil { + style.fill = object.FillColor + } + if object.StrokeColor != nil { + style.stroke = (*FillColor)(object.StrokeColor) + } + if object.LineWidth > 0 { + style.options.Width = object.LineWidth + } + if object.Cap != "" { + style.options.Cap = object.Cap + } + if object.Join != "" { + style.options.Join = object.Join + } + if object.MiterLimit > 0 { + style.options.MiterLimit = object.MiterLimit + } + if object.DashPattern != "" || object.dashPatternSet { + style.options.Dashes = parseFloats(object.DashPattern) + } + if object.DashOffset != nil { + style.options.DashOffset = *object.DashOffset + } + if style.stroke == nil { + style.stroke = &FillColor{} + } + style.fill = withFillAlpha(style.fill, object.Alpha) + style.stroke = withFillAlpha(style.stroke, object.Alpha) + if scale := math.Sqrt(math.Abs(linear.a*linear.d - linear.b*linear.c)); scale > 0 { + style.options.Width *= scale + style.options.DashOffset *= scale + for i := range style.options.Dashes { + style.options.Dashes[i] *= scale + } + } + return style +} + +// pathObject 编译填充、描边和虚线路径 +// 入参: object 路径对象, state 继承状态 +// 返回: error 路径或画刷错误 +func (c *semanticCompiler) pathObject(object PathObject, state RenderState) error { + if object.Visible != nil && !*object.Visible { + return nil + } + matrix, linear := renderObjectMatrix(object.Boundary, NewMatrix(object.CTM), state) + local := object + local.Boundary = "0 0 1 1" + local.CTM = "" + var path GeometryPath + var err error + tinyMatrix := matrix + if state.BoundaryInCTM && state.Parent != nil { + tinyMatrix, _ = renderObjectMatrix(object.Boundary, NewMatrix(object.CTM), RenderState{}) + } + path = tinyFillRectPath(object, tinyMatrix) + if path != nil { + if state.BoundaryInCTM && state.Parent != nil { + path, err = c.geometry.Transform(path, *state.Parent) + } + } else { + path, err = c.geometry.Path(local) + if err != nil { + return err + } + path, err = c.geometry.Transform(path, matrix) + } + if err != nil { + return err + } + clip, err := c.renderer.objectGeometryClip(object.Clips, object.Boundary, NewMatrix(object.CTM), state) + if err != nil { + return err + } + style := c.objectStyle(object, state.Defaults, linear) + shading, _ := renderObjectMatrix(object.Boundary, IdentityMatrix, state) + if object.Fill != nil && *object.Fill { + if err := c.fill(path, c.renderer.ResolvePaint(style.fill), object.Rule == "Even-Odd", clip, matrix, shading); err != nil { + return err + } + } + 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 nil +} + +// textObject 共用字形定位处理绘制、原文提取和轮廓 +// 入参: object 文字对象, state 继承状态 +// 返回: error 字体或几何错误 +func (c *semanticCompiler) textObject(object TextObject, state RenderState) error { + if object.Visible != nil && !*object.Visible || object.Fill != nil && !*object.Fill && (object.Stroke == nil || !*object.Stroke) { + return nil + } + positioned, err := c.renderer.PositionText(object, state) + if err != nil { + return err + } + if !c.pattern && positioned.Run.Text != "" { + c.text.Runs = append(c.text.Runs, positioned.Run) + } + if c.textOnly { + return nil + } + if c.measure && !c.contours { + for _, box := range positioned.Run.Boxes { + c.measurement.Bounds = unionTextBox(c.measurement.Bounds, box) + } + return nil + } + _, linear := renderObjectMatrix(object.Boundary, NewMatrix(object.CTM), state) + style := c.objectStyle(PathObject{DrawParam: object.DrawParam, FillColor: object.FillColor, StrokeColor: object.StrokeColor, Alpha: object.Alpha, LineWidth: object.LineWidth, Join: object.Join, MiterLimit: object.MiterLimit}, state.Defaults, linear) + if style.fill == nil { + style.fill = withFillAlpha(&FillColor{}, object.Alpha) + } + shading, _ := renderObjectMatrix(object.Boundary, IdentityMatrix, state) + for _, glyph := range positioned.Glyphs { + if object.Fill == nil || *object.Fill { + if err := c.fill(glyph.Path, c.renderer.ResolvePaint(style.fill), false, positioned.Clip, positioned.Matrix, shading); err != nil { + return err + } + } + 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 { + return err + } + } + if err := c.fill(glyph.Underline, c.renderer.ResolvePaint(style.fill), false, positioned.Clip, positioned.Matrix, shading); err != nil { + return err + } + } + return nil +} + +// fillPattern 在对象裁剪区域内展开底纹,保留单元反射和继承样式 +// 入参: clip 绘制区域, pattern 底纹, object 对象矩阵 +// 返回: error 编译错误 +func (c *semanticCompiler) fillPattern(clip GeometryPath, pattern *PatternPaint, object Matrix) error { + xstep, ystep := math.Max(pattern.XStep, pattern.Width), math.Max(pattern.YStep, pattern.Height) + if xstep <= 0 || ystep <= 0 { + return fmt.Errorf("invalid pattern cell size") + } + matrix := NewMatrix(pattern.CTM) + if pattern.RelativeTo != "Page" { + matrix = object.Multiply(matrix) + } + inverse, ok := matrix.Invert() + if !ok { + return fmt.Errorf("invalid pattern transform") + } + box, err := c.geometry.Bounds(clip) + if err != nil { + 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)} + 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)) + if x%2 != 0 && (pattern.ReflectMethod == "Column" || pattern.ReflectMethod == "RowAndColumn") { + tile = tile.Multiply(Matrix{a: -1, d: 1, e: pattern.Width}) + } + 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 + } + } + } + } + return nil +} + +// tinyFillRectPath 保留历史文档中微小填充块的缺省末端处理 +// 入参: object 路径对象, matrix 不含外层变换的页面矩阵 +// 返回: GeometryPath 修正路径,非匹配格式时为空 +func tinyFillRectPath(object PathObject, matrix Matrix) GeometryPath { + if object.Fill == nil || !*object.Fill || object.Stroke == nil || *object.Stroke { + return nil + } + box, err := ParseBox(object.Boundary) + if err != nil || box.W <= 0 || box.H <= 0 || box.W > .6 || box.H > .6 { + return nil + } + tokens := strings.Fields(object.AbbreviatedData) + if len(tokens) != 11 || tokens[0] != "M" || tokens[3] != "L" || tokens[6] != "L" || tokens[9] != "L" || tokens[10] != "C" { + return nil + } + var minX, minY, maxX, maxY float64 + for i := 1; i < 10; i += 3 { + x, errX := strconv.ParseFloat(tokens[i], 64) + y, errY := strconv.ParseFloat(tokens[i+1], 64) + if errX != nil || errY != nil { + return nil + } + x, y = matrix.Transform(x, y) + if i == 1 { + minX, maxX, minY, maxY = x, x, y, y + } else { + minX = math.Min(minX, x) + maxX = math.Max(maxX, x) + minY = math.Min(minY, y) + maxY = math.Max(maxY, y) + } + } + expand := math.Min(box.W, box.H) * .08 + return geometryRectangle(Box{X: minX - expand, Y: minY - expand, W: maxX - minX + 2*expand, H: maxY - minY + 2*expand}) +} diff --git a/ofdgo_render_compile_image.go b/ofdgo_render_compile_image.go new file mode 100644 index 0000000..4f39c05 --- /dev/null +++ b/ofdgo_render_compile_image.go @@ -0,0 +1,197 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "fmt" + "image" + "slices" +) + +// imageObject 共用图片定位、裁剪和边框,不在度量时读取像素 +// 入参: object 图片对象, state 继承状态 +// 返回: error 资源或几何错误 +func (c *semanticCompiler) imageObject(object ImageObject, state RenderState) error { + if object.Visible != nil && !*object.Visible || object.Alpha != nil && *object.Alpha == 0 { + return nil + } + box, _ := ParseBox(object.Boundary) + local := NewMatrix(object.CTM) + if object.CTM == "" { + local = Matrix{a: box.W, d: box.H} + } + matrix, _ := renderObjectMatrix(object.Boundary, local, state) + clip, err := c.renderer.objectGeometryClip(object.Clips, object.Boundary, local, state) + if err != nil { + return err + } + if c.measure { + path, err := c.geometry.Transform(geometryRectangle(Box{W: 1, H: 1}), matrix) + if err != nil { + return err + } + path, err = clipGeometry(c.geometry, path, clip) + if err != nil { + return err + } + if err = c.addBounds(path, false); err != nil { + return err + } + } else { + img, err := c.renderer.ImageResource(object.ResourceID) + if err != nil { + return err + } + if object.ImageMask != "" { + mask, err := c.renderer.ImageResource(object.ImageMask) + if err != nil { + return err + } + img = imageWithMask(img, mask) + } + img = imageWithAlpha(img, object.Alpha) + bounds := img.Bounds() + matrix = matrix.Multiply(Matrix{a: 1 / float64(bounds.Dx()), d: 1 / float64(bounds.Dy())}).Multiply(TranslationMatrix(-float64(bounds.Min.X), -float64(bounds.Min.Y))) + if err := c.addImage(img, matrix, clip); err != nil { + return err + } + } + if object.Border != nil { + border := imageBorderObject(object, box) + if border.LineWidth == 0 { + return nil + } + state.Clip = clip + state.Defaults = nil + return c.pathObject(border, state) + } + return nil +} + +// imageBorderObject 共用图像圆角边框的标准路径与样式 +// 入参: object 图像对象, box 物理范围 +// 返回: PathObject 边框 +func imageBorderObject(object ImageObject, box Box) PathObject { + border := object.Border + width := defaultPathLineWidth + if border.LineWidth != nil { + width = *border.LineWidth + } + w, h := box.W, box.H + data := fmt.Sprintf("M 0 0 L %g 0 L %g %g L 0 %g C", w, w, h, h) + rx, ry := min(border.HorizonalCornerRadius, w/2), min(border.VerticalCornerRadius, h/2) + if rx > 0 && ry > 0 { + data = fmt.Sprintf("M 0 %g A %g %g 0 0 1 %g 0 L %g 0 A %g %g 0 0 1 %g %g L %g %g A %g %g 0 0 1 %g %g L %g %g A %g %g 0 0 1 0 %g C", + ry, rx, ry, rx, w-rx, rx, ry, w, ry, w, h-ry, rx, ry, w-rx, h, rx, h, rx, ry, h-ry) + } + return PathObject{Boundary: object.Boundary, LineWidth: width, DashOffset: &border.DashOffset, DashPattern: border.DashPattern, Alpha: object.Alpha, StrokeColor: border.BorderColor, AbbreviatedData: data} +} + +// addImage 保留图像资源与页面坐标裁剪,延迟像素采样 +// 入参: img 图像, matrix 像素到页面矩阵, clip 页面裁剪 +// 返回: error 裁剪转换错误 +func (c *semanticCompiler) addImage(img image.Image, matrix Matrix, clip *GeometryPath) error { + command := RasterCommand{Image: img, Transform: RasterMatrix(matrix.Values())} + if clip != nil { + var err error + command.Clip, err = c.segments(*clip) + if err != nil { + return err + } + } + c.page.Commands = append(c.page.Commands, command) + return nil +} + +// DrawStamp 保留签章图片和嵌套OFD的绘制顺序,不混入正文搜索 +// 入参: stamp 印章 +// 返回: error 印章解析或编译错误 +func (c *semanticCompiler) DrawStamp(stamp Stamp) error { + if c.textOnly || c.measure || len(stamp.Data) == 0 { + return nil + } + if stamp.Type == "ofd" { + reader, err := NewReader(bytes.NewReader(stamp.Data), int64(len(stamp.Data))) + if err != nil { + return err + } + defer reader.Close() + document, err := reader.Doc() + if err != nil { + return err + } + renderer := c.renderer.childRenderer(reader) + renderer.TransparentBackground = true + for _, reference := range document.Pages.Page { + content, err := reader.PageContent(reference) + if err != nil { + return err + } + page, err := renderer.CompilePage(content) + if err != nil { + return err + } + if stamp.Clip != nil { + if renderer.backends.Raster == nil { + return fmt.Errorf("stamp raster: %w", ErrBackendUnavailable) + } + img, err := renderer.backends.Raster.Render(page) + if err != nil { + return err + } + return c.stampImage(stampImageWithTransparentWhite(img), stamp) + } + 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.Clip != nil { + command.Clip = slices.Clone(command.Clip) + for i := range command.Clip { + segment := command.Clip[i] + segment.End = RasterMatrix(matrix.Values()).Apply(segment.End) + segment.Control1 = RasterMatrix(matrix.Values()).Apply(segment.Control1) + segment.Control2 = RasterMatrix(matrix.Values()).Apply(segment.Control2) + command.Clip[i] = segment + } + } + c.page.Commands = append(c.page.Commands, command) + } + } + return nil + } + img, _, err := decodeImageData(stamp.Data) + if err != nil { + return err + } + return c.stampImage(stampImageWithTransparentWhite(img), stamp) +} + +// stampImage 按印章裁剪区域放置图像 +// 入参: img 印章图像, stamp 印章 +// 返回: error 编译错误 +func (c *semanticCompiler) stampImage(img image.Image, stamp Stamp) error { + box := stamp.Box + if stamp.Clip != nil { + img = clipStampImage(img, box, *stamp.Clip) + box.X += stamp.Clip.X + box.Y += stamp.Clip.Y + box.W = stamp.Clip.W + box.H = stamp.Clip.H + } + bounds := img.Bounds() + matrix := TranslationMatrix(box.X, box.Y).Multiply(Matrix{a: box.W / float64(bounds.Dx()), d: box.H / float64(bounds.Dy())}).Multiply(TranslationMatrix(-float64(bounds.Min.X), -float64(bounds.Min.Y))) + return c.addImage(img, matrix, nil) +} diff --git a/ofdgo_render_shading.go b/ofdgo_render_shading.go new file mode 100644 index 0000000..93212f1 --- /dev/null +++ b/ofdgo_render_shading.go @@ -0,0 +1,108 @@ +// 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 "math" + +// reflectShdPosition 获取镜像周期内的位置 +// 入参: position 渐变位置 +// 返回: float64 周期内的位置 +func reflectShdPosition(position float64) float64 { + index := math.Floor(position) + position -= index + if math.Mod(index, 2) != 0 { + return 1 - position + } + return position +} + +// shadingClip 按渐变延伸规则裁剪绘制与选区,避免只在采样时隐藏区间外像素 +// 入参: path 页面路径, paint 渐变画刷, matrix 渐变父矩阵 +// 返回: GeometryPath 可见路径, error 几何错误 +func (c *semanticCompiler) shadingClip(path GeometryPath, paint Paint, matrix Matrix) (GeometryPath, error) { + gradient, view := semanticGradient(paint, matrix) + spread := gradient.Spread + if spread == nil || spread.Extend == 3 { + return path, nil + } + inverse, ok := view.Invert() + if !ok { + return nil, nil + } + local, err := c.geometry.Transform(path, inverse) + if err != nil { + return nil, err + } + if gradient.Kind == RasterLinear { + dx, dy := gradient.End.X-gradient.Start.X, gradient.End.Y-gradient.Start.Y + axis := Matrix{a: dx, b: dy, c: -dy, d: dx, e: gradient.Start.X, f: gradient.Start.Y} + inv, _ := axis.Invert() + box, err := c.geometry.Bounds(local) + if err != nil { + return nil, err + } + box = inv.TransformBox(box) + end := box.X + box.W + if spread.Extend&1 == 0 { + box.X = math.Max(box.X, 0) + } + if spread.Extend&2 == 0 { + end = math.Min(end, 1) + } + box.W = end - box.X + if box.W <= 0 { + return nil, nil + } + clip, err := c.geometry.Transform(geometryRectangle(box), axis) + if err != nil { + return nil, err + } + local, err = c.geometry.Combine(local, clip, GeometryIntersect) + if err != nil { + return nil, err + } + } else { + inner, outer, r0, r1, extend := gradient.Start, gradient.End, gradient.R0, gradient.R1, spread.Extend + if r1 < r0 { + inner, outer, r0, r1 = outer, inner, r1, r0 + extend = (extend&1)<<1 | (extend&2)>>1 + } + if extend&2 == 0 { + local, err = c.geometry.Combine(local, geometryCircle(outer, r1), GeometryIntersect) + if err != nil { + return nil, err + } + } + if extend&1 == 0 && r0 > 0 { + local, err = c.geometry.Combine(local, geometryCircle(inner, r0), GeometrySubtract) + if err != nil { + return nil, err + } + } + } + return c.geometry.Transform(local, view) +} + +// geometryCircle 构建标准圆弧,不提前折线化 +// 入参: center 圆心, radius 半径 +// 返回: GeometryPath 圆路径 +func geometryCircle(center RasterPoint, radius float64) GeometryPath { + return GeometryPath{ + {Verb: GeometryMove, End: Point{X: center.X + radius, Y: center.Y}}, + {Verb: GeometryArc, RadiusX: radius, RadiusY: radius, Sweep: true, End: Point{X: center.X - radius, Y: center.Y}}, + {Verb: GeometryArc, RadiusX: radius, RadiusY: radius, Sweep: true, End: Point{X: center.X + radius, Y: center.Y}}, + {Verb: GeometryClose}, + } +} diff --git a/ofdgo_render_text.go b/ofdgo_render_text.go new file mode 100644 index 0000000..d984149 --- /dev/null +++ b/ofdgo_render_text.go @@ -0,0 +1,373 @@ +// 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" + "strings" +) + +// PositionedGlyph 保存页面坐标字形与对应原文区间 +type PositionedGlyph struct { + Path GeometryPath + Underline GeometryPath + Start, End int +} + +// PositionedText 保存绘制、搜索和度量共同使用的定位结果 +// Glyphs中的路径和Clip为只读页面坐标,Run保留原文而非包装字符 +type PositionedText struct { + Glyphs []PositionedGlyph + Run TextRun + Clip *GeometryPath + Matrix Matrix +} + +// glyphOutlineKey 区分字体、字形与毫米字号 +type glyphOutlineKey struct { + font *PreparedFont + glyph uint16 + size float64 +} + +// preparedMetrics 复用已包装字体的后端度量,不混用不同后端实例 +// 入参: prepared 已包装字体 +// 返回: FontMetrics 字体度量, error 字体解析错误 +func (r *Renderer) preparedMetrics(prepared *PreparedFont) (FontMetrics, error) { + if metrics := r.fontMetrics[prepared]; metrics != nil { + return metrics, nil + } + metrics, err := r.backends.Fonts.OpenFont(prepared.Data) + if err != nil { + return nil, err + } + r.fontMetrics[prepared] = metrics + return metrics, nil +} + +// preparedOutline 按字形和字号复用只读轮廓 +// 入参: prepared 字体, metrics 度量, glyph 字形编号, size 毫米字号 +// 返回: 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 { + return path, nil + } + provider, ok := metrics.(FontOutlines) + if !ok { + return nil, fmt.Errorf("font outlines: %w", ErrBackendUnavailable) + } + path, err := provider.GlyphOutline(glyph, size) + if err == nil { + r.glyphOutlines[key] = path + } + return path, err +} + +// renderObjectMatrix 统一边界、局部变换与父变换的组合顺序 +// 入参: boundary 对象边界, local 局部矩阵, state 继承状态 +// 返回: Matrix 页面矩阵, Matrix 不含边界平移的组合矩阵 +func renderObjectMatrix(boundary string, local Matrix, state RenderState) (Matrix, Matrix) { + box, _ := ParseBox(boundary) + linear := local + if state.Parent != nil { + linear = state.Parent.Multiply(local) + } + matrix := TranslationMatrix(box.X, box.Y).Multiply(linear) + if state.BoundaryInCTM && state.Parent != nil { + matrix = state.Parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(local) + } + return matrix, linear +} + +// objectGeometryClip 解析对象裁剪并合并父裁剪 +// 入参: clips 对象裁剪, boundary 边界, local 局部矩阵, state 继承状态 +// 返回: *GeometryPath 页面裁剪, error 几何错误 +func (r *Renderer) objectGeometryClip(clips *Clips, boundary string, local Matrix, state RenderState) (*GeometryPath, error) { + if clips == nil { + return state.Clip, nil + } + matrix, _ := renderObjectMatrix(boundary, local, state) + if clips.TransFlag != nil && !*clips.TransFlag { + copy := *clips + copy.TransFlag = nil + clips = © + box, _ := ParseBox(boundary) + matrix = TranslationMatrix(box.X, box.Y) + if state.BoundaryInCTM && state.Parent != nil { + matrix = state.Parent.Multiply(matrix) + } + } + geometry, err := r.Geometry() + if err != nil { + return nil, err + } + return geometry.Clip(r, clips, matrix, state.Clip) +} + +// clipGeometry 将非零填充路径与页面裁剪相交 +// 入参: geometry 几何后端, path 页面路径, clip 页面裁剪 +// 返回: GeometryPath 裁剪路径, error 几何错误 +func clipGeometry(geometry GeometryBackend, path GeometryPath, clip *GeometryPath) (GeometryPath, error) { + if clip == nil { + return path, nil + } + return geometry.Combine(path, *clip, GeometryIntersect) +} + +// PositionText 定位文字、字形与搜索范围,不依赖页面输出后端 +// 入参: object 文字对象, state 继承状态 +// 返回: *PositionedText 定位结果, error 字体或几何错误 +func (r *Renderer) PositionText(object TextObject, state RenderState) (*PositionedText, error) { + result := &PositionedText{Run: TextRun{ID: object.ID, Text: object.Text()}} + result.Run.Boxes = make([]Box, len([]rune(result.Run.Text))) + if object.Visible != nil && !*object.Visible { + return result, nil + } + geometry, err := r.Geometry() + if err != nil { + return nil, err + } + local := NewMatrix(object.CTM) + matrix, linear := renderObjectMatrix(object.Boundary, local, state) + result.Matrix = matrix + result.Clip, err = r.objectGeometryClip(object.Clips, object.Boundary, local, state) + if err != nil { + return nil, err + } + defaults := r.drawParamDefaults(object.DrawParam, state.Defaults) + size := object.Size + if size == 0 && defaults != nil { + size = defaults.Size + } + if size == 0 { + size = 3.5 + } + if object.VScale != 0 { + size *= object.VScale + } + horizontal := object.HScale + if horizontal == 0 { + horizontal = 1 + } + id := object.Font + if id == "" && defaults != nil { + id = defaults.Font + } + prepared, err := r.PrepareFont(id) + if err != nil { + return nil, err + } + if len(prepared.Data) == 0 { + box, _ := ParseBox(object.Boundary) + boundary, _ := renderObjectMatrix(object.Boundary, IdentityMatrix, state) + path, err := geometry.Transform(geometryRectangle(Box{W: box.W, H: box.H}), boundary) + if err != nil { + return nil, err + } + path, err = clipGeometry(geometry, path, result.Clip) + if err != nil { + return nil, err + } + box, err = geometry.Bounds(path) + if err != nil { + return nil, err + } + for i := range result.Run.Boxes { + result.Run.Boxes[i] = box + } + return result, nil + } + metrics, err := r.preparedMetrics(prepared) + if err != nil { + return nil, err + } + weight, italic := object.Weight, object.Italic + if defaults != nil { + if weight == 0 { + weight = defaults.Weight + } + italic = italic || defaults.Italic + } + bold := weight >= 700 + if definition := r.Reader.fontCache[id]; definition != nil { + bold = bold && !definition.Bold + if definition.FontFile == "" && fontNoSyntheticBold(definition.FontName, definition.FamilyName) { + bold = false + } + italic = italic && !definition.Italic + } + ascent, descent, _ := metrics.VerticalMetrics() + unit := size / float64(metrics.UnitsPerEm()) + transforms := r.textObjectGlyphTransforms(id, object) + codeOffset, textOffset := 0, 0 + angle := float64(object.CharDirection) * math.Pi / 180 + rotate := Matrix{a: math.Cos(angle), b: math.Sin(angle), c: -math.Sin(angle), d: math.Cos(angle)} + glyphLinear := Matrix{a: linear.a, b: linear.b, c: linear.c, d: linear.d}.Multiply(rotate) + for index, code := range object.TextCode { + if object.textCodeLineBreak(index) { + textOffset++ + } + runes := textCodeRunes(code.Value) + glyphs := textCodeGlyphs(runes, transforms, codeOffset) + spans := textGlyphSpans(runes, transforms, codeOffset) + if code.Index != "" { + runes = r.parseIndexRunes(code.Index, id) + glyphs = textRuneGlyphs(runes) + spans = make([][2]int, len(glyphs)) + for i := range spans { + spans[i] = [2]int{0, 1} + } + } + positioner := NewTextPositioner(code, object.ReadDirection) + for i, glyph := range glyphs { + gid := uint16(glyph.GlyphID) + if glyph.GlyphID < 0 { + characters := []rune(glyph.Text) + if len(characters) > 0 { + gid = metrics.GlyphIndex(characters[0]) + } + } + path, err := r.preparedOutline(prepared, metrics, gid, size) + if err != nil { + return nil, err + } + width := float64(metrics.GlyphAdvance(gid)) * unit + advance := width * horizontal + if (object.ReadDirection-object.CharDirection)%180 != 0 { + advance = size + } + origin := positioner.Next(advance) + scaleX := horizontal + if object.CharDirection == 0 { + limit := textGlyphAdvanceLimit(positioner.dxs, positioner.dys, positioner.xs, i, len(glyphs), origin.X) + if limit > 0 && width*scaleX > limit { + scaleX = limit / width + } + } + if bold && len(path) > 0 { + outline, err := geometry.Stroke(path, StrokeOptions{Width: size * 0.04, Join: "Round", Cap: "Round"}) + if err != nil { + return nil, err + } + path, err = geometry.Combine(path, outline, GeometryUnion) + if err != nil { + return nil, err + } + } + if italic { + path, err = geometry.Transform(path, Matrix{a: 1, c: -0.3, d: 1}) + if err != nil { + return nil, err + } + } + x, y := matrix.Transform(origin.X, origin.Y) + transform := TranslationMatrix(x, y).Multiply(glyphLinear).Multiply(Matrix{a: scaleX, d: 1}) + bounds := Box{Y: -float64(ascent) * unit, W: width, H: (float64(ascent) + float64(descent)) * unit} + if len(path) > 0 { + shape, err := geometry.Bounds(path) + if err != nil { + return nil, err + } + bounds = unionTextBox(bounds, shape) + } + placed, err := geometry.Transform(path, transform) + if err != nil { + return nil, err + } + clipped, err := clipGeometry(geometry, placed, result.Clip) + if err != nil { + return nil, err + } + box, err := geometry.Bounds(clipped) + if err != nil { + return nil, err + } + span := spans[i] + item := PositionedGlyph{Path: placed, Start: textOffset + span[0], End: textOffset + span[1]} + if strings.Contains(object.Decoration, "Underline") { + line := GeometryPath{{Verb: GeometryMove, End: Point{Y: size * 0.1}}, {Verb: GeometryLine, End: Point{X: width * scaleX, Y: size * 0.1}}} + line, err = geometry.Stroke(line, StrokeOptions{Width: size * 0.05}) + if err != nil { + return nil, err + } + item.Underline, err = geometry.Transform(line, TranslationMatrix(x, y).Multiply(glyphLinear)) + if err != nil { + return nil, err + } + } + for j := item.Start; j < item.End; j++ { + result.Run.Boxes[j] = unionTextBox(result.Run.Boxes[j], box) + } + if err := result.Run.addGeometrySpan(geometry, item.Start, item.End, bounds, transform, result.Clip); err != nil { + return nil, err + } + result.Glyphs = append(result.Glyphs, item) + } + codeOffset += len(runes) + if code.Index == "" { + textOffset += len(runes) + } else { + textOffset++ + } + } + return result, nil +} + +// addGeometrySpan 以公共几何语义收集文字选择区域,保留旋转和斜切 +// 入参: geometry 几何后端, start 起始字符, end 结束字符, bounds 局部范围, matrix 页面变换, clip 页面裁剪 +// 返回: error 几何错误 +func (run *TextRun) addGeometrySpan(geometry GeometryBackend, start, end int, bounds Box, matrix Matrix, clip *GeometryPath) error { + inverse, ok := matrix.Invert() + if !ok { + return nil + } + if clip != nil { + path, err := geometry.Transform(geometryRectangle(bounds), matrix) + if err != nil { + return err + } + path, err = clipGeometry(geometry, path, clip) + if err != nil { + return err + } + if len(path) == 0 { + return nil + } + path, err = geometry.Transform(path, inverse) + if err != nil { + return err + } + bounds, err = geometry.Bounds(path) + if err != nil { + return err + } + } + if bounds.W < 0 || bounds.H <= 0 { + return nil + } + matrix = matrix.Multiply(TranslationMatrix(bounds.X, bounds.Y)).Multiply(Matrix{a: bounds.W, d: bounds.H}) + if n := len(run.Spans); n > 0 && run.Spans[n-1].Start == start && run.Spans[n-1].End == end { + previous := MatrixFromValues(run.Spans[n-1].Matrix) + if inverse, ok := previous.Invert(); ok { + box := unionTextBox(Box{W: 1, H: 1}, inverse.Multiply(matrix).TransformBox(Box{W: 1, H: 1})) + matrix = previous.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(Matrix{a: box.W, d: box.H}) + } + run.Spans = run.Spans[:n-1] + } + run.Spans = append(run.Spans, TextSpan{Start: start, End: end, Matrix: matrix.Values()}) + return nil +} diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 866aa95..30a365f 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -43,6 +43,9 @@ type Renderer struct { backendStates map[any]any resolvedFonts map[resolvedFontKey]resolvedFontResult preparedFonts map[string]*PreparedFont + fontSourcesCache *fontSourceCache + fontMetrics map[*PreparedFont]FontMetrics + glyphOutlines map[glyphOutlineKey]GeometryPath renderError error TransparentBackground bool } diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index 51e0847..abb9026 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -58,7 +58,9 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) { } source = converted } - drawRasterImage(img, source, page.PixelTransform(cmd.Transform, h)) + if err := drawRasterImageCommand(b, page, img, source, cmd); err != nil { + return nil, err + } continue } p := &canvas.Path{} @@ -93,6 +95,9 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) { default: return nil, fmt.Errorf("canvas command %d: unsupported gradient %d", i, g.Kind) } + if g.Spread != nil { + paint = canvas.Paint{Gradient: rasterGradientCanvas{g}} + } } style := canvas.Style{Fill: paint, FillRule: canvas.NonZero} if cmd.EvenOdd { @@ -104,3 +109,23 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) { r.Close() return img, nil } + +// rasterGradientCanvas 将公共渐变交给Canvas采样,不改变OFD周期语义 +type rasterGradientCanvas struct{ gradient *RasterGradient } + +// At 返回局部坐标的预乘颜色 +// 入参: x 横坐标, y 纵坐标 +// 返回: color.RGBA 渐变颜色 +func (g rasterGradientCanvas) At(x, y float64) color.RGBA { return g.gradient.At(x, y) } + +// SetColorSpace 返回转换颜色空间后的独立渐变 +// 入参: space 目标颜色空间 +// 返回: canvas.Gradient 采样器 +func (g rasterGradientCanvas) SetColorSpace(space canvas.ColorSpace) canvas.Gradient { + copy := *g.gradient + copy.Stops = append([]ColorStop(nil), copy.Stops...) + for i := range copy.Stops { + copy.Stops[i].Color = space.ToLinear(copy.Stops[i].Color) + } + return rasterGradientCanvas{©} +} diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index 3ac4128..85716ac 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -15,6 +15,7 @@ package ofdgo import ( + "fmt" "image" "github.com/tdewolff/canvas" @@ -68,28 +69,7 @@ func (r *clipRenderer) RenderImage(img image.Image, m canvas.Matrix) {} // 入参: clips 裁剪对象, pageH 页面高度, bx 边界X坐标, by 边界Y坐标, objectCTM 对象CTM, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM // 返回: *canvas.Path 路径对象 func (r *Renderer) buildObjectClipPath(clips *Clips, pageH float64, bx, by float64, objectCTM Matrix, parentCTM *Matrix, boundaryInCTM bool) *canvas.Path { - if clips == nil { - return nil - } - if !boundaryInCTM && parentCTM != nil { - objectCTM = parentCTM.Multiply(objectCTM) - } - if clips.TransFlag != nil && !*clips.TransFlag { - copy := *clips - copy.TransFlag = nil - clips = © - objectCTM = NewMatrix("") - } - matrix := TranslationMatrix(bx, by).Multiply(objectCTM) - if boundaryInCTM && parentCTM != nil { - matrix = parentCTM.Multiply(matrix) - } - geometry, err := r.Geometry() - if err != nil { - r.renderError = err - return nil - } - path, err := geometry.Clip(r, clips, matrix, nil) + path, err := r.objectGeometryClip(clips, fmt.Sprintf("%g %g 0 0", bx, by), objectCTM, RenderState{Parent: parentCTM, BoundaryInCTM: boundaryInCTM}) if err != nil { r.renderError = err return nil diff --git a/ofdgo_renderer_compile_gg.go b/ofdgo_renderer_compile_gg.go new file mode 100644 index 0000000..d4f8185 --- /dev/null +++ b/ofdgo_renderer_compile_gg.go @@ -0,0 +1,88 @@ +// 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 "image/color" + +// CompilePage 使用公共OFD语义和配置的字形、几何能力编译页面 +// 入参: r 渲染器, page 页面 +// 返回: *RasterPage 绘制页面, error 编译错误 +func (GGBackend) CompilePage(r *Renderer, page *PageContent) (*RasterPage, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + c, err := newSemanticCompiler(r) + if err != nil { + return nil, err + } + c.page = &RasterPage{Width: box.W, Height: box.H, DPI: r.DPI} + if _, _, err := c.page.PixelSize(); err != nil { + return nil, err + } + if !r.TransparentBackground { + if err := c.fill(geometryRectangle(Box{W: box.W, H: box.H}), Paint{Kind: PaintSolid, Color: color.RGBA{255, 255, 255, 255}}, false, nil, IdentityMatrix, IdentityMatrix); err != nil { + return nil, err + } + } + if err := r.WalkPage(page, c); err != nil { + return nil, err + } + if r.OnPageText != nil { + r.OnPageText(page, &c.text) + } + return c.page, nil +} + +// PageText 提取与GG页面绘制相同定位的原文,不读取图片像素 +// 入参: r 渲染器, page 页面 +// 返回: *PageText 页面文字, error 定位错误 +func (GGBackend) PageText(r *Renderer, page *PageContent) (*PageText, error) { + if _, err := r.GetPageBox(page); err != nil { + return nil, err + } + c, err := newSemanticCompiler(r) + if err != nil { + return nil, err + } + c.textOnly = true + if err := r.WalkPage(page, c); err != nil { + return nil, err + } + return &c.text, nil +} + +// MeasureObject 复用文字和图形编译规则收集范围,不分配页面像素 +// 入参: r 渲染器, object 对象, options 度量上下文 +// 返回: ObjectMeasurement 对象范围与轮廓, error 度量错误 +func (GGBackend) MeasureObject(r *Renderer, object GraphicObject, options MeasureOptions) (ObjectMeasurement, error) { + c, err := newSemanticCompiler(r) + if err != nil { + return ObjectMeasurement{}, err + } + boundary, ctm := editorGeometry(object) + if _, err := creationBox(boundary); err != nil { + return ObjectMeasurement{}, err + } + if ctm != "" { + if _, err := creationNumbers(ctm, 6); err != nil { + return ObjectMeasurement{}, err + } + } + c.measure = true + c.contours = options.Contours + err = r.WalkObject(&object, RenderState{Defaults: options.Defaults, Parent: options.Parent, BoundaryInCTM: options.BoundaryInCTM, Clip: options.Clip}, c) + return c.measurement, err +} diff --git a/ofdgo_renderer_font_canvas.go b/ofdgo_renderer_font_canvas.go index 3f9a26c..20450dc 100644 --- a/ofdgo_renderer_font_canvas.go +++ b/ofdgo_renderer_font_canvas.go @@ -15,9 +15,6 @@ package ofdgo import ( - "fmt" - "path" - "github.com/tdewolff/canvas" "github.com/tdewolff/font" ) @@ -25,54 +22,8 @@ import ( // ResolveFont 按需加载字体资源,保留内嵌数据及外部字体的名称、样式和集合索引匹配 // 入参: r 渲染器, id 字体标识, exact 是否禁止无关回退 // 返回: ResolvedFont 字体与来源, error 字体不可用错误 -func (CanvasBackend) ResolveFont(r *Renderer, id string, exact bool) (ResolvedFont, error) { - definition := r.Reader.fontCache[id] - if definition == nil && !r.Reader.fontResourcesRead && r.Reader.OFD != nil { - if _, err := r.Reader.Fonts(); err != nil { - return ResolvedFont{}, err - } - definition = r.Reader.fontCache[id] - } - if definition == nil { - if exact { - return ResolvedFont{}, fmt.Errorf("font %q not found", id) - } - return r.canvasFallbackFont(), nil - } - if definition.FontFile != "" { - data, err := r.Reader.FontData(id) - if err != nil { - return ResolvedFont{}, err - } - face, err := r.Reader.embeddedFontFace(*definition) - if err != nil { - return ResolvedFont{}, err - } - return ResolvedFont{Data: data, Source: path.Base(definition.FontFile), Face: face, Exact: true}, nil - } - style := canvasFontStyle(definition) - if source, family := r.fontSourceMatch(id, definition, exact); family != nil { - sfnt := family.Face(12, style).Font.SFNT - face := fontFaceInfo(sfnt.Tables["name"], source.face) - r.canvasState().fontSourceUsed[id] = source - return ResolvedFont{Data: fontSFNTData(sfnt), Source: source.name, Face: &face, Exact: source.exact}, nil - } - if !exact { - return r.canvasFallbackFont(), nil - } - return ResolvedFont{}, fmt.Errorf("font %q is unavailable", definition.FontName) -} - -// canvasFallbackFont 按需加载默认阅读字体,不用于精确编辑匹配 -// 返回: ResolvedFont 默认字体,未安装时数据为空 -func (r *Renderer) canvasFallbackFont() ResolvedFont { - if r.canvasState().fontFamily == nil { - r.initCanvasFonts() - } - if r.canvasState().defaultFontLoaded { - return ResolvedFont{Data: fontSFNTData(r.canvasState().fontFamily.Face(12, canvas.FontRegular).Font.SFNT)} - } - return ResolvedFont{} +func (b CanvasBackend) ResolveFont(r *Renderer, id string, exact bool) (ResolvedFont, error) { + return r.resolveFontSource(b, id, exact) } // OpenFont 解析独立SFNT数据,度量与原有编辑排版保持一致 @@ -97,8 +48,13 @@ func (f canvasFontMetrics) Write() []byte { return fontSFNTData(f.SFNT) } -// initCanvasFonts 初始化Canvas默认字体 -func (r *Renderer) initCanvasFonts() { - r.canvasState().fontFamily = canvas.NewFontFamily("default") - r.canvasState().defaultFontLoaded = r.loadDefaultFonts() +// GlyphOutline 返回基线原点、纵轴向下的字形轮廓 +// 入参: glyph 字形编号, size 字号,单位为毫米 +// 返回: GeometryPath 字形路径, error 字形解析错误 +func (f canvasFontMetrics) GlyphOutline(glyph uint16, size float64) (GeometryPath, error) { + path := &canvas.Path{} + if err := f.GlyphPath(path, glyph, 0, 0, 0, size/float64(f.UnitsPerEm()), font.NoHinting); err != nil { + return nil, err + } + return *geometryFromCanvasPath(path), nil } diff --git a/ofdgo_renderer_font_gg.go b/ofdgo_renderer_font_gg.go new file mode 100644 index 0000000..a3c084a --- /dev/null +++ b/ofdgo_renderer_font_gg.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 ( + "bytes" + "fmt" + "math" + + ggtext "github.com/gogpu/gg/text" +) + +// ResolveFont 使用公共字体来源匹配,不创建Canvas字体 +// 入参: r 渲染器, id 字体ID, exact 是否禁止无关回退 +// 返回: ResolvedFont 字体及来源, error 解析错误 +func (b GGBackend) ResolveFont(r *Renderer, id string, exact bool) (ResolvedFont, error) { + return r.resolveFontSource(b, id, exact) +} + +// OpenFont 通过GG解析独立字体,保留原始SFNT编码 +// 入参: data 字体数据 +// 返回: FontMetrics 字体度量, error 解析错误 +func (GGBackend) OpenFont(data []byte) (FontMetrics, error) { + data = bytes.Clone(data) + source, err := ggtext.NewFontSource(data) + if err != nil { + return nil, err + } + return &ggFontMetrics{font: source.Parsed(), data: data}, nil +} + +// ggFontMetrics 适配GG设计单位度量与字形轮廓 +type ggFontMetrics struct { + font ggtext.ParsedFont + data []byte +} + +// UnitsPerEm 返回字体设计单位 +// 返回: uint16 每字单位数 +func (f *ggFontMetrics) UnitsPerEm() uint16 { return uint16(f.font.UnitsPerEm()) } + +// NumGlyphs 返回字形数量 +// 返回: uint16 字形数量 +func (f *ggFontMetrics) NumGlyphs() uint16 { return uint16(f.font.NumGlyphs()) } + +// GlyphIndex 返回字符对应的字形编号 +// 入参: character 字符 +// 返回: uint16 字形编号 +func (f *ggFontMetrics) GlyphIndex(character rune) uint16 { return f.font.GlyphIndex(character) } + +// GlyphAdvance 返回字形设计单位推进宽度 +// 入参: glyph 字形编号 +// 返回: uint16 推进宽度 +func (f *ggFontMetrics) GlyphAdvance(glyph uint16) uint16 { + return uint16(math.Round(f.font.GlyphAdvance(glyph, float64(f.font.UnitsPerEm())))) +} + +// VerticalMetrics 返回非负的上升、下降与行间隙 +// 返回: uint16 上升高度, uint16 下降高度, uint16 行间隙 +func (f *ggFontMetrics) VerticalMetrics() (uint16, uint16, uint16) { + m := f.font.Metrics(float64(f.font.UnitsPerEm())) + return uint16(math.Round(math.Max(0, m.Ascent))), uint16(math.Round(math.Max(0, -m.Descent))), uint16(math.Round(math.Max(0, m.LineGap))) +} + +// Write 返回独立字体数据,不更新字体元信息 +// 返回: []byte SFNT数据 +func (f *ggFontMetrics) Write() []byte { return bytes.Clone(f.data) } + +// GlyphOutline 返回基线原点、纵轴向下的字形路径 +// 入参: glyph 字形编号, size 字号,单位为毫米 +// 返回: GeometryPath 字形路径, error 轮廓解析错误 +func (f *ggFontMetrics) GlyphOutline(glyph uint16, size float64) (GeometryPath, error) { + if !rasterPositive(size) || int(glyph) >= f.font.NumGlyphs() { + return nil, fmt.Errorf("invalid glyph or size") + } + outline, err := ggtext.NewOutlineExtractor().ExtractOutline(f.font, ggtext.GlyphID(glyph), size) + if err != nil || outline == nil { + return nil, err + } + result := make(GeometryPath, 0, len(outline.Segments)+1) + point := func(p ggtext.OutlinePoint) Point { return Point{X: float64(p.X), Y: float64(p.Y)} } + for _, s := range outline.Segments { + segment := GeometrySegment{End: point(s.Points[0])} + switch s.Op { + case ggtext.OutlineOpMoveTo: + if len(result) > 0 { + result = append(result, GeometrySegment{Verb: GeometryClose}) + } + segment.Verb = GeometryMove + case ggtext.OutlineOpLineTo: + segment.Verb = GeometryLine + case ggtext.OutlineOpQuadTo: + segment.Verb, segment.Control1, segment.End = GeometryQuad, point(s.Points[0]), point(s.Points[1]) + case ggtext.OutlineOpCubicTo: + segment.Verb, segment.Control1, segment.Control2, segment.End = GeometryCubic, point(s.Points[0]), point(s.Points[1]), point(s.Points[2]) + default: + return nil, fmt.Errorf("unsupported GG outline command %d", s.Op) + } + result = append(result, segment) + } + if len(result) > 0 { + result = append(result, GeometrySegment{Verb: GeometryClose}) + } + return result, nil +} diff --git a/ofdgo_renderer_font_loader_canvas.go b/ofdgo_renderer_font_loader_canvas.go index 28dc7db..b493738 100644 --- a/ofdgo_renderer_font_loader_canvas.go +++ b/ofdgo_renderer_font_loader_canvas.go @@ -14,51 +14,7 @@ package ofdgo -import ( - "bytes" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "runtime" - "sort" - - "github.com/tdewolff/canvas" -) - -// fontSourceKind 字体来源类型 -type fontSourceKind uint8 - -const ( - fontSourceFile fontSourceKind = iota - fontSourceFS - fontSourceSystem -) - -// fontSource 字体来源 -type fontSource struct { - kind fontSourceKind - index int - face int - name string - exact bool - priority int -} - -// fontSourceKey 字体来源去重键 -type fontSourceKey struct { - kind fontSourceKind - index int - face int - name string -} - -// fontCacheKey 字体加载缓存键 -type fontCacheKey struct { - fontSourceKey - style canvas.FontStyle -} +import "github.com/tdewolff/canvas" // loadFont 加载字体 // 入参: fontID 字体ID @@ -86,9 +42,7 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily { r.renderError = err return nil } - if r.Reader.fontCache[fontID] != nil { - r.canvasState().fontMap[fontID] = ff - } + r.canvasState().fontMap[fontID] = ff return ff } @@ -105,211 +59,3 @@ func canvasFontStyle(font *Font) canvas.FontStyle { } return style } - -// fontSources 获取字体来源列表 -// 入参: fontID 字体ID, font OFD字体定义, style Canvas字体样式 -// 返回: []fontSource 字体来源列表 -func (r *Renderer) fontSources(fontID string, font *Font, style canvas.FontStyle) []fontSource { - if sources, ok := r.canvasState().fontSourceCache[fontID]; ok { - return sources - } - bold := style&canvas.FontBold != 0 - italic := style&canvas.FontItalic != 0 - names := []string{font.FontName, font.FamilyName} - sources := make([]fontSource, 0) - seen := make(map[fontSourceKey]bool) - for _, dir := range r.fontDirs { - for _, match := range r.matchFontFiles(dir, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) - } - } - for index := range r.fontFS { - for _, match := range r.matchFontFS(index, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) - } - } - sortFontSources(sources) - systemStart := len(sources) - if canLoadSystemFonts() { - for _, dir := range systemFontDirs() { - for _, match := range r.matchFontFiles(dir, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) - } - } - for _, name := range fontSystemNames(names...) { - source := fontSource{kind: fontSourceSystem, name: name, exact: true} - for i, requested := range names { - if fontNormalizeName(name) == fontNormalizeName(requested) { - source.priority = (i - len(names)) * 2 - break - } - } - sources = appendFontSource(sources, seen, source) - } - } - sortFontSources(sources[systemStart:]) - for index := range r.fontFS { - for _, match := range r.matchFontFS(index, nil, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face}) - } - } - for index, fsys := range r.fontFS { - names, _ := fs.Glob(fsys, "*") - for _, name := range names { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: name}) - } - } - r.canvasState().fontSourceCache[fontID] = sources - return sources -} - -// sortFontSources 将完整名称匹配排在回退候选之前 -// 入参: sources 字体来源列表 -func sortFontSources(sources []fontSource) { - sort.SliceStable(sources, func(i, j int) bool { - return sources[i].priority < sources[j].priority - }) -} - -// appendFontSource 追加字体来源 -// 入参: sources 字体来源列表, seen 去重映射, source 字体来源 -// 返回: []fontSource 字体来源列表 -func appendFontSource(sources []fontSource, seen map[fontSourceKey]bool, source fontSource) []fontSource { - if source.name == "" { - return sources - } - key := fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face} - if seen[key] { - return sources - } - seen[key] = true - return append(sources, source) -} - -// fontSourceMatch 获取匹配的字体来源 -// 入参: fontID 字体ID, font OFD字体定义, exact 是否禁止无关回退 -// 返回: fontSource 字体来源, *canvas.FontFamily 已加载的字体族,未匹配时为空 -func (r *Renderer) fontSourceMatch(fontID string, font *Font, exact bool) (fontSource, *canvas.FontFamily) { - if source, ok := r.canvasState().fontSourceUsed[fontID]; ok && (!exact || source.exact) && r.canvasState().fontMap[fontID] != nil { - return source, r.canvasState().fontMap[fontID] - } - style := canvasFontStyle(font) - for _, source := range r.fontSources(fontID, font, style) { - if exact && !source.exact { - continue - } - key := fontCacheKey{fontSourceKey: fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face}, style: style} - if cached, ok := r.canvasState().fontCache[key]; ok { - if cached != nil { - return source, cached - } - continue - } - family := canvas.NewFontFamily(font.FontName) - if loaded := r.loadFontSource(family, source, style); loaded != nil { - return source, loaded - } - } - return fontSource{}, nil -} - -// loadFontSource 加载字体来源 -// 入参: family 字体族, source 字体来源, style Canvas字体样式 -// 返回: *canvas.FontFamily 字体族 -func (r *Renderer) loadFontSource(family *canvas.FontFamily, source fontSource, style canvas.FontStyle) *canvas.FontFamily { - key := fontCacheKey{fontSourceKey: fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face}, style: style} - if cached, ok := r.canvasState().fontCache[key]; ok { - return cached - } - var err error - var data []byte - switch source.kind { - case fontSourceFile: - data, err = os.ReadFile(source.name) - case fontSourceFS: - data, err = fs.ReadFile(r.fontFS[source.index], source.name) - case fontSourceSystem: - err = family.LoadSystemFont(source.name, style) - } - if err == nil && source.kind != fontSourceSystem { - if bytes.HasPrefix(data, []byte("ttcf")) { - data, err = extractCollectionFont(data, source.face) - } - if err == nil { - err = family.LoadFont(data, 0, style) - } - } - if err != nil { - r.canvasState().fontCache[key] = nil - return nil - } - r.canvasState().fontCache[key] = family - return family -} - -// matchFontFiles 查找字体文件 -// 入参: dir 字体目录, names 字体名称, bold 是否粗体, italic 是否斜体 -// 返回: []fontFileMatch 字体匹配列表 -func (r *Renderer) matchFontFiles(dir string, names []string, bold, italic bool) []fontFileMatch { - candidates, ok := r.canvasState().fontDirCandidates[dir] - if !ok { - files, _ := filepath.Glob(filepath.Join(dir, "*")) - candidates = fontFileCandidates(files, filepath.Base) - for i, candidate := range candidates { - file, err := os.Open(candidate.name) - if err != nil { - continue - } - candidates = appendFontFileNames(candidates, i, fontFileNames(file)) - file.Close() - } - r.canvasState().fontDirCandidates[dir] = candidates - } - return fontFileMatches(candidates, names, bold, italic) -} - -// matchFontFS 匹配字体文件系统中的字体名称 -// 入参: index 字体文件系统索引, names 字体名称, bold 是否粗体, italic 是否斜体 -// 返回: []fontFileMatch 字体匹配列表 -func (r *Renderer) matchFontFS(index int, names []string, bold, italic bool) []fontFileMatch { - fsys := r.fontFS[index] - if fonts, ok := fsys.(*FontFS); ok { - return fonts.matchStyle(names, bold, italic) - } - candidates, ok := r.canvasState().fontFSCandidates[index] - if !ok { - files, _ := fs.Glob(fsys, "*") - candidates = fontFileCandidates(files, path.Base) - for i, candidate := range candidates { - file, err := fsys.Open(candidate.name) - if err != nil { - continue - } - reader, ok := file.(io.ReaderAt) - if !ok { - data, _ := io.ReadAll(file) - reader = bytes.NewReader(data) - } - candidates = appendFontFileNames(candidates, i, fontFileNames(reader)) - file.Close() - } - r.canvasState().fontFSCandidates[index] = candidates - } - return fontFileMatches(candidates, names, bold, italic) -} - -// systemFontDirs 获取系统字体目录 -// 返回: []string 字体目录列表 -func systemFontDirs() []string { - switch runtime.GOOS { - case "android": - return []string{"/product/fonts", "/system/fonts"} - case "darwin": - return []string{`/Library/Fonts`} - case "linux": - return []string{`/usr/share/fonts`} - case "windows": - return []string{`C:\Windows\Fonts`} - } - return nil -} diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 5e36327..b5a1ef5 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -21,6 +21,28 @@ import ( "github.com/tdewolff/canvas" ) +// canvasNativeStroke 判断描边是否仍由Canvas提供,组合几何不改变矢量描边编码 +// 入参: geometry 几何后端 +// 返回: bool 是否采用原生描边 +func canvasNativeStroke(geometry GeometryBackend) bool { + if combined, ok := geometry.(GGGeometryBackend); ok { + return canvasNativeStroke(combined.GeometryBackend) + } + _, ok := geometry.(CanvasBackend) + return ok +} + +// Curves 在输出边界转换椭圆弧,其他曲线保持原样 +// 入参: path 页面路径 +// 返回: GeometryPath 贝塞尔路径, error 路径错误 +func (CanvasBackend) Curves(path GeometryPath) (GeometryPath, error) { + p, err := geometryToCanvasPath(&path) + if err != nil { + return nil, err + } + return *geometryFromCanvasPath(p.ReplaceArcs()), nil +} + // canvasStrokeOptions 转换Canvas描边样式,虚线由调用方传入 // 入参: width 描边宽度, cap 线帽, join 连接, tolerance 展开误差 // 返回: StrokeOptions 公共描边样式 diff --git a/ofdgo_renderer_geom_gg.go b/ofdgo_renderer_geom_gg.go new file mode 100644 index 0000000..7c9a20d --- /dev/null +++ b/ofdgo_renderer_geom_gg.go @@ -0,0 +1,135 @@ +// 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" + + "github.com/gogpu/gg" +) + +// GGGeometryBackend 使用GG处理贝塞尔边界和变换,其他运算由显式配置的几何后端承担 +// GeometryBackend不可为空,椭圆弧保持精确表示,不为接入GG而预先离散化 +type GGGeometryBackend struct{ GeometryBackend } + +// Name 返回实际几何提供者组合 +// 返回: string 后端组合标识 +func (b GGGeometryBackend) Name() string { return "gg+" + backendName(b.GeometryBackend) } + +// 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) +} + +// Bounds 使用GG计算贝塞尔曲线极值,椭圆弧交给配置的几何后端 +// 入参: path 页面路径 +// 返回: Box 精确范围, error 路径或能力错误 +func (b GGGeometryBackend) Bounds(path GeometryPath) (Box, error) { + for _, s := range path { + if s.Verb == GeometryArc { + if b.GeometryBackend == nil { + return Box{}, ErrBackendUnavailable + } + return b.GeometryBackend.Bounds(path) + } + } + p, err := ggGeometryPath(path) + if err != nil { + return Box{}, err + } + if len(path) == 0 { + return Box{}, nil + } + box := p.BoundingBox() + return Box{X: box.Min.X, Y: box.Min.Y, W: box.Max.X - box.Min.X, H: box.Max.Y - box.Min.Y}, nil +} + +// Transform 使用GG变换控制点,椭圆弧交给配置的几何后端保持弧线参数 +// 入参: path 页面路径, matrix 页面变换 +// 返回: GeometryPath 新路径, error 路径或变换错误 +func (b GGGeometryBackend) Transform(path GeometryPath, matrix Matrix) (GeometryPath, error) { + for _, v := range matrix.Values() { + if !finite(v) { + return nil, fmt.Errorf("invalid geometry transform") + } + } + for _, s := range path { + if s.Verb == GeometryArc { + if b.GeometryBackend == nil { + return nil, ErrBackendUnavailable + } + return b.GeometryBackend.Transform(path, matrix) + } + } + p, err := ggGeometryPath(path) + if err != nil { + return nil, err + } + return geometryFromGG(p.Transform(gg.Matrix{A: matrix.a, B: matrix.c, C: matrix.e, D: matrix.b, E: matrix.d, F: matrix.f})), nil +} + +// ggGeometryPath 转换贝塞尔路径,不舍入控制点或静默略过指令 +// 入参: path 库路径 +// 返回: *gg.Path GG路径, error 不支持的路径指令 +func ggGeometryPath(path GeometryPath) (*gg.Path, error) { + p := gg.NewPath() + for _, s := range path { + switch s.Verb { + case GeometryMove: + p.MoveTo(s.End.X, s.End.Y) + case GeometryLine: + p.LineTo(s.End.X, s.End.Y) + case GeometryQuad: + p.QuadraticTo(s.Control1.X, s.Control1.Y, s.End.X, s.End.Y) + case GeometryCubic: + p.CubicTo(s.Control1.X, s.Control1.Y, s.Control2.X, s.Control2.Y, s.End.X, s.End.Y) + case GeometryClose: + p.Close() + default: + return nil, fmt.Errorf("unsupported GG geometry command %d", s.Verb) + } + } + return p, nil +} + +// geometryFromGG 返回库自有路径,不保留GG对象 +// 入参: path GG路径 +// 返回: GeometryPath 库路径 +func geometryFromGG(path *gg.Path) GeometryPath { + var result GeometryPath + path.Iterate(func(verb gg.PathVerb, p []float64) { + s := GeometrySegment{} + switch verb { + case gg.MoveTo: + s.Verb, s.End = GeometryMove, Point{p[0], p[1]} + case gg.LineTo: + s.Verb, s.End = GeometryLine, Point{p[0], p[1]} + case gg.QuadTo: + s.Verb, s.Control1, s.End = GeometryQuad, Point{p[0], p[1]}, Point{p[2], p[3]} + case gg.CubicTo: + s.Verb, s.Control1, s.Control2, s.End = GeometryCubic, Point{p[0], p[1]}, Point{p[2], p[3]}, Point{p[4], p[5]} + case gg.Close: + s.Verb = GeometryClose + } + result = append(result, s) + }) + return result +} diff --git a/ofdgo_renderer_gg.go b/ofdgo_renderer_gg.go index 0e6c894..c33995e 100644 --- a/ofdgo_renderer_gg.go +++ b/ofdgo_renderer_gg.go @@ -22,7 +22,7 @@ import ( "github.com/gogpu/gg" ) -// GGBackend 不注册全局GPU设备或回退到其他绘图库的CPU后端 +// GGBackend 提供字体、页面编译和CPU绘制,不注册全局GPU设备或隐式切换后端 type GGBackend struct{} // Name 返回后端标识 @@ -43,7 +43,9 @@ func (GGBackend) Render(page *RasterPage) (image.Image, error) { for i, cmd := range page.Commands { m := page.PixelTransform(cmd.Transform, h) if cmd.Image != nil { - drawRasterImage(img, cmd.Image, m) + if err := drawRasterImageCommand(GGBackend{}, page, img, imagePixelSource(cmd.Image), cmd); err != nil { + return nil, err + } continue } path := gg.NewPath() diff --git a/ofdgo_renderer_image_canvas.go b/ofdgo_renderer_image_canvas.go index 81d165c..95a2fea 100644 --- a/ofdgo_renderer_image_canvas.go +++ b/ofdgo_renderer_image_canvas.go @@ -121,30 +121,11 @@ func (r *Renderer) canvasEncodedImage(img image.Image) image.Image { // renderImageBorder 渲染图像边框 // 入参: ctx 画布上下文, obj 图片对象, box 图像边界, pageH 页面高度, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM, clipPath 裁剪路径 func (r *Renderer) renderImageBorder(ctx *canvas.Context, obj ImageObject, box Box, pageH float64, parentCTM *Matrix, boundaryInCTM bool, clipPath *canvas.Path) { - border := obj.Border - width := defaultPathLineWidth - if border.LineWidth != nil { - width = *border.LineWidth - } - if width == 0 { + border := imageBorderObject(obj, box) + if border.LineWidth == 0 { return } - w, h := box.W, box.H - data := fmt.Sprintf("M 0 0 L %g 0 L %g %g L 0 %g C", w, w, h, h) - rx, ry := min(border.HorizonalCornerRadius, w/2), min(border.VerticalCornerRadius, h/2) - if rx > 0 && ry > 0 { - data = fmt.Sprintf("M 0 %g A %g %g 0 0 1 %g 0 L %g 0 A %g %g 0 0 1 %g %g L %g %g A %g %g 0 0 1 %g %g L %g %g A %g %g 0 0 1 0 %g C", - ry, rx, ry, rx, w-rx, rx, ry, w, ry, w, h-ry, rx, ry, w-rx, h, rx, h, rx, ry, h-ry) - } - r.renderPath(ctx, PathObject{ - Boundary: obj.Boundary, - LineWidth: width, - DashOffset: &border.DashOffset, - DashPattern: border.DashPattern, - Alpha: obj.Alpha, - StrokeColor: border.BorderColor, - AbbreviatedData: data, - }, pageH, nil, parentCTM, boundaryInCTM, clipPath) + r.renderPath(ctx, border, pageH, nil, parentCTM, boundaryInCTM, clipPath) } // imageWithClip 应用图片裁剪区域 diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index ab0aad9..0463e10 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -173,6 +173,22 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 if r.textOnly { return } + if bounds, ok := ctx.Renderer.(*boundsRenderer); ok { + compiler, err := newSemanticCompiler(r) + if err != nil { + r.renderError = err + return + } + compiler.measure, compiler.contours = true, bounds.collect + err = compiler.pathObject(obj, RenderState{Defaults: defaults, Parent: parentCTM, BoundaryInCTM: boundaryInCTM, Clip: geometryFromCanvasPath(parentClip)}) + if err != nil { + r.renderError = err + return + } + bounds.box = unionTextBox(bounds.box, compiler.measurement.Bounds) + bounds.contours = append(bounds.contours, compiler.measurement.Contours...) + return + } if obj.Visible != nil && !*obj.Visible { return } @@ -280,8 +296,7 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 } strokeClip := intersectClipPath(clipPath, shadingClip) _, repeat := strokePaint.(*repeatAxialGradient) - _, nativeGeometry := r.backends.Geometry.(CanvasBackend) - if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !nativeGeometry { + if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !canvasNativeStroke(r.backends.Geometry) { sp = sp.Copy() sp = r.strokeCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin) sp = applyClipPath(sp, strokeClip) @@ -458,41 +473,14 @@ func (r *Renderer) buildPath(obj PathObject, pageH float64, ctm Matrix, boundary // 入参: obj 路径对象, pageH 页面高度, ctm 变换矩阵, bx 边界X坐标, by 边界Y坐标 // 返回: *canvas.Path 路径对象 func (r *Renderer) buildTinyFillRectPath(obj PathObject, pageH float64, ctm Matrix, bx, by float64) *canvas.Path { - if obj.Fill == nil || !*obj.Fill || obj.Stroke == nil || *obj.Stroke { + path := tinyFillRectPath(obj, TranslationMatrix(bx, by).Multiply(ctm)) + if path == nil { return nil } - box, err := ParseBox(obj.Boundary) - if err != nil || box.W <= 0 || box.H <= 0 || box.W > 0.6 || box.H > 0.6 { + p, err := geometryToCanvasPath(&path) + if err != nil { + r.renderError = err return nil } - tokens := strings.Fields(obj.AbbreviatedData) - if len(tokens) != 11 || tokens[0] != "M" || tokens[3] != "L" || tokens[6] != "L" || tokens[9] != "L" || tokens[10] != "C" { - return nil - } - points := make([][2]float64, 0, 4) - for i := 1; i < 10; i += 3 { - x, errX := strconv.ParseFloat(tokens[i], 64) - y, errY := strconv.ParseFloat(tokens[i+1], 64) - if errX != nil || errY != nil { - return nil - } - tx, ty := ctm.Transform(x, y) - points = append(points, [2]float64{tx + bx, pageH - (ty + by)}) - } - minX, maxX := points[0][0], points[0][0] - minY, maxY := points[0][1], points[0][1] - for _, point := range points[1:] { - minX = math.Min(minX, point[0]) - maxX = math.Max(maxX, point[0]) - minY = math.Min(minY, point[1]) - maxY = math.Max(maxY, point[1]) - } - expand := math.Min(box.W, box.H) * 0.08 - p := &canvas.Path{} - p.MoveTo(minX-expand, minY-expand) - p.LineTo(maxX+expand, minY-expand) - p.LineTo(maxX+expand, maxY+expand) - p.LineTo(minX-expand, maxY+expand) - p.Close() - return p + return p.Translate(0, pageH) } diff --git a/ofdgo_renderer_shading_canvas.go b/ofdgo_renderer_shading_canvas.go index 9f59785..81a22ac 100644 --- a/ofdgo_renderer_shading_canvas.go +++ b/ofdgo_renderer_shading_canvas.go @@ -287,15 +287,3 @@ func reflectShdGrad(gradient canvas.Grad, period, lo, hi float64) canvas.Grad { } return append(result, canvas.Stop{Offset: 1, Color: gradient.At(reflectShdPosition(hi / period))}) } - -// reflectShdPosition 获取镜像周期内的位置 -// 入参: position 渐变位置 -// 返回: float64 周期内的位置 -func reflectShdPosition(position float64) float64 { - index := math.Floor(position) - position -= index - if math.Mod(index, 2) != 0 { - return 1 - position - } - return position -} diff --git a/ofdgo_renderer_state_canvas.go b/ofdgo_renderer_state_canvas.go index 2eccb98..31dc9da 100644 --- a/ofdgo_renderer_state_canvas.go +++ b/ofdgo_renderer_state_canvas.go @@ -23,15 +23,8 @@ import ( // canvasBackendState 保存Canvas字体、字形和编码图片适配缓存 type canvasBackendState struct { images map[*EncodedImage]image.Image - fontFamily *canvas.FontFamily - defaultFontLoaded bool fontMap map[string]*canvas.FontFamily - fontCache map[fontCacheKey]*canvas.FontFamily svgFontCache map[*canvas.Font]SVGFont - fontSourceCache map[string][]fontSource - fontSourceUsed map[string]fontSource - fontDirCandidates map[string][]fontFileCandidate - fontFSCandidates map[int][]fontFileCandidate textGlyphPathCache map[textGlyphPathCacheKey]textGlyphPathCacheValue } @@ -43,16 +36,16 @@ func (r *Renderer) canvasState() *canvasBackendState { return state.(*canvasBackendState) } state := &canvasBackendState{ - images: make(map[*EncodedImage]image.Image), - fontMap: make(map[string]*canvas.FontFamily), - fontCache: make(map[fontCacheKey]*canvas.FontFamily), - svgFontCache: make(map[*canvas.Font]SVGFont), - fontSourceCache: make(map[string][]fontSource), - fontSourceUsed: make(map[string]fontSource), - fontDirCandidates: make(map[string][]fontFileCandidate), - fontFSCandidates: make(map[int][]fontFileCandidate), - textGlyphPathCache: make(map[textGlyphPathCacheKey]textGlyphPathCacheValue), + images: make(map[*EncodedImage]image.Image), } + state.resetFonts() r.backendStates[key] = state return state } + +// resetFonts 清除Canvas字体及字形缓存,保留编码图片适配对象 +func (s *canvasBackendState) resetFonts() { + s.fontMap = make(map[string]*canvas.FontFamily) + s.svgFontCache = make(map[*canvas.Font]SVGFont) + s.textGlyphPathCache = make(map[textGlyphPathCacheKey]textGlyphPathCacheValue) +}